Kurs JavaScript i TypeScript · Moduł 11: Testowanie z Jest
Testowanie TypeScript z Jest
W tej lekcji5
TypeScript złapie tekst podany zamiast liczby, ale nie złapie błędu w logice: funkcja, która zwraca zdrowie 110 zamiast 100, jest pod względem typów bez zarzutu. Z kolei Jest sam z siebie nie rozumie składni TypeScriptu, więc ktoś musi mu ją przetłumaczyć. Ta lekcja łączy oba światy.
TypeScript dodaje do JavaScript system typów, ale testy nadal sprawdzają zachowanie w runtime. W Parku Jurajskim TypeScript to jak dokumentacja gatunków - mówi nam czego się spodziewać, ale testy weryfikują, czy dinozaury naprawdę się tak zachowują. W piramidzie testów TypeScript należy do fundamentu analizy statycznej, a testy pracują piętro wyżej.
Konfiguracja ts-jest
Aby testować TypeScript z Jest, potrzebujemy ts-jest. To transformer: zanim Jest uruchomi plik .ts, ts-jest kompiluje go do JavaScriptu. Paczka @types/jest dostarcza typy dla describe, it i expect, żeby edytor i kompilator je rozpoznawały. Zakładam, że jest i typescript masz już w projekcie, a jeśli nie, dopisz je do tej samej komendy:
1npm install --save-dev ts-jest @types/jestZamiast @types/jest możesz użyć paczki @jest/globals i importować describe, it oraz expect jawnie. @types/jest utrzymuje społeczność DefinitelyTyped, dlatego dokumentacja Jesta radzi, by jego wersja jak najbliżej pasowała do wersji Jesta.
Teraz wystarczy powiedzieć Jestowi, żeby pliki przepuszczał przez ts-jest. Robi to jedna linia preset, a testMatch zawęża poszukiwania do plików .test.ts:
1// jest.config.js
2module.exports = {
3 preset: 'ts-jest',
4 testEnvironment: 'node',
5 testMatch: ['**/*.test.ts'],
6};Plik możesz też wygenerować komendą npx ts-jest config:init. Opcja testEnvironment: 'node' oznacza, że testy działają w czystym Node.js, bez symulowanej przeglądarki. Istnieje też druga droga: babel-jest z presetem @babel/preset-typescript. Babel tylko usuwa typy, nie sprawdzając ich, a ts-jest domyślnie zgłasza błędy typów i oblewa test. Dlatego polecam ts-jest gdy zależy Ci na tej dodatkowej kontroli.
Testowanie interfejsów i typów
Interfejs Dinosaur opisuje kształt okazu. Pole diet przyjmuje tylko trzy wartości, bo jest unią typów literalnych. Zapis Dinosaur['diet'] w parametrze pobiera typ tego jednego pola, więc funkcja nie przyjmie diety spoza listy:
1// dinosaur.ts
2interface Dinosaur {
3 name: string;
4 species: string;
5 health: number;
6 diet: 'carnivore' | 'herbivore' | 'omnivore';
7}
8
9function createDinosaur(
10 name: string,
11 species: string,
12 diet: Dinosaur['diet']
13): Dinosaur {
14 return {
15 name,
16 species,
17 health: 100,
18 diet,
19 };
20}Test porównuje cały zwrócony obiekt przez toEqual, a osobno sprawdza, że zdrowie jest liczbą z zakresu od 0 do 100:
1// dinosaur.test.ts
2describe('createDinosaur', () => {
3 it('should return object matching Dinosaur interface', () => {
4 const dino = createDinosaur('Rex', 'T-Rex', 'carnivore');
5
6 // TypeScript sprawdza typy w compile-time
7 // Testy sprawdzają wartości w runtime
8 expect(dino).toEqual({
9 name: 'Rex',
10 species: 'T-Rex',
11 health: 100,
12 diet: 'carnivore',
13 });
14 });
15
16 it('should have health as number', () => {
17 const dino = createDinosaur('Blue', 'Velociraptor', 'carnivore');
18 expect(typeof dino.health).toBe('number');
19 expect(dino.health).toBeGreaterThanOrEqual(0);
20 expect(dino.health).toBeLessThanOrEqual(100);
21 });
22});Interfejsu nie da się przetestować bezpośrednio: po kompilacji znika bez śladu i w runtime nie istnieje. Testujemy więc wartości, a o typy dba kompilator. Wywołanie createDinosaur('Pinky', 'Compsognathus', 'fruitarian') nie przeszłoby kompilacji, a ts-jest zgłosiłby błąd, zanim test w ogóle ruszy.
Testowanie funkcji generycznych
Repository<T> to interfejs z parametrem typu T: jedno repozytorium może przechowywać dinozaury, strażników albo jaja, a TypeScript pilnuje, żeby w jednym nie mieszać gatunków. Ograniczenie T extends { id: string } wymaga, by każdy element miał pole id:
1// repository.ts
2interface Repository<T> {
3 items: T[];
4 add(item: T): void;
5 findById(id: string): T | undefined;
6 getAll(): T[];
7}
8
9function createRepository<T extends { id: string }>(): Repository<T> {
10 const items: T[] = [];
11
12 return {
13 items,
14 add(item: T) {
15 items.push(item);
16 },
17 findById(id: string) {
18 return items.find(item => item.id === id);
19 },
20 getAll() {
21 return [...items];
22 },
23 };
24}W teście podstawiamy za T pomocniczy interfejs TestDino, a beforeEach tworzy świeże repozytorium przed każdym testem:
1// repository.test.ts
2interface TestDino {
3 id: string;
4 name: string;
5 dangerous: boolean;
6}
7
8describe('Repository<T>', () => {
9 let repo: Repository<TestDino>;
10
11 beforeEach(() => {
12 repo = createRepository<TestDino>();
13 });
14
15 it('should add and retrieve items', () => {
16 const dino: TestDino = {
17 id: '001',
18 name: 'Rex',
19 dangerous: true,
20 };
21
22 repo.add(dino);
23
24 expect(repo.getAll()).toHaveLength(1);
25 expect(repo.findById('001')).toEqual(dino);
26 });
27
28 it('should return undefined for missing item', () => {
29 expect(repo.findById('999')).toBeUndefined();
30 });
31
32 it('should return copy of items array', () => {
33 repo.add({ id: '001', name: 'Rex', dangerous: true });
34
35 const items = repo.getAll();
36 items.push({ id: '002', name: 'Blue', dangerous: true });
37
38 // Oryginalna tablica nie zmieniona
39 expect(repo.getAll()).toHaveLength(1);
40 });
41});Ostatni test pilnuje ważnej obietnicy: getAll zwraca kopię tablicy ([...items]), więc push na wyniku nie zmienia repozytorium. Gdyby ktoś w refaktorze zwrócił items bezpośrednio, alarm by zawył. Zwróć też uwagę, że findById zwraca dokładnie ten obiekt, który dodaliśmy, więc zamiast toEqual przeszłoby nawet toBe.
Testowanie klas TypeScript
Klasa Enclosure łączy kilka mechanizmów TypeScriptu. private ukrywa tablicę dinozaurów przed kodem spoza klasy, a public readonly w konstruktorze to skrót, dzięki któremu TypeScript sam tworzy pola name, capacity i securityLevel tylko do odczytu. Metoda addDinosaur rzuca wyjątki przy pełnym wybiegu i przy zbyt słabych zabezpieczeniach dla mięsożerców:
1// enclosure.ts
2class Enclosure {
3 private dinosaurs: Dinosaur[] = [];
4
5 constructor(
6 public readonly name: string,
7 public readonly capacity: number,
8 public readonly securityLevel: number
9 ) {}
10
11 addDinosaur(dino: Dinosaur): void {
12 if (this.dinosaurs.length >= this.capacity) {
13 throw new Error('Enclosure is full');
14 }
15 if (dino.diet === 'carnivore' && this.securityLevel < 8) {
16 throw new Error('Security level too low for carnivores');
17 }
18 this.dinosaurs.push(dino);
19 }
20
21 getDinosaurCount(): number {
22 return this.dinosaurs.length;
23 }
24
25 isEmpty(): boolean {
26 return this.dinosaurs.length === 0;
27 }
28}Testy korzystają z createDinosaur z pierwszego przykładu i z beforeEach, który przed każdym testem stawia nowy wybieg na trzy okazy z poziomem zabezpieczeń 9:
1// enclosure.test.ts
2describe('Enclosure', () => {
3 let enclosure: Enclosure;
4
5 beforeEach(() => {
6 enclosure = new Enclosure('Zone A', 3, 9);
7 });
8
9 it('should create enclosure with properties', () => {
10 expect(enclosure.name).toBe('Zone A');
11 expect(enclosure.capacity).toBe(3);
12 expect(enclosure.securityLevel).toBe(9);
13 });
14
15 it('should start empty', () => {
16 expect(enclosure.isEmpty()).toBe(true);
17 expect(enclosure.getDinosaurCount()).toBe(0);
18 });
19
20 it('should add dinosaur', () => {
21 const dino = createDinosaur('Rex', 'T-Rex', 'carnivore');
22 enclosure.addDinosaur(dino);
23
24 expect(enclosure.getDinosaurCount()).toBe(1);
25 expect(enclosure.isEmpty()).toBe(false);
26 });
27
28 it('should throw when full', () => {
29 const dinos = [
30 createDinosaur('Rex', 'T-Rex', 'carnivore'),
31 createDinosaur('Blue', 'Velociraptor', 'carnivore'),
32 createDinosaur('Delta', 'Velociraptor', 'carnivore'),
33 ];
34
35 dinos.forEach(d => enclosure.addDinosaur(d));
36
37 const extra = createDinosaur('Echo', 'Velociraptor', 'carnivore');
38 expect(() => enclosure.addDinosaur(extra)).toThrow('Enclosure is full');
39 });
40
41 it('should reject carnivore with low security', () => {
42 const lowSecurity = new Enclosure('Zone B', 5, 5);
43 const rex = createDinosaur('Rex', 'T-Rex', 'carnivore');
44
45 expect(() => lowSecurity.addDinosaur(rex)).toThrow(
46 'Security level too low for carnivores'
47 );
48 });
49});Test nie sięga do prywatnej tablicy dinosaurs, bo kompilator by na to nie pozwolił. Sprawdza stan przez publiczne metody getDinosaurCount i isEmpty, czyli tak, jak z klasy korzysta reszta parku. To dobra zasada: testuj zachowanie widoczne z zewnątrz, a nie szczegóły implementacji.
Sprawdzanie kształtu obiektu (matchery asymetryczne)
Uwaga na nazewnictwo: w TypeScripcie "type assertion" to zapis wartość as Typ, który tylko ucisza kompilator i niczego nie sprawdza w runtime. Matchery asymetryczne pozwalają sprawdzić, że wartości mają oczekiwany kształt, nawet gdy nie znasz dokładnych wartości. getApiResponse to przykładowa funkcja zwracająca odpowiedź API. expect.objectContaining wymaga tylko wymienionych pól, expect.any(String) przyjmuje dowolny string, a expect.arrayContaining tablicę zawierającą wskazane elementy:
1test('response has correct shape', () => {
2 const response = getApiResponse();
3
4 // Sprawdzenie struktury obiektu
5 expect(response).toEqual(
6 expect.objectContaining({
7 status: expect.any(String),
8 data: expect.any(Array),
9 timestamp: expect.any(Number),
10 })
11 );
12
13 // Sprawdzenie elementów tablicy
14 expect(response.data).toEqual(
15 expect.arrayContaining([
16 expect.objectContaining({
17 id: expect.any(String),
18 name: expect.any(String),
19 })
20 ])
21 );
22});Takie asercje są odporne na zmienne dane: identyfikator czy znacznik czasu zmienia się przy każdym wywołaniu, a test i tak przechodzi, dopóki zgadza się kształt. To runtime'owy odpowiednik interfejsu, który sprawdza w działającym kodzie to, co TypeScript sprawdził podczas kompilacji.
W testach TypeScript describe, it i test działają tak samo jak w JavaScripcie, a it wciąż jest tylko aliasem test. W projekcie końcowym połączysz wszystkie narzędzia z tego modułu, a w laboratorium poniżej zobaczysz symulację testów klasy Enclosure i generycznego repozytorium.
Pamiętaj: TypeScript to katalog gatunków, a testy to obserwacja w terenie - dopiero razem mówią Ci, że dinozaur jest tym, za kogo się podaje.
Kod do tej lekcji: index.js
1// Testowanie TypeScript z Jest
2console.log("=== Park Jurajski - TypeScript Testing ===\n");
3
4let passed = 0, failed = 0;
5function assert(cond, msg) {
6 if (cond) { passed++; console.log(" [PASS] " + msg); }
7 else { failed++; console.log(" [FAIL] " + msg); }
8}
9
10// --- Konfiguracja ts-jest ---
11console.log("--- Konfiguracja ts-jest ---\n");
12console.log("npm install --save-dev ts-jest @types/jest\n");
13console.log("// jest.config.js");
14console.log("module.exports = {");
15console.log(" preset: 'ts-jest',");
16console.log(" testEnvironment: 'node',");
17console.log("};\n");
18
19// --- Testowanie interfejsow ---
20console.log("--- Testowanie interfejsow i klas TypeScript ---\n");
21
22// Symulacja TypeScript (to jest JS w Sandpack)
23function createDinosaur(name, species, diet) {
24 return { name, species, health: 100, diet };
25}
26
27class Enclosure {
28 constructor(name, capacity, securityLevel) {
29 this.name = name;
30 this.capacity = capacity;
31 this.securityLevel = securityLevel;
32 this.dinosaurs = [];
33 }
34
35 addDinosaur(dino) {
36 if (this.dinosaurs.length >= this.capacity) {
37 throw new Error("Enclosure is full");
38 }
39 if (dino.diet === "carnivore" && this.securityLevel < 8) {
40 throw new Error("Security level too low for carnivores");
41 }
42 this.dinosaurs.push(dino);
43 }
44
45 getDinosaurCount() { return this.dinosaurs.length; }
46 isEmpty() { return this.dinosaurs.length === 0; }
47}
48
49// Testy Enclosure
50console.log("Suite: Enclosure\n");
51
52let enclosure = new Enclosure("Zone A", 3, 9);
53assert(enclosure.name === "Zone A", "has correct name");
54assert(enclosure.capacity === 3, "has correct capacity");
55assert(enclosure.isEmpty(), "starts empty");
56assert(enclosure.getDinosaurCount() === 0, "count is 0");
57
58const rex = createDinosaur("Rex", "T-Rex", "carnivore");
59enclosure.addDinosaur(rex);
60assert(!enclosure.isEmpty(), "not empty after add");
61assert(enclosure.getDinosaurCount() === 1, "count is 1");
62
63// Test: throw when full
64enclosure = new Enclosure("Zone B", 2, 9);
65enclosure.addDinosaur(createDinosaur("Rex1", "T-Rex", "carnivore"));
66enclosure.addDinosaur(createDinosaur("Rex2", "T-Rex", "carnivore"));
67try {
68 enclosure.addDinosaur(createDinosaur("Rex3", "T-Rex", "carnivore"));
69 assert(false, "should throw when full");
70} catch (e) {
71 assert(e.message === "Enclosure is full", "throws 'Enclosure is full'");
72}
73
74// Test: low security
75console.log("\nSuite: Security levels\n");
76const lowSec = new Enclosure("Zone C", 5, 5);
77try {
78 lowSec.addDinosaur(createDinosaur("Rex", "T-Rex", "carnivore"));
79 assert(false, "should reject carnivore");
80} catch (e) {
81 assert(e.message === "Security level too low for carnivores", "rejects low security");
82}
83
84// --- Generic Repository ---
85console.log("\nSuite: Generic Repository\n");
86
87function createRepository() {
88 const items = [];
89 return {
90 items,
91 add(item) { items.push(item); },
92 findById(id) { return items.find(i => i.id === id) || undefined; },
93 getAll() { return [...items]; },
94 };
95}
96
97const repo = createRepository();
98repo.add({ id: "001", name: "Rex", dangerous: true });
99
100assert(repo.getAll().length === 1, "has 1 item");
101assert(repo.findById("001").name === "Rex", "finds by id");
102assert(repo.findById("999") === undefined, "undefined for missing");
103
104// Test immutability
105const all = repo.getAll();
106all.push({ id: "002", name: "Blue", dangerous: true });
107assert(repo.getAll().length === 1, "getAll returns copy (immutable)");
108
109// --- expect.objectContaining ---
110console.log("\nSuite: Asercje strukturalne\n");
111
112function getApiResponse() {
113 return {
114 status: "ok",
115 data: [{ id: "001", name: "Rex" }],
116 timestamp: Date.now(),
117 };
118}
119
120const response = getApiResponse();
121assert(typeof response.status === "string", "status is string");
122assert(Array.isArray(response.data), "data is array");
123assert(typeof response.timestamp === "number", "timestamp is number");
124assert(typeof response.data[0].id === "string", "item.id is string");
125assert(typeof response.data[0].name === "string", "item.name is string");
126
127console.log(`\n=== Wyniki: ${passed} passed, ${failed} failed ===`);Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Jaki preset w konfiguracji Jest jest potrzebny do testowania TypeScript?
2. Jaka jest różnica między test() a it() w Jest?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Układanie w pionie
Ułóż kroki konfiguracji testów TypeScript z Jest:
- Układanie w poziomie
Ułóż elementy typowanego testu TypeScript:
- Klikanie w kolejności
Ułóż elementy testu rzucającego wyjątek w klasie TypeScript:
- Układanie w pionie
Ułóż kroki testowania generycznego repozytorium:
- Układanie w pionie
Ułóż elementy składni jest.mock z argumentem modułu:
- Układanie w poziomie
Ułóż elementy asercji sprawdzającej kształt obiektu:
- Klikanie w kolejności
Ułóż warstwy piramidy testów od podstawy (najwięcej testów) do szczytu (najmniej):
- Układanie w pionie
Ułóż etapy mockowania zewnętrznego modułu w prawidłowej kolejności:
- Układanie w pionie
Ułóż kroki pisania kompletnego testu od początku do końca:
- Układanie w poziomie
Ułóż elementy użycia spyOn:
- Klikanie w kolejności
Ułóż kroki mockowania modułu w Jest:
- Układanie w pionie
Ułóż etapy testowania asynchronicznego kodu z fake timers: