Kurs NestJS · Moduł 7: Testowanie

Mocking - symulacja warunków bojowych

5 min czytania
W tej lekcji6

Chcesz przetestować serwis, który wypłaca żołd. Sęk w tym, że sięga on do bazy danych i do zewnętrznego skarbca Imperium. Test wymagałby więc działającej bazy, sieci i cudzego serwera - byłby wolny, a padłby przy pierwszej awarii łącza, choć Twój kod byłby bez zarzutu.

Legion ćwiczy inaczej. Na placu manewrowym stawia się kukły zamiast prawdziwych przeciwników. Kukła nie oddaje ciosów, ale pozwala sprawdzić, czy legionista zachowuje szyk. W testach takie kukły nazywamy test doubles - dublerami.

Cztery rodzaje kukieł

Dublery różnią się tym, ile potrafią. Warto znać całą czwórkę, bo nazwy wracają w każdej dokumentacji - ułożone od najprostszej do najbardziej złożonej:

  1. Dummy - kukła wypchana słomą. Wypełnia miejsce parametru, którego metoda i tak nie użyje. Nie robi nic.
  2. Stub - kukła z gotową odpowiedzią. Zapytana, zawsze mówi to samo, bez cienia logiki. "Legionista o id 1? Oto Marek."
  3. Spy - kukła, która zapamiętuje ciosy. Odpowiada jak stub, ale dodatkowo zapisuje, ile razy ją zawołano i z czym.
  4. Mock - kukła z oczekiwaniami. Nie tylko zapisuje wywołania, ale służy do weryfikowania interakcji: czy serwis naprawdę zawołał repozytorium, i to dokładnie raz.

W praktyce Jest zaciera granicę między trzema ostatnimi - ta sama funkcja bywa stubem i spy zarazem. Ale rozróżnienie ról zostaje: stub dostarcza dane, spy obserwuje, mock weryfikuje.

jest.fn() - kukła zbudowana od zera

Podstawowe narzędzie tworzy pustą funkcję-atrapę:

1const findOne = jest.fn();

Ta funkcja nic nie robi i zwraca undefined, ale Jest śledzi każde jej wywołanie - zapamiętuje, ile razy padła i z jakimi argumentami. Samo jest.fn() jest więc już szpiegiem; brakuje mu tylko odpowiedzi.

Odpowiedź dokładamy jedną z czterech metod, które różnią się tym, co zwracają:

1const repo = {
2  findOne: jest.fn().mockResolvedValue({ id: 1, name: 'Marek' }),
3  count: jest.fn().mockReturnValue(42),
4  save: jest.fn().mockRejectedValue(new Error('Skarbiec niedostępny')),
5};

mockReturnValue zwraca wartość od razu, synchronicznie - nadaje się do metod, które nie są asynchroniczne. mockResolvedValue zwraca spełnioną obietnicę, więc pasuje wszędzie tam, gdzie kod robi await. mockRejectedValue zwraca obietnicę odrzuconą - to nim sprawdzisz, czy Twój serwis poprawnie obsługuje awarię.

Jest też wariant z przyrostkiem Once: mockReturnValueOnce odpowiada tak tylko za pierwszym razem, a potem wraca do zachowania domyślnego. Przydaje się, gdy chcesz, by pierwsze wywołanie się nie powiodło, a ponowienie już tak.

Tak przygotowaną kukłę wstawiamy w miejsce prawdziwej zależności:

1const module = await Test.createTestingModule({
2  providers: [
3    PayService,
4    { provide: getRepositoryToken(Legionary), useValue: repo },
5  ],
6}).compile();

useValue mówi NestJS: gdy ktoś poprosi o repozytorium legionistów, podaj mu ten obiekt zamiast prawdziwego. Serwis niczego nie zauważy - dostanie coś, co ma te same metody.

jest.spyOn() - szpieg przy istniejącej metodzie

Czasem nie chcesz budować kukły od zera, tylko podejrzeć prawdziwy obiekt. Do tego służy druga metoda:

