RxJS'i Test Etmek: TestScheduler ile Marble Testing, Fake Timer'lar ve Angular Service'leri
RxJS'te zamanla çalışan kod, TestScheduler.run() içinde sanal zamanla ve marble diagram'larla, gerçek saat beklemeden ve her değerin frame'i kontrol edilerek test edilir. Bookstore'un arama akışını, backoff'lu retry'ını ve iptal edilen istekleri marble ile, aynı testleri Vitest fake timer'larıyla, Angular service'lerini HttpTestingController ile test ediyor; Promise ve run() dışı delay tuzaklarını gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki örnekler RxJS 7.8.2, TypeScript 6.0, Node.js 24, Vitest 5.1 ve Angular 22.2 ile denenmiştir (Angular projesi, Angular 22.2'nin desteklediği TypeScript 6.0'ı kullanıyor); Angular testleri ng test ile, Vitest ve jsdom üzerinde çalıştırılmıştır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) arama kutusu, önceki yazıda yazdığımız settledTerm(300) operator'ünü kullanıyor: son tuştan 300 ms sonra terimi kırpıp, tekrar etmiyorsa geçiriyor. Ekipten biri ilk testini yazmış:
import { of } from "rxjs";
import { expect, it } from "vitest";
import { settledTerm } from "../11/operators.ts";
it("searches 300 ms after the last key", () => {
const terms: string[] = [];
of("d", "du", "dun", "dune").pipe(settledTerm(300)).subscribe((term) => terms.push(term));
expect(terms).toEqual(["dune"]);
});
Test geçer mi? Geçiyorsa ne kadar sürer ve 300 ms beklemeyi gerçekten test etmiş olur mu?
Bu yazıda RxJS kodunu test etmenin iki yoluna bakıyoruz: RxJS'in kendi TestScheduler'ı ile marble testing ve Vitest'in fake timer'ları. İkisiyle de önceki yazıların arama akışını (debounceTime + switchMap) ve backoff'lu retry'ı test ediyor, sonra HttpTestingController ile Bookstore'un Angular service'lerine geçiyoruz. Test terimleri (fake, stub, spy) Unit Test serisinin "Test Double'lar: Dummy, Stub, Spy, Mock ve Fake Arasındaki Fark" yazısında, Angular'da test kurulumunun genel hâli Frontend Testleri serisinde; burada RxJS'e özgü kısma odaklanıyoruz.
Sanal Zaman ve TestScheduler.run()
Tahmin örneğindeki test geçiyor ve 1 ms sürüyor:
✓ src/12/01-predict.test.ts > searches 300 ms after the last key 1ms
Tests 1 passed (1)
Ama 300 ms beklemeyi test etmiyor. of dört değeri senkron verip hemen tamamlanıyor; zaman operator'leri yazısında gördüğümüz gibi debounceTime, kaynak tamamlanınca bekleyen son değeri beklemeden veriyor. Test, bir metin kutusunda hiç olmayan bir durumu, yani tamamlanan bir input'u test etmiş oldu. settledTerm(0) ya da settledTerm(5000) yazsaydık da geçerdi.
Zamanı test etmek için zamanı kontrol etmek gerekiyor. RxJS bunun için rxjs/testing'deki TestScheduler'ı veriyor. run() callback'i çalışırken debounceTime, delay, timer gibi zamanla çalışan operator'ler gerçek saat yerine sanal bir saat kullanıyor; callback dönünce sanal saat senkron olarak sonuna kadar ilerletiliyor. Zamanlı örnekleri bu seride zaten böyle çalıştırıyorduk; bu kez sonuçları log'a yazmak yerine bir beklentiyle karşılaştırıyoruz. Örnek projeye Vitest 5.1'i ekledik; testler npm test ile çalışıyor ve vitest.config.ts yalnız src altındaki *.test.ts dosyalarını topluyor, bookstore-web'in testlerini değil:
import { TestScheduler } from "rxjs/testing";
import { beforeEach, expect, it } from "vitest";
import { settledTerm } from "../11/operators.ts";
let testScheduler: TestScheduler;
beforeEach(() => {
// scheduler kaydedilen frame'leri deep equality ile karşılaştırır; farkı Vitest raporlar
testScheduler = new TestScheduler((actual, expected) => expect(actual).toEqual(expected));
});
it("emits a term 300 ms after the last key", () => {
testScheduler.run(({ hot, expectObservable }) => {
// 100 ms arayla "d", "du", "dun", "dune": bir değer bir frame kaplar, bu yüzden "a 99ms b" 100 ms eder
const input = hot("a 99ms b 99ms c 99ms d", { a: "d", b: "du", c: "dun", d: "dune" });
expectObservable(input.pipe(settledTerm(300))).toBe("600ms d", { d: "dune" });
});
});
it("trims the term and skips a repeat", () => {
testScheduler.run(({ hot, expectObservable }) => {
const input = hot("a 399ms b 399ms c", { a: "dune", b: "dune ", c: " emma" });
expectObservable(input.pipe(settledTerm(300))).toBe("300ms a 799ms c", { a: "dune", c: "emma" });
});
});
TestScheduler'ın constructor'ı iki değeri karşılaştıran bir fonksiyon istiyor; RxJS hangi test framework'ünü kullandığını bilmiyor, karşılaştırmayı Vitest'in expect(…).toEqual(…)'ına bırakıyoruz. Marble söz dizimini (syntax) marble diagram yazısında gördük; run() içinde birkaç ek kuralı var. Bir frame 1 sanal milisaniye. 100ms, 1.5s gibi time progression ifadeleri zamanı o kadar ilerletiyor ve başta değillerse iki yanında boşluk istiyor. Boşluklar run() içinde zamanı ilerletmiyor, yalnız hizalama için. hot() zaten çalışan bir kaynak, cold() her subscription'da baştan başlayan bir kaynak üretiyor. Beklenen çıktı da aynı söz dizimiyle yazılıyor: "600ms d", 600. milisaniyede "dune" değeri ve tamamlanma yok demek.
Bir tuzak var: bir değer karakteri de bir frame kaplıyor. Kullanıcı 100 ms arayla yazıyor diye input'u "a 100ms b 100ms c 100ms d" yazınca test şöyle başarısız oluyor:
AssertionError: expected [ { frame: 603, …(1) } ] to deeply equal [ { frame: 600, …(1) } ]
- Expected
+ Received
@@ -1,8 +1,8 @@
[
{
- "frame": 600,
+ "frame": 603,
"notification": {
"error": undefined,
"kind": "N",
"value": "dune",
},
a 0. frame'de yayılıp bir frame ilerletiyor, sonra 100 ms geçiyor; b 101. milisaniyeye düşüyor. RxJS dokümanı da bunu açıkça yazıyor ve istediğin süreden 1 çıkarmanı öneriyor; testteki "a 99ms b" bu yüzden. Yeni yazdığın bir marble testi ilk kez çalıştırırken frame numaralarına bak: hata mesajı hangi frame'i beklediğini ve hangisini aldığını tam olarak söylüyor.
hot, cold ve expectSubscriptions: Arama Akışı
Higher-order mapping yazısındaki arama akışında önemli olan, çıktıdan çok iptal: kullanıcı yeni bir terime geçince eski istek bırakılmalı. Bunu çıktıdan görmek zor, ama cold() ile üretilen her Observable kendisine kimin ne zaman subscribe olup ne zaman bıraktığını kaydediyor ve expectSubscriptions bunu test edebiliyor:
import { switchMap, tap } from "rxjs";
import { TestScheduler } from "rxjs/testing";
import { beforeEach, expect, it } from "vitest";
import { settledTerm } from "../11/operators.ts";
let testScheduler: TestScheduler;
beforeEach(() => {
testScheduler = new TestScheduler((actual, expected) => expect(actual).toEqual(expected));
});
it("cancels the request for a term the user has moved on from", () => {
testScheduler.run(({ hot, cold, expectObservable, expectSubscriptions, flush }) => {
const input = hot("a 399ms b", { a: "em", b: "emma" });
// sahte API cevapları: her istek 500 ms sürer, sonra tamamlanır
const responses = {
em: cold("500ms r|", { r: ["Emma"] }),
emma: cold("500ms r|", { r: ["Emma"] }),
};
const requests: string[] = [];
const results = input.pipe(
settledTerm(300),
tap((term) => requests.push(term)),
switchMap((term) => responses[term as keyof typeof responses]),
);
expectObservable(results).toBe("1200ms r", { r: ["Emma"] });
// "em" 300. ms'de başlar, "emma" oturunca 700. ms'de bırakılır
expectSubscriptions(responses.em.subscriptions).toBe("300ms ^ 399ms !");
expectSubscriptions(responses.emma.subscriptions).toBe("700ms ^ 500ms !");
// flush sanal saati şimdi çalıştırır, böylece side effect'ler run() içinde kontrol edilebilir
flush();
expect(requests).toEqual(["em", "emma"]);
});
});
Subscription marble'larında ^ subscribe anı, ! unsubscribe anı. "em" isteği 300. ms'de başladı ve "emma" 700. ms'de oturunca bırakıldı; "emma" isteği 700. ms'de başladı ve 500 ms sonra tamamlanınca bitti. switchMap'i mergeMap yapıp denedik: "em" isteği bırakılmadığı için 800. ms'de fazladan bir sonuç geliyor ve test expected [ { frame: 800, …(1) }, …(1) ] to deeply equal [ { frame: 1200, …(1) } ] ile düşüyor.
Testin sonunda flush() var. expectObservable ve expectSubscriptions hemen karşılaştırma yapmıyor, karşılaştırmayı sanal saat bittiğinde yapılmak üzere planlıyor. tap ile toplanan requests gibi bir side effect'i run() içinde normal bir expect ile kontrol etmek istiyorsan önce flush() ile sanal saati ilerletmen gerekiyor; yoksa array henüz boş.
Backoff'lu Retry'ı Test Etmek
Error handling yazısındaki retry'ın en önemli davranışları, bekleme sürelerinin büyümesi, 4xx'in hiç tekrar denenmemesi ve başarılı bir denemeden sonra durmasıydı. Üçü de bir marble testine dönüşüyor:
import { defer } from "rxjs";
import { TestScheduler } from "rxjs/testing";
import { beforeEach, expect, it } from "vitest";
import { retryWithBackoff } from "../11/operators.ts";
let testScheduler: TestScheduler;
beforeEach(() => {
testScheduler = new TestScheduler((actual, expected) => expect(actual).toEqual(expected));
});
const httpError = (status: number) => ({ status });
const isRetryable = (err: unknown) => (err as { status: number }).status >= 500;
it("waits 500, 1000 and 2000 ms, then gives up", () => {
testScheduler.run(({ cold, expectObservable, expectSubscriptions }) => {
const request = cold("-#", undefined, httpError(503));
// random: () => 1 jitter'ı kaldırır, bekleme süreleri tavan değerler olur
const result = request.pipe(retryWithBackoff({ count: 3, isRetryable, random: () => 1 }));
expectObservable(result).toBe("3504ms #", undefined, httpError(503));
expectSubscriptions(request.subscriptions).toBe([
"^!",
"501ms ^!",
"1502ms ^!",
"3503ms ^!",
]);
});
});
it("does not retry a 404", () => {
testScheduler.run(({ cold, expectObservable, expectSubscriptions }) => {
const request = cold("-#", undefined, httpError(404));
const result = request.pipe(retryWithBackoff({ count: 3, isRetryable, random: () => 1 }));
expectObservable(result).toBe("-#", undefined, httpError(404));
expectSubscriptions(request.subscriptions).toBe("^!");
});
});
it("succeeds on the third attempt", () => {
testScheduler.run(({ cold, expectObservable }) => {
let attempt = 0;
// her subscription yeni bir deneme: iki kez 503, sonra cevap
const request = defer(() =>
++attempt < 3 ? cold("-#", undefined, httpError(503)) : cold("-(s|)", { s: 12 }),
);
const result = request.pipe(retryWithBackoff({ count: 3, isRetryable, random: () => 1 }));
expectObservable(result).toBe("1503ms (s|)", { s: 12 });
});
});
Önceki yazıda retryWithBackoff'a bir random seçeneği eklemiştik; burada () => 1 vererek jitter'ı kaldırıyoruz ve bekleme süreleri tam olarak 500, 1000 ve 2000 ms oluyor. Bu testte her şey frame frame okunabiliyor. cold("-#") subscribe olunduktan 1 ms sonra hata veriyor. İlk deneme 0'da başlayıp 1'de bitiyor, 500 ms bekleniyor ve ikinci deneme 501'de başlıyor; 502'de hata, 1000 ms bekleme ve 1502; ardından 2000 ms bekleme ve 3503. Dördüncü hata 3504'te aşağıya iletiliyor. Rastgeleliği dışarıdan verilebilir yapmak burada işe yarıyor: Math.random'ı doğrudan çağıran bir operator'ü bu kadar kesin test edemezdin.
Üçüncü test her subscription'da farklı davranan bir kaynak için defer kullanıyor: ilk iki denemede 503, üçüncüde cevap. Gerçek bir HTTP isteği de tam olarak böyle: her retry yeni bir subscription ve yeni bir istek.
Aynı Testler Vitest Fake Timer'larıyla
RxJS'in zaman operator'leri sonunda setTimeout ve setInterval çağırıyor ve bekleme sürelerini Date.now() ile ölçüyor. Vitest'in vi.useFakeTimers()'ı bunların hepsini sahte bir saatle değiştiriyor; vi.advanceTimersByTime(ms) saati ilerletiyor. Aynı üç senaryo:
import { defer, map, Subject, switchMap, throwError, timer } from "rxjs";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { retryWithBackoff, settledTerm } from "../11/operators.ts";
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("emits a term 300 ms after the last key", () => {
const input = new Subject<string>();
const terms: string[] = [];
input.pipe(settledTerm(300)).subscribe((term) => terms.push(term));
for (const term of ["d", "du", "dun", "dune"]) {
input.next(term);
vi.advanceTimersByTime(100);
}
// "dune"dan 100 ms sonra: henüz hiçbir şey yok
expect(terms).toEqual([]);
vi.advanceTimersByTime(200);
expect(terms).toEqual(["dune"]);
});
it("cancels the request for a term the user has moved on from", () => {
const input = new Subject<string>();
const requests: string[] = [];
const results: string[][] = [];
input
.pipe(
settledTerm(300),
switchMap((term) => {
requests.push(term);
return timer(500).pipe(map(() => [term]));
}),
)
.subscribe((titles) => results.push(titles));
input.next("em");
vi.advanceTimersByTime(400);
input.next("emma");
vi.advanceTimersByTime(1000);
expect(requests).toEqual(["em", "emma"]);
expect(results).toEqual([["emma"]]);
});
it("waits 500, 1000 and 2000 ms, then gives up", () => {
const attempts: number[] = [];
let failure: unknown;
defer(() => {
attempts.push(Date.now());
return throwError(() => ({ status: 503 }));
})
.pipe(retryWithBackoff({ count: 3, random: () => 1 }))
.subscribe({ error: (err: unknown) => (failure = err) });
vi.runAllTimers();
const start = attempts[0];
expect(attempts.map((t) => t - start)).toEqual([0, 500, 1500, 3500]);
expect(failure).toEqual({ status: 503 });
});
✓ src/12/01-predict.test.ts > searches 300 ms after the last key 1ms
✓ src/12/03-search.test.ts > cancels the request for a term the user has moved on from 4ms
✓ src/12/04-retry-with-backoff.test.ts > waits 500, 1000 and 2000 ms, then gives up 2ms
✓ src/12/04-retry-with-backoff.test.ts > does not retry a 404 0ms
✓ src/12/04-retry-with-backoff.test.ts > succeeds on the third attempt 0ms
✓ src/12/02-settled-term.test.ts > emits a term 300 ms after the last key 3ms
✓ src/12/02-settled-term.test.ts > trims the term and skips a repeat 0ms
✓ src/12/05-fake-timers.test.ts > emits a term 300 ms after the last key 3ms
✓ src/12/05-fake-timers.test.ts > cancels the request for a term the user has moved on from 1ms
✓ src/12/05-fake-timers.test.ts > waits 500, 1000 and 2000 ms, then gives up 1ms
✓ src/12/06-pitfalls.test.ts > a Promise does not run on virtual time 4ms
✓ src/12/06-pitfalls.test.ts > delay outside run() waits in real time 1002ms
✓ src/12/06-pitfalls.test.ts > delay inside run() uses virtual time 0ms
Test Files 6 passed (6)
Tests 12 passed | 1 expected fail (13)
Bütün testler geçiyor ve gerçek bir saniye bekleyen tek test, aşağıda bilerek yazdığımız delay tuzağı. İki yaklaşım aynı şeyi test ediyor ama farklı sorulara iyi cevap veriyor:
| Konu | TestScheduler (marble) | Vitest fake timer'ları |
|---|---|---|
| Ne kontrol edilir | Her değerin, hatanın ve subscription'ın tam frame'i | Belirli anlardaki durum (array'ler, sayaçlar) |
| Kaynak nasıl üretilir | hot ve cold ile, tek satırda | Subject, defer, elle next |
| İptal testi | expectSubscriptions doğrudan gösterir | Dolaylı: sonuç ya da sayaç üzerinden |
Promise ve fetch | Sanal zamanın dışında kalır | Microtask'ları ayrıca beklemek gerekir |
| RxJS dışı kod | Yalnız RxJS scheduler'larını kontrol eder | Aynı saat setTimeout kullanan her kodu kapsar |
| Okuması | Marble söz dizimini bilmeyen için zor | Sıradan test kodu |
Pratikte kendi custom operator'lerin için marble testi seç: operator'ün sözleşmesi zaman ekseninde tanımlanıyor ve marble bunu en kısa yoldan söylüyor. Angular service'leri ve RxJS'in başka kodla karıştığı yerler için fake timer'lar daha doğal; sıradaki bölümdeki testler de öyle.
Angular Service'lerini Test Etmek: HttpTestingController
Bir Angular service'i HttpClient ile Observable döndürüyorsa onu test etmek için gerçek bir sunucu gerekmiyor. provideHttpClientTesting(), HttpClient'ın altındaki backend'i bir test backend'iyle değiştiriyor: istekler ağa gitmiyor, HttpTestingController'da bekliyor ve test onlara flush ile cevap veriyor. Multicasting yazısındaki CategoryService'in vaadi "uygulama boyunca tek istek"ti:
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { apiUrl } from '../api';
import { CategoryService } from './category-service';
describe('CategoryService', () => {
let service: CategoryService;
let httpTesting: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(CategoryService);
httpTesting = TestBed.inject(HttpTestingController);
});
afterEach(() => {
// testin beklemediği bir istek gönderildiyse testi düşürür
httpTesting.verify();
});
it('sends one request for every subscriber, early or late', () => {
const seen: string[][] = [];
service.categories$.subscribe((list) => seen.push(list));
service.categories$.subscribe((list) => seen.push(list));
// eşleşen istek sıfır ya da iki tane ise expectOne testi düşürür
const request = httpTesting.expectOne(`${apiUrl}/categories`);
expect(request.request.method).toBe('GET');
request.flush(['science-fiction', 'classics']);
// geç gelen subscriber yeni istek olmadan cache'teki listeyi alır
service.categories$.subscribe((list) => seen.push(list));
expect(seen).toEqual([
['science-fiction', 'classics'],
['science-fiction', 'classics'],
['science-fiction', 'classics'],
]);
});
});
expectOne eşleşen tam bir istek bekliyor; sıfır ya da iki istek varsa testi düşürüyor. afterEach'teki verify() da beklenmeyen bir istek kalmışsa testi düşürüyor; geç gelen üçüncü subscriber'ın yeni bir istek göndermediği bu sayede de kontrol ediliyor. Polling yapan OrderService.status$ ise timer(0, 5000) ile çalışıyor; burada fake timer'lar ile HttpTestingController birlikte kullanılıyor:
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { apiUrl } from '../api';
import { OrderService, type OrderStatus } from './order-service';
describe('OrderService.status$', () => {
let service: OrderService;
let httpTesting: HttpTestingController;
const statusUrl = `${apiUrl}/orders/1042/status`;
beforeEach(() => {
// timer(0, 5000) setInterval ile çalışır, bu yüzden fake timer'lar polling'i ilerletir
vi.useFakeTimers();
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(OrderService);
httpTesting = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpTesting.verify();
vi.useRealTimers();
});
it('polls every 5 seconds and stops after Delivered', () => {
const statuses: string[] = [];
let completed = false;
service.status$(1042).subscribe({
next: (order) => statuses.push(order.status),
complete: () => (completed = true),
});
vi.advanceTimersByTime(0);
httpTesting.expectOne(statusUrl).flush({ id: 1042, status: 'Paid' } satisfies OrderStatus);
vi.advanceTimersByTime(5000);
httpTesting.expectOne(statusUrl).flush({ id: 1042, status: 'Shipped' } satisfies OrderStatus);
vi.advanceTimersByTime(5000);
httpTesting.expectOne(statusUrl).flush({ id: 1042, status: 'Delivered' } satisfies OrderStatus);
// Delivered'dan sonra: ne kadar beklersek bekleyelim yeni istek yok
vi.advanceTimersByTime(60_000);
httpTesting.expectNone(statusUrl);
expect(statuses).toEqual(['Paid', 'Shipped', 'Delivered']);
expect(completed).toBe(true);
});
it('never has two requests in flight when the API is slow', () => {
service.status$(1042).subscribe();
vi.advanceTimersByTime(0);
const first = httpTesting.expectOne(statusUrl);
// cevap 12 saniye sürüyor: bu arada timer iki kez tetikleniyor
vi.advanceTimersByTime(12_000);
httpTesting.expectNone(statusUrl);
first.flush({ id: 1042, status: 'Paid' } satisfies OrderStatus);
// exhaustMap 5. ve 10. saniyedeki tetiklemeleri yok saydı; sonraki istek 15. saniyede
vi.advanceTimersByTime(3000);
httpTesting.expectOne(statusUrl).flush({ id: 1042, status: 'Delivered' } satisfies OrderStatus);
});
});
İkinci test zaman operator'leri yazısındaki "önceki istek bitmeden yenisi başlamasın" kuralını kontrol ediyor: API 12 saniye boyunca cevap vermiyor, bu sürede timer iki kez tetikleniyor ve exhaustMap ikisini de yok sayıyor. Angular projesindeki testler npx ng test --watch=false ile çalışıyor; Angular 22'nin varsayılan test builder'ı Vitest'i jsdom üzerinde çalıştırıyor, tarayıcı açmıyor. Spec dosyalarında describe, it ve vi'nin import edilmemesinin sebebi tsconfig.spec.json'daki vitest/globals tipi:
✓ |bookstore-web| src/app/catalog/category-service.spec.ts > CategoryService > sends one request for every subscriber, early or late 8ms
✓ |bookstore-web| src/app/orders/order-service.spec.ts > OrderService.status$ > polls every 5 seconds and stops after Delivered 9ms
✓ |bookstore-web| src/app/orders/order-service.spec.ts > OrderService.status$ > never has two requests in flight when the API is slow 1ms
Test Files 2 passed (2)
Tests 3 passed (3)
Bir testin değerini, korumak istediği davranışı bozunca başarısız olup olmadığına bakarak ölçtük. exhaustMap'i mergeMap yapınca ve CategoryService'ten shareReplay'i silince:
--- order-service.ts with mergeMap instead of exhaustMap
FAIL |bookstore-web| src/app/orders/order-service.spec.ts > OrderService.status$ > never has two requests in flight when the API is slow
Error: Expected zero matching requests for criteria "Match URL: http://localhost:5180/api/orders/1042/status", found 2.
--- category-service.ts without shareReplay
FAIL |bookstore-web| src/app/catalog/category-service.spec.ts > CategoryService > sends one request for every subscriber, early or late
Error: Expected one matching request for criteria "Match URL: http://localhost:5180/api/categories", found 2 requests.
İki test de tam olarak korudukları kuralı söyleyen bir mesajla düştü. Son olarak, önceki yazıda eklediğimiz no-ignored-subscription kuralı spec dosyalarındaki subscribe çağrılarının hepsini işaretledi. Testte değerleri görmek için subscribe olmak testin amacı; kuralı yalnız spec dosyaları için kapattık:
{
// testte değerlere bakmak için subscribe olmak işin kendisi
files: ['src/**/*.spec.ts'],
rules: { 'rxjs-x/no-ignored-subscription': 'off' },
},
TestScheduler'ın Sınırları: Promise ve run() Dışı
TestScheduler yalnız RxJS'in scheduler'larını sanal zamana alıyor. Promise'ler JavaScript'in microtask queue'sunda çalışıyor ve RxJS'in onları kontrol etme yolu yok. Bu yüzden iki hatalı test sık görülüyor; ikisini de bilerek yazdık:
// it.fails: bu testin başarısız olması bekleniyor, mesaj yazıda
it.fails("a Promise does not run on virtual time", () => {
testScheduler.run(({ expectObservable }) => {
const price = defer(() => Promise.resolve(450));
expectObservable(price).toBe("(p|)", { p: 450 });
});
});
it("delay outside run() waits in real time", async () => {
const term = await firstValueFrom(of("dune").pipe(delay(1000)));
expect(term).toBe("dune");
});
it("delay inside run() uses virtual time", () => {
testScheduler.run(({ expectObservable }) => {
expectObservable(of("dune").pipe(delay(1000))).toBe("1000ms (a|)", { a: "dune" });
});
});
İlk testin it.fails işaretini kaldırınca Vitest şunu söylüyor:
AssertionError: expected [] to deeply equal [ { frame: +0, …(1) }, …(1) ]
Compared values have no visual difference.
Beklenen 0. frame'de bir değer ve tamamlanma; gelen hiçbir şey. run() sanal saati senkron olarak bitirip karşılaştırmayı yaptığında Promise henüz çözülmemişti; çözüldüğünde test çoktan bitmişti. "Compared values have no visual difference" satırı da yanıltıcı: Vitest diff'i kısaltırken iki tarafı aynı gösteriyor, ama ilk satırdaki [] asıl cevap. RxJS dokümanı da Promise tüketen kodun TestScheduler ile güvenilir şekilde test edilemeyeceğini yazıyor. Böyle bir kodu async bir testte firstValueFrom ile bekleyerek ya da Promise'i bir Observable'la değiştirip test ederek çöz; Angular tarafında HttpClient zaten Observable döndürdüğü için sorun çıkmıyor.
İkinci ve üçüncü test aynı delay(1000)'u ölçüyor. Süreler:
✓ src/12/06-pitfalls.test.ts > a Promise does not run on virtual time 4ms
✓ src/12/06-pitfalls.test.ts > delay outside run() waits in real time 1002ms
✓ src/12/06-pitfalls.test.ts > delay inside run() uses virtual time 0ms
run() dışında delay gerçek bir setTimeout kuruyor ve test gerçekten bir saniye bekliyor. Tek bir test için önemsiz görünüyor, ama yüz tane böyle test CI'da dakikalar demek; daha kötüsü, gerçek zamana bağlı testler yük altında rastgele başarısız olabiliyor.
Üretimde Dikkat
Zamana bağlı her şey test edilebilir olmalı: bekleme süreleri, rastgelelik ve saat. retryWithBackoff'un random seçeneği gibi, üretimde varsayılanı kullanılan ama testte değiştirilebilen parametreler bırak. Angular'da bunu bir injection token ile de yapabilirsin; önemli olan operator'ün içinde gizli bir Math.random() ya da Date.now() kalmaması.
Fake timer kullanan testlerde afterEach'te vi.useRealTimers()'ı unutma. Unutulursa aynı dosyadaki sonraki testler sahte saatle çalışır ve hiç ilgisi olmayan bir test zaman aşımına düşebilir; bu tür hataları bulmak uzun sürer, çünkü düşen test hatayı yapan test değildir.
Marble testlerini code review'da okunur tutmak için input ve beklenen marble'ı alt alta hizala ve değer harflerini anlamlı seç. Bir testin neyi koruduğunu adı söylesin: "cancels the request for a term the user has moved on from" bir kural, "switchMap test" değil. Son olarak bir testi yazdıktan sonra, yukarıdaki gibi korumak istediği satırı bozup başarısız olduğunu bir kez gör; hiç başarısız olmayan bir test hiçbir şeyi korumuyor olabilir.
Sık Düşülen Tuzaklar
- Zaman operator'lerini tamamlanan bir kaynakla (
of,from(array)) test etmek.debounceTimeve benzerleri tamamlanmada bekleyeni hemen verir; test zamanı hiç ölçmez. - Time progression'da değer karakterinin de bir frame kapladığını unutmak.
"a 100ms b"'deb101. milisaniyededir. run()içindeflush()çağırmadan side effect'leriexpectile kontrol etmek. Sanal saat henüz ilerlemediği için değerler boştur.- Promise tüketen kodu marble ile test etmeye çalışmak. Promise sanal zamanın dışında kalır.
run()dışındadelay,timerya dadebounceTimekullanan bir testi gerçek zamanla beklemek. Testler yavaşlar ve yük altında rastgele düşer.- Fake timer'ları test sonunda geri almamak.
- Operator'ün içinde
Math.random()ya daDate.now()'ı doğrudan kullanmak. Kesin bir beklenti yazılamaz.
Küçük Bir Deneme
★☆☆ 02-settled-term.test.ts'e, kullanıcı terimi silip boş bırakınca boş string'in de yayıldığını kontrol eden bir test ekle. Marble'da boş string için hangi harfi kullanacaksın ve değer object'inde nasıl tanımlayacaksın?
★★☆ 03-search.test.ts'teki switchMap'i concatMap yap ve testin hangi satırlarının başarısız olduğuna bak. Testi concatMap'in davranışına göre yeniden yaz: "emma" sonucu hangi frame'de gelir, "em" isteğinin subscription marble'ı ne olur?
★★★ errorInterceptor için bir Angular testi yaz: GET isteği iki kez 503, sonra 200 alınca tek bir cevap dönsün ve konsola iki uyarı yazılsın; POST isteği 503'te hiç tekrar denenmesin. Math.random'ı vi.spyOn ile sabitle, zamanı fake timer'larla ilerlet ve isteklere HttpTestingController ile cevap ver.
Seri RxJS 7.8 ile başladı ve burada bitiyor gibi görünüyor, ama RxJS'in kendisi değişiyor. Son yazıda RxJS 6'dan 7'ye gelen değişikliklere, yayımlanmadan durdurulan RxJS 8'e, beta aşamasındaki RxJS 9'a ve tarayıcıya gelen native Observable'a bakıyor ve bir Angular projesinin ne zaman yükseltmesi gerektiğine karar veriyoruz.