@venizia/ignis-docs 0.0.4-0 → 0.0.4-2
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/package.json +1 -1
- package/wiki/best-practices/api-usage-examples.md +1 -0
- package/wiki/best-practices/code-style-standards/advanced-patterns.md +259 -0
- package/wiki/best-practices/code-style-standards/constants-configuration.md +225 -0
- package/wiki/best-practices/code-style-standards/control-flow.md +245 -0
- package/wiki/best-practices/code-style-standards/documentation.md +221 -0
- package/wiki/best-practices/code-style-standards/function-patterns.md +142 -0
- package/wiki/best-practices/code-style-standards/index.md +110 -0
- package/wiki/best-practices/code-style-standards/naming-conventions.md +174 -0
- package/wiki/best-practices/code-style-standards/route-definitions.md +150 -0
- package/wiki/best-practices/code-style-standards/tooling.md +155 -0
- package/wiki/best-practices/code-style-standards/type-safety.md +165 -0
- package/wiki/best-practices/common-pitfalls.md +164 -3
- package/wiki/best-practices/contribution-workflow.md +1 -1
- package/wiki/best-practices/data-modeling.md +102 -2
- package/wiki/best-practices/error-handling.md +468 -0
- package/wiki/best-practices/index.md +204 -21
- package/wiki/best-practices/performance-optimization.md +180 -0
- package/wiki/best-practices/security-guidelines.md +249 -0
- package/wiki/best-practices/testing-strategies.md +620 -0
- package/wiki/changelogs/2026-01-05-range-queries-content-range.md +184 -0
- package/wiki/changelogs/2026-01-06-basic-authentication.md +103 -0
- package/wiki/changelogs/2026-01-07-controller-route-customization.md +209 -0
- package/wiki/changelogs/index.md +3 -0
- package/wiki/guides/core-concepts/components-guide.md +1 -1
- package/wiki/guides/core-concepts/persistent/models.md +10 -0
- package/wiki/guides/tutorials/complete-installation.md +1 -1
- package/wiki/guides/tutorials/testing.md +1 -1
- package/wiki/references/base/bootstrapping.md +4 -3
- package/wiki/references/base/components.md +47 -29
- package/wiki/references/base/controllers.md +220 -24
- package/wiki/references/base/filter-system/fields-order-pagination.md +84 -0
- package/wiki/references/base/middlewares.md +37 -3
- package/wiki/references/base/models.md +40 -2
- package/wiki/references/base/providers.md +1 -2
- package/wiki/references/base/repositories/index.md +3 -1
- package/wiki/references/base/services.md +2 -2
- package/wiki/references/components/authentication.md +261 -247
- package/wiki/references/helpers/index.md +1 -1
- package/wiki/references/helpers/socket-io.md +1 -1
- package/wiki/references/quick-reference.md +2 -2
- package/wiki/references/src-details/core.md +1 -1
- package/wiki/references/utilities/statuses.md +4 -4
- package/wiki/best-practices/code-style-standards.md +0 -1193
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
# Testing Strategies
|
|
2
|
+
|
|
3
|
+
Comprehensive testing guide for Ignis applications using Bun's built-in test runner.
|
|
4
|
+
|
|
5
|
+
## Testing Philosophy
|
|
6
|
+
|
|
7
|
+
| Principle | Description |
|
|
8
|
+
|-----------|-------------|
|
|
9
|
+
| **Test Behavior** | Test what the code does, not how it does it |
|
|
10
|
+
| **Isolation** | Each test should be independent |
|
|
11
|
+
| **Fast Feedback** | Tests should run quickly |
|
|
12
|
+
| **Meaningful Coverage** | Cover critical paths and edge cases |
|
|
13
|
+
|
|
14
|
+
## 1. Project Setup
|
|
15
|
+
|
|
16
|
+
### Configure Test Environment
|
|
17
|
+
|
|
18
|
+
**`bunfig.toml`:**
|
|
19
|
+
```toml
|
|
20
|
+
[test]
|
|
21
|
+
preload = ["./test/setup.ts"]
|
|
22
|
+
coverage = true
|
|
23
|
+
coverageDir = "coverage"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**`test/setup.ts`:**
|
|
27
|
+
```typescript
|
|
28
|
+
import { beforeAll, afterAll, afterEach } from 'bun:test';
|
|
29
|
+
import { TestDatabase } from './helpers/test-database';
|
|
30
|
+
|
|
31
|
+
// Global setup
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
await TestDatabase.initialize();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Clean up after each test
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
await TestDatabase.truncateAll();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Global teardown
|
|
42
|
+
afterAll(async () => {
|
|
43
|
+
await TestDatabase.close();
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**`test/helpers/test-database.ts`:**
|
|
48
|
+
```typescript
|
|
49
|
+
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
50
|
+
import { Pool } from 'pg';
|
|
51
|
+
import * as schema from '@/models';
|
|
52
|
+
|
|
53
|
+
export class TestDatabase {
|
|
54
|
+
private static pool: Pool;
|
|
55
|
+
private static db: ReturnType<typeof drizzle>;
|
|
56
|
+
|
|
57
|
+
static async initialize() {
|
|
58
|
+
this.pool = new Pool({
|
|
59
|
+
host: process.env.TEST_DB_HOST ?? 'localhost',
|
|
60
|
+
port: Number(process.env.TEST_DB_PORT ?? 5433),
|
|
61
|
+
user: process.env.TEST_DB_USER ?? 'test',
|
|
62
|
+
password: process.env.TEST_DB_PASSWORD ?? 'test',
|
|
63
|
+
database: process.env.TEST_DB_NAME ?? 'ignis_test',
|
|
64
|
+
});
|
|
65
|
+
this.db = drizzle({ client: this.pool, schema });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static getDb() {
|
|
69
|
+
return this.db;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
static async truncateAll() {
|
|
73
|
+
const tables = Object.keys(schema);
|
|
74
|
+
for (const table of tables) {
|
|
75
|
+
await this.db.execute(sql`TRUNCATE TABLE ${sql.identifier(table)} CASCADE`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static async close() {
|
|
80
|
+
await this.pool.end();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Running Tests
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# Run all tests
|
|
89
|
+
bun test
|
|
90
|
+
|
|
91
|
+
# Run specific test file
|
|
92
|
+
bun test src/__tests__/user.service.test.ts
|
|
93
|
+
|
|
94
|
+
# Run tests matching pattern
|
|
95
|
+
bun test --grep "UserService"
|
|
96
|
+
|
|
97
|
+
# Watch mode
|
|
98
|
+
bun test --watch
|
|
99
|
+
|
|
100
|
+
# With coverage
|
|
101
|
+
bun test --coverage
|
|
102
|
+
|
|
103
|
+
# With specific environment
|
|
104
|
+
NODE_ENV=test bun test --env-file=.env.test
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## 2. Unit Testing Services
|
|
108
|
+
|
|
109
|
+
Test business logic in isolation by mocking dependencies.
|
|
110
|
+
|
|
111
|
+
**`src/services/__tests__/user.service.test.ts`:**
|
|
112
|
+
```typescript
|
|
113
|
+
import { describe, it, expect, beforeEach, mock } from 'bun:test';
|
|
114
|
+
import { UserService } from '../user.service';
|
|
115
|
+
import type { IUserRepository } from '@/repositories';
|
|
116
|
+
|
|
117
|
+
describe('UserService', () => {
|
|
118
|
+
let service: UserService;
|
|
119
|
+
let mockRepo: IUserRepository;
|
|
120
|
+
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
// Create mock repository
|
|
123
|
+
mockRepo = {
|
|
124
|
+
findById: mock(() => Promise.resolve({ data: null })),
|
|
125
|
+
findOne: mock(() => Promise.resolve({ data: null })),
|
|
126
|
+
create: mock((opts) => Promise.resolve({ data: { id: 'new-id', ...opts.data }, count: 1 })),
|
|
127
|
+
updateById: mock(() => Promise.resolve({ data: null, count: 0 })),
|
|
128
|
+
} as unknown as IUserRepository;
|
|
129
|
+
|
|
130
|
+
// Inject mock
|
|
131
|
+
service = new UserService(mockRepo);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('createUser', () => {
|
|
135
|
+
it('should create a user with valid data', async () => {
|
|
136
|
+
const userData = { email: 'test@example.com', name: 'Test User' };
|
|
137
|
+
|
|
138
|
+
const result = await service.createUser(userData);
|
|
139
|
+
|
|
140
|
+
expect(result.data).toMatchObject({
|
|
141
|
+
id: 'new-id',
|
|
142
|
+
email: 'test@example.com',
|
|
143
|
+
name: 'Test User',
|
|
144
|
+
});
|
|
145
|
+
expect(mockRepo.create).toHaveBeenCalledTimes(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should throw error for duplicate email', async () => {
|
|
149
|
+
mockRepo.findOne = mock(() => Promise.resolve({
|
|
150
|
+
data: { id: 'existing', email: 'test@example.com' },
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
await expect(
|
|
154
|
+
service.createUser({ email: 'test@example.com', name: 'Test' })
|
|
155
|
+
).rejects.toThrow('Email already exists');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should hash password before storing', async () => {
|
|
159
|
+
const userData = { email: 'test@example.com', name: 'Test', password: 'secret123' };
|
|
160
|
+
|
|
161
|
+
await service.createUser(userData);
|
|
162
|
+
|
|
163
|
+
const createCall = (mockRepo.create as ReturnType<typeof mock>).mock.calls[0][0];
|
|
164
|
+
expect(createCall.data.password).not.toBe('secret123');
|
|
165
|
+
expect(createCall.data.password).toMatch(/^\$2[aby]?\$/); // bcrypt hash
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('updateUser', () => {
|
|
170
|
+
it('should throw NotFound when user does not exist', async () => {
|
|
171
|
+
mockRepo.findById = mock(() => Promise.resolve({ data: null }));
|
|
172
|
+
|
|
173
|
+
await expect(
|
|
174
|
+
service.updateUser('nonexistent', { name: 'New Name' })
|
|
175
|
+
).rejects.toThrow('User not found');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should only update provided fields', async () => {
|
|
179
|
+
mockRepo.findById = mock(() => Promise.resolve({
|
|
180
|
+
data: { id: '1', email: 'old@test.com', name: 'Old Name' },
|
|
181
|
+
}));
|
|
182
|
+
mockRepo.updateById = mock((opts) => Promise.resolve({
|
|
183
|
+
data: { ...opts.data, id: opts.id },
|
|
184
|
+
count: 1,
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
await service.updateUser('1', { name: 'New Name' });
|
|
188
|
+
|
|
189
|
+
const updateCall = (mockRepo.updateById as ReturnType<typeof mock>).mock.calls[0][0];
|
|
190
|
+
expect(updateCall.data).toEqual({ name: 'New Name' });
|
|
191
|
+
expect(updateCall.data.email).toBeUndefined();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## 3. Integration Testing Repositories
|
|
198
|
+
|
|
199
|
+
Test repositories with a real (test) database.
|
|
200
|
+
|
|
201
|
+
**`src/repositories/__tests__/user.repository.test.ts`:**
|
|
202
|
+
```typescript
|
|
203
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
204
|
+
import { UserRepository } from '../user.repository';
|
|
205
|
+
import { TestDatabase } from '@test/helpers/test-database';
|
|
206
|
+
import { User } from '@/models';
|
|
207
|
+
|
|
208
|
+
describe('UserRepository', () => {
|
|
209
|
+
let repo: UserRepository;
|
|
210
|
+
|
|
211
|
+
beforeEach(async () => {
|
|
212
|
+
const db = TestDatabase.getDb();
|
|
213
|
+
repo = new UserRepository(db);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
afterEach(async () => {
|
|
217
|
+
await TestDatabase.truncateAll();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('create', () => {
|
|
221
|
+
it('should create a user and return with generated id', async () => {
|
|
222
|
+
const result = await repo.create({
|
|
223
|
+
data: { email: 'test@example.com', name: 'Test User' },
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(result.data).toMatchObject({
|
|
227
|
+
email: 'test@example.com',
|
|
228
|
+
name: 'Test User',
|
|
229
|
+
});
|
|
230
|
+
expect(result.data?.id).toBeDefined();
|
|
231
|
+
expect(result.count).toBe(1);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should enforce unique email constraint', async () => {
|
|
235
|
+
await repo.create({
|
|
236
|
+
data: { email: 'test@example.com', name: 'First' },
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await expect(
|
|
240
|
+
repo.create({ data: { email: 'test@example.com', name: 'Second' } })
|
|
241
|
+
).rejects.toThrow(); // Unique constraint violation
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('find', () => {
|
|
246
|
+
beforeEach(async () => {
|
|
247
|
+
// Seed test data
|
|
248
|
+
await repo.createMany({
|
|
249
|
+
data: [
|
|
250
|
+
{ email: 'alice@test.com', name: 'Alice', status: 'ACTIVE' },
|
|
251
|
+
{ email: 'bob@test.com', name: 'Bob', status: 'ACTIVE' },
|
|
252
|
+
{ email: 'charlie@test.com', name: 'Charlie', status: 'INACTIVE' },
|
|
253
|
+
],
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should filter by status', async () => {
|
|
258
|
+
const result = await repo.find({
|
|
259
|
+
filter: { where: { status: 'ACTIVE' } },
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect(result.data).toHaveLength(2);
|
|
263
|
+
expect(result.data.map(u => u.name)).toContain('Alice');
|
|
264
|
+
expect(result.data.map(u => u.name)).toContain('Bob');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should support pagination', async () => {
|
|
268
|
+
const page1 = await repo.find({
|
|
269
|
+
filter: { limit: 2, offset: 0, order: ['name ASC'] },
|
|
270
|
+
});
|
|
271
|
+
const page2 = await repo.find({
|
|
272
|
+
filter: { limit: 2, offset: 2, order: ['name ASC'] },
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
expect(page1.data).toHaveLength(2);
|
|
276
|
+
expect(page1.data[0].name).toBe('Alice');
|
|
277
|
+
expect(page2.data).toHaveLength(1);
|
|
278
|
+
expect(page2.data[0].name).toBe('Charlie');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('should support complex filters', async () => {
|
|
282
|
+
const result = await repo.find({
|
|
283
|
+
filter: {
|
|
284
|
+
where: {
|
|
285
|
+
or: [
|
|
286
|
+
{ name: { like: 'A%' } },
|
|
287
|
+
{ status: 'INACTIVE' },
|
|
288
|
+
],
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(result.data).toHaveLength(2);
|
|
294
|
+
expect(result.data.map(u => u.name)).toContain('Alice');
|
|
295
|
+
expect(result.data.map(u => u.name)).toContain('Charlie');
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe('relations', () => {
|
|
300
|
+
it('should load related entities', async () => {
|
|
301
|
+
// Assuming User has Posts relation
|
|
302
|
+
const user = await repo.create({
|
|
303
|
+
data: { email: 'author@test.com', name: 'Author' },
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Create posts for the user
|
|
307
|
+
const postRepo = new PostRepository(TestDatabase.getDb());
|
|
308
|
+
await postRepo.createMany({
|
|
309
|
+
data: [
|
|
310
|
+
{ title: 'Post 1', authorId: user.data!.id },
|
|
311
|
+
{ title: 'Post 2', authorId: user.data!.id },
|
|
312
|
+
],
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const result = await repo.findById({
|
|
316
|
+
id: user.data!.id,
|
|
317
|
+
filter: { include: [{ relation: 'posts' }] },
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
expect(result.data?.posts).toHaveLength(2);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
## 4. E2E Testing Controllers
|
|
327
|
+
|
|
328
|
+
Test full request/response cycle using Hono's test client.
|
|
329
|
+
|
|
330
|
+
**`src/controllers/__tests__/user.controller.test.ts`:**
|
|
331
|
+
```typescript
|
|
332
|
+
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'bun:test';
|
|
333
|
+
import { testClient } from 'hono/testing';
|
|
334
|
+
import { Application } from '@/application';
|
|
335
|
+
import { TestDatabase } from '@test/helpers/test-database';
|
|
336
|
+
|
|
337
|
+
describe('UserController E2E', () => {
|
|
338
|
+
let app: Application;
|
|
339
|
+
let client: ReturnType<typeof testClient>;
|
|
340
|
+
|
|
341
|
+
beforeAll(async () => {
|
|
342
|
+
await TestDatabase.initialize();
|
|
343
|
+
app = new Application();
|
|
344
|
+
await app.boot();
|
|
345
|
+
client = testClient(app.server);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
afterEach(async () => {
|
|
349
|
+
await TestDatabase.truncateAll();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
afterAll(async () => {
|
|
353
|
+
await TestDatabase.close();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe('POST /api/users', () => {
|
|
357
|
+
it('should create a user with valid data', async () => {
|
|
358
|
+
const response = await client.api.users.$post({
|
|
359
|
+
json: {
|
|
360
|
+
email: 'test@example.com',
|
|
361
|
+
name: 'Test User',
|
|
362
|
+
password: 'SecurePass123!',
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
expect(response.status).toBe(201);
|
|
367
|
+
const body = await response.json();
|
|
368
|
+
expect(body.email).toBe('test@example.com');
|
|
369
|
+
expect(body.id).toBeDefined();
|
|
370
|
+
expect(body.password).toBeUndefined(); // Should not expose password
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('should return 422 for invalid email', async () => {
|
|
374
|
+
const response = await client.api.users.$post({
|
|
375
|
+
json: {
|
|
376
|
+
email: 'not-an-email',
|
|
377
|
+
name: 'Test',
|
|
378
|
+
password: 'SecurePass123!',
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
expect(response.status).toBe(422);
|
|
383
|
+
const body = await response.json();
|
|
384
|
+
expect(body.details.cause[0].path).toBe('email');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('should return 409 for duplicate email', async () => {
|
|
388
|
+
// First create
|
|
389
|
+
await client.api.users.$post({
|
|
390
|
+
json: { email: 'test@example.com', name: 'First', password: 'Pass123!' },
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// Duplicate
|
|
394
|
+
const response = await client.api.users.$post({
|
|
395
|
+
json: { email: 'test@example.com', name: 'Second', password: 'Pass123!' },
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
expect(response.status).toBe(409);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe('GET /api/users/:id', () => {
|
|
403
|
+
it('should return user by id', async () => {
|
|
404
|
+
// Create user first
|
|
405
|
+
const createRes = await client.api.users.$post({
|
|
406
|
+
json: { email: 'test@example.com', name: 'Test', password: 'Pass123!' },
|
|
407
|
+
});
|
|
408
|
+
const created = await createRes.json();
|
|
409
|
+
|
|
410
|
+
const response = await client.api.users[':id'].$get({
|
|
411
|
+
param: { id: created.id },
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
expect(response.status).toBe(200);
|
|
415
|
+
const body = await response.json();
|
|
416
|
+
expect(body.id).toBe(created.id);
|
|
417
|
+
expect(body.email).toBe('test@example.com');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('should return 404 for nonexistent user', async () => {
|
|
421
|
+
const response = await client.api.users[':id'].$get({
|
|
422
|
+
param: { id: 'nonexistent-uuid' },
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
expect(response.status).toBe(404);
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
describe('Protected Routes', () => {
|
|
430
|
+
let authToken: string;
|
|
431
|
+
|
|
432
|
+
beforeEach(async () => {
|
|
433
|
+
// Create user and get token
|
|
434
|
+
await client.api.users.$post({
|
|
435
|
+
json: { email: 'auth@test.com', name: 'Auth User', password: 'Pass123!' },
|
|
436
|
+
});
|
|
437
|
+
const loginRes = await client.api.auth.login.$post({
|
|
438
|
+
json: { email: 'auth@test.com', password: 'Pass123!' },
|
|
439
|
+
});
|
|
440
|
+
const { token } = await loginRes.json();
|
|
441
|
+
authToken = token;
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('should return 401 without token', async () => {
|
|
445
|
+
const response = await client.api.users.me.$get();
|
|
446
|
+
expect(response.status).toBe(401);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('should return user profile with valid token', async () => {
|
|
450
|
+
const response = await client.api.users.me.$get({
|
|
451
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
expect(response.status).toBe(200);
|
|
455
|
+
const body = await response.json();
|
|
456
|
+
expect(body.email).toBe('auth@test.com');
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
## 5. Mocking Patterns
|
|
463
|
+
|
|
464
|
+
### Mock External Services
|
|
465
|
+
|
|
466
|
+
```typescript
|
|
467
|
+
import { mock } from 'bun:test';
|
|
468
|
+
|
|
469
|
+
// Mock email service
|
|
470
|
+
const mockEmailService = {
|
|
471
|
+
send: mock(() => Promise.resolve({ messageId: 'mock-id' })),
|
|
472
|
+
sendBulk: mock(() => Promise.resolve({ sent: 10, failed: 0 })),
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
// Mock Redis
|
|
476
|
+
const mockRedis = {
|
|
477
|
+
get: mock((key: string) => Promise.resolve(null)),
|
|
478
|
+
set: mock(() => Promise.resolve('OK')),
|
|
479
|
+
del: mock(() => Promise.resolve(1)),
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
// Reset mocks between tests
|
|
483
|
+
beforeEach(() => {
|
|
484
|
+
mockEmailService.send.mockClear();
|
|
485
|
+
mockRedis.get.mockClear();
|
|
486
|
+
});
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
### Mock Time
|
|
490
|
+
|
|
491
|
+
```typescript
|
|
492
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
493
|
+
|
|
494
|
+
describe('Time-dependent tests', () => {
|
|
495
|
+
const realDate = Date;
|
|
496
|
+
|
|
497
|
+
beforeEach(() => {
|
|
498
|
+
// Mock Date to fixed time
|
|
499
|
+
const mockDate = new Date('2024-01-15T10:00:00Z');
|
|
500
|
+
global.Date = class extends realDate {
|
|
501
|
+
constructor() {
|
|
502
|
+
super();
|
|
503
|
+
return mockDate;
|
|
504
|
+
}
|
|
505
|
+
static now() {
|
|
506
|
+
return mockDate.getTime();
|
|
507
|
+
}
|
|
508
|
+
} as DateConstructor;
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
afterEach(() => {
|
|
512
|
+
global.Date = realDate;
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('should use mocked time', () => {
|
|
516
|
+
const now = new Date();
|
|
517
|
+
expect(now.toISOString()).toBe('2024-01-15T10:00:00.000Z');
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
### Spy on Methods
|
|
523
|
+
|
|
524
|
+
```typescript
|
|
525
|
+
import { spyOn } from 'bun:test';
|
|
526
|
+
|
|
527
|
+
it('should call logger on error', async () => {
|
|
528
|
+
const loggerSpy = spyOn(service.logger, 'error');
|
|
529
|
+
|
|
530
|
+
await expect(service.riskyOperation()).rejects.toThrow();
|
|
531
|
+
|
|
532
|
+
expect(loggerSpy).toHaveBeenCalledWith(
|
|
533
|
+
expect.stringContaining('Operation failed'),
|
|
534
|
+
expect.any(Error)
|
|
535
|
+
);
|
|
536
|
+
});
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
## 6. Test Organization
|
|
540
|
+
|
|
541
|
+
### File Structure
|
|
542
|
+
|
|
543
|
+
```
|
|
544
|
+
src/
|
|
545
|
+
├── services/
|
|
546
|
+
│ ├── user.service.ts
|
|
547
|
+
│ └── __tests__/
|
|
548
|
+
│ └── user.service.test.ts
|
|
549
|
+
├── repositories/
|
|
550
|
+
│ ├── user.repository.ts
|
|
551
|
+
│ └── __tests__/
|
|
552
|
+
│ └── user.repository.test.ts
|
|
553
|
+
└── controllers/
|
|
554
|
+
├── user.controller.ts
|
|
555
|
+
└── __tests__/
|
|
556
|
+
└── user.controller.test.ts
|
|
557
|
+
test/
|
|
558
|
+
├── setup.ts
|
|
559
|
+
├── helpers/
|
|
560
|
+
│ ├── test-database.ts
|
|
561
|
+
│ └── fixtures.ts
|
|
562
|
+
└── e2e/
|
|
563
|
+
└── full-flow.test.ts
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
### Test Naming Convention
|
|
567
|
+
|
|
568
|
+
```typescript
|
|
569
|
+
describe('UserService', () => {
|
|
570
|
+
describe('createUser', () => {
|
|
571
|
+
it('should create user with valid data', () => {});
|
|
572
|
+
it('should throw ValidationError for invalid email', () => {});
|
|
573
|
+
it('should throw ConflictError for duplicate email', () => {});
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
describe('updateUser', () => {
|
|
577
|
+
describe('when user exists', () => {
|
|
578
|
+
it('should update provided fields only', () => {});
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
describe('when user does not exist', () => {
|
|
582
|
+
it('should throw NotFoundError', () => {});
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
## 7. Coverage Guidelines
|
|
589
|
+
|
|
590
|
+
| Category | Target | Priority |
|
|
591
|
+
|----------|--------|----------|
|
|
592
|
+
| Services (business logic) | > 80% | High |
|
|
593
|
+
| Repositories (data access) | > 70% | Medium |
|
|
594
|
+
| Controllers (E2E) | > 60% | Medium |
|
|
595
|
+
| Utilities/Helpers | > 90% | High |
|
|
596
|
+
|
|
597
|
+
**Generate coverage report:**
|
|
598
|
+
```bash
|
|
599
|
+
bun test --coverage
|
|
600
|
+
|
|
601
|
+
# Coverage summary will be in ./coverage/
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
## Testing Checklist
|
|
605
|
+
|
|
606
|
+
| Category | Check |
|
|
607
|
+
|----------|-------|
|
|
608
|
+
| **Setup** | Test database configured and isolated |
|
|
609
|
+
| **Unit** | Services tested with mocked dependencies |
|
|
610
|
+
| **Integration** | Repositories tested with real database |
|
|
611
|
+
| **E2E** | Critical user flows covered |
|
|
612
|
+
| **Edge Cases** | Error conditions and boundaries tested |
|
|
613
|
+
| **Security** | Auth/authz scenarios tested |
|
|
614
|
+
| **Performance** | Slow tests identified and optimized |
|
|
615
|
+
|
|
616
|
+
## See Also
|
|
617
|
+
|
|
618
|
+
- [Error Handling](./error-handling) - Test error scenarios
|
|
619
|
+
- [Common Pitfalls](./common-pitfalls) - Testing mistakes to avoid
|
|
620
|
+
- [API Usage Examples](./api-usage-examples) - What to test
|