Mert Özen Her Satırda Daha İleri
Test ve Kalite TDD — #05

Angular'da TDD: Vitest ve Signal Tabanlı Component'i Test-first Geliştirmek

Mert Özen 14 Kas 2026 11 dk 1 görüntülenme
Angular'da TDD: Vitest ve Signal Tabanlı Component'i Test-first Geliştirmek

Angular'da TDD'ye component'ten değil saf TypeScript fonksiyonundan başlamak testleri hızlı, component'i ince tutar. Angular 22 ve Vitest ile sepet toplamlarını kayan nokta sürprizini yakalayarak, signal input'lu CartSummary component'ini whenStable ve setInput ile, HttpClient service'ini provideHttpClientTesting ile isteği önce yazarak geliştiriyor; zoneless projede fakeAsync'in neden çalışmadığını gerçek çıktıyla görüyoruz.

Sürüm notu: Bu yazıdaki kodlar Angular 22.2, Vitest 5.1.0, jsdom 30.1 ve Node.js 24 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un Angular arayüzünde sepet özetini hesaplayan bir fonksiyon yazıyoruz. Önceki testler yeşil: boş sepet sıfır, iki "Domain-Driven Design" ve bir "Refactoring" 121, indirim düşülüyor, toplam sıfırın altına inmiyor. Implementasyon şu satıra dayanıyor:

const subtotal = lines.reduce((sum, line) => sum + line.unitPrice * line.quantity, 0);

Test list'te bir madde daha var. Tanesi 9,95 olan defterden üç adet sepete ekleniyor:

it('adds prices without floating point drift', () => {
  const notebook: CartLine = { isbn: '978-0000000001', title: 'Notebook', unitPrice: 9.95, quantity: 3 };

  expect(calculateCartTotals([notebook], 0).subtotal).toBe(29.85);
});

Bu test yeşil mi olur? Kırmızı olursa ekranda hangi sayı görünür? Cevabı ilk bölümün sonunda göreceğiz.

Angular 22'de Test Ortamı: Kısa Bir Bakış

Angular serisinin Angular 21 yazısında anlattığımız gibi, yeni Angular projelerinde varsayılan test runner artık Vitest. Angular 22 ile ng new komutu projeyi Vitest ve jsdom ile kuruyor; ng test, @angular/build:unit-test builder'ı üzerinden testleri derleyip Vitest'e veriyor. jsdom, DOM'u Node.js içinde taklit ediyor; gerçek tarayıcı açılmadığı için döngü hızlı. Gerçek tarayıcıda koşan browser mode da var, ama bu yazının konusu değil. Kurulumun, configuration seçeneklerinin ve component harness'lerin ayrıntısı ilerideki Frontend Testleri serisinde.

TDD (Test-Driven Development; önce testi, sonra kodu yazma yaklaşımı) açısından iki şey önemli. Birincisi, ng test varsayılan olarak watch modunda çalışır: dosyayı kaydettiğinde ilgili testler yeniden koşar. Bu, red-green-refactor ritmini backend'deki kadar kısa tutar. İkincisi, Angular builder testleri derlerken TypeScript tip kontrolü de yapar. Var olmayan bir dosyayı import eden ilk testin kırmızısı bu yüzden derleyiciden gelir:

✘ [ERROR] TS2307: Cannot find module './cart-totals' or its corresponding type declarations. [plugin angular-compiler]

    src/app/cart/cart-totals.spec.ts:1:36:
      1 │ import { calculateCartTotals } from './cart-totals';
        ╵                                     ~~~~~~~~~~~~~~~

Önce Saf TypeScript: Sepet Toplamları

Frontend'de TDD'ye component'ten başlamak cazip gelir, çünkü ekranda bir şey görmek istersin. Ama component testleri DOM, change detection ve dependency injection getirir; saf bir fonksiyon testi bunların hiçbirini istemez. Hesap mantığını önce Angular'dan bağımsız bir fonksiyonda büyütmek, hem testleri hızlı tutar hem de component'i ince bırakır. Test list'imiz:

// Test list: calculateCartTotals
// [ ] Boş sepet: hepsi sıfır
// [ ] Ara toplam = birim fiyat × adet toplamı
// [ ] İndirim ara toplamdan düşülür
// [ ] Toplam sıfırın altına inmez (indirim ara toplamı aşamaz)
// [ ] Kayan nokta sapması olmadan toplanır

İlk testi fake it ile, sıfır dönen bir fonksiyonla geçiriyoruz. İkinci test, sabitin yetmediğini söylüyor:

AssertionError: expected { subtotal: +0, discount: +0, …(1) } to deeply equal { subtotal: 121, discount: +0, …(1) }

reduce ile toplamı ve subtotal - discount ile toplamı yazınca iki test yeşil; indirim testi de ilk koşuda geçiyor, çünkü implementasyona onu da eklemiştik. Toplamın negatif olmaması testi kırmızı (expected { subtotal: 30, discount: 50, …(1) }), Math.min ile yeşil.

Şimdi tahmin sorusunun cevabı:

× adds prices without floating point drift 2ms
AssertionError: expected 29.849999999999998 to be 29.85 // Object.is equality

JavaScript'te number, IEEE (Institute of Electrical and Electronics Engineers) 754 standardındaki çift duyarlıklı kayan nokta sayısıdır ve 9,95 gibi ondalık değerleri tam temsil edemez. Backend'deki decimal'ın verdiği güvence burada yok. Bu test olmasaydı, sepet ekranında bir gün "29.849999999999998" görecektik ya da daha kötüsü, yuvarlanmış ekran değeri ile sunucuya giden tutar arasında kuruş farkı oluşacaktı. Çözüm, hesabı tam sayı kuruşlarla yapmak:

const toCents = (amount: number) => Math.round(amount * 100);

export function calculateCartTotals(lines: CartLine[], discount: number): CartTotals {
  const subtotalCents = lines.reduce((sum, line) => sum + toCents(line.unitPrice) * line.quantity, 0);
  const discountCents = Math.min(toCents(discount), subtotalCents);
  return {
    subtotal: subtotalCents / 100,
    discount: discountCents / 100,
    total: (subtotalCents - discountCents) / 100,
  };
}
      Tests  7 passed (7)
   Duration  513ms (environment 61%, setup 20%, transform 10%, tests 5%, import 3%, worker 1%)

Yedi testin ikisi şablonla gelen App testleri. Vitest'in raporladığı sürenin büyük kısmı ortam kurulumu; testlerin kendisi milisaniyeler.

Component'i Test-first: CartSummary

Hesap hazır. Component'in işi artık yalnız üç şey: input'ları almak, hesabı çağırmak, sonucu göstermek. Önce testleri yazıyoruz. Component'i bir parent'ın vereceği input'larla oluşturan küçük bir render yardımcısı, testleri kısa tutuyor:

async function render(inputs: Record<string, unknown> = {}): Promise<HTMLElement> {
  const fixture = TestBed.createComponent(CartSummary);
  fixture.componentRef.setInput('lines', lines);
  for (const [name, value] of Object.entries(inputs)) {
    fixture.componentRef.setInput(name, value);
  }
  await fixture.whenStable();
  return fixture.nativeElement;
}

const text = (host: HTMLElement, selector: string) => host.querySelector(selector)?.textContent?.trim();

describe('CartSummary', () => {
  it('shows the subtotal and the total', async () => {
    const host = await render();

    expect(text(host, '[data-testid="subtotal"]')).toBe('121.00');
    expect(text(host, '[data-testid="total"]')).toBe('121.00');
  });

  it('shows the discount row only when a coupon applies', async () => {
    expect((await render()).querySelector('[data-testid="discount"]')).toBeNull();
    expect(text(await render({ discount: 20 }), '[data-testid="discount"]')).toBe('-20.00');
  });

  it('explains why a coupon was rejected', async () => {
    const host = await render({ couponError: 'Expired' });

    expect(text(host, '[role="alert"]')).toBe('This coupon has expired.');
  });
});

Testi yazmak birkaç tasarım kararını verdirdi. Component üç input alıyor ve componentRef.setInput, input'u bir parent'ın vereceği gibi veriyor; bu, signal input'larla da doğru çalışıyor. Zoneless bir uygulamada TestBed.createComponent change detection'ı senkron çalıştırmaz; await fixture.whenStable() render'ın bitmesini bekler. Element'leri CSS class'larıyla değil, data-testid ve role ile buluyoruz.

