IlmHamroh
JavaScript Full-stack/31-qism. Prodakshn backend va AI integratsiya32/34-dars24 daqiqa
Mundarija (28)

Contract va API sxema testlari: OpenAPI surati, iste'molchi shartnomasi, Pact va Schemathesis

Qisqacha: Shartnoma testi (contract test) — API va uni ishlatadigan dastur orasidagi kelishuv buzilmaganini avtomatik tekshirish. Buzuvchi o'zgarish (breaking change) — eski mijozni ishlamay qoldiradigan o'zgarish: majburiy maydon qo'shish, maydonni olib tashlash, turini o'zgartirish. Kanon ikki qatlam qo'ydi: OpenAPI surati (API sxemasi faylga yoziladi, har test uni joriy sxema bilan solishtiradi) va iste'molchi shartnomasi (frontend tayanadigan javob maydonlari Zod bilan). Tajribada surat so'rov tomonidagi buzilishni ushladi, lekin javob turidagi o'zgarishni ko'rmadi — Swagger hujjatimizda javob sxemalari yo'q. Uni faqat iste'molchi shartnomasi ushladi.

Bu darsda

  • Buzuvchi va buzmaydigan o'zgarishlarni farqlaysiz.
  • OpenAPI hujjatini ixcham suratga aylantirib, ikki versiyani solishtira olasiz.
  • Iste'molchi shartnomasini Zod looseObject bilan yozasiz va nega strictObject emasligini tushuntirasiz.
  • Pact va Schemathesis nima qilishini va qachon kerakligini bilasiz.
  • vazifalar-api ga test/shartnoma.test.ts va docs/openapi-snapshot.json ni qo'shasiz.

Oldin bilishingiz kerak: Backend test strategiyasi va contract testlar, API versiyalash va deprecation, Swagger: @nestjs/swagger, Asinxron komponentlarni testlash.

1. Nega bu kerak?

Backend test strategiyasi darsida vazifalar-api tarixidagi bir voqeani ko'rgansiz. Xato javobi { "xato": "…" } dan Problem Details'ga o'tdi. API testlari yashil edi — ular yangi formatni kutardi. Frontend esa eski xato maydonini o'qirdi va buni faqat brauzerdagi qo'lda sinov ko'rsatdi.

Muammo testlarning ko'pligida emas. Har ikki tomon o'z testlariga ega: backend "men to'g'ri javob beryapman" deydi, frontend "men soxta javob bilan to'g'ri ishlayapman" deydi (MSW darsidagi soxta API). Ikkalasi ham haq. Lekin ular bir xil javob haqida gapirayaptimi — buni hech kim tekshirmaydi.

O'xshatish: elektr vilkasi va rozetka. Zavod vilkani o'z standartiga ko'ra sinaydi, quruvchi rozetkani o'z standartiga ko'ra o'rnatadi. Ikkala sinov o'tadi — lekin vilka rozetkaga sig'maydi. Shartnoma testi — ikkalasini bitta o'lchov shabloni bilan tekshirish.

2. Buzuvchi o'zgarish nima

API versiyalash va deprecation darsida buzuvchi o'zgarishni (breaking change) versiya bilan boshqarishni o'rgandik. Endi uni avtomatik aniqlash kerak. Avval qaysi o'zgarish buzadi, qaysi biri yo'q — jadvalda:

O'zgarish So'rovda Javobda
Ixtiyoriy maydon qo'shish buzmaydi buzmaydi (mijoz bilmagan maydonni e'tiborsiz qoldirsa)
Majburiy maydon qo'shish buzadi — eski mijoz yubormaydi buzmaydi
Maydonni olib tashlash buzadi (sxema qat'iy bo'lsa — 422) buzadi — mijoz o'qiydi
Maydon turini o'zgartirish buzadi buzadi
Yangi endpoint buzmaydi —
Endpoint yoki parametrni olib tashlash buzadi —

Jadvalda bir nosimmetriya bor. So'rovga maydon qo'shish xavfli (agar majburiy bo'lsa), javobga qo'shish — xavfsiz. Javobdan olib tashlash xavfli, so'rovdan olib tashlash — ham xavfli, chunki bizning sxemalarimiz qat'iy: Zod 4 serverda darsidagi z.strictObject noma'lum maydonni 422 bilan rad etadi. Eski frontend olib tashlangan maydonni hamon yuborsa — xato oladi.

Javob tomonidagi "ixtiyoriy maydon qo'shish buzmaydi" faqat bir shartda to'g'ri: mijoz bag'rikeng o'quvchi (tolerant reader) bo'lsa — o'ziga keraklisini o'qib, qolganini e'tiborsiz qoldirsa. Bu shart iste'molchi shartnomasining markaziy g'oyasi, unga qaytamiz.

Tekshirib ko'ring: GET /v1/vazifalar javobidagi bajarildi maydoni boolean dan "ha" | "yo'q" satriga o'zgardi. Frontend if (v.bajarildi) deb yozilgan. Nima bo'ladi?

Javob

Hamma vazifa "bajarilgan" bo'lib ko'rinadi: "yo'q" — bo'sh bo'lmagan satr, JavaScript'da u rost (truthy). Xato ham, ogohlantirish ham chiqmaydi — eng yomon turdagi buzilish. Tur o'zgarishi shuning uchun buzuvchi: u har doim ham yiqilish bilan ko'rinmaydi.

3. Uch yondashuv

Shartnomani tekshirishning uchta mashhur yo'li bor:

Yondashuv Kim yozadi Vositalar Kanonda
Sxema surati (snapshot) provayder o'z buzilishlar(), oasdiff bor
Iste'molchi boshqaradigan (consumer-driven) iste'molchi Pact g'oyasi — Zod bilan
Xususiyatga asoslangan (property-based) hech kim — sxemadan generatsiya Schemathesis sinalmagan

Sxema surati provayder tomonida: API sxemasini faylga yozib qo'yamiz va har o'zgarishda yangi sxema bilan solishtiramiz. U "nima o'zgardi" ni ko'radi, lekin "kimga zarar" ni bilmaydi.

Iste'molchi shartnomasi teskari tomondan: iste'molchi "men aynan shu maydonlarga tayanaman" deb yozadi. Provayder istagan narsani o'zgartirishi mumkin — faqat shu ro'yxat buzilmasin.

Xususiyatga asoslangan test sxemadan minglab so'rov yasaydi (chegaraviy qiymatlar, bo'sh satr, juda katta son) va javob sxemaga mosligini va server 500 qaytarmasligini tekshiradi. U shartnomadan ko'ra ko'proq "sxema rost gapiryaptimi" ni tekshiradi.

