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

Asinxron komponentlarni testlash: soxta soat, navbat, cron, webhook va email testlari

Qisqacha: Asinxron komponent — natijasi "keyinroq" chiqadigan kod: navbatdagi ish, kechasi ishlaydigan cron, 30 soniyalik kesh, 5 soniyadan keyingi qayta urinish, kelgan email. Uni testlashning uchta yo'li bor: ishlovchini to'g'ridan chaqirish (navbat va jadvalsiz), soxta soat (vaqtni test suradi) va kutib tekshirish (haqiqiy Redis, Mailpit bilan — natija chiqguncha qisqa oraliqda so'rab turish). Kanonning yangi asinxron.test.ts fayli 30 soniyalik keshni va 5 soniyalik qayta urinishni soxta soat bilan millisekundlarda tekshiradi. Asosiy tuzoq: await bor kodda vaqtni advanceTimersByTime emas, advanceTimersByTimeAsync bilan surish kerak.

Bu darsda

  • Asinxron komponentni testlashning uch yo'lini farqlaysiz va har biriga misol keltirasiz.
  • advanceTimersByTime va advanceTimersByTimeAsync farqini haqiqiy Vitest chiqishida ko'rasiz.
  • BullMQ ishchisini, cron ishini va webhook imzosini qanday testlashni bilasiz.
  • Email va realtime hodisalarni "kutib tekshirish" bilan, beqaror testsiz tekshirasiz.
  • vazifalar-api ga test/asinxron.test.ts ni qo'shasiz.

Oldin bilishingiz kerak: Vitest II: mock, spy, taymerlar va coverage, Vaqt va tasodifni boshqarish, BullMQ asoslari, Test strategiyasi va flaky testlar.

31-qismning so'nggi to'rt darsi — test haqida. Prodakshn naqshlarini qurdik: kesh, kvota, qayta urinish, cron, navbat. Endi ularni ishonchli tekshiramiz. Bu darsda vaqtga va fon jarayonlariga bog'liq kod, keyingilarida — API shartnomasi, yuklama va butun test tizimi.

1. Nega bu kerak?

vazifalar-api da "keyinroq" bo'ladigan ishlar ko'p:

  • muddat yaqinlashsa, navbatdagi ish 30 daqiqa oldin eslatma yuboradi;
  • har kuni 04:15 da ishchi eski ma'lumotni tozalaydi;
  • feature flag'lar 30 soniya keshda turadi;
  • AI provayderi 429 qaytarsa, Retry-After: 5 ga ko'ra 5 soniyadan keyin qayta uriniladi;
  • Telegram bot dialog holatini Redis'ga yozadi.

Oddiy test bularning hammasida qoqiladi. Haqiqiy 5 soniya kutsangiz, 340 ta testli to'plam soatlab yuradi. Kutmasangiz, natijani tekshira olmaysiz. "Taxminan 100 ms kutamiz" desangiz, test yuklangan mashinada yiqiladi — beqaror (flaky) test tug'iladi.

O'xshatish: oshxonada palov 40 daqiqa damlanadi. Oshpazni tekshiruvchi inspektor 40 daqiqa kutib o'tirmaydi. U yo retseptni o'qiydi (ishlovchini to'g'ridan chaqirish), yo soatni o'zi buradi (soxta soat), yo har 5 daqiqada qozonga qaraydi va tayyor bo'lganda yozadi (kutib tekshirish). Uchalasi ham to'g'ri — vaziyatga qarab.

2. Uch yo'l

flowchart TB
  S{"Nimani tekshiramiz?"}
  S -->|"ishning mantig'i"| A["Ishlovchini to'g'ridan chaqirish"]
  S -->|"vaqt: TTL, backoff"| B["Soxta soat"]
  S -->|"haqiqiy tashqi tizim"| C["Kutib tekshirish"]
  A --> A1["eslatmaIshlovi(d)(ish)"]
  B --> B1["advanceTimersByTimeAsync"]
  C --> C1["kutib: har 50 ms, chegara 8 s"]

Diagrammani yuqoridan o'qing: birinchi savol — "aynan nimani tekshiryapman?". Ishning ichidagi mantiq bo'lsa — navbat ham, Redis ham kerak emas. Vaqtning o'zi (kesh muddati, kutish) bo'lsa — soxta soat. Navbat haqiqatan ishni ishchiga yetkazadimi, email haqiqatan jo'nadimi — buni faqat haqiqiy tizim va kutib tekshirish ko'rsatadi.

Yo'l Tezlik Nimani isbotlaydi Nimani isbotlamaydi
Ishlovchini chaqirish millisekund mantiq to'g'ri navbat, jadval, ulanish
Soxta soat millisekund vaqt chegaralari aniq haqiqiy I/O
Kutib tekshirish soniyalar butun zanjir ishlaydi aniq vaqt chegarasi

Kanon uchalasini ham ishlatadi. Keling, har birini kanondagi haqiqiy test bilan ko'ramiz.

3. Soxta soat: await tuzog'i

3.1 Eslatma

Soxta taymerlarni Vitest II darsida debounce bilan, Vaqt va tasodifni boshqarish darsida mock.timers bilan ishlatgansiz. U yerdagi kod sinxron edi: taymer qo'yildi, vaqt surildi, callback ishladi. Backend kodi esa deyarli doim async. Qayta urinishning eng oddiy shakli:

ts
const kut = (ms: number) =>
  new Promise((ok) => setTimeout(ok, ms));

