Mert Özen Her Satırda Daha İleri
Frontend Angular — #18

Angular'da Test'e Giriş: Karma, Jasmine, TestBed ve HttpClientTestingModule

Mert Özen 31 Eki 2026 7 dk 1 görüntülenme
Angular'da Test'e Giriş: Karma, Jasmine, TestBed ve HttpClientTestingModule

Angular 12'de ng test ile Karma ve Jasmine, spec dosyasının yapısı, TestBed ve ComponentFixture ile component testi, sahte bağımlılıkla service testi ve HttpClientTestingModule ile HTTP mock.

Sürüm notu: Bu yazıdaki kod ve komutlar Angular 12 içindir.

O spec Dosyaları Ne İşe Yarıyor?

CLI ile her component ya da service ürettiğinde yanında bir .spec.ts dosyası oluştu ve şimdiye kadar ona hiç dokunmadık. Bu dosyalar uygulamanın testleri. Kodun bugün doğru çalıştığını kanıtlamak için değil, yarın biri bir şeyi değiştirdiğinde hâlâ doğru çalıştığından emin olmak için yazılırlar.

Bir trapez sanatçısını düşün. Ağ olmadan da atlayabilir, ama ağ varken daha cesur hareket eder. Testler de o ağdır. Bir service'i refactor ederken ya da bir component'e yeni özellik eklerken, bir şeyi kırıp kırmadığını kullanıcıdan önce sen öğrenirsin.

ng test: Karma ve Jasmine

Angular 12 projesinde testleri çalıştırmak için tek komut yeterli:

ng test

Bu komut iki aracı birlikte çalıştırır. Jasmine, testleri yazdığın dili ve kuralları verir: describe, it, expect gibi fonksiyonlar ondan gelir. Karma ise bu testleri gerçek bir tarayıcıda çalıştıran araçtır. Komutu çalıştırdığında bir Chrome penceresi açılır, testler orada koşar ve sonuç hem tarayıcıda hem terminalde görünür.

Ayarlar projenin kökündeki karma.conf.js ve src/test.ts dosyalarında durur. test.ts, projedeki bütün .spec.ts dosyalarını bulup yükler; normalde ikisini de değiştirmen gerekmez. ng test izleme modunda çalışır: bir dosyayı kaydettiğinde testler kendiliğinden yeniden koşar.

Bir spec Dosyasının Anatomisi

Önce Angular'a hiç bağımlı olmayan basit bir service ile başlayalım:

import { Injectable } from '@angular/core';

export interface CartItem {
  name: string;
  price: number;
  quantity: number;
}

@Injectable({ providedIn: 'root' })
export class CartService {
  private items: CartItem[] = [];

  add(item: CartItem): void {
    this.items.push(item);
  }

  getTotal(): number {
    return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }
}

Testi de şöyle:

import { CartService } from './cart.service';

describe('CartService', () => {
  let service: CartService;

  beforeEach(() => {
    service = new CartService();
  });

  it('boş sepetin toplamı 0 olmalı', () => {
    expect(service.getTotal()).toBe(0);
  });

  it('ürünlerin fiyat ve adedine göre toplamı hesaplamalı', () => {
    service.add({ name: 'Kitap', price: 50, quantity: 2 });
    service.add({ name: 'Kalem', price: 10, quantity: 3 });

    expect(service.getTotal()).toBe(130);
  });
});

describe bir test grubunu açar; genelde test ettiğin class'ın adını verirsin. Her it tek bir davranışı test eder ve açıklaması bir cümle gibi okunmalı. beforeEach her it'ten önce çalışır; burada her teste tertemiz bir CartService veriyoruz, böylece testler birbirini etkilemiyor.

expect(...).toBe(...) ise asıl kontrol. Jasmine'de bunlara matcher denir. En sık kullanacakların toBe (birebir eşitlik), toEqual (nesne ve dizilerde içerik eşitliği), toBeTruthy, toContain ve toThrow. toBe ile toEqual farkına dikkat et: iki ayrı nesne içerik olarak aynı olsa da toBe testi başarısız olur.

TestBed ile Component Testi

CLI'nin her component için ürettiği spec dosyası şuna benzer:

import { ComponentFixture, TestBed } from '@angular/core/testing';

