Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

PROJEKT - kompleksowe testowanie systemu legionów

Przez cały moduł pisaliśmy testy pojedynczo: tu test serwisu, tam test kontrolera, gdzie indziej test przez HTTP. Projekt jest miejscem, w którym mają stać się zestawem - takim, który ktoś uruchomi za pół roku i uwierzy w jego wynik.

Twoim zadaniem jest pokrycie testami systemu zarządzania legionami: legiony i kohorty, przypisywanie legionistów, planowanie wypraw, śledzenie tributów.

Trzy poziomy, trzy pytania

Zanim napiszesz pierwszy plik, ustal, na jakie pytanie odpowiada każdy poziom:

  • Test jednostkowy - czy ta metoda liczy poprawnie, gdy wszystko dookoła jest zamockowane?
  • Test integracyjny - czy moduły dogadują się między sobą, z prawdziwym kontenerem zależności?
  • Test E2E - czy pełna ścieżka użytkownika działa przez HTTP, od żądania do odpowiedzi?

Każdy poziom kosztuje inaczej. Jednostkowych pisz najwięcej, bo są tanie i wskazują palcem winowajcę; E2E najmniej, bo są wolne, a gdy padają, mówią tylko „coś jest nie tak".

Given-When-Then

Każdy test, niezależnie od poziomu, ma tę samą trójdzielną budowę.

Given
to warunki wstępne,
When
to akcja,
Then
to oczekiwany wynik
:

1it('should create a legion with the given name', async () => {
2  // Given - warunki wstępne
3  const dto = { name: 'Legio X Equestris', maxSoldiers: 5000 };
4  mockRepository.save.mockResolvedValue({ id: 1, ...dto });
5
6  // When - akcja
7  const result = await service.create(dto);
8
9  // Then - oczekiwany wynik
10  expect(result.id).toBe(1);
11  expect(mockRepository.save).toHaveBeenCalledWith(dto);
12});

Nie myl tego podziału z trzema poziomami testów -

Given
nie oznacza testu jednostkowego, a
Then
testu E2E. Nie chodzi też o rodzaje atrap: mock, spy i stub to narzędzia, których używasz w części
Given
. Ani o warstwy HTTP: dane, żądanie i kod odpowiedzi są tylko jednym z możliwych wypełnień tego szkieletu.

Wartość wzorca jest praktyczna. Gdy w teście brakuje wyraźnego

When
, zwykle znaczy to, że test sprawdza dwie akcje naraz - i przy porażce nie będziesz wiedzieć, która zawiodła.

Dwie zasady, które decydują o wiarygodności

Testy muszą być niezależne od siebie i izolowane. To pierwsza zasada testowania jednostkowego i jedyna, której złamanie psuje cały zestaw naraz.

Testy dzielące stan przechodzą w kolejności, w jakiej je napisano, a padają po zmianie kolejności, po uruchomieniu równoległym albo po dopisaniu jednego testu w środku pliku. Najgorsze, że wyglądają wtedy na wykrywające błąd - a wykrywają tylko własne powiązanie.

Dwa nieporozumienia warto od razu odsunąć. Nazwy testów mają być długie i opisowe, nie jak najkrótsze: nazwa testu jest komunikatem błędu, który zobaczysz na czerwonym tle w CI, i

t1
nie powie ci wtedy nic. Testować trzeba też przypadki brzegowe (edge cases), nie tylko ścieżkę pozytywną (happy path) - bo błędy mieszkają dokładnie tam, gdzie nikt nie zaglądał: pusta lista, wartość zero, znak specjalny w nazwie.

Hooki i mockowanie zależności

Izolację zapewniają hooki, a ich kolejność wykonania jest stała:

beforeAll()
beforeEach()
test/it()
afterAll()
:

1describe('LegionsController', () => {
2  let controller: LegionsController;
3  let module: TestingModule;
4
5  const mockLegionsService = {
6    findAll: jest.fn(),
7    create: jest.fn(),
8  };
9
10  beforeAll(async () => {
11    module = await Test.createTestingModule({
12      controllers: [LegionsController],
13      providers: [{ provide: LegionsService, useValue: mockLegionsService }],
14    }).compile();
15
16    controller = module.get(LegionsController);
17  });
18
19  beforeEach(() => {
20    jest.clearAllMocks();
21  });
22
23  afterAll(async () => {
24    await module.close();
25  });
26});