Input'ları tanımlayan ama template'i boş bir component ile üç test doğru nedenle kırmızı:

     × shows the subtotal and the total 17ms
     × shows the discount row only when a coupon applies 5ms
     × explains why a coupon was rejected 3ms
AssertionError: expected undefined to be '121.00' // Object.is equality
AssertionError: expected undefined to be '-20.00' // Object.is equality
AssertionError: expected undefined to be 'This coupon has expired.' // Object.is equality

İkinci testin ilk satırı (toBeNull) boş template'te de geçiyor; o assertion tek başına olsaydı hemen yeşil olan bir testle karşı karşıya kalırdık. İkinci satır onu dürüst tutuyor. Implementasyon:

const rejectionMessages: Record<string, string> = {
  Expired: 'This coupon has expired.',
  BelowMinimumSubtotal: 'Your cart is below the minimum amount for this coupon.',
};

@Component({
  selector: 'app-cart-summary',
  imports: [DecimalPipe],
  template: `
    <dl>
      <dt>Subtotal</dt>
      <dd data-testid="subtotal">{{ totals().subtotal | number: '1.2-2' }}</dd>
      @if (totals().discount > 0) {
        <dt>Discount</dt>
        <dd data-testid="discount">-{{ totals().discount | number: '1.2-2' }}</dd>
      }
      <dt>Total</dt>
      <dd data-testid="total">{{ totals().total | number: '1.2-2' }}</dd>
    </dl>
    @if (errorMessage(); as message) {
      <p role="alert">{{ message }}</p>
    }
  `,
})
export class CartSummary {
  readonly lines = input.required<CartLine[]>();
  readonly discount = input(0);
  readonly couponError = input<string | null>(null);

  protected readonly totals = computed(() => calculateCartTotals(this.lines(), this.discount()));
  protected readonly errorMessage = computed(() => {
    const reason = this.couponError();
    return reason ? (rejectionMessages[reason] ?? 'This coupon cannot be used.') : null;
  });
}
      Tests  10 passed (10)
   Duration  595ms (environment 64%, setup 20%, transform 8%, tests 4%, import 3%, worker 1%)

Neyi DOM'dan, Neyi Fonksiyondan Test Etmeli

Kural basit: hesap ve karar fonksiyonda, bağlantı ve görünürlük DOM'da test edilir. calculateCartTotals'ın beş testi bütün sayısal edge case'leri taşıyor: boş sepet, negatif toplam, kayan nokta. Component testleri ise yalnız şunları soruyor: hesap doğru input'larla çağrılıyor mu, sonuç doğru yerde görünüyor mu, koşullu satırlar doğru anda görünüyor mu, hata mesajı kullanıcıya ulaşıyor mu.

Kayan nokta testini component seviyesinde tekrar etmek, aynı davranışı daha yavaş ve daha kırılgan bir yoldan test etmek olurdu. Tersine, "indirim satırı yalnız kupon varken görünür" kuralını fonksiyonda test edemezsin; o bir template kararı. Bu ayrım, Unit Test serisinin test edilebilir tasarım yazısındaki functional core ve humble object fikrinin frontend'deki karşılığı: mantık saf fonksiyonda, component olabildiğince ince.

Test hızı açısından fark ölçülebilir değil ama birikir. Bu yazıdaki 13 testin tamamı bir saniyenin altında koşuyor. Component sayısı yüzleri bulduğunda, her hesap edge case'ini TestBed üzerinden test eden bir suite ile saf fonksiyonlarda test eden bir suite arasındaki fark, watch modunun kullanılabilir kalıp kalmadığını belirler.

HttpClient Sınırında Test-first: CouponApi

Backend'de geliştirdiğimiz POST /api/cart/{id}/coupon endpoint'ini çağıracak service'i yazıyoruz. Test-first burada çok somut bir anlam taşıyor: kodu yazmadan önce, gitmesi gereken HTTP isteğini testte tarif ediyorsun.

