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.
Zanim napiszesz pierwszy plik, ustal, na jakie pytanie odpowiada każdy poziom:
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".
Każdy test, niezależnie od poziomu, ma tę samą trójdzielną budowę.
to warunki wstępne, Given
to akcja, When
to oczekiwany wynik:Then
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.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.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.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:
otwiera żądanie do działającej aplikacji, const response = await request(app.getHttpServer())
wskazuje metodę i ścieżkę, .get('/legions')
sprawdza kod odpowiedzi, a .expect(200)
zagląda do jej treści..expect(res => expect(res.body).toHaveLength(3))
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.Czerwony test debuguje się w czterech krokach, zawsze w tej kolejności:
expect w jednym teście to nie jest oczywiste - stąd zalecenie, by testów nie przeciążać.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.
Projekt jest gotowy, gdy zawiera:
TestingModule.describe dla każdego poziomu, tak by dało się je uruchamiać niezależnie.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.