import { LikeButtonComponent } from './like-button.component';

describe('LikeButtonComponent', () => {
  let component: LikeButtonComponent;
  let fixture: ComponentFixture<LikeButtonComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ LikeButtonComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(LikeButtonComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

Component'ler service gibi new ile oluşturulamaz; template'i, bağımlılıkları ve bir module'ü vardır. TestBed bu yüzden test için küçük bir Angular ortamı kurar. configureTestingModule, daha önce gördüğümüz @NgModule'ün test için kurulan minyatür bir kopyasıdır. Component'i declarations'a yazarsın, ihtiyaç duyduğu module'leri imports'a, service'leri providers'a.

createComponent bize bir ComponentFixture döndürür. Fixture, component'i bir test tezgâhına bağlanmış hâlde tutar: componentInstance ile class'a, nativeElement ile oluşan DOM'a erişirsin. fixture.detectChanges() ise change detection'ı elle çalıştırır. Testlerde Angular bunu kendiliğinden yapmaz; ilk detectChanges çağrısı ngOnInit'i de tetikler.

DOM'u Sorgulamak ve Tıklamak

Test ettiğimiz component basit bir beğeni butonu olsun:

@Component({
  selector: 'app-like-button',
  template: `
    <button (click)="like()">Beğen</button>
    <span class="count">{{ count }}</span>
  `
})
export class LikeButtonComponent {
  count = 0;

  like(): void {
    this.count++;
  }
}

Kullanıcı gözüyle test edelim: butona tıklanınca ekrandaki sayı artıyor mu?

it('butona tıklanınca sayacı artırmalı', () => {
  const element = fixture.nativeElement as HTMLElement;
  const button = element.querySelector('button') as HTMLButtonElement;

  button.click();
  expect(element.querySelector('.count')?.textContent).toBe('0');

  fixture.detectChanges();
  expect(element.querySelector('.count')?.textContent).toBe('1');
});

nativeElement'i HTMLElement olarak alıyoruz ve bildiğin querySelector ile içinde dolaşıyoruz. button.click() gerçek bir tıklama event'i üretir ve like() çalışır. İlk expect'e dikkat et: class'taki count 1 olmuş ama ekranda hâlâ 0 yazıyor. detectChanges() çağırana kadar DOM güncellenmez. Yeni başlayanların en sık takıldığı nokta budur; "değeri değiştirdim ama test eski değeri görüyor" diyorsan büyük ihtimalle bir detectChanges eksiktir.

Service Testi ve Sahte Bağımlılıklar

CLI'nin service için ürettiği spec dosyası new yerine TestBed.inject kullanır:

beforeEach(() => {
  TestBed.configureTestingModule({});
  service = TestBed.inject(CartService);
});

Bağımlılığı olmayan bir service için ikisi arasında fark yok. Ama service başka service'lere bağımlıysa TestBed onları daha önce gördüğümüz dependency injection ile çözer. Asıl güzel tarafı da şu: gerçek bağımlılığın yerine sahtesini koyabilirsin. Mesela CheckoutService, CartService'i kullanıyor olsun; getShippingFee() metodu sepet toplamı 500 TL'yi geçiyorsa 0, geçmiyorsa 40 dönsün:

describe('CheckoutService', () => {
  let service: CheckoutService;
  let cartSpy: jasmine.SpyObj<CartService>;

  beforeEach(() => {
    cartSpy = jasmine.createSpyObj('CartService', ['getTotal']);

    TestBed.configureTestingModule({
      providers: [{ provide: CartService, useValue: cartSpy }]
    });
    service = TestBed.inject(CheckoutService);
  });

  it('500 TL üzeri siparişte kargo ücretsiz olmalı', () => {
    cartSpy.getTotal.and.returnValue(650);

    expect(service.getShippingFee()).toBe(0);
    expect(cartSpy.getTotal).toHaveBeenCalled();
  });
});

jasmine.createSpyObj verdiğin metod isimleriyle sahte bir nesne üretir. Daha önce gördüğümüz useValue ile CartService yerine bu sahte nesneyi veriyoruz. and.returnValue(650) "biri getTotal'ı çağırırsa 650 dön" demek. Böylece CheckoutService'i sepetin içiyle hiç uğraşmadan, tek başına test ediyoruz. toHaveBeenCalled ise metodun gerçekten çağrıldığını doğrular. Aynı yöntem, component testinde bir service'i sahtesiyle değiştirmek için de kullanılır.

HttpClient'ı Taklit Etmek

HTTP isteği atan bir service'i test ederken gerçek sunucuya gitmek istemeyiz; sunucu kapalı olabilir, veri değişebilir, test yavaşlar. Angular bunun için HttpClientTestingModule'ü sunar:

import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

import { Product, ProductService } from './product.service';

describe('ProductService', () => {
  let service: ProductService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule]
    });
    service = TestBed.inject(ProductService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify();
  });

  it('ürünleri GET isteğiyle getirmeli', () => {
    const fakeProducts: Product[] = [
      { id: 1, name: 'Mekanik Klavye', price: 1450 },
      { id: 2, name: 'Kablosuz Mouse', price: 600 }
    ];

    service.getProducts().subscribe(products => {
      expect(products.length).toBe(2);
      expect(products[0].name).toBe('Mekanik Klavye');
    });

    const req = httpMock.expectOne('/api/products');
    expect(req.request.method).toBe('GET');
    req.flush(fakeProducts);
  });
});