describe('CouponApi', () => {
  let api: CouponApi;
  let http: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
    api = TestBed.inject(CouponApi);
    http = TestBed.inject(HttpTestingController);
  });

  afterEach(() => http.verify());

  it('posts the code to the cart coupon endpoint', async () => {
    const result = firstValueFrom(api.apply(42, 'BOOK10'));

    const request = http.expectOne({ method: 'POST', url: '/api/cart/42/coupon' });
    expect(request.request.body).toEqual({ code: 'BOOK10' });
    request.flush({ id: 42, subtotal: 200, discount: 20, total: 180, couponCode: 'BOOK10' });

    expect(await result).toEqual({
      kind: 'applied',
      cart: { id: 42, subtotal: 200, discount: 20, total: 180, couponCode: 'BOOK10' },
    });
  });
});

Angular dokümanı provider sırasını özellikle vurgular: provideHttpClientTesting(), provideHttpClient()'ın bazı parçalarını ezdiği için ondan sonra gelmeli. firstValueFrom observable'a subscribe olur ve isteği gönderir; istek biz flush edene kadar bekler, bu yüzden önce isteğin kendisini doğrulayabiliyoruz. afterEach'teki verify, beklenmeyen bir isteğin sessizce kaçmasını engeller.

EMPTY dönen bir apply ile kırmızı tam olarak eksik davranışı söylüyor:

Error: Expected one matching request for criteria "Match method: POST, URL: /api/cart/42/coupon", found none.

HttpClient.post ile yeşile geçiyoruz. İkinci test, backend'in reddedilen kupon için döndüğü 422 problem cevabını bir sonuca çeviriyor:

it('turns a 422 problem into a rejection reason', async () => {
  const result = firstValueFrom(api.apply(42, 'OLD5'));

  http
    .expectOne('/api/cart/42/coupon')
    .flush({ title: 'Coupon rejected', detail: 'Expired' }, { status: 422, statusText: 'Unprocessable Entity' });

  expect(await result).toEqual({ kind: 'rejected', reason: 'Expired' });
});

Kırmızı: HttpErrorResponse: Http failure response for /api/cart/42/coupon: 422 Unprocessable Entity. 422'yi yakalayıp diğer hataları olduğu gibi geçiren bir catchError ile on iki test yeşil:

apply(cartId: number, code: string): Observable<ApplyCouponResult> {
  return this.http
    .post<CartSummaryDto>(`/api/cart/${cartId}/coupon`, { code })
    .pipe(
      map((cart): ApplyCouponResult => ({ kind: 'applied', cart })),
      catchError((error: HttpErrorResponse) =>
        error.status === 422
          ? of<ApplyCouponResult>({ kind: 'rejected', reason: error.error.detail })
          : throwError(() => error),
      ),
    );
}

reason değeri, CartSummary'nin couponError input'unun beklediği değerle aynı. Backend'deki CouponRejection enum'unun adı, iki tarafın sözleşmesi oldu. Bu sözleşmenin iki tarafta da aynı kaldığını doğrulamak contract testing'in işi; Test Stratejisi serisinin test türleri yazısında adını andık.

Zoneless Dünyada Zaman: fakeAsync Yerine Vitest Timer'ları

Angular'ı Zone.js ile yazmış biri, zamanlayıcı içeren bir testte refleks olarak fakeAsync ve tick'e uzanır. Angular 22 projesi varsayılan olarak zoneless ve Zone.js bağımlılığı yok. Refleksle yazılan test şöyle karşılanıyor:

 FAIL  |bookstore-web| src/app/cart/fake-async-probe.spec.ts [ src/app/cart/fake-async-probe.spec.ts ]
Error: zone-testing.js is needed for the fakeAsync() test helper but could not be found.
        Please make sure that your environment includes zone.js/testing

Dikkat: hata tek bir testi değil, bütün dosyayı düşürüyor; aynı dosyadaki diğer testler de koşmuyor. Angular'ın component testing scenarios dokümanı da fakeAsync'in artık önerilmediğini, native async stratejileri ya da başka fake timer'ları tercih etmeyi söylüyor. Vitest'in kendi timer'ları bu iş için var:

it('waits for a debounce with Vitest timers', async () => {
  vi.useFakeTimers();
  let fired = false;
  setTimeout(() => (fired = true), 300);
  await vi.advanceTimersByTimeAsync(300);
  expect(fired).toBe(true);
  vi.useRealTimers();
});
      Tests  13 passed (13)

