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

Angular Testinde Bağımlılıkları Taklit Etmek: Provider Override, vi.fn, ng-mocks, MSW ve RouterTestingHarness

Mert Özen 14 Kas 2026 12 dk 12 görüntülenme
Angular Testinde Bağımlılıkları Taklit Etmek: Provider Override, vi.fn, ng-mocks, MSW ve RouterTestingHarness

Process dışına çıkan HTTP, zaman ve tarayıcı API'lerini taklit et, CartStore ve router gibi ucuz bağımlılıkları gerçek bırak: her şeyi mock'layan bir test, sepeti siparişten önce boşaltan bir hatayı kaçırdı. Bookstore'un checkout ve sipariş detay sayfalarında provider override, vi.fn, vi.spyOn, elle yazılmış fake, ng-mocks ile child component, MSW 3 ile network seviyesinde taklit ve gerçek route'larla RouterTestingHarness testlerini gerçek koşularla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar Angular 22.2 (TypeScript 6.0), Vitest 5.1, jsdom 30, @testing-library/angular 19.6, Mock Service Worker 3.0 ve ng-mocks 14.18 ile Node.js 24 üzerinde derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un (sitedeki serilerin ortak örnek uygulaması olan online kitapçı) checkout sayfası, 4. yazıdaki formdan gelen bilgileri sepet satırlarıyla birleştirip OrderApi'ye gönderiyor, sepeti boşaltıyor ve yeni siparişin sayfasına gidiyor. Bir refactor sonrasında place method'u şöyle oldu:

async place(details: CheckoutDetails): Promise<void> {
  this.failed.set(false);
  try {
    this.cart.clear();
    const lines = this.cart.lines().map((line) => ({ isbn: line.book.isbn, quantity: line.quantity }));
    const order = await this.orders.place({ ...details, lines });
    await this.router.navigate(['/orders', order.id]);
  } catch {
    this.failed.set(true);
  }
}

Sayfanın testi bütün bağımlılıkları taklit ediyor: OrderApi, CartStore ve Router birer vi.fn() nesnesi. Kullanıcı formu doldurup "Place order"a basıyor:

it('sends the cart lines and opens the new order (all mocked)', async () => {
  const orders = { place: vi.fn().mockResolvedValue(placedOrder) };
  const cart = { lines: vi.fn().mockReturnValue([{ book: dune, quantity: 2 }]), clear: vi.fn() };
  const router = { navigate: vi.fn().mockResolvedValue(true) };
  await render(CheckoutPage, {
    providers: [
      { provide: OrderApi, useValue: orders },
      { provide: CartStore, useValue: cart },
      { provide: Router, useValue: router },
    ],
  });

  await fillAndSubmit(userEvent.setup());

  expect(orders.place).toHaveBeenCalledWith(expect.objectContaining({ lines: [{ isbn: dune.isbn, quantity: 2 }] }));
  expect(cart.clear).toHaveBeenCalled();
  expect(router.navigate).toHaveBeenCalledWith(['/orders', 42]);
});

Kodda bir hata var mı? Varsa bu test onu yakalar mı?

Cevap: Mock, Gerçeğin Davranışını Taşımıyor

Test geçti. Kodda ise ciddi bir hata var: sepet, satırlar okunmadan önce boşaltılıyor; production'da her sipariş API'ye boş lines ile gidiyor. Testteki cart.lines sabit bir değer dönen bir vi.fn(); clear() çağrılsa da çağrılmasa da aynı satırları veriyor. Gerçek CartStore'da ise clear() signal'ı boşaltıyor ve sonraki lines() boş array dönüyor. Mock, gerçek service'in en önemli davranışını, method'lar arasındaki ilişkiyi taşımıyor.

Aynı testi gerçek CartStore ve gerçek router'la, yalnız OrderApi'yi taklit ederek yazınca hata hemen çıktı:

