@tonycasey/lisa 0.5.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/dist/cli.js +390 -0
- package/dist/lib/interfaces/IDockerClient.js +2 -0
- package/dist/lib/interfaces/IMcpClient.js +2 -0
- package/dist/lib/interfaces/IServices.js +2 -0
- package/dist/lib/interfaces/ITemplateCopier.js +2 -0
- package/dist/lib/mcp.js +35 -0
- package/dist/lib/services.js +57 -0
- package/dist/package.json +36 -0
- package/dist/templates/agents/.sample.env +12 -0
- package/dist/templates/agents/docs/STORAGE_SETUP.md +161 -0
- package/dist/templates/agents/skills/common/group-id.js +193 -0
- package/dist/templates/agents/skills/init-review/SKILL.md +119 -0
- package/dist/templates/agents/skills/init-review/scripts/ai-enrich.js +258 -0
- package/dist/templates/agents/skills/init-review/scripts/init-review.js +769 -0
- package/dist/templates/agents/skills/lisa/SKILL.md +92 -0
- package/dist/templates/agents/skills/lisa/cache/.gitkeep +0 -0
- package/dist/templates/agents/skills/lisa/scripts/storage.js +374 -0
- package/dist/templates/agents/skills/memory/SKILL.md +31 -0
- package/dist/templates/agents/skills/memory/scripts/memory.js +533 -0
- package/dist/templates/agents/skills/prompt/SKILL.md +19 -0
- package/dist/templates/agents/skills/prompt/scripts/prompt.js +184 -0
- package/dist/templates/agents/skills/tasks/SKILL.md +31 -0
- package/dist/templates/agents/skills/tasks/scripts/tasks.js +489 -0
- package/dist/templates/claude/config.js +40 -0
- package/dist/templates/claude/hooks/README.md +158 -0
- package/dist/templates/claude/hooks/common/complexity-rater.js +290 -0
- package/dist/templates/claude/hooks/common/context.js +263 -0
- package/dist/templates/claude/hooks/common/group-id.js +188 -0
- package/dist/templates/claude/hooks/common/mcp-client.js +131 -0
- package/dist/templates/claude/hooks/common/transcript-parser.js +256 -0
- package/dist/templates/claude/hooks/common/zep-client.js +175 -0
- package/dist/templates/claude/hooks/session-start.js +401 -0
- package/dist/templates/claude/hooks/session-stop-worker.js +341 -0
- package/dist/templates/claude/hooks/session-stop.js +122 -0
- package/dist/templates/claude/hooks/user-prompt-submit.js +256 -0
- package/dist/templates/claude/settings.json +46 -0
- package/dist/templates/docker/.env.lisa.example +17 -0
- package/dist/templates/docker/docker-compose.graphiti.yml +45 -0
- package/dist/templates/rules/shared/clean-architecture.md +333 -0
- package/dist/templates/rules/shared/code-quality-rules.md +469 -0
- package/dist/templates/rules/shared/git-rules.md +64 -0
- package/dist/templates/rules/shared/testing-principles.md +469 -0
- package/dist/templates/rules/typescript/coding-standards.md +751 -0
- package/dist/templates/rules/typescript/testing.md +629 -0
- package/dist/templates/rules/typescript/typescript-config-guide.md +465 -0
- package/package.json +64 -0
- package/scripts/postinstall.js +710 -0
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
# TypeScript Testing Standards
|
|
2
|
+
|
|
3
|
+
**Note:** For universal testing principles, see `shared/testing-principles.md`. This document covers TypeScript and Jest-specific practices.
|
|
4
|
+
|
|
5
|
+
## Testing Framework
|
|
6
|
+
|
|
7
|
+
We use **Jest** with **ts-jest** for TypeScript projects.
|
|
8
|
+
|
|
9
|
+
### Setup
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install --save-dev jest ts-jest @types/jest
|
|
13
|
+
npx ts-jest config:init
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
### Configuration
|
|
17
|
+
|
|
18
|
+
```javascript
|
|
19
|
+
// jest.config.js
|
|
20
|
+
module.exports = {
|
|
21
|
+
preset: 'ts-jest',
|
|
22
|
+
testEnvironment: 'node',
|
|
23
|
+
roots: ['<rootDir>/tests'],
|
|
24
|
+
testMatch: ['**/*.test.ts'],
|
|
25
|
+
collectCoverageFrom: [
|
|
26
|
+
'src/**/*.ts',
|
|
27
|
+
'!src/**/*.d.ts',
|
|
28
|
+
'!src/**/*.test.ts'
|
|
29
|
+
],
|
|
30
|
+
coverageThreshold: {
|
|
31
|
+
global: {
|
|
32
|
+
branches: 80,
|
|
33
|
+
functions: 80,
|
|
34
|
+
lines: 80,
|
|
35
|
+
statements: 80
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Test Structure
|
|
42
|
+
|
|
43
|
+
### File Naming
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
tests/
|
|
47
|
+
├── unit/
|
|
48
|
+
│ ├── domain/
|
|
49
|
+
│ │ └── services/
|
|
50
|
+
│ │ └── ProductService.test.ts
|
|
51
|
+
│ └── application/
|
|
52
|
+
│ └── services/
|
|
53
|
+
│ └── OrderService.test.ts
|
|
54
|
+
└── integration/
|
|
55
|
+
└── repositories/
|
|
56
|
+
└── ProductRepository.integration.test.ts
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Describe/It Blocks
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
describe('ProductService', () => {
|
|
63
|
+
describe('getProductById', () => {
|
|
64
|
+
it('should return product when it exists', async () => {
|
|
65
|
+
// Test implementation
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should throw ProductNotFoundError when product does not exist', async () => {
|
|
69
|
+
// Test implementation
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should log warning when product not found', async () => {
|
|
73
|
+
// Test implementation
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('createProduct', () => {
|
|
78
|
+
it('should create product with valid data', async () => {
|
|
79
|
+
// Test implementation
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should throw ValidationError for invalid data', async () => {
|
|
83
|
+
// Test implementation
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Unit Testing
|
|
90
|
+
|
|
91
|
+
### Testing Services with Mocked Dependencies
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
import { ProductService } from '@/application/services/ProductService';
|
|
95
|
+
import { IProductRepository } from '@/domain/interfaces/IProductRepository';
|
|
96
|
+
import { ILogger } from '@/domain/interfaces/ILogger';
|
|
97
|
+
import { ProductNotFoundError } from '@/domain/errors/ProductNotFoundError';
|
|
98
|
+
|
|
99
|
+
describe('ProductService', () => {
|
|
100
|
+
let service: ProductService;
|
|
101
|
+
let mockProductRepository: jest.Mocked<IProductRepository>;
|
|
102
|
+
let mockLogger: jest.Mocked<ILogger>;
|
|
103
|
+
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
// Create mocks
|
|
106
|
+
mockProductRepository = {
|
|
107
|
+
getById: jest.fn(),
|
|
108
|
+
save: jest.fn(),
|
|
109
|
+
delete: jest.fn(),
|
|
110
|
+
findByCategory: jest.fn()
|
|
111
|
+
} as jest.Mocked<IProductRepository>;
|
|
112
|
+
|
|
113
|
+
mockLogger = {
|
|
114
|
+
info: jest.fn(),
|
|
115
|
+
warn: jest.fn(),
|
|
116
|
+
error: jest.fn()
|
|
117
|
+
} as jest.Mocked<ILogger>;
|
|
118
|
+
|
|
119
|
+
// Create service with mocked dependencies
|
|
120
|
+
service = new ProductService(mockProductRepository, mockLogger);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
afterEach(() => {
|
|
124
|
+
jest.clearAllMocks();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('getProduct', () => {
|
|
128
|
+
it('should return product when it exists', async () => {
|
|
129
|
+
// Arrange
|
|
130
|
+
const mockProduct = {
|
|
131
|
+
id: '123',
|
|
132
|
+
name: 'Test Product',
|
|
133
|
+
price: 99.99
|
|
134
|
+
};
|
|
135
|
+
mockProductRepository.getById.mockResolvedValue(mockProduct);
|
|
136
|
+
|
|
137
|
+
// Act
|
|
138
|
+
const result = await service.getProduct('123');
|
|
139
|
+
|
|
140
|
+
// Assert
|
|
141
|
+
expect(result).toEqual(mockProduct);
|
|
142
|
+
expect(mockProductRepository.getById).toHaveBeenCalledWith('123');
|
|
143
|
+
expect(mockProductRepository.getById).toHaveBeenCalledTimes(1);
|
|
144
|
+
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
145
|
+
'Product retrieved',
|
|
146
|
+
{ productId: '123' }
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should throw ProductNotFoundError when product does not exist', async () => {
|
|
151
|
+
// Arrange
|
|
152
|
+
mockProductRepository.getById.mockResolvedValue(null);
|
|
153
|
+
|
|
154
|
+
// Act & Assert
|
|
155
|
+
await expect(service.getProduct('999')).rejects.toThrow(ProductNotFoundError);
|
|
156
|
+
expect(mockProductRepository.getById).toHaveBeenCalledWith('999');
|
|
157
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
158
|
+
'Product not found',
|
|
159
|
+
{ productId: '999' }
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Testing with Type-Safe Mocks
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
// Helper to create type-safe mocks
|
|
170
|
+
function createMock<T>(): jest.Mocked<T> {
|
|
171
|
+
return {} as jest.Mocked<T>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Usage
|
|
175
|
+
const mockRepository = createMock<IProductRepository>();
|
|
176
|
+
mockRepository.getById = jest.fn().mockResolvedValue(mockProduct);
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Integration Testing
|
|
180
|
+
|
|
181
|
+
### Testing Repositories
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
import { ProductRepository } from '@/infrastructure/repositories/ProductRepository';
|
|
185
|
+
import { IDatabaseService } from '@/infrastructure/interfaces/IDatabaseService';
|
|
186
|
+
import { IProduct } from '@/domain/entities/IProduct';
|
|
187
|
+
|
|
188
|
+
describe('ProductRepository Integration', () => {
|
|
189
|
+
let repository: ProductRepository;
|
|
190
|
+
let db: IDatabaseService;
|
|
191
|
+
|
|
192
|
+
beforeAll(async () => {
|
|
193
|
+
// Set up test database
|
|
194
|
+
db = await createTestDatabase();
|
|
195
|
+
repository = new ProductRepository(db);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
afterAll(async () => {
|
|
199
|
+
// Clean up
|
|
200
|
+
await db.disconnect();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
beforeEach(async () => {
|
|
204
|
+
// Clear database before each test
|
|
205
|
+
await db.clearCollection('products');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('save and getById', () => {
|
|
209
|
+
it('should save and retrieve product', async () => {
|
|
210
|
+
// Arrange
|
|
211
|
+
const product: IProduct = {
|
|
212
|
+
id: '123',
|
|
213
|
+
name: 'Test Product',
|
|
214
|
+
price: 99.99,
|
|
215
|
+
categoryId: 'cat1'
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// Act
|
|
219
|
+
await repository.save(product);
|
|
220
|
+
const retrieved = await repository.getById('123');
|
|
221
|
+
|
|
222
|
+
// Assert
|
|
223
|
+
expect(retrieved).toEqual(product);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('findByCategory', () => {
|
|
228
|
+
it('should return products in category', async () => {
|
|
229
|
+
// Arrange
|
|
230
|
+
const products: IProduct[] = [
|
|
231
|
+
{ id: '1', name: 'Product 1', price: 10, categoryId: 'cat1' },
|
|
232
|
+
{ id: '2', name: 'Product 2', price: 20, categoryId: 'cat1' },
|
|
233
|
+
{ id: '3', name: 'Product 3', price: 30, categoryId: 'cat2' }
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
for (const product of products) {
|
|
237
|
+
await repository.save(product);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Act
|
|
241
|
+
const results = await repository.findByCategory('cat1');
|
|
242
|
+
|
|
243
|
+
// Assert
|
|
244
|
+
expect(results).toHaveLength(2);
|
|
245
|
+
expect(results.map(p => p.id)).toEqual(['1', '2']);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## Assertions
|
|
252
|
+
|
|
253
|
+
### Jest Matchers
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
// Equality
|
|
257
|
+
expect(value).toBe(expected); // Strict equality (===)
|
|
258
|
+
expect(value).toEqual(expected); // Deep equality
|
|
259
|
+
expect(value).toStrictEqual(expected); // Strict deep equality
|
|
260
|
+
|
|
261
|
+
// Truthiness
|
|
262
|
+
expect(value).toBeTruthy();
|
|
263
|
+
expect(value).toBeFalsy();
|
|
264
|
+
expect(value).toBeNull();
|
|
265
|
+
expect(value).toBeUndefined();
|
|
266
|
+
expect(value).toBeDefined();
|
|
267
|
+
|
|
268
|
+
// Numbers
|
|
269
|
+
expect(value).toBeGreaterThan(3);
|
|
270
|
+
expect(value).toBeGreaterThanOrEqual(3);
|
|
271
|
+
expect(value).toBeLessThan(5);
|
|
272
|
+
expect(value).toBeLessThanOrEqual(5);
|
|
273
|
+
expect(value).toBeCloseTo(0.3, 5); // Floating point
|
|
274
|
+
|
|
275
|
+
// Strings
|
|
276
|
+
expect(value).toMatch(/pattern/);
|
|
277
|
+
expect(value).toContain('substring');
|
|
278
|
+
|
|
279
|
+
// Arrays
|
|
280
|
+
expect(array).toContain(item);
|
|
281
|
+
expect(array).toHaveLength(3);
|
|
282
|
+
expect(array).toContainEqual(object);
|
|
283
|
+
|
|
284
|
+
// Objects
|
|
285
|
+
expect(obj).toHaveProperty('key');
|
|
286
|
+
expect(obj).toHaveProperty('key', value);
|
|
287
|
+
expect(obj).toMatchObject({ key: value });
|
|
288
|
+
|
|
289
|
+
// Exceptions
|
|
290
|
+
expect(() => fn()).toThrow();
|
|
291
|
+
expect(() => fn()).toThrow(Error);
|
|
292
|
+
expect(() => fn()).toThrow('error message');
|
|
293
|
+
expect(async () => await fn()).rejects.toThrow();
|
|
294
|
+
|
|
295
|
+
// Mock functions
|
|
296
|
+
expect(mockFn).toHaveBeenCalled();
|
|
297
|
+
expect(mockFn).toHaveBeenCalledTimes(1);
|
|
298
|
+
expect(mockFn).toHaveBeenCalledWith(arg1, arg2);
|
|
299
|
+
expect(mockFn).toHaveBeenLastCalledWith(arg1, arg2);
|
|
300
|
+
expect(mockFn).toHaveReturnedWith(value);
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
## Mocking
|
|
304
|
+
|
|
305
|
+
### Mock Functions
|
|
306
|
+
|
|
307
|
+
```typescript
|
|
308
|
+
// Create mock function
|
|
309
|
+
const mockFn = jest.fn();
|
|
310
|
+
|
|
311
|
+
// Mock return value
|
|
312
|
+
mockFn.mockReturnValue(42);
|
|
313
|
+
mockFn.mockReturnValueOnce(42);
|
|
314
|
+
|
|
315
|
+
// Mock resolved value (Promise)
|
|
316
|
+
mockFn.mockResolvedValue(data);
|
|
317
|
+
mockFn.mockResolvedValueOnce(data);
|
|
318
|
+
|
|
319
|
+
// Mock rejected value (Promise)
|
|
320
|
+
mockFn.mockRejectedValue(new Error('Failed'));
|
|
321
|
+
mockFn.mockRejectedValueOnce(new Error('Failed'));
|
|
322
|
+
|
|
323
|
+
// Mock implementation
|
|
324
|
+
mockFn.mockImplementation((arg) => arg * 2);
|
|
325
|
+
mockFn.mockImplementationOnce((arg) => arg * 2);
|
|
326
|
+
|
|
327
|
+
// Check calls
|
|
328
|
+
expect(mockFn).toHaveBeenCalled();
|
|
329
|
+
expect(mockFn).toHaveBeenCalledWith(arg1, arg2);
|
|
330
|
+
expect(mockFn.mock.calls).toHaveLength(2);
|
|
331
|
+
expect(mockFn.mock.calls[0][0]).toBe(arg1);
|
|
332
|
+
expect(mockFn.mock.results[0].value).toBe(returnValue);
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Mocking Modules
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
// Mock entire module
|
|
339
|
+
jest.mock('@/infrastructure/database/FirebaseService');
|
|
340
|
+
|
|
341
|
+
// Mock specific function
|
|
342
|
+
jest.mock('@/utils/helpers', () => ({
|
|
343
|
+
generateId: jest.fn(() => 'test-id'),
|
|
344
|
+
formatDate: jest.fn((date) => date.toISOString())
|
|
345
|
+
}));
|
|
346
|
+
|
|
347
|
+
// Partial mock (keep some real implementations)
|
|
348
|
+
jest.mock('@/utils/helpers', () => ({
|
|
349
|
+
...jest.requireActual('@/utils/helpers'),
|
|
350
|
+
generateId: jest.fn(() => 'test-id')
|
|
351
|
+
}));
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
### Spy on Methods
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
// Spy on method
|
|
358
|
+
const spy = jest.spyOn(object, 'method');
|
|
359
|
+
|
|
360
|
+
// Spy and mock implementation
|
|
361
|
+
const spy = jest.spyOn(object, 'method').mockImplementation(() => 'mocked');
|
|
362
|
+
|
|
363
|
+
// Restore original implementation
|
|
364
|
+
spy.mockRestore();
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
## Test Data
|
|
368
|
+
|
|
369
|
+
### Fixtures
|
|
370
|
+
|
|
371
|
+
```typescript
|
|
372
|
+
// tests/fixtures/products.ts
|
|
373
|
+
export const mockProduct: IProduct = {
|
|
374
|
+
id: '123',
|
|
375
|
+
name: 'Test Product',
|
|
376
|
+
price: 99.99,
|
|
377
|
+
categoryId: 'cat1',
|
|
378
|
+
createdAt: new Date('2024-01-01'),
|
|
379
|
+
updatedAt: new Date('2024-01-01')
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
export const mockProducts: IProduct[] = [
|
|
383
|
+
mockProduct,
|
|
384
|
+
{
|
|
385
|
+
id: '456',
|
|
386
|
+
name: 'Another Product',
|
|
387
|
+
price: 49.99,
|
|
388
|
+
categoryId: 'cat2',
|
|
389
|
+
createdAt: new Date('2024-01-02'),
|
|
390
|
+
updatedAt: new Date('2024-01-02')
|
|
391
|
+
}
|
|
392
|
+
];
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### Test Builders
|
|
396
|
+
|
|
397
|
+
```typescript
|
|
398
|
+
// tests/builders/ProductBuilder.ts
|
|
399
|
+
export class ProductBuilder {
|
|
400
|
+
private product: Partial<IProduct> = {
|
|
401
|
+
id: '123',
|
|
402
|
+
name: 'Test Product',
|
|
403
|
+
price: 10.00,
|
|
404
|
+
categoryId: 'cat1'
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
withId(id: string): ProductBuilder {
|
|
408
|
+
this.product.id = id;
|
|
409
|
+
return this;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
withName(name: string): ProductBuilder {
|
|
413
|
+
this.product.name = name;
|
|
414
|
+
return this;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
withPrice(price: number): ProductBuilder {
|
|
418
|
+
this.product.price = price;
|
|
419
|
+
return this;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
build(): IProduct {
|
|
423
|
+
return this.product as IProduct;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Usage in tests
|
|
428
|
+
const product = new ProductBuilder()
|
|
429
|
+
.withId('custom-id')
|
|
430
|
+
.withName('Custom Product')
|
|
431
|
+
.withPrice(99.99)
|
|
432
|
+
.build();
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
## Async Testing
|
|
436
|
+
|
|
437
|
+
### Testing Promises
|
|
438
|
+
|
|
439
|
+
```typescript
|
|
440
|
+
it('should handle async operations', async () => {
|
|
441
|
+
// Using async/await
|
|
442
|
+
const result = await service.getData();
|
|
443
|
+
expect(result).toBeDefined();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('should handle promise rejections', async () => {
|
|
447
|
+
// Expect rejection
|
|
448
|
+
await expect(service.failingMethod()).rejects.toThrow(Error);
|
|
449
|
+
await expect(service.failingMethod()).rejects.toThrow('Specific message');
|
|
450
|
+
});
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
### Testing Callbacks
|
|
454
|
+
|
|
455
|
+
```typescript
|
|
456
|
+
it('should handle callbacks', (done) => {
|
|
457
|
+
service.methodWithCallback((error, result) => {
|
|
458
|
+
expect(error).toBeNull();
|
|
459
|
+
expect(result).toBeDefined();
|
|
460
|
+
done();
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
## Coverage
|
|
466
|
+
|
|
467
|
+
### Running Coverage
|
|
468
|
+
|
|
469
|
+
```bash
|
|
470
|
+
# Run tests with coverage
|
|
471
|
+
npm test -- --coverage
|
|
472
|
+
|
|
473
|
+
# Coverage for specific files
|
|
474
|
+
npm test -- --coverage --collectCoverageFrom='src/services/**/*.ts'
|
|
475
|
+
|
|
476
|
+
# Watch mode
|
|
477
|
+
npm test -- --watch
|
|
478
|
+
|
|
479
|
+
# Update snapshots
|
|
480
|
+
npm test -- -u
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
### Coverage Reports
|
|
484
|
+
|
|
485
|
+
```bash
|
|
486
|
+
# HTML report
|
|
487
|
+
npm test -- --coverage --coverageReporters=html
|
|
488
|
+
open coverage/index.html
|
|
489
|
+
|
|
490
|
+
# Text report
|
|
491
|
+
npm test -- --coverage --coverageReporters=text
|
|
492
|
+
|
|
493
|
+
# LCOV for CI
|
|
494
|
+
npm test -- --coverage --coverageReporters=lcov
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
## Best Practices
|
|
498
|
+
|
|
499
|
+
### Do's
|
|
500
|
+
|
|
501
|
+
- ✅ Use `async/await` for async tests
|
|
502
|
+
- ✅ Clear mocks between tests (`afterEach`)
|
|
503
|
+
- ✅ Use type-safe mocks
|
|
504
|
+
- ✅ Test error cases
|
|
505
|
+
- ✅ Use descriptive test names
|
|
506
|
+
- ✅ Arrange-Act-Assert pattern
|
|
507
|
+
- ✅ One concept per test
|
|
508
|
+
- ✅ Use builders for complex objects
|
|
509
|
+
|
|
510
|
+
### Don'ts
|
|
511
|
+
|
|
512
|
+
- ❌ Don't use `any` in tests
|
|
513
|
+
- ❌ Don't test implementation details
|
|
514
|
+
- ❌ Don't share state between tests
|
|
515
|
+
- ❌ Don't mock everything
|
|
516
|
+
- ❌ Don't ignore failing tests
|
|
517
|
+
- ❌ Don't skip assertions
|
|
518
|
+
- ❌ Don't test third-party code
|
|
519
|
+
|
|
520
|
+
## Example: Complete Test Suite
|
|
521
|
+
|
|
522
|
+
```typescript
|
|
523
|
+
import { OrderService } from '@/application/services/OrderService';
|
|
524
|
+
import { IOrderRepository } from '@/domain/interfaces/IOrderRepository';
|
|
525
|
+
import { IProductRepository } from '@/domain/interfaces/IProductRepository';
|
|
526
|
+
import { ILogger } from '@/domain/interfaces/ILogger';
|
|
527
|
+
import { OrderBuilder } from '@/tests/builders/OrderBuilder';
|
|
528
|
+
import { ProductBuilder } from '@/tests/builders/ProductBuilder';
|
|
529
|
+
|
|
530
|
+
describe('OrderService', () => {
|
|
531
|
+
let service: OrderService;
|
|
532
|
+
let mockOrderRepository: jest.Mocked<IOrderRepository>;
|
|
533
|
+
let mockProductRepository: jest.Mocked<IProductRepository>;
|
|
534
|
+
let mockLogger: jest.Mocked<ILogger>;
|
|
535
|
+
|
|
536
|
+
beforeEach(() => {
|
|
537
|
+
mockOrderRepository = {
|
|
538
|
+
save: jest.fn(),
|
|
539
|
+
getById: jest.fn(),
|
|
540
|
+
findByUser: jest.fn()
|
|
541
|
+
} as jest.Mocked<IOrderRepository>;
|
|
542
|
+
|
|
543
|
+
mockProductRepository = {
|
|
544
|
+
getById: jest.fn(),
|
|
545
|
+
findByIds: jest.fn()
|
|
546
|
+
} as jest.Mocked<IProductRepository>;
|
|
547
|
+
|
|
548
|
+
mockLogger = {
|
|
549
|
+
info: jest.fn(),
|
|
550
|
+
warn: jest.fn(),
|
|
551
|
+
error: jest.fn()
|
|
552
|
+
} as jest.Mocked<ILogger>;
|
|
553
|
+
|
|
554
|
+
service = new OrderService(
|
|
555
|
+
mockOrderRepository,
|
|
556
|
+
mockProductRepository,
|
|
557
|
+
mockLogger
|
|
558
|
+
);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
afterEach(() => {
|
|
562
|
+
jest.clearAllMocks();
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
describe('createOrder', () => {
|
|
566
|
+
it('should create order with valid data', async () => {
|
|
567
|
+
// Arrange
|
|
568
|
+
const products = [
|
|
569
|
+
new ProductBuilder().withId('p1').withPrice(10).build(),
|
|
570
|
+
new ProductBuilder().withId('p2').withPrice(20).build()
|
|
571
|
+
];
|
|
572
|
+
|
|
573
|
+
mockProductRepository.findByIds.mockResolvedValue(products);
|
|
574
|
+
mockOrderRepository.save.mockResolvedValue(undefined);
|
|
575
|
+
|
|
576
|
+
const orderData = {
|
|
577
|
+
userId: 'user1',
|
|
578
|
+
productIds: ['p1', 'p2']
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
// Act
|
|
582
|
+
const order = await service.createOrder(orderData);
|
|
583
|
+
|
|
584
|
+
// Assert
|
|
585
|
+
expect(order).toBeDefined();
|
|
586
|
+
expect(order.userId).toBe('user1');
|
|
587
|
+
expect(order.total).toBe(30);
|
|
588
|
+
expect(mockProductRepository.findByIds).toHaveBeenCalledWith(['p1', 'p2']);
|
|
589
|
+
expect(mockOrderRepository.save).toHaveBeenCalledWith(
|
|
590
|
+
expect.objectContaining({
|
|
591
|
+
userId: 'user1',
|
|
592
|
+
total: 30
|
|
593
|
+
})
|
|
594
|
+
);
|
|
595
|
+
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
596
|
+
'Order created',
|
|
597
|
+
expect.objectContaining({ orderId: order.id })
|
|
598
|
+
);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it('should throw ValidationError for empty product list', async () => {
|
|
602
|
+
// Arrange
|
|
603
|
+
const orderData = {
|
|
604
|
+
userId: 'user1',
|
|
605
|
+
productIds: []
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
// Act & Assert
|
|
609
|
+
await expect(service.createOrder(orderData)).rejects.toThrow(ValidationError);
|
|
610
|
+
expect(mockOrderRepository.save).not.toHaveBeenCalled();
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
## Checklist
|
|
617
|
+
|
|
618
|
+
- [ ] Tests use TypeScript with proper types
|
|
619
|
+
- [ ] Mocks are type-safe (`jest.Mocked<T>`)
|
|
620
|
+
- [ ] async/await used for async tests
|
|
621
|
+
- [ ] Arrange-Act-Assert pattern followed
|
|
622
|
+
- [ ] Mocks cleared between tests
|
|
623
|
+
- [ ] Error cases tested
|
|
624
|
+
- [ ] Test names are descriptive
|
|
625
|
+
- [ ] Coverage meets thresholds (80%+)
|
|
626
|
+
- [ ] No `any` types in tests
|
|
627
|
+
- [ ] Test data uses builders or fixtures
|
|
628
|
+
|
|
629
|
+
For universal testing principles, see `shared/testing-principles.md`.
|