Utilizziamo i cookie per migliorare la tua esperienza sul sito
CodeWorlds

Unit Testing - testowanie poszczególnych elementów fortu

Witaj, inżynierze legionowy! Konsul Caesar.js powierzył Ci kluczowe zadanie - sprawdzenie każdego elementu, każdej broni i każdej katapulty w naszym forcie. Unit testing to jak szczegółowa inspekcja każdego elementu przed kampanią - sprawdzamy wszystko w izolacji, aby upewnić się, że działa perfekcyjnie!

Czym jest Unit Testing w rzymskim forcie?

Wyobraź sobie, że jesteś głównym mechanikiem fortu. Twoja praca to sprawdzenie każdego mechanizmu z osobna:

  • Każda machina wojenna musi ładować się i strzelać niezależnie
  • Każda tarcza musi wytrzymać uderzenie bez wsparcia innych
  • Każdy system dowodzenia musi działać autonomicznie
  • Każdy mechanizm musi być testowany w kontrolowanych warunkach

Unit testy sprawdzają najmniejsze jednostki kodu (klasy, metody, funkcje) w izolacji od reszty systemu.

1// Przykład prostego unit testu
2describe('Bolt', () => {
3 it('powinno obliczyć poprawną wagę', () => {
4 const bolt = new Bolt('iron', 5);
5 expect(bolt.getWeight()).toBe(5);
6 });
7
8 it('powinno obliczyć poprawną siłę uderzenia', () => {
9 const bolt = new Bolt('iron', 5);
10 expect(bolt.getImpactForce()).toBe(500); // 5kg * 100 coefficient
11 });
12});

Anatomy jednostkowego testu w NestJS

1. Testowanie Service'ów

Service'y to serce logiki biznesowej - tu skupiamy większość unit testów:

1// tribute-calculator.service.ts
2@Injectable()
3export class TributeCalculatorService {
4 calculateTotalValue(tributes: Tribute[]): number {
5 return tributes.reduce((sum, tribute) => sum + tribute.value, 0);
6 }
7
8 calculateTax(value: number, taxRate: number): number {
9 if (value < 0) {
10 throw new Error('Value cannot be negative');
11 }
12 if (taxRate < 0 || taxRate > 1) {
13 throw new Error('Tax rate must be between 0 and 1');
14 }
15 return value * taxRate;
16 }
17
18 categorizeByValue(tribute: Tribute): 'legendary' | 'epic' | 'rare' | 'common' {
19 if (tribute.value >= 10000) return 'legendary';
20 if (tribute.value >= 5000) return 'epic';
21 if (tribute.value >= 1000) return 'rare';
22 return 'common';
23 }
24}
25
26// tribute-calculator.service.spec.ts
27describe('TributeCalculatorService', () => {
28 let service: TributeCalculatorService;
29
30 beforeEach(async () => {
31 const module: TestingModule = await Test.createTestingModule({
32 providers: [TributeCalculatorService],
33 }).compile();
34
35 service = module.get<TributeCalculatorService>(TributeCalculatorService);
36 });
37
38 describe('calculateTotalValue', () => {
39 it('powinno zwrócić 0 dla pustej tablicy', () => {
40 const result = service.calculateTotalValue([]);
41 expect(result).toBe(0);
42 });
43
44 it('powinno obliczyć sumę dla jednego tributy', () => {
45 const tributes = [{ id: 1, name: 'Golden Coin', value: 100 }];
46 const result = service.calculateTotalValue(tributes);
47 expect(result).toBe(100);
48 });
49
50 it('powinno obliczyć sumę dla wielu tributów', () => {
51 const tributes = [
52 { id: 1, name: 'Golden Coin', value: 100 },
53 { id: 2, name: 'Silver Ring', value: 50 },
54 { id: 3, name: 'Diamond', value: 1000 },
55 ];
56 const result = service.calculateTotalValue(tributes);
57 expect(result).toBe(1150);
58 });
59 });
60
61 describe('calculateTax', () => {
62 it('powinno obliczyć podatek poprawnie', () => {
63 const result = service.calculateTax(1000, 0.1);
64 expect(result).toBe(100);
65 });
66
67 it('powinno zwrócić 0 dla wartości 0', () => {
68 const result = service.calculateTax(0, 0.1);
69 expect(result).toBe(0);
70 });
71
72 it('powinno rzucić błąd dla ujemnej wartości', () => {
73 expect(() => service.calculateTax(-100, 0.1))
74 .toThrow('Value cannot be negative');
75 });
76
77 it('powinno rzucić błąd dla nieprawidłowej stawki podatku', () => {
78 expect(() => service.calculateTax(1000, 1.5))
79 .toThrow('Tax rate must be between 0 and 1');
80 
81 expect(() => service.calculateTax(1000, -0.1))
82 .toThrow('Tax rate must be between 0 and 1');
83 });
84 });
85
86 describe('categorizeByValue', () => {
87 it('powinno klasyfikować jako legendary dla wartości >= 10000', () => {
88 const tribute = { id: 1, name: 'Crown', value: 15000 };
89 expect(service.categorizeByValue(tribute)).toBe('legendary');
90 });
91
92 it('powinno klasyfikować jako epic dla wartości >= 5000', () => {
93 const tribute = { id: 1, name: 'Sword', value: 7500 };
94 expect(service.categorizeByValue(tribute)).toBe('epic');
95 });
96
97 it('powinno klasyfikować jako rare dla wartości >= 1000', () => {
98 const tribute = { id: 1, name: 'Necklace', value: 2500 };
99 expect(service.categorizeByValue(tribute)).toBe('rare');
100 });
101
102 it('powinno klasyfikować jako common dla wartości < 1000', () => {
103 const tribute = { id: 1, name: 'Coin', value: 50 };
104 expect(service.categorizeByValue(tribute)).toBe('common');
105 });
106
107 it('powinno obsłużyć wartości graniczne', () => {
108 expect(service.categorizeByValue({ id: 1, name: 'Test', value: 10000 }))
109 .toBe('legendary');
110 expect(service.categorizeByValue({ id: 1, name: 'Test', value: 9999 }))
111 .toBe('epic');
112 expect(service.categorizeByValue({ id: 1, name: 'Test', value: 5000 }))
113 .toBe('epic');
114 expect(service.categorizeByValue({ id: 1, name: 'Test', value: 4999 }))
115 .toBe('rare');
116 });
117 });
118});