1const spy = jest.spyOn(legionService, 'findAll');

Różnica między nimi jest zasadnicza i o nią właśnie pytają najczęściej. jest.fn() tworzy nową funkcję, która nigdzie wcześniej nie istniała. jest.spyOn() bierze istniejącą metodę istniejącego obiektu i owija ją obserwacją.

Kluczowe jest to, czego spyOn domyślnie nie robi: nie podmienia zachowania. Prawdziwa metoda nadal się wykonuje, a Ty tylko widzisz, że ją zawołano. Gdy chcesz również podstawić odpowiedź, dokładasz ją tak samo jak wcześniej:

1jest.spyOn(legionService, 'findAll').mockResolvedValue([]);

Teraz prawdziwa metoda już się nie wykona. Ta para - obserwuj albo obserwuj i zastąp - to cały spyOn.

Weryfikacja - czy kukła została zawołana

Skoro dublery zapisują wywołania, możemy o nie zapytać w asercjach:

1expect(spy).toHaveBeenCalledTimes(1);
2expect(repo.findOne).toHaveBeenCalledWith({ where: { id: 1 } });

toHaveBeenCalledTimes sprawdza liczbę wywołań, toHaveBeenCalledWith - argumenty. To właśnie w tym momencie dubler pełni rolę mocka: nie interesuje nas już, co zwrócił, tylko czy rozmowa w ogóle się odbyła i w jakiej formie.

Uważaj jednak, żeby nie przesadzić. Testowanie każdego wywołania wiąże test z wewnętrzną budową serwisu - drobny refaktor zepsuje wtedy testy, choć zachowanie kodu się nie zmieniło. Sprawdzaj interakcje tam, gdzie sama interakcja jest celem: że e-mail został wysłany, że zapis do bazy nastąpił. Dla zwykłych odczytów wystarczy sprawdzić wynik.

Sprzątanie po ćwiczeniach

Dublery pamiętają wywołania - także te z poprzedniego testu. Bez czyszczenia toHaveBeenCalledTimes(1) zacznie w drugim teście widzieć dwa wywołania:

1afterEach(() => {
2  jest.clearAllMocks();
3});

clearAllMocks zeruje liczniki i zapisane argumenty wszystkich atrap. To jedna z tych linijek, których brak objawia się dopiero wtedy, gdy testy zaczynają przechodzić lub padać zależnie od kolejności uruchomienia - a to najtrudniejszy rodzaj usterki do wyśledzenia. Wstaw ją od razu.

Podsumowanie

Plac manewrowy gotowy, kukły ustawione:

  • test doubles zastępują prawdziwe zależności, dzięki czemu test nie potrzebuje bazy ani sieci,
  • czwórka od najprostszej: Dummy (wypełniacz), Stub (gotowa odpowiedź), Spy (zapamiętuje wywołania), Mock (weryfikuje interakcje),
  • jest.fn() tworzy nową funkcję-atrapę i od razu śledzi jej wywołania,
  • odpowiedzi: mockReturnValue synchronicznie, mockResolvedValue spełniona obietnica, mockRejectedValue odrzucona, warianty ...Once działają tylko raz,
  • useValue w createTestingModule podstawia atrapę zamiast prawdziwej zależności,
  • jest.spyOn() obserwuje istniejącą metodę i domyślnie nie zmienia jej zachowania - dopiero mockResolvedValue je podmienia,
  • toHaveBeenCalledTimes sprawdza liczbę wywołań, toHaveBeenCalledWith - argumenty,
  • nie weryfikuj każdego wywołania: test przywiązany do wewnętrznej budowy pęka przy refaktorze,
  • jest.clearAllMocks() w afterEach zeruje liczniki - bez tego testy zaczną zależeć od kolejności.

W następnej lekcji sprawdzimy, ile z kodu naprawdę pokryły Twoje testy - poznasz test coverage. A na razie zapamiętaj: dubler to kukła na placu manewrowym; jest.fn() ją buduje, jest.spyOn() przebiera za nią kogoś prawdziwego, a asercje pytają, czy legionista w ogóle zadał cios.