// Birinchi urinish yiqiladi, 5 s kutib ikkinchisi o'tadi
async function qaytaUrin(ish: () => Promise<string>) {
  try {
    return await ish();
  } catch {
    await kut(5000);
    return ish();
  }
}

Shu funksiyani soxta soat bilan ikki xil testlaymiz: vaqtni advanceTimersByTime (sinxron) va advanceTimersByTimeAsync (asinxron) bilan suramiz.

3.2 Haqiqiy Vitest chiqishi

ts
function ishYasa() {
  let n = 0;
  return () => (++n === 1
    ? Promise.reject(new Error("429"))
    : Promise.resolve("ok"));
}

beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });

test("sinxron surish: taymer hali qo'yilmagan", async () => {
  let tugadi = false;
  void qaytaUrin(ishYasa()).then(() => { tugadi = true; });
  vi.advanceTimersByTime(5000);
  await Promise.resolve();
  console.log("navbatdagi taymerlar:", vi.getTimerCount());
  expect(tugadi).toBe(true);
});

test("asinxron surish: mikrovazifalar ham bajariladi", async () => {
  let tugadi = false;
  void qaytaUrin(ishYasa()).then(() => { tugadi = true; });
  await vi.advanceTimersByTimeAsync(5000);
  console.log("navbatdagi taymerlar:", vi.getTimerCount());
  expect(tugadi).toBe(true);
});

Chiqishda ikki narsaga qarang: har testdagi navbatdagi taymerlar soni va pastdagi × / ✓ belgilari. Biz ishga tushirdik (Vitest 5.0.5, Node 24.21.0; chiqish qisqartirilgan):

text
stdout | test/demo31.test.ts > sinxron surish: taymer hali qo'yilmagan
navbatdagi taymerlar: 1

stdout | test/demo31.test.ts > asinxron surish: mikrovazifalar ham bajariladi
navbatdagi taymerlar: 0

 × |asosiy| test/demo31.test.ts > sinxron surish: taymer hali qo'yilmagan 14ms
   → expected false to be true // Object.is equality
 ✓ |asosiy| test/demo31.test.ts > asinxron surish: mikrovazifalar ham bajariladi 2ms

Birinchi test 5 000 ms surdi, lekin ish tugamadi va navbatda bitta taymer qoldi. Ikkinchisida navbat bo'sh, ish tugagan.

3.3 Nega shunday

Vaqtni surgan lahzada kut(5000) taymeri hali qo'yilmagan edi. Ketma-ketlikni kuzating:

  1. qaytaUrin chaqirildi, ish() rad etilgan Promise qaytardi.
  2. catch bloki darhol emas, mikrovazifa (microtask) sifatida ishlaydi — joriy sinxron kod tugagach (Microtask va macrotask chuqur darsidagi navbat).
  3. vi.advanceTimersByTime(5000) esa sinxron: u soatni shu zahoti suradi. Bu paytda navbatda birorta taymer yo'q — surish hech narsani ishga tushirmaydi.
  4. Keyin catch ishlaydi va setTimeout(ok, 5000) qo'yadi — endi yangi soat bo'yicha, yana 5 soniya keyinga.

advanceTimersByTimeAsync har taymerdan oldin va keyin mikrovazifalarga navbat beradi. catch ulguradi, taymer qo'yiladi, keyin surish uni ishga tushiradi.

Buni paketsiz modelda ko'ramiz. SoxtaSoat — Vitest soxta soatining juda soddalashtirilgan nusxasi: taymerlar ro'yxati va ikki xil "surish":

js
const haqiqiyTimeout = setTimeout;