2. Testowanie z Mock Dependencies

Gdy service zależy od innych serwisów, używamy mocków:

1// legionariusze-service.ts
2@Injectable()
3export class LegionaryService {
4 constructor(
5 private readonly tributeService: TributeService,
6 private readonly experienceService: ExperienceService,
7 ) {}
8
9 async promoteLegionary(legionariuszeId: number): Promise<Legionary> {
10 const legionariusze = await this.findById(legionariuszeId);
11 const tributesFound = await this.tributeService.countByLegionary(legionariuszeId);
12 
13 if (tributesFound < 5) {
14 throw new Error('Legionary needs at least 5 tributes to be promoted');
15 }
16
17 const newRank = this.calculateNewRank(legionariusze.currentRank);
18 legionariusze.rank = newRank;
19 
20 await this.experienceService.addPromotion(legionariuszeId, newRank);
21 
22 return legionariusze;
23 }
24
25 private calculateNewRank(currentRank: string): string {
26 const ranks = ['Tiro', 'Legionary', 'Quaestor', 'Optio', 'Centurion'];
27 const currentIndex = ranks.indexOf(currentRank);
28 
29 if (currentIndex === -1 || currentIndex === ranks.length - 1) {
30 throw new Error('Cannot promote further');
31 }
32 
33 return ranks[currentIndex + 1];
34 }
35}
36
37// legionariusze-service.spec.ts
38describe('LegionaryService', () => {
39 let service: LegionaryService;
40 let tributeService: jest.Mocked<TributeService>;
41 let experienceService: jest.Mocked<ExperienceService>;
42
43 const mockTributeService = {
44 countByLegionary: jest.fn(),
45 };
46
47 const mockExperienceService = {
48 addPromotion: jest.fn(),
49 };
50
51 beforeEach(async () => {
52 const module: TestingModule = await Test.createTestingModule({
53 providers: [
54 LegionaryService,
55 {
56 provide: TributeService,
57 useValue: mockTributeService,
58 },
59 {
60 provide: ExperienceService,
61 useValue: mockExperienceService,
62 },
63 ],
64 }).compile();
65
66 service = module.get<LegionaryService>(LegionaryService);
67 tributeService = module.get(TributeService);
68 experienceService = module.get(ExperienceService);
69 });
70
71 afterEach(() => {
72 jest.clearAllMocks();
73 });
74
75 describe('promoteLegionary', () => {
76 const mockLegionary = {
77 id: 1,
78 name: 'Marcus Aquila',
79 currentRank: 'Legionary',
80 rank: 'Legionary',
81 };
82
83 beforeEach(() => {
84 jest.spyOn(service, 'findById').mockResolvedValue(mockLegionary);
85 });
86
87 it('powinno awansować legionariusza gdy ma wystarczająco tributów', async () => {
88 tributeService.countByLegionary.mockResolvedValue(10);
89 experienceService.addPromotion.mockResolvedValue();
90
91 const result = await service.promoteLegionary(1);
92
93 expect(result.rank).toBe('Quaestor');
94 expect(tributeService.countByLegionary).toHaveBeenCalledWith(1);
95 expect(experienceService.addPromotion).toHaveBeenCalledWith(1, 'Quaestor');
96 });
97
98 it('powinno rzucić błąd gdy legionariusz ma za mało tributów', async () => {
99 tributeService.countByLegionary.mockResolvedValue(3);
100
101 await expect(service.promoteLegionary(1)).rejects.toThrow(
102 'Legionary needs at least 5 tributes to be promoted'
103 );
104
105 expect(experienceService.addPromotion).not.toHaveBeenCalled();
106 });
107
108 it('powinno rzucić błąd gdy legionariusz ma już najwyższą rangę', async () => {
109 const centurionLegionary = { ...mockLegionary, currentRank: 'Centurion' };
110 jest.spyOn(service, 'findById').mockResolvedValue(centurionLegionary);
111 tributeService.countByLegionary.mockResolvedValue(10);
112
113 await expect(service.promoteLegionary(1)).rejects.toThrow(
114 'Cannot promote further'
115 );
116 });
117 });
118
119 describe('calculateNewRank', () => {
120 it('powinno awansować z Tiro na Legionary', () => {
121 const result = service['calculateNewRank']('Tiro');
122 expect(result).toBe('Legionary');
123 });
124
125 it('powinno awansować z Legionary na Quaestor', () => {
126 const result = service['calculateNewRank']('Legionary');
127 expect(result).toBe('Quaestor');
128 });
129
130 it('powinno rzucić błąd dla nieznanej rangi', () => {
131 expect(() => service['calculateNewRank']('Unknown Rank'))
132 .toThrow('Cannot promote further');
133 });
134
135 it('powinno rzucić błąd dla centuriona', () => {
136 expect(() => service['calculateNewRank']('Centurion'))
137 .toThrow('Cannot promote further');
138 });
139 });
140});