Podział pracy między hookami wynika wprost z ich kolejności.

beforeAll
wykonuje się raz - tu buduje się kosztowny moduł testowy.
beforeEach
wykonuje się przed każdym testem i to on realizuje zasadę izolacji:
jest.clearAllMocks()
kasuje historię wywołań, żeby test nie widział śladów po poprzedniku.
afterAll
sprząta na końcu - zamyka moduł, połączenia, otwarte uchwyty.

Zwróć uwagę na zapis atrapy w

providers
. Kolejność jest zawsze ta sama:
{ provide:
otwiera obiekt,
LegionsService,
wskazuje token do podmiany,
useValue:
zapowiada wartość, a
mockLegionsService }
ją podaje. Czyta się to jak zdanie: „w miejsce
LegionsService
użyj tej oto wartości".

Samo mockowanie repozytorium sprowadza się do jednej linijki na przypadek:

mockRepository.save.mockResolvedValue(expectedResult)
sprawia, że metoda zwróci gotowy wynik zamiast dotykać bazy. Do sprawdzenia ścieżki błędu użyjesz
mockRejectedValue
.

Test E2E

Na najwyższym poziomie mówisz do aplikacji tak, jak zrobi to klient - przez HTTP, biblioteką

supertest
:

1it('GET /legions returns all legions', async () => {
2  const response = await request(app.getHttpServer())
3    .get('/legions')
4    .expect(200)
5    .expect((res) => expect(res.body).toHaveLength(3));
6});
7
8it('POST /legions rejects a body without a name', async () => {
9  await request(app.getHttpServer())
10    .post('/legions')
11    .send({ maxSoldiers: 5000 })
12    .expect(400);
13});

Asercja składa się z czterech ogniw w stałej kolejności:

const response = await request(app.getHttpServer())
otwiera żądanie do działającej aplikacji,
.get('/legions')
wskazuje metodę i ścieżkę,
.expect(200)
sprawdza kod odpowiedzi, a
.expect(res => expect(res.body).toHaveLength(3))
zagląda do jej treści.

Drugi test pokazuje rzecz, o której łatwo zapomnieć: sprawdzaj też odrzucenia. Żądanie bez wymaganego pola musi dostać

400
, i to jedyny sposób, by upewnić się, że
ValidationPipe
jest naprawdę podpięty w konfiguracji testowej, a nie tylko w
main.ts
.

Gdy test nie przechodzi

Czerwony test debuguje się w czterech krokach, zawsze w tej kolejności:

  1. Przeczytaj komunikat błędu i stack trace. Jest w nim zwykle wszystko: czego oczekiwano, co otrzymano, i w której linii.
  2. Zidentyfikuj, która asercja zawiodła. Przy kilku
    expect
    w jednym teście to nie jest oczywiste - stąd zalecenie, by testów nie przeciążać.
  3. Sprawdź dane wejściowe i mocki. Najczęstsza przyczyna nie leży w kodzie, tylko w atrapie, która zwraca co innego, niż myślisz - albo pamięta wywołanie z poprzedniego testu.
  4. Napraw test albo testowany kod. Dopiero teraz, gdy wiadomo, co jest zepsute.

Kolejność ma znaczenie, bo naturalny odruch - zacząć od czwartego kroku i poprawiać kod na wyczucie - kończy się zmianą działającej implementacji pod błędny test.

Co oddajesz

Projekt jest gotowy, gdy zawiera:

  1. Testy jednostkowe serwisów z zamockowanymi repozytoriami, pokrywające także ścieżki błędów.
  2. Testy integracyjne sprawdzające współpracę modułów na prawdziwym
    TestingModule
    .
  3. Testy E2E dla kluczowych ścieżek, z asercjami na kodzie odpowiedzi i na treści.
  4. Osobne bloki
    describe
    dla każdego poziomu, tak by dało się je uruchamiać niezależnie.
  5. Raport pokrycia z uzasadnieniem tego, czego świadomie nie pokryłeś.

Na koniec wykonaj próbę, która sprawdza cały zestaw naraz: uruchom testy w losowej kolejności (

jest --randomize
). Jeśli którykolwiek padnie, masz gdzieś współdzielony stan, @name - a zestaw, który przechodzi tylko w jednej kolejności, nie mówi prawdy o kodzie.

Prześlij link do repozytorium, gdy skończysz.

Przejdź do CodeWorlds