Kod do tej lekcji: src/mocking.spec.ts
1// Mocking - Symulacja Warunkow Imperialnych
2import { Test, TestingModule } from '@nestjs/testing';
3import { Injectable } from '@nestjs/common';
4
5// Interfejs zewnetrznego serwisu
6interface WeatherAPI {
7  getConditions(region: string): Promise<{
8    wind: number;
9    waves: number;
10    visibility: number;
11  }>;
12}
13
14// Serwis nawigacji - zalezy od WeatherAPI
15@Injectable()
16class NavigationService {
17  constructor(private weatherApi: WeatherAPI) {}
18
19  async canSail(region: string): Promise<boolean> {
20    const conditions = await this.weatherApi.getConditions(region);
21    return conditions.wind < 50 && conditions.waves < 5;
22  }
23
24  async getRouteRisk(region: string): Promise<string> {
25    const conditions = await this.weatherApi.getConditions(region);
26    if (conditions.wind > 70) return 'extreme';
27    if (conditions.wind > 50) return 'high';
28    if (conditions.wind > 30) return 'moderate';
29    return 'low';
30  }
31}
32
33describe('NavigationService - Mocking', () => {
34  let service: NavigationService;
35
36  // Mock WeatherAPI - symulujemy warunki pogodowe
37  const mockWeatherApi: WeatherAPI = {
38    getConditions: jest.fn(),
39  };
40
41  beforeEach(async () => {
42    const module: TestingModule = await Test.createTestingModule({
43      providers: [
44        NavigationService,
45        { provide: 'WeatherAPI', useValue: mockWeatherApi },
46      ],
47    }).compile();
48
49    service = new NavigationService(mockWeatherApi);
50    jest.clearAllMocks();
51  });
52
53  it('should allow marching in calm conditions', async () => {
54    // Konfiguracja mocka - spokojne Imperium
55    (mockWeatherApi.getConditions as jest.Mock).mockResolvedValue({
56      wind: 20, waves: 2, visibility: 10,
57    });
58
59    const canSail = await service.canSail('Mediterranean');
60    expect(canSail).toBe(true);
61    expect(mockWeatherApi.getConditions).toHaveBeenCalledWith('Mediterranean');
62  });
63
64  it('should prevent marching in storm', async () => {
65    // Konfiguracja mocka - burza
66    (mockWeatherApi.getConditions as jest.Mock).mockResolvedValue({
67      wind: 80, waves: 8, visibility: 1,
68    });
69
70    const canSail = await service.canSail('Atlantic');
71    expect(canSail).toBe(false);
72  });
73
74  it('should assess route risk correctly', async () => {
75    (mockWeatherApi.getConditions as jest.Mock).mockResolvedValue({
76      wind: 55, waves: 4, visibility: 5,
77    });
78
79    const risk = await service.getRouteRisk('North Sea');
80    expect(risk).toBe('high');
81  });
82
83  it('should handle API errors gracefully', async () => {
84    (mockWeatherApi.getConditions as jest.Mock).mockRejectedValue(
85      new Error('Weather service unavailable')
86    );
87
88    await expect(service.canSail('Unknown')).rejects.toThrow(
89      'Weather service unavailable'
90    );
91  });
92});
93

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. jest.fn() w testach tworzy:

  2. 2. Jaka jest różnica między jest.fn() a jest.spyOn()?

To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Edytor kodu

    Zamockuj repozytorium bazy danych za pomocą jest.fn()

  • Klikanie w kolejności

    Ułóż poprawną konfigurację mockResolvedValue:

  • Układanie w pionie

    Uporządkuj warianty mockowania od synchronicznego do asynchronicznego:

  • Edytor kodu

    Zainstaluj szpiega na metodzie findAll() serwisu legionów

  • Klikanie w kolejności

    Ułóż asercję sprawdzającą liczbę wywołań mocka:

  • Układanie w pionie

    Ułóż Test Doubles od najprostszego do najbardziej złożonego:

Przydatne artykuły