3. Testowanie z Spy Functions

Spy pozwalają sprawdzać, czy metody zostały wywołane z odpowiednimi parametrami:

1// battle.service.ts
2@Injectable()
3export class BattleService {
4 constructor(private readonly logger: Logger) {}
5
6 engageBattle(attackingLegion: Legion, defendingLegion: Legion): BattleResult {
7 this.logger.log(`Battle started: ${attackingLegion.name} vs ${defendingLegion.name}`);
8
9 const attackPower = this.calculateAttackPower(attackingLegion);
10 const defensePower = this.calculateDefensePower(defendingLegion);
11
12 const damage = Math.max(0, attackPower - defensePower);
13 defendingLegion.health -= damage;
14
15 const result = {
16 winner: defendingLegion.health <= 0 ? attackingLegion : defendingLegion,
17 damage,
18 attackPower,
19 defensePower,
20 };
21
22 this.logger.log(`Battle ended. Winner: ${result.winner.name}, Damage: ${damage}`);
23
24 return result;
25 }
26
27 private calculateAttackPower(cohort: Legion): number {
28 return cohort.ballistae * cohort.legionSize * 10;
29 }
30
31 private calculateDefensePower(cohort: Legion): number {
32 return cohort.armor * cohort.agility * 5;
33 }
34}
35
36// battle.service.spec.ts
37describe('BattleService', () => {
38 let service: BattleService;
39 let logger: jest.Mocked<Logger>;
40
41 const mockLogger = {
42 log: jest.fn(),
43 error: jest.fn(),
44 warn: jest.fn(),
45 debug: jest.fn(),
46 verbose: jest.fn(),
47 };
48
49 beforeEach(async () => {
50 const module: TestingModule = await Test.createTestingModule({
51 providers: [
52 BattleService,
53 {
54 provide: Logger,
55 useValue: mockLogger,
56 },
57 ],
58 }).compile();
59
60 service = module.get<BattleService>(BattleService);
61 logger = module.get(Logger);
62 });
63
64 afterEach(() => {
65 jest.clearAllMocks();
66 });
67
68 describe('engageBattle', () => {
69 const attackingLegion = {
70 name: 'Equus Niger',
71 ballistae: 20,
72 legionSize: 30,
73 health: 100,
74 armor: 5,
75 agility: 8,
76 };
77
78 const defendingLegion = {
79 name: 'Batavus Volans',
80 ballistae: 15,
81 legionSize: 25,
82 health: 100,
83 armor: 10,
84 agility: 6,
85 };
86
87 it('powinno przeprowadzić bitwę i zwrócić wynik', () => {
88 const result = service.engageBattle(attackingLegion, defendingLegion);
89
90 expect(result).toHaveProperty('winner');
91 expect(result).toHaveProperty('damage');
92 expect(result).toHaveProperty('attackPower');
93 expect(result).toHaveProperty('defensePower');
94 expect(result.attackPower).toBe(6000); // 20 * 30 * 10
95 expect(result.defensePower).toBe(300); // 10 * 6 * 5
96 expect(result.damage).toBe(5700); // 6000 - 300
97 });
98
99 it('powinno logować początek bitwy', () => {
100 service.engageBattle(attackingLegion, defendingLegion);
101
102 expect(logger.log).toHaveBeenCalledWith(
103 'Battle started: Equus Niger vs Batavus Volans'
104 );
105 });
106
107 it('powinno logować koniec bitwy', () => {
108 const result = service.engageBattle(attackingLegion, defendingLegion);
109
110 expect(logger.log).toHaveBeenCalledWith(
111 `Battle ended. Winner: ${result.winner.name}, Damage: ${result.damage}`
112 );
113 });
114
115 it('powinno logować dokładnie 2 razy', () => {
116 service.engageBattle(attackingLegion, defendingLegion);
117
118 expect(logger.log).toHaveBeenCalledTimes(2);
119 });
120
121 it('powinno wywołać prywatne metody kalkulacyjne', () => {
122 const calculateAttackPowerSpy = jest.spyOn(service as any, 'calculateAttackPower');
123 const calculateDefensePowerSpy = jest.spyOn(service as any, 'calculateDefensePower');
124
125 service.engageBattle(attackingLegion, defendingLegion);
126
127 expect(calculateAttackPowerSpy).toHaveBeenCalledWith(attackingLegion);
128 expect(calculateDefensePowerSpy).toHaveBeenCalledWith(defendingLegion);
129 });
130
131 it('powinno ograniczyć obrażenia do 0 gdy obrona > atak', () => {
132 const weakAttacker = { ...attackingLegion, ballistae: 1, legionSize: 1 }; // Attack: 10
133 const strongDefender = { ...defendingLegion, armor: 20, agility: 10 }; // Defense: 1000
134
135 const result = service.engageBattle(weakAttacker, strongDefender);
136
137 expect(result.damage).toBe(0);
138 expect(strongDefender.health).toBe(100); // Nie zmienione
139 });
140 });
141});