AssertionError: expected "vi.fn()" to be called with arguments: [ ObjectContaining{…} ]

  1st vi.fn() call:

  [
-   ObjectContaining {
-     "lines": [
    {
-         "isbn": "9780441013593",
-         "quantity": 2,
-       },
-     ],
+     "city": "Izmir",
+     "email": "ada@example.com",
+     "fullName": "Ada Lovelace",
+     "lines": [],
    },
  ]

Doğrusu, satırları okuyup siparişi gönderdikten sonra sepeti boşaltmak; Bookstore'daki kod öyle. Bu yazının sorusu şu: bir Angular testinde neyi taklit etmeli, neyi gerçek bırakmalı ve taklit edeceksen hangi araçla? Kavramsal tarafını, yani dummy, stub, spy, mock ve fake arasındaki farkı Unit Test serisinin "Test Double'lar" yazısı, aşırı mock'lamanın neden kırılgan testler ürettiğini de "Mock'u Ne Zaman Kullanmamalı" yazısı anlatıyor. Burada bunların Angular karşılığına bakıyoruz.

Neyi Taklit Etmeli?

Bookstore'da kullandığımız kural basit: process dışına çıkan şeyleri taklit et, process içinde ucuz olanları gerçek bırak. HTTP, zaman, rastgelelik ve jsdom'da olmayan tarayıcı API'leri taklit ediliyor. HTTP için 2. yazıdaki HttpTestingController'ın yanında bir seçenek daha var: MSW (Mock Service Worker; HTTP isteklerini network katmanında yakalayıp sahte cevap döndüren library). CartStore, PricePipe, router ve küçük child component'ler gerçek kalıyor; hızlılar, deterministikler ve davranışları testin parçası. Seçenekler ve maliyetleri:

AraçNeyi taklit ederNe zaman
Provider override + vi.fnbir service'in tek method'utek testlik cevap, çağrı argümanını kontrol
vi.spyOngerçek service'in bir method'uservice'in geri kalanı gerçek kalmalıysa
Elle yazılmış fakebir service'in tamamıbirden çok testte, state'i olan davranış
ng-mocks MockComponentbir child componentchild pahalıysa ya da kendi bağımlılıkları varsa
HttpTestingControllerHttpClient backend'iisteğin şeklini test etmek, Angular'a özgü
MSWnetworksayfa testleri, aynı handler'ları başka ortamlarda kullanmak
RouterTestingHarnesshiçbir şey, gerçek routerguard, resolver, route param

vi.fn, vi.spyOn ve Elle Yazılmış Fake

Angular'da bağımlılığı değiştirmenin yolu dependency injection: providers ile token'a başka bir değer ver. vi.fn tek testlik bir cevap için en kısa yol. Tip parametresi vermek, mock'un gerçek method'un imzasına uymasını sağlıyor:

it('sends the cart lines and opens the new order', async () => {
  const place = vi.fn<OrderApi['place']>().mockResolvedValue(placedOrder);
  await render(CheckoutPage, { providers: [{ provide: OrderApi, useValue: { place } }], routes });
  const cart = fillCart();

  await fillAndSubmit(userEvent.setup());

  expect(place).toHaveBeenCalledExactlyOnceWith({
    fullName: 'Ada Lovelace',
    email: 'ada@example.com',
    city: 'Izmir',
    lines: [{ isbn: dune.isbn, quantity: 2 }],
  });
  expect(cart.lines()).toEqual([]);
  expect(TestBed.inject(Router).url).toBe('/orders/42');
});

routes burada [{ path: 'orders/:id', children: [] }]; router gerçek, yalnız gidilecek sayfa boş. Böylece router.navigate'in hangi argümanla çağrıldığını değil, URL'in gerçekten /orders/42 olduğunu kontrol ediyoruz.

vi.spyOn gerçek service'in tek bir method'unu değiştiriyor, geri kalanını olduğu gibi bırakıyor. 1. yazıda vitest-base.config.mts'e koyduğumuz restoreMocks: true sayesinde spy bir sonraki testten önce kendiliğinden geri alınıyor:

it('keeps the cart and shows an error when the order fails', async () => {
  await render(CheckoutPage, { routes });
  const cart = fillCart();
  vi.spyOn(TestBed.inject(OrderApi), 'place').mockRejectedValue(new Error('503'));

  await fillAndSubmit(userEvent.setup());

  expect(await screen.findByRole('alert')).toHaveTextContent('We could not place your order. Please try again.');
  expect(cart.count()).toBe(2);
  expect(TestBed.inject(Router).url).toBe('/');
});

Elle yazılmış fake ise davranışı olan küçük bir class. Birden çok testte kullanılacaksa ya da çağrılar arasında state tutması gerekiyorsa vi.fn zincirlerinden daha okunaklı:

class FakeOrderApi {
  readonly requests: PlaceOrderRequest[] = [];

  place(request: PlaceOrderRequest): Promise<Order> {
    this.requests.push(request);
    return Promise.resolve({ ...placedOrder, id: 100 + this.requests.length });
  }
}

it('opens every new order it places', async () => {
  await render(CheckoutPage, { providers: [{ provide: OrderApi, useClass: FakeOrderApi }], routes });
  const fake = TestBed.inject(OrderApi) as unknown as FakeOrderApi;
  const user = userEvent.setup();
  fillCart();

  await fillAndSubmit(user);
  expect(TestBed.inject(Router).url).toBe('/orders/101');

  fillCart();
  await user.click(screen.getByRole('button', { name: 'Place order' }));
  expect(TestBed.inject(Router).url).toBe('/orders/102');
  expect(fake.requests).toHaveLength(2);
});

Jasmine'den gelenler için karşılıklar: jasmine.createSpyObj('OrderApi', ['place']) yerine { place: vi.fn() }, and.returnValue yerine mockReturnValue, and.resolveTo yerine mockResolvedValue, and.callFake yerine mockImplementation. Karşılıklar bire bir; ama alışkanlığın kendisi de taşınıyor, sorun da orada.

Child Component'i Taklit Etmek: ng-mocks

CheckoutPage'in testleri şimdiye kadar gerçek CheckoutForm'u kullandı; her test formu user-event ile dolduruyor. Formu taklit edip yalnız sayfanın kendi mantığını test etmek de mümkün. ng-mocks'un MockComponent'i, gerçek component'in selector'ını, input'larını ve output'larını taşıyan ama template'i boş bir component üretiyor. Angular Testing Library'nin (ATL) importOverrides seçeneği de standalone bir component'in import'unu testte değiştiriyor:

it('opens the order when the form emits', async () => {
  const place = vi.fn<OrderApi['place']>().mockResolvedValue(placedOrder);
  await render(CheckoutPage, {
    importOverrides: [{ replace: CheckoutForm, with: MockComponent(CheckoutForm) }],
    providers: [{ provide: OrderApi, useValue: { place } }],
    routes,
  });

  ngMocks.findInstance(CheckoutForm).placeOrder.emit({ fullName: 'Ada Lovelace', email: 'ada@example.com', city: 'Izmir' });

  await vi.waitFor(() => expect(TestBed.inject(Router).url).toBe('/orders/42'));
  expect(screen.queryByLabelText('Email')).not.toBeInTheDocument();
});

Son satır formun gerçekten taklit edildiğini gösteriyor: sayfada e-posta alanı yok. ng-mocks 14.18'in peer dependency aralığı Angular 22'yi kapsıyor ve signal tabanlı output() ile sorunsuz çalıştı.