Kanon birinchi ikkitasini kutubxonasiz qurdi. Keling, ularni ko'ramiz.

4. OpenAPI surati

4.1 Swagger hujjatidan ixcham shaklga

Swagger darsida @nestjs/swagger /docs-json manzilida OpenAPI hujjatini berishini ko'rgansiz. Unda har operatsiya uchun parametrlar, so'rov tanasi sxemasi va javoblar bor. POST /v1/vazifalar uchun bo'lagi (biz ishga tushirdik, /docs-json dan):

json
{
  "operationId": "yarat",
  "parameters": [],
  "requestBody": {
    "required": true,
    "content": {
      "application/json": {
        "schema": {
          "type": "object",
          "properties": {
            "matn": {
              "type": "string",
              "minLength": 1,
              "maxLength": 100
            },
            "bajarildi": {
              "type": "boolean"
            }
          },
          "required": ["matn"],
          "additionalProperties": false
        }
      }
    }
  },
  "responses": {
    "201": { "description": "" }
  },
  "tags": ["Vazifalar"]
}

Ikki narsaga e'tibor bering. Birinchisi: additionalProperties: false — Zod'dagi strictObject ning OpenAPI'dagi ifodasi. Ikkinchisi: "201": { "description": "" } — javob haqida hech narsa yo'q, sxemasi ham. Bunga qaytamiz.

Butun hujjatni solishtirish noqulay: unda tavsiflar, teglar, misollar bor — ular o'zgarsa, mijoz buzilmaydi. Kanon hujjatni ixcham shaklga keltiradi: har operatsiya uchun faqat shartnomaga tegishli narsa. docs/openapi-snapshot.json dan ikki yozuv:

json
{
  "GET /v1/vazifalar/{id}": {
    "parametrlar": ["path:id!"],
    "tana": null,
    "javoblar": ["200"]
  },
  "POST /v1/vazifalar": {
    "parametrlar": [],
    "tana": {
      "matn": { "tur": "\"string\"", "majburiy": true },
      "bajarildi": { "tur": "\"boolean\"", "majburiy": false }
    },
    "javoblar": ["201"]
  }
}

path:id! — undov belgisi "majburiy" degani. Butun suratda 13 ta operatsiya.

4.2 Solishtirish qoidalari

Solishtirgich buzilishlar(eski, yangi) jadvaldagi qoidalarni kodga aylantiradi. Uning soddalashtirilgan nusxasi brauzerda ishlaydi — faqat tana maydonlari va operatsiyalar bilan:

js
function buzilishlar(eski, yangi) {
  const b = [];
  for (const [op, e] of Object.entries(eski)) {
    const y = yangi[op];
    if (!y) { b.push(`${op}: olib tashlandi`); continue; }
    for (const [k, t] of Object.entries(y.tana ?? {})) {
      const et = e.tana?.[k];
      if (t.majburiy && !et?.majburiy) {
        b.push(`${op}: tana.${k} endi majburiy`);
      }
      if (et && et.tur !== t.tur) {
        b.push(`${op}: tana.${k} turi ${et.tur} → ${t.tur}`);
      }
    }
    for (const k of Object.keys(e.tana ?? {})) {
      if (!y.tana?.[k]) b.push(`${op}: tana.${k} olib tashlandi`);
    }
  }
  return b;
}

const m = (tur, majburiy) => ({ tur, majburiy });
const eski = {
  "POST /v1/vazifalar": {
    tana: { matn: m("string", true), bajarildi: m("boolean", false) },
  },
  "DELETE /v1/vazifalar/{id}": { tana: null },
};

const v1 = structuredClone(eski);
v1["POST /v1/vazifalar"].tana.muddat = m("string", false);
v1["GET /v1/statistika"] = { tana: null };
console.log("ixtiyoriy maydon + yangi endpoint:",
  buzilishlar(eski, v1));

const v2 = {
  "POST /v1/vazifalar": {
    tana: { matn: m("string", true), muddat: m("string", true) },
  },
};
console.log(buzilishlar(eski, v2));

Konsolda:

text
ixtiyoriy maydon + yangi endpoint: []
[
  'POST /v1/vazifalar: tana.muddat endi majburiy',
  'POST /v1/vazifalar: tana.bajarildi olib tashlandi',
  'DELETE /v1/vazifalar/{id}: olib tashlandi'
]

Birinchi o'zgarishda ikki narsa qo'shildi — ikkalasi ham buzmaydi, ro'yxat bo'sh. Ikkinchisida uchta buzilish: yangi majburiy maydon, olib tashlangan maydon va o'chirilgan endpoint. Solishtirish eskidan yangiga yo'nalgan: eski mijozning har kutgan narsasi yangisida bormi?

Kanon nusxasi bulardan tashqari parametrlarni (olib tashlangan, yangi majburiy) va javob kodlarini (olib tashlangan) ham tekshiradi. To'liq kod «Loyiha qadami» bo'limida.

4.3 Tajriba: buzuvchi o'zgarish testda

Endi haqiqiy loyihada sinaymiz. Tasavvur qiling: Sardor vazifa yaratishga muddat qo'shmoqchi. U src/vazifalar/sxema.ts dagi sxemani o'zgartirdi — muddat ni majburiy qildi va "endi kerak emas" deb bajarildi ni olib tashladi:

diff
 export const YangiVazifaSxemasi = z.strictObject({
-  matn: Matn, bajarildi: Bajarildi.optional(),
+  matn: Matn, muddat: z.iso.datetime(),
 });

(Faylda bu bitta qator — telefon uchun bo'lib ko'rsatdik.)

Chiqishda × belgili testlar sonini va har FAIL ostidagi birinchi qatorni o'qing — u sababni aytadi. Biz ishga tushirdik (loyiha nusxasida, keyin o'zgarishni qaytardik; chiqish qisqartirilgan):

text
   × OpenAPI snapshot: breaking o'zgarish yo'q (qo'shimchalar — ruxsat) 87ms
   ✓ solishtirgich o'zi: parametr olib tashlash, majburiy maydon, tur o'zgarishi, operatsiya o'chirish — ushlanadi 1ms
   × iste'molchi shartnomasi: ro'yxat, yaratish, olish, PATCH, 404 va 422 javoblari frontend kutgan shaklda (+ Content-Type) 75ms

 FAIL  |asosiy| test/shartnoma.test.ts > OpenAPI snapshot: breaking o'zgarish yo'q (qo'shimchalar — ruxsat)
AssertionError: Expected values to be strictly deep-equal:
+ actual - expected

+ [
+   'POST /v1/vazifalar: tana.muddat endi majburiy',
+   'POST /v1/vazifalar: tana.bajarildi olib tashlandi (strict sxemada mijoz yuborsa — 422)'
+ ]
- []

 FAIL  |asosiy| test/shartnoma.test.ts > iste'molchi shartnomasi: ro'yxat, yaratish, olish, PATCH, 404 va 422 javoblari frontend kutgan shaklda (+ Content-Type)
Error: expected 201 "Created", got 422 "Unprocessable Entity"

Ikkala qatlam ham yiqildi. Surat aniq sababni aytdi: ikki buzilish, nomi bilan. Iste'molchi shartnomasi oqibatni ko'rsatdi: frontend odatdagidek { matn } yuborganda 201 o'rniga 422 keladi.

Sardorga to'g'ri yo'l — muddat ni ixtiyoriy qilish va bajarildi ni qoldirish. Shunda ikkala test yashil bo'ladi. Agar o'zgarish haqiqatan kerak bo'lsa — yangi versiya (API versiyalash) yoki muvofiqlashtirilgan reliz.

4.4 Ataylab o'zgartirish

Yangi ixtiyoriy maydon qo'shsangiz, test o'tadi, lekin surat eskiradi. Shuning uchun suratni yangilash usuli bor:

bash
SHARTNOMA_YANGILA=1 npx vitest run test/shartnoma.test.ts
git diff docs/openapi-snapshot.json

Ikkinchi buyruq muhimroq. Surat fayli repoda — har o'zgarish PR'da ko'rinadi. Ko'rib chiquvchi docs/openapi-snapshot.json dagi farqqa qaraydi va so'raydi: "bu maydon nega majburiy bo'ldi?". Bu — Vitest II darsidagi snapshot test g'oyasi, faqat UI emas, API uchun.

Diqqat: SHARTNOMA_YANGILA=1 — buzilishni "yashirish" tugmasi ham bo'lishi mumkin. Yiqilgan testni o'qimasdan suratni yangilash — testni o'chirish bilan teng. Qoida: surat faqat ataylab qilingan, kelishilgan o'zgarishda yangilanadi.

5. Surat ko'rmagan narsa: javob

5.1 Topilma

«Swagger hujjatidan ixcham shaklga» bo'limidagi "201": { "description": "" } ni eslang. 30-qismda Swagger'ni ulaganimizda so'rov sxemalari Zod'dan avtomatik chiqdi, javob sxemalari esa yozilmadi. Natijada suratdagi javoblar — faqat status kodlari ro'yxati: ["200"]. Javob tanasining shakli umuman yo'q.

Bu nimani anglatadi? Ikkinchi tajriba. Endi xotira omborida yangi vazifaning tartib maydonini son o'rniga satr qilib qaytaramiz — kimdir bexosdan String(...) bilan o'radi:

diff
-      tartib: Math.max(0, ...tartiblar) + 1,
+      tartib: String(Math.max(0, ...tartiblar) + 1),

(Qisqartirilgan: tartiblar o'rnida faylda mavjud vazifalarning tartib qiymatlarini yig'adigan ifoda turadi.)

Bu safar qaysi test yashil qolganiga e'tibor bering. Biz ishga tushirdik (chiqish qisqartirilgan):

text
   ✓ OpenAPI snapshot: breaking o'zgarish yo'q (qo'shimchalar — ruxsat) 115ms
   ✓ solishtirgich o'zi: parametr olib tashlash, majburiy maydon, tur o'zgarishi, operatsiya o'chirish — ushlanadi 2ms
   × iste'molchi shartnomasi: ro'yxat, yaratish, olish, PATCH, 404 va 422 javoblari frontend kutgan shaklda (+ Content-Type) 87ms

 FAIL  |asosiy| test/shartnoma.test.ts > iste'molchi shartnomasi: ro'yxat, yaratish, olish, PATCH, 404 va 422 javoblari frontend kutgan shaklda (+ Content-Type)
ZodError: [
  {
    "expected": "number",
    "code": "invalid_type",
    "path": [
      "tartib"
    ],
    "message": "Noto‘g‘ri kirish: kutilgan raqam, qabul qilingan string"
  }
]

Surat yashil — u javob turini bilmaydi. Iste'molchi shartnomasi tutdi: tartib — son kutilgan, satr keldi. Xabar o'zbekcha, chunki loyiha Zod'ga o'zbek lokalini ulagan (Zod 4 serverda).

flowchart LR
  O["O'zgarish"] --> S{"Qayerda?"}
  S -->|"so'rov sxemasi"| A["Surat ushlaydi"]
  S -->|"javob shakli"| B["Surat ko'rmaydi"]
  B --> C["Iste'molchi shartnomasi ushlaydi"]
  A --> C

Diagramma kanondagi ikki qatlamning vazifasini ko'rsatadi: so'rov tomonidagi buzilishni ikkalasi ham ushlaydi (surat aniqroq), javob tomonidagisini — faqat iste'molchi shartnomasi. Ikkinchi qatlam aynan shuning uchun yozilgan.

5.2 Uni qanday yopish mumkin

Ikki yo'l bor. Birinchisi — Swagger'ga javob sxemalarini qo'shish (@ApiResponse va Zod'dan toJSONSchema). Shunda surat javob turini ham solishtirar edi. Kanonda bu qilinmagan, keyingi ish ro'yxatida turibdi. Ikkinchisi — iste'molchi shartnomasi, kanon tanlagan yo'l. Ular bir-birini almashtirmaydi: javob sxemasi provayderning va'dasi, iste'molchi shartnomasi — iste'molchining talabi.

6. Iste'molchi shartnomasi

6.1 Frontend nimaga tayanadi

22-qismdagi vazifalar frontendi har vazifadan sakkiz maydon o'qiydi: id, matn, bajarildi, teglar, yaratildi, yangilandi, tartib, versiya. Ro'yxatdan — vazifalar va sahifa.limit. Xatodan — type, title, status, ixtiyoriy detail. Iste'molchi shartnomasi aynan shu ro'yxat, boshqa hech narsa:

ts
const Vazifa = z.looseObject({
  id: z.int().positive(),
  matn: z.string(),
  bajarildi: z.boolean(),
  teglar: z.array(z.string()),
  yaratildi: z.iso.datetime(),
  yangilandi: z.iso.datetime(),
  tartib: z.int(),
  versiya: z.int().positive(),
});

API javobida bulardan ko'proq maydon bor (masalan muddat, ustuvorlik). Shartnomada ular yo'q — frontend ularga tayanmaydi, demak ular o'zgarsa ham hech narsa buzilmaydi.

6.2 Nega looseObject

Zod'da obyektning uch xil sxemasi bor. Farqni haqiqiy Zod bilan ko'ramiz:

ts
import { z } from "zod";

const shakl = { id: z.int(), matn: z.string() };
const javob = { id: 7, matn: "Non olish", muddat: null };

const qattiq = z.strictObject(shakl).safeParse(javob);
console.log("strictObject:", qattiq.success,
  qattiq.error?.issues[0]?.code);
console.log("object:", z.object(shakl).parse(javob));
console.log("looseObject:", z.looseObject(shakl).parse(javob));

Konsolda:

text
strictObject: false unrecognized_keys
object: { id: 7, matn: 'Non olish' }
looseObject: { id: 7, matn: 'Non olish', muddat: null }

strictObject bilan yozilgan iste'molchi shartnomasi provayder yangi maydon qo'shgan zahoti yiqiladi — garchi bu buzmaydigan o'zgarish bo'lsa ham. Shartnoma o'zi "yolg'on signal" beradi va jamoa unga ishonmay qo'yadi. object ortiqcha maydonni jim olib tashlaydi, looseObject qoldiradi. Tekshiruv uchun ikkalasi ham to'g'ri. Kanon looseObject ni tanladi: shartnomaning ma'nosi ochiq ko'rinsin — "boshqa maydonlar bo'lishi mumkin".

Qoida qisqa: provayder qabul qilishda qat'iy, iste'molchi o'qishda bag'rikeng. Server noma'lum maydonli so'rovni 422 bilan rad etadi (strictObject). Mijoz esa javobdagi noma'lum maydonni e'tiborsiz qoldiradi (looseObject).

Intervyuda bu mavzu Postel qonuni (robustness principle) nomi bilan so'raladi: "yuborishda qat'iy, qabul qilishda bag'rikeng bo'l". Bir qarashda bizning server bu qonunga zid: u so'rovni qat'iy qabul qiladi. Ziddiyat yo'q — ikki xil narsa qabul qilinadi. Server buyruq qabul qiladi: noma'lum maydonni jim tashlab yuborsa, mijoz xatosi (masalan, muddat o'rniga mudat) sezilmay qoladi. Mijoz esa ma'lumot o'qiydi: ortiqcha maydon unga zarar bermaydi. Shuning uchun bag'rikenglik o'quvchi tomonda, qat'iylik buyruq qabul qiluvchi tomonda.

Tekshirib ko'ring: 31-qismda xato javoblaridagi title inglizchadan o'zbekchaga o'zgardi: "Not Found" o'rniga "Topilmadi". 25-qismdagi uchta test kutuvi yangilandi. Iste'molchi shartnomasi uchun bu buzuvchi o'zgarishmi?

Javob

Yo'q. Shartnomada title: z.string() — tur va borligi talab qilinadi, qiymat emas. Frontend title ni foydalanuvchiga ko'rsatadi, uning matniga qarab qaror qilmaydi — qaror uchun type va status bor (Problem Details). Shuning uchun 25-qism darslarida ko'rgan inglizcha title lar endi o'zbekcha chiqadi va bu kutilgan. Agar frontend if (x.title === "Not Found") deb yozilgan bo'lsa edi — o'zgarish uni buzardi. Shartnoma faqat tekshirgan narsasini himoya qiladi.

6.3 Problem javoblari va Content-Type

Shartnoma maydonlardan tashqari sarlavhani ham tekshiradi: 404 va 422 javoblari application/problem+json turida kelishi kerak. Frontend xatoni shu tur bo'yicha taniydi. 422 uchun qo'shimcha talab — xatolar massivi (joy, xabar): forma qaysi maydon yonida xato ko'rsatishini shundan biladi.

7. Pact va Schemathesis

7.1 Pact

Kanondagi iste'molchi shartnomasi bitta kamchilikka ega: uni backend repo'sida backend dasturchisi yozgan. Haqiqiy iste'molchi boshqaradigan shartnomada uni frontend yozadi. Pact shu ish oqimini quradi. Rasmiy hujjat bo'yicha (sinalmagan, 2026-11):

sequenceDiagram
  participant F as Frontend testi
  participant P as Pact fayli
  participant B as Broker
  participant A as API (provayder)
  F->>P: kutilgan so'rov va javob
  P->>B: nashr
  A->>B: shartnomalarni olish
  A->>A: so'rovlarni o'ziga yuborish
  A->>B: natija

Diagrammani yuqoridan o'qing. Frontend testida Pact soxta server ko'taradi: "shu so'rovga shunday javob kutaman". Test o'tsa, kutilganlar JSON faylga (pact) yoziladi va broker ga — umumiy omborga — yuboriladi. API'ning CI'si brokerdan hamma iste'molchilar shartnomalarini oladi va ularni haqiqiy serverga qarshi "o'ynaydi". Biror shartnoma buzilsa, API deploy qilinmaydi. Brokerdagi "can I deploy?" tekshiruvi aynan shu savolga javob beradi.

Pact qachon kerak: iste'molchilar ko'p (veb, mobil, boshqa jamoalarning xizmatlari) va ular alohida jamoalar bo'lsa. vazifalar da bitta frontend va bitta jamoa — shuning uchun kanon g'oyani kutubxonasiz, bitta test faylida qo'lladi.

7.2 Schemathesis

Schemathesis — OpenAPI hujjatini o'qib, o'zi test so'rovlari yasaydigan vosita (Python'da yozilgan, CLI sifatida ishlaydi). U xususiyatga asoslangan test (property-based testing, Chegaraviy holatlar va algoritmni testlash) yondashuvidan foydalanadi: "har qanday to'g'ri so'rovga server 500 qaytarmasin va javob hujjatdagi sxemaga mos bo'lsin". Sxemada maxLength: 100 bo'lsa, u 100 va 101 belgili satrlarni ham, bo'sh satrni ham, Unicode belgilarni ham yuboradi. Kanon uni sinamagan (hujjat bo'yicha, 2026-11).

Bizning loyihada bitta jiddiy to'siq bor: javob sxemalari yo'q. Schemathesis javobni tekshira olmaydi — faqat "500 yo'q" va so'rov sxemasini sinay oladi. Avval «Uni qanday yopish mumkin» bo'limidagi birinchi yo'l kerak.

Vosita Nima beradi Qachon
Sxema surati aniq "nima o'zgardi" ro'yxati har doim, arzon
Iste'molchi shartnomasi (Zod) "frontend buziladimi" bitta iste'molchi
Pact ko'p iste'molchi, alohida jamoalar mikroservislar
Schemathesis sxema va kod mosligi, chegaraviy kirishlar javob sxemalari bor bo'lsa

8. Ko'p uchraydigan xatolar

Xato Oqibati To'g'risi
Iste'molchi shartnomasini strictObject bilan har yangi maydonda yolg'on yiqilish looseObject yoki object
Yiqilgan suratni o'qimay yangilash buzilish prodakshnga ketadi farqni PR'da ko'rib chiqish
Butun OpenAPI hujjatini solishtirish tavsif o'zgarsa ham yiqiladi faqat shartnomaga tegishli qismni
"Swagger bor — shartnoma tekshirilgan" javob sxemasi bo'lmasa, javob tekshirilmaydi javob sxemasi yoki iste'molchi shartnomasi
Shartnomada hamma maydon provayder hech narsani o'zgartira olmaydi faqat iste'molchi ishlatadigan maydonlar
Qiymatni shartnomaga kiritish (title: "Not Found") matn o'zgarsa, test buziladi tur va borligi; qaror — type/status bo'yicha

9. Mashqlar

Mashqlar kurs/mashqlar/31/32-shartnoma/ da. 2- va 3-mashq yechimlari Node 24.21.0 da tekshirilgan (biz ishga tushirdik).

1-mashq (oson): buzadimi?

  • Javobga yangi muddat maydoni qo'shildi — buzuvchi o'zgarish:
  • So'rovda teg query parametri olib tashlandi — buzuvchi:
  • Iste'molchi shartnomasi uchun Zod'dagi bag'rikeng obyekt — z.
  • Kanondagi suratni ataylab yangilash o'zgaruvchisi — SHARTNOMA_=1
  • Iste'molchilar shartnomalarini saqlaydigan umumiy ombor Pact'da —
Yechim
  • yo'q — bag'rikeng mijoz bilmagan maydonni e'tiborsiz qoldiradi.
  • ha — uni yuborgan eski mijoz 422 oladi (qat'iy sxema) yoki filtr ishlamay qoladi.
  • looseObject — noma'lum maydonlar qoladi va xato bermaydi.
  • YANGILA — SHARTNOMA_YANGILA=1 npx vitest run test/shartnoma.test.ts.
  • broker — API CI'si shartnomalarni shundan oladi.

2-mashq (o'rta): parametrlarni solishtirish

Kanon suratida parametrlar "query:teg", "path:id!" ko'rinishida saqlanadi (! — majburiy). parametrBuzilishlari(eski, yangi) funksiyasini yozing: olib tashlangan parametr (majburiy belgisi olib tashlangan bo'lsa ham — bu buzilish emas) va yangi majburiy parametr. Ishora: p.replace(/!$/, ""), endsWith("!"), includes (Matn ichida qidirish).

Yechim
js
function parametrBuzilishlari(eski, yangi) {
  const b = [];
  for (const p of eski) {
    const qoldi = yangi.includes(p) ||
      yangi.includes(p.replace(/!$/, ""));
    if (!qoldi) b.push(`parametr ${p} olib tashlandi`);
  }
  for (const p of yangi) {
    if (p.endsWith("!") && !eski.includes(p)) {
      b.push(`yangi MAJBURIY parametr ${p}`);
    }
  }
  return b;
}

const eski = ["query:teg", "query:limit", "query:q!"];
console.log(parametrBuzilishlari(eski,
  ["query:teg", "query:limit", "query:q", "query:sort"]));
console.log(parametrBuzilishlari(eski,
  ["query:limit", "query:q!", "query:sahifa!"]));

Konsolda:

text
[]
[
  'parametr query:teg olib tashlandi',
  'yangi MAJBURIY parametr query:sahifa!'
]

Birinchi holatda q majburiydan ixtiyoriyga o'tdi va ixtiyoriy sort qo'shildi — ikkalasi ham eski mijozni buzmaydi. Kanondagi buzilishlar() aynan shu qoidalarni ishlatadi.

3-mashq (qiyin): iste'molchi shartnomasi paketsiz

Zod'siz kichik tekshirgich yozing: shartnoma(sxema, javob) — sxema { maydon: "string" | "number" | "boolean" | "array" } ko'rinishida; har talab qilingan maydon bor va turi mos bo'lsin, ortiqcha maydonlar — ruxsat. Natija — xatolar ro'yxati ("tartib: number kutilgan, string keldi"). Ikkita javobni tekshiring: to'g'ri va tartib satr bo'lgan. Ishora: typeof, Array.isArray.

Yechim
js
function tur(q) {
  if (Array.isArray(q)) return "array";
  return q === null ? "null" : typeof q;
}

function shartnoma(sxema, javob) {
  const xatolar = [];
  for (const [k, kutilgan] of Object.entries(sxema)) {
    if (!(k in javob)) { xatolar.push(`${k}: yo'q`); continue; }
    const keldi = tur(javob[k]);
    if (keldi !== kutilgan) {
      xatolar.push(`${k}: ${kutilgan} kutilgan, ${keldi} keldi`);
    }
  }
  return xatolar;
}

const Vazifa = {
  id: "number", matn: "string", bajarildi: "boolean",
  teglar: "array", tartib: "number",
};
const togri = {
  id: 7, matn: "Non olish", bajarildi: false,
  teglar: ["uy"], tartib: 3, muddat: null,
};
console.log(shartnoma(Vazifa, togri));
console.log(shartnoma(Vazifa, { ...togri, tartib: "3" }));
console.log(shartnoma(Vazifa, { id: 7, matn: "x" }));

Konsolda:

text
[]
[ 'tartib: number kutilgan, string keldi' ]
[ "bajarildi: yo'q", "teglar: yo'q", "tartib: yo'q" ]

Ortiqcha muddat: null xato bermadi — bu bag'rikeng o'quvchi. typeof [] "object" qaytaradi, shuning uchun massiv alohida tekshirildi. Uchinchi qatordagi xabarlar qo'shtirnoqda chiqdi — ichida apostrof bor.

10. Loyiha qadami: test/shartnoma.test.ts

Loyihaning yakuniy bosqichida ikkinchi fayl. U ikki narsa qo'shadi:

  1. test/shartnoma.test.ts — uchta test: OpenAPI surati, solishtirgichning o'z testi va iste'molchi shartnomasi.
  2. docs/openapi-snapshot.json — surat. Uni qo'lda yozmaysiz: test birinchi ishga tushganda fayl yo'q bo'lsa, o'zi yaratadi.

Test ilovani xotira ombori bilan ko'taradi (XotiraOmbori) — baza kerak emas, Supertest so'rovlari jarayon ichida ketadi.

test/shartnoma.test.ts (kanon fayli; qatorlar telefon uchun bo'lingan, ba'zi uzun ifodalar oraliq o'zgaruvchiga ajratilgan — mantiq aynan; holat nusxasida tsc, ESLint va Vitest bilan tekshirildi):

ts
// shartnoma.test.ts — shartnoma (contract) testlari.
// (1) OpenAPI SURATI (/docs-json) — docs/openapi-snapshot.json bilan
//     solishtiriladi: olib tashlangan operatsiya/parametr, yangi
//     MAJBURIY maydon, tur o'zgarishi — BREAKING (test yiqiladi);
//     qo'shimchalar — ruxsat. Ataylab o'zgartirilganda:
//     SHARTNOMA_YANGILA=1 npx vitest run test/shartnoma.test.ts
// (2) ISTE'MOLCHI shartnomasi (Pact g'oyasi, kutubxonasiz): frontend
//     (22-qism) TAYANADIGAN javob shakllari — Zod bilan.
//     Swagger hujjatida javob sxemalari YO'Q — shuning uchun (2).
import { test } from "vitest";
import assert from "node:assert/strict";
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import request from "supertest";
import { z } from "zod";
import { expressIlova } from "./yordamchi.ts";
import { XotiraOmbori } from "../src/vazifalar/xotira-ombor.ts";

type Param = { name: string; in: string; required?: boolean };
type Hujjat = {
  paths: Record<string, Record<string, {
    parameters?: Param[];
    requestBody?: { content: Record<string, { schema: unknown }> };
    responses: Record<string, unknown>;
  }>>;
};
type Maydon = { tur: string; majburiy: boolean };
type Operatsiya = {
  parametrlar: string[];
  tana: Record<string, Maydon> | null;
  javoblar: string[];
};
type Sxema = {
  properties?: Record<string, { type?: unknown }>;
  required?: string[];
};

const SNAPSHOT =
  new URL("../docs/openapi-snapshot.json", import.meta.url);

/** OpenAPI → ixcham shakl: "METOD /yo'l" → parametrlar,
 *  tana maydonlari (tur, majburiy), javob kodlari */
export function normallashtir(h: Hujjat): Record<string, Operatsiya> {
  const n: Record<string, Operatsiya> = {};
  for (const [yol, ops] of Object.entries(h.paths)) {
    for (const [metod, o] of Object.entries(ops)) {
      const kontent = Object.values(o.requestBody?.content ?? {});
      const s = kontent[0]?.schema as Sxema | undefined;
      const majburiy = s?.required ?? [];
      n[`${metod.toUpperCase()} ${yol}`] = {
        parametrlar: (o.parameters ?? [])
          .map((p) =>
            `${p.in}:${p.name}${p.required === true ? "!" : ""}`)
          .sort(),
        tana: s?.properties === undefined
          ? null
          : Object.fromEntries(
            Object.entries(s.properties).map(([k, v]) => [k, {
              tur: JSON.stringify(v.type ?? "?"),
              majburiy: majburiy.includes(k),
            }]),
          ),
        javoblar: Object.keys(o.responses).sort(),
      };
    }
  }
  return n;
}

/** eski → yangi: breaking o'zgarishlar ro'yxati (bo'sh — mos) */
export function buzilishlar(
  eski: Record<string, Operatsiya>,
  yangi: Record<string, Operatsiya>,
): string[] {
  const b: string[] = [];
  for (const [op, e] of Object.entries(eski)) {
    const y = yangi[op];
    if (y === undefined) {
      b.push(`${op}: olib tashlandi`);
      continue;
    }
    for (const p of e.parametrlar) {
      const qoldi = y.parametrlar.includes(p) ||
        y.parametrlar.includes(p.replace(/!$/, ""));
      if (!qoldi) b.push(`${op}: parametr ${p} olib tashlandi`);
    }
    for (const p of y.parametrlar) {
      if (p.endsWith("!") && !e.parametrlar.includes(p)) {
        b.push(`${op}: yangi MAJBURIY parametr ${p}`);
      }
    }
    for (const [k, t] of Object.entries(y.tana ?? {})) {
      const et = e.tana?.[k];
      if (t.majburiy && et?.majburiy !== true) {
        b.push(`${op}: tana.${k} endi majburiy`);
      }
      if (et !== undefined && et.tur !== t.tur) {
        b.push(`${op}: tana.${k} turi ${et.tur} → ${t.tur}`);
      }
    }
    for (const k of Object.keys(e.tana ?? {})) {
      if (y.tana?.[k] === undefined) {
        b.push(`${op}: tana.${k} olib tashlandi ` +
          "(strict sxemada mijoz yuborsa — 422)");
      }
    }
    for (const j of e.javoblar) {
      if (!y.javoblar.includes(j)) {
        b.push(`${op}: javob ${j} olib tashlandi`);
      }
    }
  }
  return b;
}

const app = await expressIlova({ ombor: new XotiraOmbori() });

test("OpenAPI snapshot: breaking o'zgarish yo'q " +
  "(qo'shimchalar — ruxsat)", async () => {
  const javob = await request(app).get("/docs-json").expect(200);
  const joriy = normallashtir(javob.body as Hujjat);
  const yangila = process.env["SHARTNOMA_YANGILA"] === "1";
  if (yangila || !existsSync(SNAPSHOT)) {
    writeFileSync(SNAPSHOT, `${JSON.stringify(joriy, null, 2)}\n`);
  }
  const eski = JSON.parse(readFileSync(SNAPSHOT, "utf8")) as
    Record<string, Operatsiya>;
  assert.deepEqual(buzilishlar(eski, joriy), []);
  const yangi = Object.keys(joriy).filter((k) => !(k in eski));
  assert.ok(Array.isArray(yangi)); // yangi operatsiya — breaking emas
});

test("solishtirgich o'zi: parametr olib tashlash, majburiy maydon, " +
  "tur o'zgarishi, operatsiya o'chirish — ushlanadi", () => {
  const str = (majburiy: boolean) =>
    ({ tur: "\"string\"", majburiy });
  const eski: Record<string, Operatsiya> = {
    "GET /v1/vazifalar": {
      parametrlar: ["query:teg", "query:limit"],
      tana: null, javoblar: ["200"],
    },
    "POST /v1/vazifalar": {
      parametrlar: [],
      tana: { matn: str(true), muddat: str(false) },
      javoblar: ["201"],
    },
    "DELETE /v1/vazifalar/{id}": {
      parametrlar: ["path:id!"], tana: null, javoblar: ["204"],
    },
  };
  const yangi: Record<string, Operatsiya> = {
    "GET /v1/vazifalar": {
      parametrlar: ["query:limit", "query:sahifa!"],
      tana: null, javoblar: ["200"],
    },
    "POST /v1/vazifalar": {
      parametrlar: [],
      tana: {
        matn: str(true),
        muddat: { tur: "\"integer\"", majburiy: true },
      },
      javoblar: ["201"],
    },
  };
  assert.deepEqual(buzilishlar(eski, yangi).sort(), [
    "DELETE /v1/vazifalar/{id}: olib tashlandi",
    "GET /v1/vazifalar: parametr query:teg olib tashlandi",
    "GET /v1/vazifalar: yangi MAJBURIY parametr query:sahifa!",
    "POST /v1/vazifalar: tana.muddat endi majburiy",
    "POST /v1/vazifalar: tana.muddat turi " +
      "\"string\" → \"integer\"",
  ].sort());
});

// Frontend (22-qism) tayanadigan maydonlar — ortiqcha maydon ruxsat
// (looseObject), lekin bular SHART va turi aynan
const Vazifa = z.looseObject({
  id: z.int().positive(),
  matn: z.string(),
  bajarildi: z.boolean(),
  teglar: z.array(z.string()),
  yaratildi: z.iso.datetime(),
  yangilandi: z.iso.datetime(),
  tartib: z.int(),
  versiya: z.int().positive(),
});
const Royxat = z.looseObject({
  versiya: z.number(),
  vazifalar: z.array(Vazifa),
  sahifa: z.looseObject({ limit: z.int() }),
});
const Problem = z.looseObject({
  type: z.string(),
  title: z.string(),
  status: z.int(),
  detail: z.string().optional(),
});
const ProblemXatolar = Problem.extend({
  xatolar: z.array(z.looseObject({
    joy: z.string(), xabar: z.string(),
  })),
});
const PROBLEM = /application\/problem\+json/;

test("iste'molchi shartnomasi: ro'yxat, yaratish, olish, PATCH, " +
  "404 va 422 javoblari frontend kutgan shaklda (+ Content-Type)",
async () => {
  const yarat = await request(app).post("/v1/vazifalar")
    .send({ matn: "Shartnoma #test" }).expect(201);
  const v = Vazifa.parse(yarat.body);
  const yol = `/v1/vazifalar/${String(v.id)}`;
  Vazifa.parse((await request(app).get(yol).expect(200)).body);
  Royxat.parse((await request(app)
    .get("/v1/vazifalar?limit=5").expect(200)).body);
  Vazifa.parse((await request(app).patch(yol)
    .set("Content-Type", "application/merge-patch+json")
    .send({ bajarildi: true }).expect(200)).body);
  const yoq = await request(app).get("/v1/vazifalar/99999")
    .expect(404).expect("Content-Type", PROBLEM);
  Problem.parse(yoq.body);
  const notogri = await request(app).post("/v1/vazifalar")
    .send({ matn: "" }).expect(422).expect("Content-Type", PROBLEM);
  assert.ok(ProblemXatolar.safeParse(notogri.body).success);
});

Fayl tuzilishi bo'yicha qisqa yo'riqnoma:

  • normallashtir — har operatsiya uchun parametrlar (saralangan — tartib o'zgarsa yolg'on farq chiqmasin), tana maydonlari va javob kodlari. Tur JSON.stringify bilan satrga aylanadi: "string" va ["string","null"] farqlanadi.
  • buzilishlar — eskidan yangiga: eski mijoz kutgan hamma narsa joyidami.
  • Ikkinchi test solishtirgichning o'zini tekshiradi. Bu muhim: buzilishlar() da xato bo'lsa, birinchi test har doim yashil bo'lardi — "hech narsa buzilmadi" va "solishtirgich ishlamayapti" bir xil ko'rinadi. Testni tekshiruvchi test.
  • Uchinchi test — iste'molchi shartnomasi. U oddiy javoblarni ham, xato javoblarini ham tekshiradi.

Ikki funksiya export qilingan: keyinchalik ularni CI skriptida ham ishlatish mumkin (masalan, ikki git versiyasi orasidagi suratlarni solishtirish).

Ishga tushiring:

bash
npx vitest run test/shartnoma.test.ts --reporter=verbose

Biz ishga tushirdik (bazasiz, qatorlar qisqartirilgan):

text
 ✓ |asosiy| test/shartnoma.test.ts > OpenAPI snapshot: breaking o'zgarish yo'q (qo'shimchalar — ruxsat) 74ms
 ✓ |asosiy| test/shartnoma.test.ts > solishtirgich o'zi: parametr olib tashlash, majburiy maydon, tur o'zgarishi, operatsiya o'chirish — ushlanadi 1ms
 ✓ |asosiy| test/shartnoma.test.ts > iste'molchi shartnomasi: ro'yxat, yaratish, olish, PATCH, 404 va 422 javoblari frontend kutgan shaklda (+ Content-Type) 148ms
 Test Files  1 passed (1)
      Tests  3 passed (3)

Birinchi ishga tushishda docs/openapi-snapshot.json paydo bo'ladi — uni ham repoga qo'shasiz. Ichini oching: 13 ta operatsiya, har birida parametrlar, tana va javoblar.

TESTLAR.md ga bir qator qo'shing: test/shartnoma.test.ts — OpenAPI surati va iste'molchi shartnomasi; ataylab o'zgartirganda SHARTNOMA_YANGILA=1.

Hali commit qilmang — keyingi darsda loyihaning yuklama ostidagi xulqini o'lchaymiz, commit esa Test arxitekturasi va CI darsida.

11. Real ishda

  • Qayerda uchraydi: mobil ilovasi bor har jamoada — eski versiya foydalanuvchi telefonida oylab yashaydi va API'ning har buzuvchi o'zgarishi uni sindiradi. Mikroservislarda — har xizmat boshqasining iste'molchisi.
  • Vositalar: Pact va Pact Broker (PactFlow), oasdiff va openapi-diff (OpenAPI versiyalarini solishtirish), Schemathesis, Dredd; GraphQL'da sxema farqi — GraphQL Inspector. Ularning hech biri kanonda sinalmagan.
  • Intervyu savollari: "Buzuvchi o'zgarishni qanday aniqlaysiz?", "Consumer-driven contract nima va qachon kerak?", "API'ga maydon qo'shish xavfsizmi?", "Postel qonuni (tolerant reader) nima?"
  • Jamoada: shartnoma testi yiqilganda birinchi savol "testni qanday o'chiramiz?" emas, "qaysi iste'molchi buziladi?". Javob aniq bo'lsa — yo o'zgarish ixtiyoriy shaklga keltiriladi, yo yangi versiya chiqariladi va eski versiyaning tugash sanasi e'lon qilinadi.

Xulosa

  • Shartnoma testi ikki tomon bir xil javob haqida gapirayotganini tekshiradi; o'z testlari buni ko'rmaydi.
  • So'rovga majburiy maydon qo'shish, maydon yoki parametrni olib tashlash, turni o'zgartirish — buzadi; ixtiyoriy maydon va yangi endpoint — buzmaydi.
  • OpenAPI surati "nima o'zgardi" ni aniq aytadi; kanonda u javob turini ko'rmaydi — Swagger'da javob sxemalari yo'q.
  • Iste'molchi shartnomasi — faqat frontend ishlatadigan maydonlar, looseObject bilan: provayder qabulda qat'iy, iste'molchi o'qishda bag'rikeng.
  • Pact — ko'p iste'molchi va alohida jamoalar uchun; Schemathesis — sxemadan avtomatik so'rovlar (javob sxemalari kerak).

Keyingi dars: Yuklama va stress testlari — vazifalar-api qancha yukni ko'taradi, to'yinish nuqtasi qayerda va 31-qism qo'shgan qatlamlar har so'rovga qancha narx qo'shdi.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Contract va API sxema testlari: OpenAPI surati, iste'molchi shartnomasi, Pact va Schemathesis — IlmHamroh