4. Testowanie z Custom Matchers

Możesz tworzyć własne matchery dla bardziej ekspresywnych testów:

1// custom-matchers.ts
2declare global {
3 namespace jest {
4 interface Matchers<R> {
5 toBeValidLegionary(): R;
6 toHaveTributeWorth(expectedValue: number): R;
7 }
8 }
9}
10
11expect.extend({
12 toBeValidLegionary(received: any) {
13 const pass = received &&
14 typeof received.name === 'string' &&
15 typeof received.rank === 'string' &&
16 received.name.length > 0;
17
18 return {
19 message: () => pass
20 ? `Expected ${received} not to be a valid legionariusze`
21 : `Expected ${received} to be a valid legionariusze with name and rank`,
22 pass,
23 };
24 },
25
26 toHaveTributeWorth(received: any[], expectedValue: number) {
27 const totalValue = received.reduce((sum, tribute) => sum + tribute.value, 0);
28 const pass = totalValue === expectedValue;
29
30 return {
31 message: () => pass
32 ? `Expected tributes not to be worth ${expectedValue}, but they are`
33 : `Expected tributes to be worth ${expectedValue}, but got ${totalValue}`,
34 pass,
35 };
36 },
37});
38
39// Użycie w testach
40describe('Custom Matchers Example', () => {
41 it('powinno używać custom matcherów', () => {
42 const legionariusze = { name: 'Marcus Aquila', rank: 'Centurion' };
43 const tributes = [
44 { name: 'Gold Coin', value: 100 },
45 { name: 'Ruby', value: 500 },
46 ];
47
48 expect(legionariusze).toBeValidLegionary();
49 expect(tributes).toHaveTributeWorth(600);
50 });
51});

