Legion engineer! Consul Caesar.js has entrusted you with a key task - checking every element, every weapon, and every catapult in our fort. Unit testing is like a detailed inspection of each element before a campaign - we check everything in isolation to make sure it works perfectly!
Imagine you are the chief mechanic of the fort. Your job is to check each mechanism separately:
Unit tests check the smallest units of code (classes, methods, functions) in isolation from the rest of the system.
1// Example of a simple unit test
2describe('Bolt', () => {
3 it('should calculate correct weight', () => {
4 const bolt = new Bolt('iron', 5);
5 expect(bolt.getWeight()).toBe(5);
6 });
7
8 it('should calculate correct impact force', () => {
9 const bolt = new Bolt('iron', 5);
10 expect(bolt.getImpactForce()).toBe(500); // 5kg * 100 coefficient
11 });
12});Services are the heart of business logic - this is where we focus most unit tests:
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('should return 0 for an empty array', () => {
40 const result = service.calculateTotalValue([]);
41 expect(result).toBe(0);
42 });
43
44 it('should calculate sum for a single tribute', () => {
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('should calculate sum for multiple tributes', () => {
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('should calculate tax correctly', () => {
63 const result = service.calculateTax(1000, 0.1);
64 expect(result).toBe(100);
65 });
66
67 it('should return 0 for value 0', () => {
68 const result = service.calculateTax(0, 0.1);
69 expect(result).toBe(0);
70 });
71
72 it('should throw error for negative value', () => {
73 expect(() => service.calculateTax(-100, 0.1))
74 .toThrow('Value cannot be negative');
75 });
76
77 it('should throw error for invalid tax rate', () => {
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('should classify as legendary for value >= 10000', () => {
88 const tribute = { id: 1, name: 'Crown', value: 15000 };
89 expect(service.categorizeByValue(tribute)).toBe('legendary');
90 });
91
92 it('should classify as epic for value >= 5000', () => {
93 const tribute = { id: 1, name: 'Sword', value: 7500 };
94 expect(service.categorizeByValue(tribute)).toBe('epic');
95 });
96
97 it('should classify as rare for value >= 1000', () => {
98 const tribute = { id: 1, name: 'Necklace', value: 2500 };
99 expect(service.categorizeByValue(tribute)).toBe('rare');
100 });
101
102 it('should classify as common for value < 1000', () => {
103 const tribute = { id: 1, name: 'Coin', value: 50 };
104 expect(service.categorizeByValue(tribute)).toBe('common');
105 });
106
107 it('should handle boundary values', () => {
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});When a service depends on other services, we use mocks:
1// legionaries-service.ts
2@Injectable()
3export class LegionaryService {
4 constructor(
5 private readonly tributeService: TributeService,
6 private readonly experienceService: ExperienceService,
7 ) {}
8
9 async promoteLegionary(legionariesId: number): Promise<Legionary> {
10 const legionaries = await this.findById(legionariesId);
11 const tributesFound = await this.tributeService.countByLegionary(legionariesId);
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(legionaries.currentRank);
18 legionaries.rank = newRank;
19
20 await this.experienceService.addPromotion(legionariesId, newRank);
21
22 return legionaries;
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// legionaries-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('should promote legionary when they have enough tributes', 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('should throw error when legionary has too few tributes', 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('should throw error when legionary already has the highest rank', 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('should promote from Tiro to Legionary', () => {
121 const result = service['calculateNewRank']('Tiro');
122 expect(result).toBe('Legionary');
123 });
124
125 it('should promote from Legionary to Quaestor', () => {
126 const result = service['calculateNewRank']('Legionary');
127 expect(result).toBe('Quaestor');
128 });
129
130 it('should throw error for unknown rank', () => {
131 expect(() => service['calculateNewRank']('Unknown Rank'))
132 .toThrow('Cannot promote further');
133 });
134
135 it('should throw error for centurion', () => {
136 expect(() => service['calculateNewRank']('Centurion'))
137 .toThrow('Cannot promote further');
138 });
139 });
140});Spies allow you to check whether methods were called with the right parameters:
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: 'Cohors Nigra',
71 ballistae: 20,
72 legionSize: 30,
73 health: 100,
74 armor: 5,
75 agility: 8,
76 };
77
78 const defendingLegion = {
79 name: 'Cohors Barbarorum',
80 ballistae: 15,
81 legionSize: 25,
82 health: 100,
83 armor: 10,
84 agility: 6,
85 };
86
87 it('should conduct a battle and return the result', () => {
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('should log the start of the battle', () => {
100 service.engageBattle(attackingLegion, defendingLegion);
101
102 expect(logger.log).toHaveBeenCalledWith(
103 'Battle started: Cohors Nigra vs Cohors Barbarorum'
104 );
105 });
106
107 it('should log the end of the battle', () => {
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('should log exactly 2 times', () => {
116 service.engageBattle(attackingLegion, defendingLegion);
117
118 expect(logger.log).toHaveBeenCalledTimes(2);
119 });
120
121 it('should call private calculation methods', () => {
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('should limit damage to 0 when defense > attack', () => {
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); // Unchanged
139 });
140 });
141});You can create your own matchers for more expressive tests:
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 legionary`
21 : `Expected ${received} to be a valid legionary 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// Usage in tests
40describe('Custom Matchers Example', () => {
41 it('should use custom matchers', () => {
42 const legionaries = { name: 'Marcus Aquila', rank: 'Centurion' };
43 const tributes = [
44 { name: 'Gold Coin', value: 100 },
45 { name: 'Ruby', value: 500 },
46 ];
47
48 expect(legionaries).toBeValidLegionary();
49 expect(tributes).toHaveTributeWorth(600);
50 });
51});1describe('WeightCalculator', () => {
2 const calculator = new WeightCalculator();
3
4 describe('boundary values', () => {
5 it('should handle minimum weight', () => {
6 expect(calculator.isCarriable(0.1)).toBe(true);
7 });
8
9 it('should handle maximum weight', () => {
10 expect(calculator.isCarriable(100)).toBe(true);
11 });
12
13 it('should reject weight below minimum', () => {
14 expect(calculator.isCarriable(0)).toBe(false);
15 });
16
17 it('should reject weight above maximum', () => {
18 expect(calculator.isCarriable(100.1)).toBe(false);
19 });
20 });
21});1describe('Error scenarios', () => {
2 it('should handle null values', () => {
3 expect(() => service.process(null)).toThrow();
4 });
5
6 it('should handle undefined values', () => {
7 expect(() => service.process(undefined)).toThrow();
8 });
9
10 it('should handle empty strings', () => {
11 expect(() => service.process('')).toThrow();
12 });
13
14 it('should handle network errors', async () => {
15 mockHttpService.get.mockRejectedValue(new Error('Network error'));
16
17 await expect(service.fetchData()).rejects.toThrow('Network error');
18 });
19});1describe('Performance tests', () => {
2 it('should process large data in reasonable time', () => {
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); // Less than 100ms
14 expect(result).toBeGreaterThan(0);
15 });
16
17 it('should not cause 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); // Less than 1MB increase
30 });
31});Unit testing is the foundation of a reliable Roman fort! Each element checked separately is a guarantee that everything will work properly at a critical moment. Remember - it's better to test 100 times on land than once in the middle of the ocean!