class SoxtaSoat {
  hozir = 0;
  navbat = [];
  setTimeout(fn, ms) {
    this.navbat.push({ vaqt: this.hozir + ms, fn });
  }
  #keyingi(oxiri) {
    this.navbat.sort((a, b) => a.vaqt - b.vaqt);
    const t = this.navbat[0];
    if (!t || t.vaqt > oxiri) return undefined;
    return this.navbat.shift();
  }
  sur(ms) {
    const oxiri = this.hozir + ms;
    for (let t; (t = this.#keyingi(oxiri)); ) {
      this.hozir = t.vaqt;
      t.fn();
    }
    this.hozir = oxiri;
  }
  async surAsync(ms) {
    const oxiri = this.hozir + ms;
    const bosh = () => new Promise((r) => haqiqiyTimeout(r, 0));
    await bosh(); // mikrovazifalar ishlab olsin
    for (let t; (t = this.#keyingi(oxiri)); ) {
      this.hozir = t.vaqt;
      t.fn();
      await bosh();
    }
    this.hozir = oxiri;
  }
}

async function sina(usul) {
  const soat = new SoxtaSoat();
  const kut = (ms) => new Promise((ok) => soat.setTimeout(ok, ms));
  let n = 0;
  let tugadi = false;
  const ish = () => (++n === 1
    ? Promise.reject(new Error("429"))
    : Promise.resolve("ok"));
  (async () => {
    try { await ish(); } catch { await kut(5000); await ish(); }
    tugadi = true;
  })();
  if (usul === "sur") soat.sur(5000);
  else await soat.surAsync(5000);
  await new Promise((r) => haqiqiyTimeout(r, 0));
  console.log(usul, "→ tugadi:", tugadi,
    "| navbatda:", soat.navbat.length);
}

await sina("sur");
await sina("surAsync");

Konsolda:

text
sur → tugadi: false | navbatda: 1
surAsync → tugadi: true | navbatda: 0

Model Vitest chiqishini aynan takrorlaydi: sinxron surishdan keyin taymer navbatda qoladi. Model soddalashtirilgan — haqiqiy soxta soat Date, setInterval, clearTimeout va boshqalarni ham almashtiradi. Lekin g'oya bir: await bor kodda vaqt ...Async bilan suriladi.

Diqqat: Sinxron surish xatosi har doim ham yiqilish bilan tugamaydi. Taymer keyinroq qo'yiladi va keyingi testda ishlab ketishi mumkin. afterEach dagi vi.useRealTimers() va har testning o'z soxta soati — shuning uchun majburiy.

Tekshirib ko'ring: Test await vi.advanceTimersByTimeAsync(4_999) dan keyin tugadi === false ni, advanceTimersByTimeAsync(1) dan keyin true ni tekshiradi. Nega ikkala tekshiruv kerak, faqat ikkinchisi yetmaydimi?

Javob

Faqat ikkinchisi "5 soniyadan keyin tugadi" ni isbotlaydi, lekin "5 soniyadan oldin tugamadi" ni emas. Kod Retry-After ni e'tiborsiz qoldirib, darhol qayta urinsa ham ikkinchi tekshiruv o'tadi. 4 999 ms dagi false aynan chegarani tekshiradi — Vitest II darsidagi debounce testida 399 va 400 ms ni tekshirgandek.

3.4 Soxta soat yoki soat parametri?

Vaqt va tasodifni boshqarish darsida ikkinchi yo'lni ham ko'rgansiz: soatni funksiyaga parametr qilib, tashqaridan berish. U darsda buni "ukol" deb atagandik; 30-qismdan beri kursdagi umumiy nomi — bog'liqlikni tashqaridan berish (DI). G'oya bir: kod vaqtni o'zi olmaydi, unga beriladi. Kanonda ikkalasi bor. ChidamliProvayder ning sozlamasida kut va soat maydonlari bor — "testlar uchun". Resilience naqshlari darsidagi chidamlilik.test.ts ularni soxta funksiyalar bilan to'ldiradi. Yangi asinxron.test.ts esa ataylab haqiqiy setTimeout ni qoldiradi va soxta soat bilan tekshiradi.

Usul Afzalligi Kamchiligi
Soat parametri (kut, soat) test qaysi soatni ishlatishini aniq ko'radi prodakshndagi haqiqiy setTimeout yo'li tekshirilmaydi
Soxta soat kod o'zgarmaydi; haqiqiy yo'l tekshiriladi global holat; await tuzog'i

Ikkalasini birga ishlatish — eng ishonchli: mantiq soat parametri bilan, haqiqiy taymer yo'li — soxta soat bilan.

4. Navbat va cron: ishlovchini to'g'ridan chaqirish

4.1 BullMQ ishchisi

BullMQ asoslari darsida ish (job) navbatga tushadi, ishchi (worker) uni oladi va ishlovchi funksiyani chaqiradi. Ishlovchi — oddiy funksiya: ish obyektini oladi, natija qaytaradi. Uni testlash uchun na Redis, na navbat kerak. Kanon navbat.test.ts dan parcha (qatorlar telefon uchun bo'lingan):

ts
const ish = {
  data: { vazifaId: v, foydalanuvchiId: f, muddat: yangiMuddat },
} as Job<EslatmaMalumoti>;
assert.equal(await eslatmaIshlovi(d)(ish), "eskirgan");

Vaziyat: eslatma ishi rejalashtirilgandan keyin foydalanuvchi muddatni o'zgartirdi. Ish eski muddat bilan keldi — ishlovchi bildirishnoma yubormasligi va "eskirgan" qaytarishi kerak. Test ishni qo'lda yasaydi va ishlovchini sinxron chaqiradi (await bilan, lekin navbatsiz). Natija — millisekundlarda.

Bu yo'l ishlovchining mantiqini tekshiradi. Lekin "navbatga tushgan ish haqiqatan ishchiga yetadimi" — yo'q.

4.2 Butun zanjir: haqiqiy Redis bilan

Shu faylning birinchi testi butun yo'lni yuradi: API'da muddat qo'yiladi → outbox → tizim navbati → kechiktirilgan (delayed) eslatma ishi → bildirishnoma → email yetkazish → Mailpit. Muddat ataylab "30 daqiqa + 1.5 soniya" qilib qo'yiladi: eslatma 30 daqiqa oldin yuborilgani uchun ish ~1.5 soniyadan keyin ishlaydi. Test haqiqiy 1.5 soniyani kutadi — soxta soat bu yerda yordam bermaydi, chunki kechikishni Redis ichidagi BullMQ hisoblaydi, Node'ning setTimeout i emas.

Natijani esa test kutib tekshiradi:

ts
const kutib = async <T>(
  f: () => Promise<T | undefined>, ms = 8000,
): Promise<T> => {
  const t0 = Date.now();
  for (;;) {
    const x = await f();
    if (x !== undefined) return x;
    if (Date.now() - t0 > ms) throw new Error("kutish tugadi");
    await new Promise((ok) => setTimeout(ok, 50));
  }
};

kutib — frontenddagi waitFor ning backend nusxasi (Test strategiyasi va flaky testlar darsidagi «Async: "darhol" va "tugagach"»): shart bajarilguncha har 50 ms tekshiradi, 8 soniyadan keyin taslim bo'ladi. Muhim nuqta — ikkita raqam bor. 50 ms — qanchalik tez javob beramiz. 8 s — qancha sabr qilamiz. Shart bajarilishi bilan kutish tugaydi, shuning uchun katta chegara bo'sh mashinada testni sekinlashtirmaydi — u faqat yuklangan mashina uchun ehtiyot chegarasi.

Ishlatilishi:

ts
await kutib(async () =>
  (await navbatlar.eslatma.getDelayedCount()) === 1
    ? true : undefined);
const b = await kutib(async () => (await pool.query(
  "SELECT id, matn FROM bildirishnomalar " +
  "WHERE tur = 'vazifa.muddat'")).rows[0]);

Avval navbatda bitta kechiktirilgan ish paydo bo'lishini, keyin bazada bildirishnoma qatorini kutadi. Har qadam o'z sharti bilan — "2 soniya uxlab, keyin tekshiramiz" emas.

4.3 Cron ishi

Kanonning ishchisi har kuni 04:15 da saqlash-muddati ishini bajaradi (Ma'lumot saqlash muddati darsi). Testda 04:15 ni kutmaymiz va soxta soat bilan jadvalni ham aylantirmaymiz. Jadval — ikki narsa: "qachon" (cron ifodasi) va "nima" (ish). "Nima" ni to'g'ridan chaqiramiz. saqlash.test.ts dan:

ts
const n = await tizimIshlovi({ ishchi } as never)(
  { name: "saqlash-muddati" } as never,
) as Record<string, unknown>;

Muhim detal — ishchi ulanishi haqiqiy vazifalar_ishchi roli bilan ochilgan. Test ishning mantig'idan tashqari huquqlarni ham tekshiradi: prodakshnda ish aynan shu rol bilan ishlaydi. Superuser bilan o'tgan test prodakshndagi permission denied ni ko'rsatmas edi.

"Qachon" ni esa alohida tekshirish mumkin: cron ifodasini tahlil qilib, keyingi ishga tushish vaqtini hisoblash. Kanonda bunday test yo'q — ifoda bitta qator va Cron va rejali vazifalar darsida o'qilgan.

Tekshirib ko'ring: Nega navbat testi eslatma ishini "30 daqiqa + 1.5 soniya" muddat bilan rejalashtiradi, 30 daqiqa bilan emas?

Javob

Eslatma muddatdan 30 daqiqa oldin yuboriladi. Muddat aynan 30 daqiqa bo'lsa, eslatma vaqti "hozir" — ish kechiktirilmay darhol bajariladi va test "kechiktirilgan navbatda bitta ish" holatini ko'ra olmaydi. 1.5 soniya qo'shimcha — ish avval kechiktirilgan ro'yxatga tushishi, keyin vaqti kelganda ishlashi uchun. Bu ham chegaraviy holat.

5. Webhook imzosi va vaqt

Chiquvchi webhook'larni (Chiquvchi webhook'larni yuborish) kanon Standard Webhooks formatida imzolaydi: webhook-id, webhook-timestamp va webhook-signature sarlavhalari. Qabul qiluvchi uchta narsani tekshiradi: imzo to'g'ri, tana o'zgarmagan, vaqt belgisi eski emas (sukut 300 s).

Bu yerda ham vaqt bor. Kanonning tekshir() funksiyasi hozirSoniya ni parametr sifatida qabul qiladi — vaqt tashqaridan beriladi. Testda soxta soat kerak emas. Shu g'oyani brauzerda Web Crypto bilan modellashtiramiz (formatni Kiruvchi webhook'larni qabul qilish darsidan bilasiz):

js
const enc = new TextEncoder();

async function imzola(sir, id, vaqt, tana) {
  const kalit = await crypto.subtle.importKey(
    "raw", enc.encode(sir),
    { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
  );
  const xom = await crypto.subtle.sign(
    "HMAC", kalit, enc.encode(`${id}.${vaqt}.${tana}`),
  );
  return "v1," + btoa(String.fromCharCode(...new Uint8Array(xom)));
}

async function tekshir(sir, s, tana, hozir) {
  if (s.vaqt < hozir - 300) return "eski";
  if (s.vaqt > hozir + 300) return "kelajak";
  const kutilgan = await imzola(sir, s.id, s.vaqt, tana);
  return kutilgan === s.imzo ? "ok" : "imzo";
}

const SIR = "test-sir";
const HOZIR = 1_793_000_000;
const tana = '{"tur":"vazifa.yaratildi","id":7}';
const s = {
  id: "msg_1", vaqt: HOZIR - 10,
  imzo: await imzola(SIR, "msg_1", HOZIR - 10, tana),
};

console.log("to'g'ri:", await tekshir(SIR, s, tana, HOZIR));
console.log("tana o'zgardi:",
  await tekshir(SIR, s, tana.replace("7", "8"), HOZIR));
console.log("10 daqiqadan keyin:",
  await tekshir(SIR, s, tana, HOZIR + 600));
console.log("boshqa sir:", await tekshir("boshqa", s, tana, HOZIR));

Konsolda:

text
to'g'ri: ok
tana o'zgardi: imzo
10 daqiqadan keyin: eski
boshqa sir: imzo

To'rt holat — to'rt test. Uchinchisi soxta soatsiz: "hozir" ni parametr bilan 600 soniya oldinga qo'ydik. Model soddalashtirilgan: kanon imzolarni vaqtga chidamli usulda solishtiradi (timingSafeEqual, vaqt hujumi haqida) va bir sarlavhada bir nechta imzoni qabul qiladi. Kanonning webhook-api.test.ts testi esa haqiqiy yo'lni yuradi: obuna → vazifa hodisalari → ishchi yuboradi → test serveri qabul qiladi → har kelgan so'rov imzosi tekshir() bilan tekshiriladi.

6. Email va realtime: tashqi tizimni kutib tekshirish

6.1 Mailpit

29-qismda emaillarni lokal Mailpit serveriga yubordik (Email yuborish: SMTP va Nodemailer). Mailpit xatlarni ushlab qoladi va HTTP API orqali ko'rsatadi. Test SMTP'ni emas, natijani tekshiradi: "Azizga 'Muddat yaqin' mavzuli xat keldimi?":

ts
const qidiruv = encodeURIComponent(
  `to:${email} subject:"Muddat yaqin"`);
const xat = await kutib(async () => {
  const r = await fetch(`${API}/api/v1/search?query=${qidiruv}`);
  const j = await r.json() as { messages: { Subject: string }[] };
  return j.messages[0];
});
assert.equal(xat.Subject, "Muddat yaqin");

Bu parcha kanondagi bitta uzun qatorni o'qish uchun bo'lib yozilgan (ma'no o'zgarmagan). Yana kutib: xat navbat → ishchi → SMTP → Mailpit yo'lini bosib o'tadi, bu vaqt oladi. Qidiruv to: va subject: bilan — boshqa testlar yuborgan xatlar aralashmasin.

6.2 SSE va WebSocket

Kanonda realtime — SSE (Server-Sent Events, /v1/oqim). jonli.test.ts haqiqiy fetch oqimini o'qiydi, hodisalarni ro'yxatga yig'adi va yana kutib tekshiradi:

ts
const h1 = await kutib(() => oyna.hodisalar[0]);

Bu kutib boshqacharoq: sinxron funksiya oladi, har 15 ms tekshiradi, 3 s kutadi — hodisalar xotiradagi massivga tushadi, bazaga so'rov kerak emas.

WebSocket uchun g'oya o'sha: test mijoz sifatida ulanadi, hodisani Promise bilan kutadi va oxirida ulanishni yopadi. Socket.IO ishlatadigan loyihalarda bu socket.io-client bilan qilinadi. Kanonda Socket.IO yo'q (29-qism qarori — SSE), shuning uchun quyidagi parcha sinalmagan (hujjat bo'yicha, 2026-11):

ts
import { io } from "socket.io-client";

const mijoz = io(`http://127.0.0.1:${port}`, {
  transports: ["websocket"], forceNew: true,
});
const keldi = new Promise((ok) => mijoz.once("vazifa", ok));
await yaratish(); // API orqali vazifa qo'shamiz
assert.deepEqual(await keldi, { id: 7, tur: "yaratildi" });
mijoz.close();

Ikki muhim qoida (har qanday realtime testi uchun): obunani hodisadan oldin oching (aks holda hodisa o'tib ketadi), va close() ni unutmang — ochiq ulanish test jarayonini to'xtatmaydi.

7. Kanon topgan hodisa: "javob keldi" ≠ "ish tugadi"

31-qism boshida kanonning birinchi to'liq tekshiruvida ikkita test yiqildi. Biri — Telegram bot testi. Bot /yangi buyrug'iga "matnini yozing" deb javob beradi, test esa shu zahoti Redis'da dialog holati bormi deb tekshirardi:

ts
n = soni(701); tg.yozadi(701, "/yangi");
assert.match((await javob(701, n)).text ?? "", /matnini yozing/);
assert.ok((await redis.keys("vz:tg:dialog:*")).length > 0);

Odatda o'tardi. Yuk ostida — yiqildi. Sabab: grammY'ning dialoglar (conversations) plagini holatni ishlovchi tugagach yozadi, javob xabari esa undan oldin jo'naydi. Test "javob keldi" ni "holat yozildi" deb hisoblagan. Bo'sh mashinada oraliq mikrosoniyalar, yuklanganida — test ulgurmaydi.

Tuzatish — taxminiy pauza emas, kutib:

ts
await kutib(async () =>
  ((await redis.keys("vz:tg:dialog:*")).length > 0
    ? true : undefined));

Bu xato turi juda keng tarqalgan. Har asinxron tizimda "foydalanuvchiga javob" va "ichki holat" alohida vaqtda yoziladi. Vitest'ning o'zida ham shu ishni qiladigan tayyor vositalar bor — vi.waitFor va expect.poll. Kanon o'z kutib funksiyasini yozgan, lekin ularni ham sinab ko'rdik:

ts
// "Ishchi" holatni 120 ms dan keyin yozadi
function ishchiYasa() {
  const holat = new Map<string, string>();
  setTimeout(() => holat.set("dialog:701", "muddat"), 120);
  return holat;
}

test("darhol tekshirish", () => {
  const holat = ishchiYasa();
  expect(holat.get("dialog:701")).toBe("muddat");
});

test("vi.waitFor", async () => {
  const holat = ishchiYasa();
  const q = await vi.waitFor(() => {
    const v = holat.get("dialog:701");
    if (v === undefined) throw new Error("hali yo'q");
    return v;
  }, { timeout: 1000, interval: 20 });
  expect(q).toBe("muddat");
});

test("expect.poll", async () => {
  const holat = ishchiYasa();
  await expect.poll(() => holat.get("dialog:701"), { timeout: 1000 })
    .toBe("muddat");
});

Biz ishga tushirdik (Vitest 5.0.5; millisekundlar har yugurishda boshqacha):

text
 × |asosiy| test/demo31b.test.ts > darhol tekshirish 9ms
   → expected undefined to be 'muddat' // Object.is equality
 ✓ |asosiy| test/demo31b.test.ts > vi.waitFor 130ms
 ✓ |asosiy| test/demo31b.test.ts > expect.poll 178ms

Nima bo'ldi? Birinchi test "ishchi" holatni yozmasidan oldin tekshirdi — undefined oldi va yiqildi. Qolgan ikkitasi kutdi va ~130–180 ms da o'tdi: shart bajarilishi bilan kutish tugadi, to'liq 1 soniya emas.

vi.waitFor funksiya xato tashlamay qolguncha qayta chaqiradi, expect.poll — tekshiruv o'tguncha. Vitest hujjatiga ko'ra (2026-11) vi.waitFor sukut bo'yicha har 50 ms da tekshiradi va 1 000 ms da taslim bo'ladi — kanondagi kutib bilan bir xil oraliq. Uchala vosita (kutib, vi.waitFor, expect.poll) bir g'oyaning shakllari.

8. Ko'p uchraydigan xatolar

Xato Nima bo'ladi To'g'risi
await bor kodda advanceTimersByTime taymer keyin qo'yiladi, test yiqiladi yoki keyingi testga "oqadi" advanceTimersByTimeAsync
vi.useRealTimers() afterEach da yo'q soxta soat keyingi testlarga o'tadi beforeEach / afterEach juftligi
"200 ms uxlab tekshiramiz" bo'sh mashinada o'tadi, CI'da yiqiladi shart bilan kutish: kutib, vi.waitFor
Soxta soat + haqiqiy Redis/Postgres drayver taymerlari to'xtaydi, test osiladi soxta soat — faqat toza mantiq uchun
Ishni faqat navbat orqali testlash sekin va sabab topish qiyin mantiq — ishlovchini chaqirib; zanjir — bitta integratsiya testi
Ishni superuser bilan testlash prodakshndagi permission denied ko'rinmaydi ishchining haqiqiy roli
Realtime'da obunani hodisadan keyin ochish hodisa o'tib ketadi, test osiladi avval obuna, keyin harakat

Uchinchi qatordagi "uxlab tekshirish" ning qisqa tarixi: 22-qismda frontend testida, 31-qismda Telegram bot testida — ikkala joyda ham beqaror test aynan shundan tug'ildi.

9. Mashqlar

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

1-mashq (oson): bo'shliqlarni to'ldiring

  • await bor kodda soxta vaqtni surish uchun vi.advanceTimersByTime ishlatiladi.
  • Soxta soat har testdan keyin vi.use() bilan o'chiriladi.
  • Navbat testida kechiktirilgan ishlar soni getDelayed() bilan olinadi.
  • Kanondagi kutib sukut bo'yicha har ms da tekshiradi.
  • Webhook vaqt belgisi sukut bo'yicha soniyadan eski bo'lsa rad etiladi.
Yechim
  • Async — mikrovazifalarga navbat beradi.
  • RealTimers — afterEach ichida.
  • Count — navbatlar.eslatma.getDelayedCount().
  • 50 — chegara esa 8 000 ms.
  • 300 — besh daqiqa; undan keyin "eski".

2-mashq (o'rta): kesh muddati soxta soat bilan

KeshTTL klassi berilgan: yoz(kalit, qiymat, ms) va ol(kalit); vaqtni hozir() funksiyasidan oladi. Soxta soat yozing (oddiy o'zgaruvchi) va tekshiring: 999 ms da qiymat bor, 1 000 ms da ham bor (chegara — "keyin" emas), 1 001 ms da yo'q. Ishora: kanondagi XotiraKesh ham tugash < Date.now() deb solishtiradi.

Yechim
js
class KeshTTL {
  #m = new Map();
  constructor(hozir) { this.hozir = hozir; }
  yoz(k, q, ms) { this.#m.set(k, { q, tugash: this.hozir() + ms }); }
  ol(k) {
    const x = this.#m.get(k);
    if (!x || x.tugash < this.hozir()) {
      this.#m.delete(k);
      return undefined;
    }
    return x.q;
  }
}

let vaqt = 0;
const kesh = new KeshTTL(() => vaqt);
kesh.yoz("bayroqlar", "ai-qadamlar:100", 1000);
for (const t of [999, 1000, 1001]) {
  vaqt = t;
  console.log(`${t} ms →`, kesh.ol("bayroqlar"));
}

Konsolda:

text
999 ms → ai-qadamlar:100
1000 ms → ai-qadamlar:100
1001 ms → undefined

1 000 ms da qiymat hali bor: tugash < hozir qat'iy solishtirish. Agar <= yozilsa, 1 000 ms da yo'qolardi — off-by-one aynan shu chegaraviy testda ushlanadi. Bu yechimda soxta soat — soat parametri: hozir funksiyasi tashqaridan berildi.

3-mashq (qiyin): o'z kutib funksiyangiz

kutib(f, { chegara, oraliq }) yozing: f() undefined dan boshqa qiymat qaytarguncha har oraliq ms da chaqiradi, chegara dan oshsa Error("kutish tugadi (100 ms)") shaklidagi xato tashlaydi. Ikki holatni sinang: holat 120 ms da paydo bo'ladi (chegara 1 000 ms) va hech qachon paydo bo'lmaydi (chegara 100 ms). Chiqishda o'lchangan vaqtni emas, natija va xato matnini ko'rsating. Ishora: Date.now(), await new Promise((ok) => setTimeout(ok, ms)).

Yechim
js
async function kutib(f, { chegara = 1000, oraliq = 20 } = {}) {
  const t0 = Date.now();
  for (;;) {
    const x = await f();
    if (x !== undefined) return x;
    if (Date.now() - t0 > chegara) {
      throw new Error(`kutish tugadi (${chegara} ms)`);
    }
    await new Promise((ok) => setTimeout(ok, oraliq));
  }
}

const holat = new Map();
setTimeout(() => holat.set("dialog:701", "muddat"), 120);
console.log(await kutib(() => holat.get("dialog:701")));

try {
  await kutib(() => holat.get("dialog:999"), { chegara: 100 });
} catch (e) {
  console.log(e.message);
}

Konsolda:

text
muddat
kutish tugadi (100 ms)

Xato matniga urinishlar sonini yoki o'tgan vaqtni qo'shmadik: ular mashina tezligiga bog'liq (100 ms ichida 20 ms li oraliq bilan taxminan 5–6 urinish) va chiqish har safar boshqacha bo'lardi. Xato matnida esa chegaraning o'zi — u o'zgarmaydi va xatoni o'qigan odamga "qancha sabr qilingan" ni aytadi. Kanondagi kutib dan farqi — sozlamalar obyekti va xato matni.

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

Loyihaning yakuniy bosqichi to'rt darsga bo'lingan. Bu darsda — vaqtga bog'liq kod uchun yangi test fayli. Navbat, SSE, Mailpit, cron va Telegram dialogi uchun testlar allaqachon bor (navbat, jonli, bildirishnoma, saqlash, telegram-bot) — ular 29–31-qismlarda yozilgan va yuqoridagi uch yo'lni ishlatadi. Yangi fayl qolgan bo'shliqni yopadi: jarayon ichidagi kesh va qayta urinish vaqtlari.

test/asinxron.test.ts (kanon fayli; qatorlar telefon uchun bo'lingan, ma'no aynan):

ts
// asinxron.test.ts — vaqtga bog'liq kodni SOXTA SOAT bilan
// testlash (kutmasdan): bayroqlar keshi (30 s TTL), AI javob keshi
// TTL, retry backoff'i haqiqiy setTimeout bilan —
// vi.useFakeTimers + advanceTimersByTimeAsync. Navbat (BullMQ), SSE,
// Mailpit, cron (ishchi) — mavjud integratsiya testlarida.
import { afterEach, beforeEach, test, vi } from "vitest";
import assert from "node:assert/strict";
import type { Pool } from "pg";
import { BayroqlarXizmati } from "../src/bayroq/bayroqlar.ts";
import { XotiraKesh } from "../src/ai/nazorat.ts";
import { ChidamliProvayder } from "../src/ai/chidamli.ts";
import { SoxtaProvayder } from "../src/ai/soxta.ts";
import { LlmXatosi } from "../src/ai/llm.ts";

beforeEach(() => {
  vi.useFakeTimers({ now: new Date("2026-11-03T09:00:00Z") });
});
afterEach(() => {
  vi.useRealTimers();
});

test("bayroqlar keshi: 30 s ichida bazaga qayta bormaydi, " +
  "30 s dan keyin — boradi; tozala() — darhol", async () => {
  let sorovlar = 0;
  const qator = {
    kalit: "ai-qadamlar", yoqilgan: true, foiz: 100,
    rollar: [], foydalanuvchilar: [],
  };
  const pool = {
    query: () => {
      sorovlar++;
      return Promise.resolve({ rows: [qator] });
    },
  } as unknown as Pool;
  const b = new BayroqlarXizmati(pool);
  await b.yoqilganmi("ai-qadamlar", { id: 1 });
  vi.advanceTimersByTime(29_999);
  await b.yoqilganmi("ai-qadamlar", { id: 2 });
  assert.equal(sorovlar, 1);
  vi.advanceTimersByTime(1);
  await b.yoqilganmi("ai-qadamlar", { id: 3 });
  assert.equal(sorovlar, 2);
  b.tozala();
  await b.hammasi();
  assert.equal(sorovlar, 3);
});

test("AI javob keshi (xotira): TTL tugagach yo'q; " +
  "chegara oshsa eng eskisi chiqadi", async () => {
  const k = new XotiraKesh(2);
  await k.yoz("a", "1", 60);
  vi.advanceTimersByTime(59_000);
  assert.equal(await k.ol("a"), "1");
  vi.advanceTimersByTime(1_001);
  assert.equal(await k.ol("a"), undefined);
  await k.yoz("b", "2", 60);
  await k.yoz("c", "3", 60);
  await k.yoz("d", "4", 60);
  assert.deepEqual(
    [await k.ol("b"), await k.ol("c"), await k.ol("d")],
    [undefined, "3", "4"],
  );
});

test("retry backoff haqiqiy setTimeout bilan — soxta soat oldinga " +
  "suriladi (test 0 ms kutadi); Retry-After 5 s hurmat qilinadi",
async () => {
  const p = new SoxtaProvayder({
    javob: () => "ok",
    xatolar: [new LlmXatosi("limit", "429", 5000, 429)],
  });
  const c = new ChidamliProvayder(p, { qaytaUrinish: 1 });
  let tugadi = false;
  const v = c
    .yarat({ tizim: "t", xabarlar: [{ rol: "user", matn: "x" }] })
    .then((j) => {
      tugadi = true;
      return j;
    });
  await vi.advanceTimersByTimeAsync(4_999);
  assert.equal(tugadi, false);
  await vi.advanceTimersByTimeAsync(1);
  assert.equal((await v).matn, "ok");
  assert.equal(p.chaqiruvlar.length, 2);
});

Uchta testni o'qish yo'riqnomasi:

  1. Bayroqlar keshi. pool — soxta obyekt: faqat query bor va u har chaqiruvni sanaydi. Feature flag'lar darsidagi BayroqlarXizmati bayroqlarni 30 s keshlaydi. 29 999 ms da — hali bitta so'rov, 30 000 ms da — ikkinchisi. tozala() keshni darhol bo'shatadi (admin bayroqni o'zgartirganda). Bu yerda await bor-u, lekin sinxron advanceTimersByTime yetadi: kesh taymer qo'ymaydi, faqat Date.now() ga qaraydi. Soxta soat Date ni ham boshqaradi.
  2. AI keshi. XotiraKesh(2) — ikki yozuvli kesh. 60 s TTL: 59 s da bor, 60.001 s da yo'q. Keyin uchta yozuv — sig'im 2, eng eskisi (b) chiqib ketadi. E'tibor bering: bu LRU emas, birinchi yozilgan birinchi chiqadi.
  3. Retry. Soxta provayder birinchi chaqiruvda 429 va Retry-After: 5000 ms li xato tashlaydi. ChidamliProvayder haqiqiy setTimeout bilan kutadi. Bu yerda ...Async shart — «Soxta soat: await tuzog'i» bo'limidagi sababga ko'ra.

useFakeTimers({ now }) soxta soatni aniq sanadan boshlaydi — test kun, oy va vaqt zonasiga bog'liq bo'lmasin.

Ishga tushiring:

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

Biz ishga tushirdik (bazasiz ham ishlaydi — uchala test soxta obyektlar bilan; qatorlar qisqartirilgan):

text
 ✓ |asosiy| test/asinxron.test.ts > bayroqlar keshi: 30 s ichida bazaga qayta bormaydi, 30 s dan keyin — boradi; tozala() — darhol 6ms
 ✓ |asosiy| test/asinxron.test.ts > AI javob keshi (xotira): TTL tugagach yo'q; chegara oshsa eng eskisi chiqadi 2ms
 ✓ |asosiy| test/asinxron.test.ts > retry backoff haqiqiy setTimeout bilan — soxta soat oldinga suriladi (test 0 ms kutadi); Retry-After 5 s hurmat qilinadi 2ms
 Test Files  1 passed (1)
      Tests  3 passed (3)

Uchala test birgalikda ~10 ms. Haqiqiy vaqt bilan ular kamida 30 + 60 + 5 = 95 soniya kutardi.

Hali commit qilmang — loyihaning bu bosqichi keyingi uch darsda davom etadi: shartnoma testlari, yuklama o'lchovi va CI. Commit — Test arxitekturasi va CI darsida.

11. Real ishda

  • Qayerda uchraydi: to'lov holati webhook bilan keyinroq keladi, email ishchi orqali jo'naydi, hisobot kechasi tayyorlanadi — bularning har birida "keyinroq" bor. Jamoalardagi beqaror testlarning katta qismi aynan shu joylardan chiqadi.
  • Vositalar: Vitest/Jest soxta taymerlari, vi.waitFor, expect.poll; Testcontainers'da haqiqiy Redis; Mailpit (ilgari MailHog) — email uchun; BullMQ'da test uchun alohida prefix (kanonda t45-<pid>) — parallel testlar bir-birining navbatiga tegmasin.
  • Intervyu savollari: "Navbat ishchisini qanday testlaysiz?", "Soxta taymerlar async kodda nega ishlamay qoladi?", "Beqaror testni qanday tuzatgansiz?", "Cron ishini qanday testlaysiz — 04:15 ni kutasizmi?"

Xulosa

  • Asinxron kodni testlashning uch yo'li: ishlovchini to'g'ridan chaqirish (mantiq), soxta soat (vaqt chegaralari), kutib tekshirish (haqiqiy zanjir).
  • await bor kodda soxta vaqt advanceTimersByTimeAsync bilan suriladi — sinxron surishda taymer hali qo'yilmagan bo'ladi.
  • Cron ishi jadvalni kutmasdan, nomi bilan chaqiriladi — va haqiqiy rol bilan, huquqlar ham tekshirilsin.
  • Haqiqiy Redis/Mailpit/SSE bilan — "N ms uxlash" emas, shart bilan kutish (kutib, vi.waitFor, expect.poll), ikki raqam: oraliq va chegara.
  • "Javob keldi" ≠ "ish tugadi": kanonning Telegram testi shu sabab yuk ostida yiqilgan.
  • test/asinxron.test.ts: 30 s kesh, 60 s TTL va 5 s retry — ~10 ms da.

Keyingi dars: Contract va API sxema testlari — API javobi frontend kutgan shakldan chiqib ketsa, buni CI'da qanday ushlaymiz: OpenAPI surati va iste'molchi shartnomasi.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Asinxron komponentlarni testlash: soxta soat, navbat, cron, webhook va email testlari — IlmHamroh