Burada ProductService'in içinde this.http.get<Product[]>('/api/products') çağıran bir getProducts() metodu olduğunu varsayıyoruz. HttpClientTestingModule, gerçek HttpClient altyapısını ağa hiç çıkmayan bir sahtesiyle değiştirir. İstekler gönderilmez, bir kenarda bekletilir.

Akış şöyle: önce subscribe olup ne beklediğimizi yazıyoruz. expectOne bu adrese tam olarak bir istek atıldığını doğrular ve o isteği bize verir. req.flush(...) ise sunucu cevabını taklit eder; subscribe içindeki expect'ler ancak bu satırdan sonra çalışır. afterEach içindeki verify(), beklenmeyen ya da cevaplanmamış bir istek kalmadığını kontrol eder.

Hata senaryosu da aynı kolaylıkta: req.flush('Sunucu hatası', { status: 500, statusText: 'Server Error' }) dersen subscribe'ın error tarafı çalışır ve daha önce yazdığın catchError mantığını test edebilirsin.

Testler Kırmızı Yandığında

Başarısız bir test, Karma'nın tarayıcı penceresinde ve terminalde kırmızıyla görünür. Mesaj genelde ne beklediğini ve ne bulduğunu söyler: Expected 120 to be 130. gibi. Hangi it'in, hangi satırda patladığı da altında yazar.

Bir teste odaklanmak istediğinde it yerine fit, describe yerine fdescribe yaz; sadece onlar çalışır. Bir testi geçici olarak kapatmak için xit ve xdescribe var. Bunları commit'lemeden önce geri almayı unutma.

CI ortamında tarayıcının açık kalmasını istemezsin. ng test --watch=false testleri bir kez çalıştırıp çıkar. ng test --code-coverage ise projenin köküne bir coverage/ klasörü üretir; içindeki HTML raporda hangi satırların testlerle hiç çalışmadığını görürsün. Angular'ın test altyapısı ilerleyen sürümlerde değişiyor; bunu sürüm yazılarında ayrıca ele alacağız.

Küçük Bir Deneme

Yukarıdaki LikeButtonComponent'e bir "Beğeniyi geri al" butonu ekle ve sayının sıfırın altına düşmesine izin verme. Sonra üç test yaz: geri al butonu sayıyı bir azaltmalı, sayı 0 iken geri al hiçbir şey yapmamalı, iki beğeni ve bir geri alma sonunda ekranda 1 yazmalı.

Testleri yazdıktan sonra component'te bilerek bir hata yap; mesela count-- yerine count -= 2 yaz. Hangi testin kırmızıya döndüğüne ve hata mesajının sana ne söylediğine bak. Ardından ng test --watch=false --code-coverage çalıştırıp raporu aç.

Testlerin hepsi yeşil olduğunda sıra uygulamayı kullanıcının önüne koymaya gelir. Bir sonraki yazıda ng build ile production paketi üretmeyi, environment dosyalarını ve uygulamayı bir sunucuya deploy etmeyi konuşacağız.