Strategie testowania Edge Cases

1. Boundary Value Testing

1describe('WeightCalculator', () => {
2 const calculator = new WeightCalculator();
3
4 describe('boundary values', () => {
5 it('powinno obsłużyć minimalną wagę', () => {
6 expect(calculator.isCarriable(0.1)).toBe(true);
7 });
8
9 it('powinno obsłużyć maksymalną wagę', () => {
10 expect(calculator.isCarriable(100)).toBe(true);
11 });
12
13 it('powinno odrzucić wagę poniżej minimum', () => {
14 expect(calculator.isCarriable(0)).toBe(false);
15 });
16
17 it('powinno odrzucić wagę powyżej maksimum', () => {
18 expect(calculator.isCarriable(100.1)).toBe(false);
19 });
20 });
21});

2. Error Path Testing

1describe('Error scenarios', () => {
2 it('powinno obsłużyć null values', () => {
3 expect(() => service.process(null)).toThrow();
4 });
5
6 it('powinno obsłużyć undefined values', () => {
7 expect(() => service.process(undefined)).toThrow();
8 });
9
10 it('powinno obsłużyć empty strings', () => {
11 expect(() => service.process('')).toThrow();
12 });
13
14 it('powinno obsłużyć network errors', async () => {
15 mockHttpService.get.mockRejectedValue(new Error('Network error'));
16 
17 await expect(service.fetchData()).rejects.toThrow('Network error');
18 });
19});

Performance i Memory Testing

1describe('Performance tests', () => {
2 it('powinno przetworzyć duże dane w rozsądnym czasie', () => {
3 const start = Date.now();
4 const largeTributeArray = new Array(10000).fill(null).map((_, i) => ({
5 id: i,
6 name: `Tribute ${i}`,
7 value: Math.random() * 1000,
8 }));
9
10 const result = tributeService.calculateTotal(largeTributeArray);
11 const duration = Date.now() - start;
12
13 expect(duration).toBeLessThan(100); // Mniej niż 100ms
14 expect(result).toBeGreaterThan(0);
15 });
16
17 it('powinno nie powodować memory leaks', () => {
18 const initialMemory = process.memoryUsage().heapUsed;
19 
20 for (let i = 0; i < 1000; i++) {
21 const tribute = tributeFactory.create();
22 tributeService.process(tribute);
23 }
24
25 global.gc?.(); // Force garbage collection if available
26 const finalMemory = process.memoryUsage().heapUsed;
27 const memoryIncrease = finalMemory - initialMemory;
28
29 expect(memoryIncrease).toBeLessThan(1024 * 1024); // Mniej niż 1MB wzrostu
30 });
31});

Unit testing to podstawa niezawodnego rzymskiego fortu! Każdy element sprawdzony z osobna to gwarancja, że w krytycznym momencie wszystko zadziała jak należy. Pamiętaj - lepiej przetestować 100 razy w kastrum, niż raz w środku bitwy!

Vai a CodeWorlds