Bu test hızlı ve formdaki değişikliklerden etkilenmiyor. Bedeli de bu: form, sayfanın kullandığı biçimde artık hiç test edilmiyor. Formun output'unun adı değişirse TypeScript findInstance(...).placeOrder satırında uyarır; ama formun validasyonu, submit davranışı ya da sayfanın formla birlikte çalışıp çalışmadığı bu testte görünmüyor. Bookstore'da child component'i yalnız iki durumda taklit ediyoruz: child'ın kendi ağır bağımlılıkları varsa (harita, editör, ödeme widget'ı) ya da render'ı testi belirgin şekilde yavaşlatıyorsa. CheckoutForm ikisine de girmiyor; snapshot'taki bu test, yaklaşımı göstermek için duruyor.

HTTP'yi İki Seviyede Taklit Etmek: HttpTestingController ve MSW

2. ve 3. yazılarda HTTP'yi HttpTestingController ile taklit ettik. O, HttpClient'ın backend'ini değiştiriyor: istek hiçbir zaman network katmanına inmiyor ve testin her isteği elle bulup cevaplaması gerekiyor. Service testleri için doğru araç, çünkü service'in sözleşmesi isteğin kendisi.

Sayfa testlerinde ise her isteği tek tek karşılamak testi kalabalıklaştırıyor. MSW burada başka bir seviyede çalışıyor: Node'da fetch'i yakalıyor. Angular 22'de HttpClient'ın varsayılan backend'i FetchBackend (kaynakta HttpBackend, useExisting: FetchBackend ile tanımlı), bu yüzden araya ek bir ayar girmiyor. Handler'lar API'nin sahte bir hâli gibi yazılıyor:

// src/mocks/handlers.ts
export const handlers = [
  http.get('/api/orders/:id', ({ params }) =>
    params['id'] === String(placedOrder.id)
      ? HttpResponse.json(placedOrder)
      : HttpResponse.json({ title: 'Not Found', status: 404 }, { status: 404 }),
  ),
  http.post('/api/orders', () => HttpResponse.json(placedOrder, { status: 201 })),
];
// src/testing/msw.ts
export const server = setupServer(...handlers);

export function useMockApi(): void {
  beforeAll(() => server.listen({ onUnhandledFrame: 'error' }));
  afterEach(() => server.resetHandlers());
  afterAll(() => server.close());
}

MSW 3'te karşılanmayan istekler için seçeneğin adı onUnhandledFrame; MSW 2 örneklerindeki onUnhandledRequest ile yazınca derleme TS2353 hatası verdi. 'error' değeri, handler'ı olmayan bir isteği sessizce geçirmek yerine şöyle raporluyor:

[MSW] Error: intercepted a request without a matching request handler:

  • GET /api/books

If you still wish to intercept this unhandled request, please create a request handler for it.

Relative URL'ler çalışıyor, çünkü Vitest'in jsdom ortamında location.href http://localhost:3000/. Bir testin tek seferlik cevabı server.use() ile ekleniyor. afterEach içindeki resetHandlers() şart: onu kaldırıp denediğimde ilk testin eklediği 403 handler'ı ikinci testte de geçerliydi ve test HttpErrorResponse: Http failure response for http://localhost:3000/api/orders/42: 403 Forbidden ile kaldı. Network seviyesinde taklit etmenin bir artısı da isteğin gerçekten nasıl gittiğini görmek; aşağıdaki test OrderApi'yi hiç taklit etmeden gönderilen JSON'u kontrol ediyor:

it('posts the order as JSON', async () => {
  let body: unknown;
  server.use(
    http.post('/api/orders', async ({ request }) => {
      body = await request.json();
      return HttpResponse.json(placedOrder, { status: 201 });
    }),
  );
  await render(CheckoutPage, { routes });
  fillCart();

  await fillAndSubmit(userEvent.setup());

  await vi.waitFor(() => expect(TestBed.inject(Router).url).toBe('/orders/42'));
  expect(body).toEqual({ fullName: 'Ada Lovelace', email: 'ada@example.com', city: 'Izmir', lines: [{ isbn: dune.isbn, quantity: 2 }] });
});

MSW dokümanına göre aynı handlers array'i msw/browser paketindeki setupWorker ile tarayıcıda da çalışıyor; 7. yazıdaki Vitest browser mode testleri ve istenirse geliştirme sunucusu aynı sahte API'yi kullanabiliyor. Hangisi ne zaman: service'in isteği doğru kurduğunu test ediyorsan HttpTestingController; sayfanın ya da route'un kullanıcıya doğru şeyi gösterdiğini test ediyorsan MSW.

Router: RouterTestingHarness ile Guard, Resolver ve Route Param

Sipariş detay sayfası /orders/:id route'unda. Route'a giriş authGuard ile korunuyor, sayfa başlığını bir resolver üretiyor ve id parametresi withComponentInputBinding() sayesinde component'e input olarak geliyor:

export const authGuard: CanActivateFn = (_route, state) =>
  inject(Session).signedIn() || inject(Router).createUrlTree(['/sign-in'], { queryParams: { returnUrl: state.url } });

export const orderTitle: ResolveFn<string> = (route) => `Order ${route.paramMap.get('id')} · Bookstore`;

{
  path: 'orders/:id',
  canActivate: [authGuard],
  title: orderTitle,
  loadComponent: () => import('./orders/order-detail').then((m) => m.OrderDetail),
}

OrderDetail, id input'unu httpResource ile /api/orders/{id}'ye çeviriyor; 403 ve 404 cevaplarında "This order could not be found." diyor. Başka bir müşterinin siparişinin var olup olmadığını bile söylememek bilinçli bir karar. Guard'ı ve resolver'ı ayrı ayrı fonksiyon olarak test etmek yerine uygulamanın gerçek route tanımıyla test ediyoruz. RouterTestingHarness testte bir router-outlet kuruyor ve gerçek bir navigasyon yapıyor:

describe('Order detail route', () => {
  useMockApi();

  beforeEach(() => {
    localStorage.clear();
    TestBed.configureTestingModule({ providers: [provideRouter(routes, withComponentInputBinding())] });
  });

  function signIn(): void {
    TestBed.inject(Session).user.set({ email: 'ada@example.com', token: 'test-token' });
  }

  it('shows the order whose id is in the URL', async () => {
    signIn();
    const harness = await RouterTestingHarness.create();

    await harness.navigateByUrl('/orders/42');

    expect(await screen.findByRole('heading', { name: 'Order BS-1042' })).toBeInTheDocument();
    expect(screen.getByRole('list', { name: 'Order lines' })).toHaveTextContent('2 × Dune');
    expect(screen.getByText('Total: ₺1.280,00')).toBeInTheDocument();
  });

  it('sends a signed-out visitor to sign in and remembers the order', async () => {
    const harness = await RouterTestingHarness.create();

    await harness.navigateByUrl('/orders/42');

    expect(TestBed.inject(Router).url).toBe('/sign-in?returnUrl=%2Forders%2F42');
    expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
  });

  it("does not reveal another customer's order", async () => {
    server.use(http.get('/api/orders/:id', () => HttpResponse.json({ title: 'Forbidden', status: 403 }, { status: 403 })));
    signIn();
    const harness = await RouterTestingHarness.create();

    await harness.navigateByUrl('/orders/77');

    expect(await screen.findByRole('alert')).toHaveTextContent('This order could not be found.');
  });
});

routes, app.routes.ts'teki gerçek tanım. Bu, guard'ın gerçekten bu route'a bağlı olduğunu, withComponentInputBinding ile id'nin component'e ulaştığını ve lazy loaded component'in yüklendiğini de test ediyor. Bir başka test TestBed.inject(Title).getTitle()'ın "Order 42 · Bookstore" olduğunu kontrol ediyor. 1. yazıdaki test haritasında "routed feature" dediğimiz seviye bu: router, guard, sayfa ve child'lar gerçek; yalnız network taklit. Dört test iki koşuda toplam 114 ve 154 ms sürdü.

Üretimde Dikkat

Handler'lar API'nin bir kopyası ve API değişince eskiyorlar. placedOrder'ın şekli API'nin gerçek cevabından ayrılırsa bütün sayfa testleri yeşil kalırken uygulama bozulabilir. Handler verisini API'nin OpenAPI tanımından üretilen tiplerle yazmak bu farkı derleme hatasına çeviriyor; iki tarafı birlikte, gerçek bir backend'le uçtan uca (E2E) kontrol etmek Integration ve E2E Test serisinin konusu.

Test yardımcılarını uygulama build'inden ayır. src/testing/msw.ts beforeAll gibi Vitest global'lerini kullanıyor; tsconfig.app.json bütün src/**/*.ts dosyalarını içerdiği için ng build TS2304: Cannot find name 'beforeAll' hatasıyla durdu. src/testing/**'i tsconfig.app.json'da exclude edip tsconfig.spec.json'a ekledik.

Suite bu yazının sonunda 15 dosya ve 56 testle 1,2 saniye civarında. Sayfa seviyesindeki testler (60–170 ms) component testlerinden belirgin şekilde yavaş, çünkü form doldurma ve navigasyon içeriyorlar. Bu hâlâ E2E testlerden çok hızlı; ama her davranışı sayfa seviyesinde test etmek suite'i hızla yavaşlatır. Kural 1. yazıdaki gibi: davranışı görebilen en alt seviye.

Sık Düşülen Tuzaklar

Her şeyi mock'layıp hiçbir şeyi test etmemek. Yazının başındaki test kodun hangi method'ları hangi sırayla çağırdığını tekrar ediyordu; davranışı değil implementation'ı kontrol ettiği için hatayı göremedi. CartStore ve Router gibi process içindeki, ucuz bağımlılıkları gerçek bırak.

createSpyObj alışkanlığını taşımak. Dönüş değeri verilmemiş bir vi.fn() undefined dönüyor. OrderApi.place'i böyle bıraktığımda order.id okunurken çıkan TypeError sayfanın catch bloğuna düştü ve test "We could not place your order" mesajını gösteren sayfayla kaldı. Hata mesajı API'nin başarısız olduğunu düşündürüyordu; asıl sorun mock'un eksik olmasıydı. vi.fn<OrderApi['place']>() gibi tipli mock'lar bu tür eksikleri daha erken gösteriyor.

resetHandlers'ı unutmak. server.use() ile eklenen handler, sıfırlanmadıkça sonraki testlerde de geçerli. 1. yazıdaki module state sorununun MSW'deki karşılığı.

TestBed.inject'i render()'dan önce çağırmak. 4. yazıda gördüğümüz gibi ATL TestBed'i kendisi yapılandırıyor; service'i önce inject edersen yapılandırma hatası alıyorsun. Spy'ları ve fake'leri render()'dan sonra kur ya da providers ile ver.

Guard'ı yalnız fonksiyon olarak test etmek. authGuard'ı TestBed.runInInjectionContext içinde çağırıp dönen UrlTree'yi kontrol etmek mümkün, ama bu guard'ın route'a bağlı olduğunu göstermiyor. Route tanımından guard'ı kaldırmak böyle bir testi kırmaz; RouterTestingHarness ile yazılan test kırar.

Küçük Bir Deneme

★☆☆ OrderDetail için 500 cevabı veren bir server.use() handler'ı ekle ve sayfanın "Could not load the order." gösterdiğini test et.

★★☆ authGuard'ı orders/:id route'undan kaldır ve testleri çalıştır. Hangi test kaldı? Aynı guard için TestBed.runInInjectionContext ile yazılmış bir fonksiyon testi bu değişikliği yakalar mıydı?

★★★ SignInPage testlerini incele: başarılı girişten sonra returnUrl'e dönülüyor mu? Yanlış şifrede Session boş kalıyor mu? Ardından MSW handler'ına 1 saniyelik bir delay() ekle ve testin hâlâ geçmesi için neyi değiştirmen gerektiğini bul.

Bu yazıdaki testler DOM'u (tarayıcının sayfayı tuttuğu element ağacı) rol ve isimle buldu; ama Bookstore'un ileride kullanacağı Angular Material gibi kütüphanelerin component'lerinde DOM yapısı senin kontrolünde değil. Bir sonraki yazıda bu sorunun Angular'daki cevabına bakıyoruz: component harness'ları, CDK (Angular'ın Component Dev Kit kütüphanesi) harness'larını kullanmak ve RatingInput için kendi harness'ını yazmak.