Kupon kodunu yazarken backend'e her tuşta istek atmamak için debounce eklemek istersen, testi bu kalıpla yazarsın: timer'ı ilerlet, sonra HttpTestingController ile tek istek bekle.

Üretimde Dikkat

Watch modunda odak. Büyük bir projede her kayıtta bütün suite'in koşması döngüyü yavaşlatır. Üzerinde çalıştığın describe'a odaklanmak için geçici olarak describe.only kullanılabilir, ama commit'e girmemeli; ng test'in include seçeneği de koşan dosyaları daraltır. CI ortamında ng test, CI=true değişkeniyle tek seferlik koşar.

jsdom'un sınırları. jsdom layout hesaplamaz: element'lerin genişliği, görünürlüğü, scroll konumu gerçek değildir. "Bu buton ekranda görünüyor mu?" sorusunu jsdom'la test-first yazamazsın; o testler browser mode ya da E2E (end-to-end) seviyesinin işi. TDD'nin iç döngüsünü jsdom'da, görsel doğrulamayı başka katmanda tutmak ritmi korur.

Para hesapları. Bu yazıda kuruşlarla hesap yaptık, ama asıl otorite backend olmalı. Frontend'in hesapladığı toplam yalnız gösterim içindir; sunucudan dönen CartSummaryDto ile arada fark olursa sunucununki geçerlidir. Bu kuralı da bir test olarak yazmak, ileride biri frontend toplamını sunucuya göndermeye kalktığında uyarır.

Sık Düşülen Tuzaklar

Template'i snapshot'layıp TDD sanmak. toMatchSnapshot() ile component'in bütün HTML'ini (HyperText Markup Language) kaydetmek, ilk koşuda her zaman yeşildir; kırmızıyı hiç görmezsin. Snapshot, var olan çıktıyı dondurur, istenen davranışı tarif etmez. Test-first yazılmış bir test, kodu yazmadan önce neyi beklediğini söyler.

Her CSS değişikliğinde kırılan testler. .summary-row:nth-child(3) span gibi selector'lar tasarımcının her dokunuşunda kırılır. data-testid, role ve erişilebilir metin, testleri görsel düzenden ayırır. Component harness'leri bu ayrımı bir adım öteye taşır; ilerideki Frontend Testleri serisinde.

Zoneless ortamda eski fakeAsync alışkanlıkları. Yukarıda gördüğümüz gibi hata bütün dosyayı düşürür. Zone.js'i yalnız testler için geri eklemek, uygulamanın çalışmadığı bir ortamda test etmek demektir. Vitest timer'ları ve whenStable ile yaz.

Hesabı component'te bırakmak. computed içinde on satırlık bir hesap, onu yalnız DOM üzerinden test edilebilir kılar. Hesap büyümeye başladığı an saf fonksiyona çıkar; component testleri kısalır, fonksiyon testleri çoğalır.

Küçük Bir Deneme

★☆☆ calculateCartTotals'a "adet sıfır ya da negatifse satır yok sayılır" maddesini test-first ekle. Bu davranış fonksiyonun mu, yoksa sepete ekleyen kodun mu sorumluluğu? Test hangisini söyledi?

★★☆ CartSummary'ye bir kupon kodu input'u ve "Uygula" butonu ekle; buton tıklanınca bir output event'i (couponSubmitted) yayılsın. Testi önce yaz: event'i componentRef.instance.couponSubmitted.subscribe ile yakala. Boş kodla tıklandığında event yayılmamalı.

★★★ Kupon kodu yazılırken 300 ms debounce ile CouponApi.apply'ı çağıran bir akış kur. Testte Vitest fake timer'larını ve HttpTestingController'ı birlikte kullan: 300 ms dolmadan hiç istek gitmediğini, dolduktan sonra tek istek gittiğini doğrula.

Bir sonraki yazıda yeni kod yazmaktan çıkıp var olan koda dönüyoruz: bir bug'ı önce kırmızı bir testle yakalamak, test'siz bir legacy class'a yeni kuralı test-first eklemek ve characterization test ile TDD testinin farkı.