Streaming AI Arayüzünü Test Etmek: Sahte Stream'ler, Vitest Fake Timer'ları, MSW ve Playwright
Streaming bir AI arayüzü, modeli devreden çıkaran sahte AsyncGenerator'lar, fake timer'lar, MSW ile ReadableStream gövdeli cevaplar ve page.route kullanan Playwright testleriyle deterministik test edilir. Bu yazıda test ortamında instanceof DOMException'ın iptali hata sandırdığını, MSW sse() handler'ının fetch akışına uymadığını ve ping'siz sahte akışta 30 sn timeout yerine 15 sn sessizliğin dolduğunu gösteriyor, 18 testin 2 saniyenin altında bittiğini ölçüyoruz.
Sürüm notu: Bu yazıdaki kodlar Angular 22.2 (TypeScript 6.0, Node.js 24) ve .NET 10 ile derlenip denenmiştir; unit testlerde Angular'ın unit-test builder'ı ile Vitest 5.1 ve jsdom 30.1, ağ seviyesinde MSW (Mock Service Worker; ağ isteklerini yakalayıp sahte cevap dönen library) 3.0.5, E2E (end-to-end; uygulamayı tarayıcıda baştan sona çalıştıran) testlerde @playwright/test 1.65 ile Chromium kullanılmıştır. Kayıtlı akış yerel Ollama 0.34.4 (qwen3:4b) ile üretildi; testlerin hiçbiri modele istek atmıyor.
Önce Tahmin Et
Bookstore asistanının (sitedeki serilerin ortak örnek uygulaması Bookstore, bir online kitapçı) store'u 3. yazıdan beri iptali şöyle tanıyor: err instanceof DOMException && err.name === 'AbortError'. Tarayıcıda Durdur butonu bununla doğru çalışıyor; 7. yazıda Escape ile bile denedik. Aynı kodu Vitest ile jsdom ortamında, AbortController'ı gerçekten abort eden bir testle sınarsan test geçer mi? İkinci soru: MSW'nin Server-Sent Events için özel bir sse() handler'ı var. Bizim fetch + POST ile okuduğumuz akışı onunla taklit edebilir miyiz?
İkisinin cevabı da "hayır" çıktı ve ilki production kodunda bir değişikliğe yol açtı. Bu yazıda streaming arayüzü üç katmanda test ediyoruz: parser ve state machine için unit testler, ağ seviyesi için MSW, tarayıcı için Playwright. Hiçbir test modele gitmiyor. Angular test kurulumunun genel tarafı Frontend Testleri serisinde; cevap kalitesinin değerlendirmesi .NET ile Yapay Zeka serisinin "IChatClient Kullanan Kodu Test Etmek: Fake Client, Deterministik Testler ve Microsoft.Extensions.AI.Evaluation" yazısında.
Neden Model Testin Dışında Kalmalı?
Bu serinin son iki yazısındaki denemelerde aynı soruya ("Which is longer, Dune or The Hobbit? Answer in one sentence.") gelen cevaplardan üçü: "Dune is longer than The Hobbit.", "Dune is longer than The Hobbit [1] [2]." ve katalog notlarını alıntılayan iki satırlık bir cümle. Süre 3,6 ile 7,6 saniye arasında değişti; 7. yazıdaki üç cümlelik soru 38 saniye sürdü. toContainText('Dune is longer than The Hobbit.') gibi bir assertion bu cevapların birinde geçer, diğerinde kalır. Bulut sağlayıcıda her test koşusu ayrıca para demek. Arayüzün sorumluluğu modelin ne dediği değil, gelen event'leri doğru işlemesi; o yüzden testlerde model yerine üç tür sahte kaynak kullanıyoruz:
- Sahte bir
AsyncGenerator: store'u ağ olmadan, zamanı kontrol ederek test etmek için. - MSW ile sahte bir HTTP cevabı:
fetch,ReadableStreamve parser'ı birlikte, gerçek chunk sınırlarıyla test etmek için. - Playwright
page.route: gerçek tarayıcıda gerçek component'leri, API olmadan test etmek için.
Hepsinin ortak malzemesi, gerçek bir model cevabından bir kez kaydedilmiş akış.
Parser'ı Test Etmek: Bölünen UTF-8 ve CRLF
2. yazıda readSse'yi iki ağ sorununa karşı yazmıştık: chunk sınırında bölünen çok baytlı bir UTF-8 (Unicode karakterlerini 1-4 byte ile kodlayan standart) karakteri ve iki chunk'a bölünen bir CRLF (satır sonunu gösteren \r\n karakter çifti). O gün bunları elle bir script'le denemiştik; şimdi kalıcı test oluyorlar:
// Verilen parçaları ayrı chunk'lar olarak veren bir ReadableStream: ağdaki bölünmeyi taklit eder
function streamOf(chunks: (string | Uint8Array<ArrayBuffer>)[]): ReadableStream<Uint8Array<ArrayBuffer>> {
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
for (const c of chunks) {
controller.enqueue(typeof c === 'string' ? encoder.encode(c) : c);
}
controller.close();
},
});
}
describe('readSse', () => {
it('joins a UTF-8 character split across two chunks', async () => {
const bytes = new TextEncoder().encode('event: delta\ndata: {"text":"Doğu"}\n\n');
const at = bytes.indexOf(0xc4); // "ğ" = C4 9F
const messages = await collect(streamOf([bytes.slice(0, at + 1), bytes.slice(at + 1)]));
expect(messages).toEqual([{ event: 'delta', data: '{"text":"Doğu"}' }]);
});
it('does not invent an empty line when CRLF is split between chunks', async () => {
const messages = await collect(streamOf(['event: delta\r', '\ndata: {"text":"a"}\r\n\r\n']));
expect(messages).toEqual([{ event: 'delta', data: '{"text":"a"}' }]);
});
});
Uint8Array<ArrayBuffer> tipi TypeScript 6.0'dan geliyor: typed array'ler artık buffer tipiyle generic ve ilk sürümdeki düz Uint8Array, ReadableStream<BufferSource> bekleyen readSse'ye verilince SharedArrayBuffer uyumsuzluğuyla derlenmedi. Testin gerçekten bir şeyi koruduğunu görmek için readSse'deki CRLF düzeltmesini geçici olarak kaldırdım:
$ npx ng test --watch=false --include src/app/assistant/sse.spec.ts
FAIL src/app/assistant/sse.spec.ts > readSse > does not invent an empty line when CRLF is split between chunks
AssertionError: expected [ { event: 'message', …(1) } ] to deeply equal [ { event: 'delta', …(1) } ]
- "event": "delta",
+ "event": "message",
Bölünen CRLF araya sahte bir boş satır sokuyor, event: delta satırı kendi başına bir event olarak kapanıyor ve veri message adıyla geliyor. Ekranda bu, cevabın bir parçasının sessizce kaybolması demek.
Sahte Stream ile State Machine
Store'u test etmek için ChatApi'nin yerine bir sahte akış koyuyoruz. Adımlar ya bir event ya da milisaniye cinsinden bir bekleme:
// Sahte akışın adımları: bir event ya da milisaniye cinsinden bekleme
export type Step = ChatEvent | number;
// Modeli devreden çıkarır: adımları sırayla verir, beklemeleri setTimeout ile yapar (fake timer'la ilerletilir)
// ve gerçek fetch gibi abort edilince signal.reason ile reject eder
export function scripted(steps: Step[]): (signal: AbortSignal) => AsyncGenerator<ChatEvent> {
return async function* (signal: AbortSignal) {
for (const step of steps) {
if (typeof step === 'number') {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, step);
signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(signal.reason);
}, { once: true });
});
} else {
signal.throwIfAborted();
yield step;
}
}
};
}
beforeEach(() => {
sessionStorage.clear();
// ChatApi'nin yerine sahte bir akış: testte model de ağ da yok
const fakeApi: Partial<ChatApi> = { stream: (_request, signal) => next(signal) };
TestBed.configureTestingModule({ providers: [{ provide: ChatApi, useValue: fakeApi }] });
store = TestBed.inject(ChatStore);
});
it('treats stop as aborted, not as an error', async () => {
vi.useFakeTimers();
play([{ type: 'delta', text: 'Dune' }, 10_000, { type: 'done' }]);
const done = store.send('Which is longer?');
await vi.advanceTimersByTimeAsync(100);
store.stop();
await done;
expect(store.status()).toBe('aborted');
expect(store.error()).toBeNull();
expect(store.messages()[1]).toMatchObject({ text: 'Dune', ending: 'stopped' });
});
Bu test ilk koşuda kaldı:
FAIL src/app/assistant/chat-store.spec.ts > ChatStore > treats stop as aborted, not as an error
AssertionError: expected 'error' to be 'aborted' // Object.is equality
Store iptali hata sandı. Sebebini küçük bir testle buldum: aynı ortamda new AbortController() ile abort edilen sinyalin reason'ı şuydu:
+ [
+ "[object DOMException]",
+ "AbortError",
+ false,
+ true,
Değer bir DOMException, adı AbortError, Error'ın bir instance'ı, ama instanceof DOMException false. jsdom ortamında global DOMException jsdom'un class'ı, AbortController ise Node.js'in; iki farklı realm'in iki farklı DOMException'ı var. Tarayıcıda tek realm olduğu için sorun görünmüyordu, ama aynı durum bir iframe'den ya da worker'dan gelen hatada da olur. Testi değil kodu değiştirdim:
// instanceof DOMException yerine ada bakar: abort sebebi başka bir realm'den (test ortamı, iframe, worker) gelebilir
function hasName(err: unknown, name: string): boolean {
return typeof err === 'object' && err !== null && (err as { name?: unknown }).name === name;
}
handleError artık hasName(err, 'AbortError') ve hasName(err, 'TimeoutError') kullanıyor. Diğer store testleri 3. yazıdaki state machine'in kurallarını sabitliyor: yarım cevap silinmiyor ve interrupted işaretleniyor, akış sürerken ikinci mesaj yok sayılıyor, 429'da Retry-After kadar geri sayım yapılıyor.
Fake Timer'larla 30 ve 15 Saniye
Store'da iki zamanlayıcı var: 30 saniyelik ilk token timeout'u ve sunucunun 5 saniyelik ping'ine dayanan 15 saniyelik sessizlik zamanlayıcısı. Gerçek zamanla test etmek her koşuyu 30 saniye uzatır; Vitest'in fake timer'larıyla zamanı kendimiz ilerletiyoruz:
// Sunucu gibi her 5 saniyede bir ping gönderen bekleme
export function pings(totalMs: number): Step[] {
const steps: Step[] = [];
for (let t = 5000; t <= totalMs; t += 5000) {
steps.push(5000, { type: 'ping' });
}
return steps;
}
it('times out after 30 seconds without a first token while pings keep the connection alive', async () => {
vi.useFakeTimers();
play([{ type: 'conversation', id: 'c1' }, ...pings(45_000), { type: 'delta', text: 'too late' }]);
const done = store.send('Which is longer?');
await vi.advanceTimersByTimeAsync(29_999);
expect(store.status()).toBe('submitted');
await vi.advanceTimersByTimeAsync(1);
await done;
expect(store.error()?.kind).toBe('timeout');
expect(store.messages()[1].text).toBe('');
});
it('treats 15 seconds of silence as a lost connection', async () => {
vi.useFakeTimers();
play([{ type: 'conversation', id: 'c1' }, { type: 'thinking' }, 60_000, { type: 'done' }]);
const done = store.send('Which is longer?');
await vi.advanceTimersByTimeAsync(15_000);
await done;
expect(store.status()).toBe('error');
expect(store.error()?.kind).toBe('network');
});
İlk testi yazarken ping'leri unutursan test 30. saniyede timeout değil, 15. saniyede network hatasıyla biter: ping gelmeyince sessizlik zamanlayıcısı ilk token zamanlayıcısından önce dolar. Yani "ilk token timeout'u" ancak sunucu bağlantıyı ping'lerle canlı tuttuğunda anlamlı; iki zamanlayıcının bu ilişkisi kodda yazmıyor, testte yazıyor. advanceTimersByTimeAsync'in Async sürümü burada şart: zamanlayıcı dolduktan sonra async generator'ın ve store'un promise zincirinin de ilerlemesi gerekiyor.
Ağ Seviyesinde: MSW ile Event-Stream
Sahte generator, fetch'i, response body'yi ve parser'ı atlıyor. Bu katmanı MSW ile test ediyoruz. İlk denemem MSW'nin sse() handler'ıydı:
FAIL src/app/assistant/msw-probe.spec.ts
Invariant Violation: Failed to construct a Server-Sent Event handler for path "/chat/sse":
the EventSource API is not supported in this environment
sse() handler'ı EventSource API'sini taklit etmek için var ve resmi dokümanı da yalnız EventSource üzerinden anlatıyor. Bizim istemcimiz EventSource kullanmıyor; 2. yazıda POST gövdesi göndermek için fetch seçmiştik. Doğru araç sıradan http.post handler'ı ve gövdesi bir ReadableStream olan bir cevap:
// Gövdeyi verilen parçalara bölüp parçaların arasına bekleme koyan bir event-stream cevabı
function eventStream(chunks: string[], delayMs = 5, onCancel?: () => void): HttpResponse<ReadableStream> {
const encoder = new TextEncoder();
let timer: ReturnType<typeof setTimeout> | undefined;
const body = new ReadableStream({
async start(controller) {
for (const [i, chunk] of chunks.entries()) {
if (i > 0) {
await new Promise((resolve) => (timer = setTimeout(resolve, delayMs)));
}
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
cancel() {
clearTimeout(timer);
onCancel?.();
},
});
return new HttpResponse(body, { headers: { 'Content-Type': 'text/event-stream' } });
}
const server = setupServer();
beforeAll(() => server.listen({ onUnhandledFrame: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
MSW 3'te listen'ın seçeneğinin adı değişmiş: MSW 2'deki onUnhandledRequest artık onUnhandledFrame; eski adı TypeScript derlemedi. 'error' değeri, handler'ı olmayan bir isteğin testi kırmasını sağlıyor; böylece yanlışlıkla gerçek API'ye ya da modele giden bir istek fark edilmeden geçemiyor. İptalin response body'ye kadar ulaştığını da burada doğrulayabiliyoruz:
it('cancels the response body when the caller aborts', async () => {
let cancelled = false;
server.use(http.post('/chat/sse', () => eventStream(['event: thinking\ndata: {}\n\n', 'event: delta\ndata: {"text":"never"}\n\n'], 10_000, () => (cancelled = true))));
const controller = new AbortController();
const events = new ChatApi().stream(request, controller.signal);
const first = await events.next();
controller.abort();
const rest = await collect(events).catch((e: unknown) => e);
expect(first.value).toEqual({ type: 'thinking' });
expect(rest).toMatchObject({ name: 'AbortError' });
expect(cancelled).toBe(true);
});
Bu testin ilk sürümü 5 saniyelik Vitest timeout'una takıldı: eventStream ilk parçadan önce de bekliyordu ve events.next() 10 saniye cevap alamadı. Bekleme yalnız parçaların arasına alındı. rest assertion'ı da instanceof değil ad kontrolü yapıyor, aynı realm sebebiyle.
Kayıtlı Gerçek Akışlar
Sahte event'leri elle yazmak kolay ama gerçek akışın ayrıntılarını kaçırıyor: sources event'inin JSON array olması, thinking'in ilk delta'dan önce gelmesi, delta'ların kelime ortasından bölünmesi. Gerçek API'den bir cevabı curl ile bir kez kaydettim ve fixture olarak sakladım; dosyanın başı:
// Gerçek /chat/sse cevabından kaydedildi (qwen3:4b, 2026-11-14); testler modele gitmeden bu akışı oynatır
// Soru: "Which is longer, Dune or The Hobbit? Answer in one sentence."
export const DUNE_SHORT = "event: conversation\ndata: {\"id\":\"f38c7df6-ac75-45a7-ac34-d3ec5be2e852\"}\n\nevent: sources\ndata: [{\"id\":1,\"tit...
Kayıt 1998 byte, 42 event: conversation, sources, thinking, 37 delta, usage ve done. Testte bu akışı 7 karakterlik parçalara bölüp MSW ile oynatıyorum; böylece event sınırları ağdaki gibi rastgele yerlerden bölünüyor:
// Bir string'i n karakterlik parçalara böler: ağdaki rastgele chunk sınırlarını taklit eder
function split(text: string, size: number): string[] {
return Array.from({ length: Math.ceil(text.length / size) }, (_, i) => text.slice(i * size, (i + 1) * size));
}
it('replays a recorded stream split into 7-character chunks', async () => {
server.use(http.post('/chat/sse', () => eventStream(split(DUNE_SHORT, 7), 1)));
const events = await collect(new ChatApi().stream(request, new AbortController().signal));
const text = events.map((e) => (e.type === 'delta' ? e.text : '')).join('');
expect(events.map((e) => e.type).filter((t) => t !== 'delta')).toEqual(['conversation', 'sources', 'thinking', 'usage', 'done']);
expect(text).toMatch(/^Dune is longer than The Hobbit/);
});
Sunucu event sözlüğünü değiştirdiğinde (yeni bir event, değişen bir alan adı) fixture'ı yeniden kaydetmek gerekiyor. Bunu bilinçli bir adım olarak tut: fixture'ın başına hangi sorudan, hangi modelle ve hangi tarihte kaydedildiğini yaz; değişen fixture code review'da görünür. Bütün unit ve ağ testleri:
$ npx ng test --watch=false --reporters=verbose
✓ sse.spec.ts > readSse > joins a UTF-8 character split across two chunks 3ms
✓ sse.spec.ts > readSse > does not invent an empty line when CRLF is split between chunks 0ms
✓ sse.spec.ts > readSse > skips comments, joins multi-line data and drops an unterminated last event 0ms
✓ chat-store.spec.ts > ChatStore > streams deltas into one assistant message and ends in done 7ms
✓ chat-store.spec.ts > ChatStore > keeps a partial answer and marks it interrupted on an error event 1ms
✓ chat-store.spec.ts > ChatStore > times out after 30 seconds without a first token while pings keep the connection alive 2ms
✓ chat-store.spec.ts > ChatStore > treats 15 seconds of silence as a lost connection 1ms
✓ chat-store.spec.ts > ChatStore > treats stop as aborted, not as an error 1ms
✓ chat-store.spec.ts > ChatStore > ignores a second message while the first one is streaming 1ms
✓ chat-store.spec.ts > ChatStore > counts down Retry-After on 429 before allowing the next message 1ms
✓ chat-api.spec.ts > ChatApi over the network (MSW) > replays a recorded stream split into 7-character chunks 384ms
✓ chat-api.spec.ts > ChatApi over the network (MSW) > turns 429 into ChatHttpError with Retry-After 3ms
✓ chat-api.spec.ts > ChatApi over the network (MSW) > cancels the response body when the caller aborts 2ms
✓ chat-api.spec.ts > ChatApi over the network (MSW) > drives the store end to end without a model 57ms
Test Files 3 passed (3)
Tests 14 passed (14)
Duration 827ms (environment 42%, tests 33%, setup 16%, transform 8%, import 1%)
30 saniyelik timeout dahil 14 test bir saniyenin altında bitiyor.
Playwright: page.route, İptal ve Yarım Markdown
Unit testler component'leri, sanitize pipeline'ını ve erişilebilirlik davranışını görmüyor. Playwright testlerinde dev server çalışıyor ama API ve model yok; /chat/sse her testte page.route ile cevaplanıyor:
// E2E testleri model ya da API olmadan çalışır: /chat/* istekleri her testte page.route ile cevaplanır
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
reporter: 'list',
use: {
baseURL: 'http://localhost:4260',
trace: 'retain-on-failure',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: 'npm start',
url: 'http://localhost:4260',
reuseExistingServer: !process.env['CI'],
},
});
test('replays a recorded stream and announces the answer once', async ({ page }) => {
await page.route('**/chat/sse', (route) =>
route.fulfill({ status: 200, contentType: 'text/event-stream', body: DUNE_SHORT }),
);
await ask(page);
const answer = page.locator('article').last();
await expect(answer).toContainText('Dune is longer than The Hobbit');
await expect(answer).toContainText('AI-generated, may contain mistakes.');
// 7. yazıdaki erişilebilirlik kuralları artık test: odak input'ta, tek duyuru
await expect(page.getByLabel('Message to the Bookstore assistant')).toBeFocused();
await expect(page.locator('.cdk-live-announcer-element')).toHaveText(/^Assistant: Dune is longer than The Hobbit/);
});
test('Stop cancels the request in the browser', async ({ page }) => {
// Cevap vermiyoruz: model hâlâ "düşünüyor"
let held: Route | undefined;
await page.route('**/chat/sse', (route) => {
held = route;
});
const failed = page.waitForEvent('requestfailed', (r) => r.url().endsWith('/chat/sse'));
await ask(page);
await page.getByRole('button', { name: 'Stop' }).click();
expect((await failed).failure()?.errorText).toBe('net::ERR_ABORTED');
await expect(page.locator('article').last()).toContainText('Stopped.');
expect(held).toBeDefined();
});
route.fulfill gövdenin tamamını tek seferde veriyor; event'leri zamana yaymak için uygun değil. İptal testinde bu yüzden hiç cevap vermiyorum: istek askıda kalıyor, Durdur'a basılınca tarayıcı isteği net::ERR_ABORTED ile kesiyor. Bu, iptalin tarayıcıda gerçekleştiğini kanıtlıyor; sunucunun modeli de durdurduğunu ise 2. yazıda sunucu log'unda ölçmüştük, o kontrol backend'in kendi testinde kalmalı.
Yarım markdown için 4. yazıdaki durumu kuruyorum: bir kod bloğu açılıyor ve kapanmadan bir error event'i geliyor. Ekran görüntüsü karşılaştırmak yerine (görüntüler işletim sistemine ve font'a göre değişiyor, Windows'ta kaydedilen baseline macOS'ta tutmuyor) erişilebilirlik ağacını karşılaştırıyorum:
test('a half code block cut by an error still renders as code', async ({ page }) => {
await page.route('**/chat/sse', (route) =>
route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: sse([
['delta', { text: 'Add the ISBN check:\n\n```csharp\n' }],
['delta', { text: 'bool valid = isbn.Length == 13' }],
['error', { message: 'The model stopped responding.', partial: true }],
]),
}),
);
await ask(page, 'Show me an ISBN check.');
const answer = page.locator('article').last();
await expect(answer.locator('pre code')).toContainText('bool valid = isbn.Length == 13');
await expect(answer).not.toContainText('```');
await expect(page.getByRole('alert')).toHaveText('The model stopped responding.');
await expect(answer).toMatchAriaSnapshot(`
- article:
- paragraph: "Add the ISBN check:"
- code: bool valid = isbn.Length == 13
- paragraph: The answer was cut off.
`);
});
Sessizlik zamanlayıcısını tarayıcıda da test ediyorum; Playwright'ın page.clock'u sayfadaki setTimeout'u kontrol ediyor:
test('15 seconds of silence becomes a connection error', async ({ page }) => {
await page.clock.install();
await page.route('**/chat/sse', () => {
// hiç cevap yok
});
await ask(page);
await page.clock.fastForward('00:15');
await expect(page.getByRole('alert')).toHaveText('Connection lost. Check your network and try again.');
});
$ npx playwright test
Running 4 tests using 4 workers
✓ 1 [chromium] › e2e/assistant.spec.ts:43:1 › a half code block cut by an error still renders as code (490ms)
✓ 3 [chromium] › e2e/assistant.spec.ts:68:1 › 15 seconds of silence becomes a connection error (493ms)
✓ 4 [chromium] › e2e/assistant.spec.ts:29:1 › Stop cancels the request in the browser (529ms)
✓ 2 [chromium] › e2e/assistant.spec.ts:16:1 › replays a recorded stream and announces the answer once (552ms)
4 passed (918ms)
Projeyi ilk kez kuruyorsan npm ci'dan sonra npx playwright install chromium ile tarayıcıyı indirmen gerekiyor.
Üretimde Dikkat
Unit ve ağ testlerini her pull request'te, Playwright testlerini de en azından main branch'e her merge'de çalıştır. Hiçbiri model ya da API gerektirmediği için CI ortamında secret veya GPU (modeli çalıştıran ekran kartı işlemcisi) istemiyorlar.
Gerçek modelle bir "smoke" testi istiyorsan onu ayrı tut. Günde bir kez, tek bir soruyla, yalnız akışın başlayıp done ile bittiğini kontrol eden bir test; cevabın metnine assertion yazma. Kalırsa bir alarm üretir, pull request'i bloklamaz.
Fixture'ları sürümle. Event sözlüğü değiştiğinde eski fixture'lar sessizce geçmeye devam edebilir; çünkü bilinmeyen event'i yok sayan istemci (1. yazı) yeni event'i de görmezden gelir. Sunucunun sözleşme testleri ile istemcinin fixture'ları aynı event listesine dayanmalı.
Zamanlayıcı testlerinde gerçek zamanı kullanma. 30 saniyelik timeout'u gerçek zamanla test etmek hem CI süresini uzatır hem de yavaş bir makinede testi flaky yapar.
Sık Düşülen Tuzaklar
Birincisi, testte gerçek modele istek atmak. Aynı soru bu seride üç farklı cümle ve iki katı süreyle cevaplandı; metne yazılan assertion er geç kalır ve her koşu para harcar.
İkincisi, hatayı instanceof DOMException ile tanımak. Test ortamında abort sebebi başka bir realm'den geldi ve store iptali hata sandı; adına bakmak her ortamda çalışıyor.
Üçüncüsü, fetch ile okunan bir akışı MSW'nin sse() handler'ıyla taklit etmeye çalışmak. O handler EventSource içindir; POST ile açılan bir akış için http.post ve ReadableStream gövdeli bir cevap kullan.
Dördüncüsü, ping'siz bir sahte akışla ilk token timeout'unu test etmek. Sessizlik zamanlayıcısı önce dolar ve test yanlış hatayı doğrular ya da yanlış sebeple geçer.
Beşincisi, streaming ekranını ekran görüntüsüyle test etmek. Font ve işletim sistemi farkı baseline'ı bozar; DOM (Document Object Model; sayfanın element ağacı) ve erişilebilirlik ağacı üzerinden assertion daha kararlı.
Küçük Bir Deneme
★☆☆ ChatStore için editLast ve regenerate testlerini yaz: sahte API'ye gelen isteğin replaceLastTurn değerini kontrol et ve yarım kalan bir cevabın yeniden üretiminde false gittiğini doğrula.
★★☆ 5. yazıdaki onay akışını test et: sahte akış bir approval event'i versin, kart görünsün, "Approve"a basıldığında ChatApi.approve'un doğru approvalId ile çağrıldığını ve cevabın aynı asistan mesajına eklendiğini doğrula. Süre dolumunu fake timer'la test et.
★★★ Gerçek API'den farklı senaryolar için (tool çağrısı, ui event'i, onay, X-Chaos: fail-after-5) fixture kaydeden küçük bir script yaz ve fixture'ların başına soru, model ve tarihi otomatik eklesin. Sonra event sözlüğüne yeni bir event ekle ve hangi testin bunu fark ettiğine bak.
Testlerle birlikte asistanın Angular tarafı tamamlandı. Serinin son yazısında ufka bakıyoruz: tarayıcının kendi içinde çalışan modeller (Chrome'un built-in AI API'leri), Angular 22'nin deneysel WebMCP API'leri ve bir özelliği önce cihazda, desteklenmiyorsa sunucuda çalıştırmak.