@studiosonrai/nestjs-testfx 1.0.0
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 +666 -0
- package/dist/src/factories/base.factory.d.ts +49 -0
- package/dist/src/factories/base.factory.js +75 -0
- package/dist/src/factories/base.factory.js.map +1 -0
- package/dist/src/factories/example.factory.d.ts +44 -0
- package/dist/src/factories/example.factory.js +117 -0
- package/dist/src/factories/example.factory.js.map +1 -0
- package/dist/src/factories/index.d.ts +2 -0
- package/dist/src/factories/index.js +10 -0
- package/dist/src/factories/index.js.map +1 -0
- package/dist/src/fixtures/example.fixture.d.ts +28 -0
- package/dist/src/fixtures/example.fixture.js +112 -0
- package/dist/src/fixtures/example.fixture.js.map +1 -0
- package/dist/src/fixtures/index.d.ts +1 -0
- package/dist/src/fixtures/index.js +13 -0
- package/dist/src/fixtures/index.js.map +1 -0
- package/dist/src/helpers/database-test.helper.d.ts +29 -0
- package/dist/src/helpers/database-test.helper.js +154 -0
- package/dist/src/helpers/database-test.helper.js.map +1 -0
- package/dist/src/helpers/index.d.ts +1 -0
- package/dist/src/helpers/index.js +6 -0
- package/dist/src/helpers/index.js.map +1 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +23 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/jest.setup.template.d.ts +9 -0
- package/dist/src/jest.setup.template.js +31 -0
- package/dist/src/jest.setup.template.js.map +1 -0
- package/dist/src/matchers/entity-comparator.d.ts +28 -0
- package/dist/src/matchers/entity-comparator.js +163 -0
- package/dist/src/matchers/entity-comparator.js.map +1 -0
- package/dist/src/matchers/index.d.ts +1 -0
- package/dist/src/matchers/index.js +7 -0
- package/dist/src/matchers/index.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
# nestjs-testfx
|
|
2
|
+
|
|
3
|
+
Shared testing utilities for NestJS + TypeORM applications. This package provides a robust foundation for writing API tests with database integration.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **DatabaseTestHelper**: Transaction-based test isolation and database cleanup
|
|
8
|
+
- **BaseFactory**: Abstract factory pattern for generating test data with Faker.js
|
|
9
|
+
- **EntityComparator**: Deep entity comparison with custom Jest matchers
|
|
10
|
+
- **Fixtures**: Pre-defined static test data with loader utilities
|
|
11
|
+
- **Jest Setup Template**: Ready-to-use Jest configuration with safety checks
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install nestjs-testfx --save-dev
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Peer Dependencies
|
|
20
|
+
|
|
21
|
+
Ensure you have these peer dependencies installed:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install --save-dev \
|
|
25
|
+
@faker-js/faker \
|
|
26
|
+
@nestjs/common \
|
|
27
|
+
@nestjs/config \
|
|
28
|
+
@nestjs/testing \
|
|
29
|
+
@nestjs/typeorm \
|
|
30
|
+
jest \
|
|
31
|
+
typeorm
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
### 1. Set Up Jest Configuration
|
|
37
|
+
|
|
38
|
+
Copy the Jest setup template to your project:
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
// src/testing/jest.setup.ts
|
|
42
|
+
import { config } from 'dotenv';
|
|
43
|
+
import { createEntityEquivalentMatcher } from 'nestjs-testfx';
|
|
44
|
+
|
|
45
|
+
config({ path: '.test.env' });
|
|
46
|
+
|
|
47
|
+
jest.setTimeout(30000);
|
|
48
|
+
|
|
49
|
+
beforeAll(() => {
|
|
50
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
51
|
+
throw new Error('Tests must run with NODE_ENV=test');
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect.extend({
|
|
56
|
+
toBeEntityEquivalent: createEntityEquivalentMatcher(),
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Update your `jest.config.js`:
|
|
61
|
+
|
|
62
|
+
```javascript
|
|
63
|
+
module.exports = {
|
|
64
|
+
setupFilesAfterEnv: ['<rootDir>/src/testing/jest.setup.ts'],
|
|
65
|
+
// ... other config
|
|
66
|
+
};
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 2. Create Your DataSource
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
// src/testing/test-data-source.ts
|
|
73
|
+
import { DataSource } from 'typeorm';
|
|
74
|
+
|
|
75
|
+
export const testDataSource = new DataSource({
|
|
76
|
+
type: 'postgres', // or 'mssql', 'mysql'
|
|
77
|
+
host: process.env.DB_HOST,
|
|
78
|
+
port: parseInt(process.env.DB_PORT || '5432'),
|
|
79
|
+
username: process.env.DB_USERNAME,
|
|
80
|
+
password: process.env.DB_PASSWORD,
|
|
81
|
+
database: process.env.DB_DATABASE,
|
|
82
|
+
entities: ['src/**/*.entity.ts'],
|
|
83
|
+
synchronize: false,
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 3. Create Factories for Your Entities
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
// src/testing/factories/user.factory.ts
|
|
91
|
+
import { DataSource } from 'typeorm';
|
|
92
|
+
import { BaseFactory } from 'nestjs-testfx';
|
|
93
|
+
import { User } from '../../entities/user.entity';
|
|
94
|
+
|
|
95
|
+
export class UserFactory extends BaseFactory<User> {
|
|
96
|
+
constructor(dataSource: DataSource) {
|
|
97
|
+
super(dataSource, User);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
build(overrides?: Partial<User>): User {
|
|
101
|
+
const fake = this.generateFakeData();
|
|
102
|
+
|
|
103
|
+
return this.createInstance(this.applyOverrides({
|
|
104
|
+
email: fake.email,
|
|
105
|
+
firstName: fake.firstName,
|
|
106
|
+
lastName: fake.lastName,
|
|
107
|
+
isActive: true,
|
|
108
|
+
createdAt: new Date(),
|
|
109
|
+
}, overrides));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async createAdmin(overrides?: Partial<User>): Promise<User> {
|
|
113
|
+
return this.create({
|
|
114
|
+
role: 'admin',
|
|
115
|
+
isActive: true,
|
|
116
|
+
...overrides,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### 4. Write Your First Test
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
// src/users/users.service.spec.ts
|
|
126
|
+
import { DatabaseTestHelper } from 'nestjs-testfx';
|
|
127
|
+
import { testDataSource } from '../testing/test-data-source';
|
|
128
|
+
import { UserFactory } from '../testing/factories/user.factory';
|
|
129
|
+
import { UsersService } from './users.service';
|
|
130
|
+
|
|
131
|
+
describe('UsersService', () => {
|
|
132
|
+
let databaseHelper: DatabaseTestHelper;
|
|
133
|
+
let userFactory: UserFactory;
|
|
134
|
+
let service: UsersService;
|
|
135
|
+
|
|
136
|
+
beforeAll(async () => {
|
|
137
|
+
databaseHelper = await DatabaseTestHelper.fromDataSource(testDataSource, {
|
|
138
|
+
cleanupOrder: ['Order', 'User'],
|
|
139
|
+
dialect: 'postgres',
|
|
140
|
+
});
|
|
141
|
+
userFactory = new UserFactory(databaseHelper.getDataSource());
|
|
142
|
+
service = new UsersService(databaseHelper.getRepository(User));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
afterAll(async () => {
|
|
146
|
+
await databaseHelper.close();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
beforeEach(async () => {
|
|
150
|
+
await databaseHelper.cleanDatabase(['User']);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should find user by email', async () => {
|
|
154
|
+
const user = await userFactory.create({ email: 'test@example.com' });
|
|
155
|
+
|
|
156
|
+
const found = await service.findByEmail('test@example.com');
|
|
157
|
+
|
|
158
|
+
expect(found).toBeDefined();
|
|
159
|
+
expect(found?.id).toBe(user.id);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## API Reference
|
|
165
|
+
|
|
166
|
+
### DatabaseTestHelper
|
|
167
|
+
|
|
168
|
+
Manages database connections and provides test isolation utilities.
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
import { DatabaseTestHelper } from 'nestjs-testfx';
|
|
172
|
+
|
|
173
|
+
// Create from existing DataSource
|
|
174
|
+
const helper = await DatabaseTestHelper.fromDataSource(dataSource, {
|
|
175
|
+
cleanupOrder: ['OrderItem', 'Order', 'User'],
|
|
176
|
+
dialect: 'postgres', // 'mssql' | 'postgres' | 'mysql'
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Transaction-based isolation
|
|
180
|
+
await helper.startTransaction();
|
|
181
|
+
const manager = helper.getTransactionManager();
|
|
182
|
+
await manager.save(User, userData);
|
|
183
|
+
await helper.rollbackTransaction(); // Undo all changes
|
|
184
|
+
|
|
185
|
+
// Clean specific tables
|
|
186
|
+
await helper.cleanDatabase(['User', 'Order']);
|
|
187
|
+
|
|
188
|
+
// Query helpers
|
|
189
|
+
const exists = await helper.entityExists(User, { email: 'test@example.com' });
|
|
190
|
+
const count = await helper.countEntities(User, { isActive: true });
|
|
191
|
+
|
|
192
|
+
// Execute in separate transaction (auto-commits)
|
|
193
|
+
const user = await helper.inNewTransaction(async (manager) => {
|
|
194
|
+
return manager.save(User, { email: 'test@example.com' });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Close connection
|
|
198
|
+
await helper.close();
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### BaseFactory
|
|
202
|
+
|
|
203
|
+
Abstract base class for entity factories.
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
import { BaseFactory, FactoryOptions } from 'nestjs-testfx';
|
|
207
|
+
|
|
208
|
+
class ProductFactory extends BaseFactory<Product> {
|
|
209
|
+
constructor(dataSource: DataSource) {
|
|
210
|
+
super(dataSource, Product);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
build(overrides?: Partial<Product>): Product {
|
|
214
|
+
const fake = this.generateFakeData();
|
|
215
|
+
return this.createInstance(this.applyOverrides({
|
|
216
|
+
name: fake.name,
|
|
217
|
+
price: fake.decimal,
|
|
218
|
+
inStock: true,
|
|
219
|
+
}, overrides));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Convenience methods
|
|
223
|
+
async createOutOfStock(overrides?: Partial<Product>): Promise<Product> {
|
|
224
|
+
return this.create({ inStock: false, ...overrides });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Usage
|
|
229
|
+
const factory = new ProductFactory(dataSource);
|
|
230
|
+
|
|
231
|
+
// Build without saving
|
|
232
|
+
const product = factory.build({ name: 'Widget' });
|
|
233
|
+
|
|
234
|
+
// Create and save
|
|
235
|
+
const saved = await factory.create({ name: 'Gadget' });
|
|
236
|
+
|
|
237
|
+
// Create multiple
|
|
238
|
+
const products = await factory.createMany(10, { inStock: true });
|
|
239
|
+
|
|
240
|
+
// Create within transaction
|
|
241
|
+
const product = await factory.create(
|
|
242
|
+
{ name: 'Test' },
|
|
243
|
+
{ manager: transactionManager }
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// Build without saving
|
|
247
|
+
const instance = await factory.create({}, { save: false });
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
#### Available Fake Data
|
|
251
|
+
|
|
252
|
+
`generateFakeData()` provides:
|
|
253
|
+
|
|
254
|
+
| Property | Description |
|
|
255
|
+
|----------|-------------|
|
|
256
|
+
| `id` | Random integer (1-999999) |
|
|
257
|
+
| `email` | Random email address |
|
|
258
|
+
| `firstName` | Random first name |
|
|
259
|
+
| `lastName` | Random last name |
|
|
260
|
+
| `phone` | Random phone number |
|
|
261
|
+
| `address` | Random street address |
|
|
262
|
+
| `city` | Random city name |
|
|
263
|
+
| `state` | Random state/province |
|
|
264
|
+
| `postalCode` | Random zip/postal code |
|
|
265
|
+
| `country` | Random country name |
|
|
266
|
+
| `name` | Random company name |
|
|
267
|
+
| `description` | Random paragraph |
|
|
268
|
+
| `createdAt` | Random past date |
|
|
269
|
+
| `updatedAt` | Random recent date |
|
|
270
|
+
| `boolean` | Random boolean |
|
|
271
|
+
| `number` | Random integer (1-1000) |
|
|
272
|
+
| `decimal` | Random float (0-100, 2 decimals) |
|
|
273
|
+
| `datetime` | Random future date |
|
|
274
|
+
| `text` | Random sentences |
|
|
275
|
+
| `url` | Random URL |
|
|
276
|
+
| `priority` | 'low' \| 'medium' \| 'high' |
|
|
277
|
+
| `status` | 'active' \| 'inactive' \| 'pending' |
|
|
278
|
+
|
|
279
|
+
### EntityComparator
|
|
280
|
+
|
|
281
|
+
Deep entity comparison with custom options.
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
import { EntityComparator } from 'nestjs-testfx';
|
|
285
|
+
|
|
286
|
+
// Basic comparison
|
|
287
|
+
const result = EntityComparator.compare(actual, expected);
|
|
288
|
+
console.log(result.areEqual); // true/false
|
|
289
|
+
|
|
290
|
+
// Ignore auto-generated fields
|
|
291
|
+
const result = EntityComparator.compare(savedUser, {
|
|
292
|
+
email: 'test@example.com',
|
|
293
|
+
firstName: 'John',
|
|
294
|
+
}, {
|
|
295
|
+
ignoreFields: ['id', 'createdAt', 'updatedAt'],
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Custom comparators
|
|
299
|
+
const result = EntityComparator.compare(entity1, entity2, {
|
|
300
|
+
customComparators: {
|
|
301
|
+
// Compare dates by day only
|
|
302
|
+
scheduledDate: (a, b) =>
|
|
303
|
+
new Date(a).toDateString() === new Date(b).toDateString(),
|
|
304
|
+
// Compare prices within tolerance
|
|
305
|
+
price: (a, b) => Math.abs(a - b) < 0.01,
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Compare subset of fields
|
|
310
|
+
const result = EntityComparator.compareSubset(fullEntity, {
|
|
311
|
+
email: 'test@example.com',
|
|
312
|
+
name: 'John',
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Format for error messages
|
|
316
|
+
if (!result.areEqual) {
|
|
317
|
+
console.log(EntityComparator.formatComparisonResult(result));
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Jest Custom Matcher
|
|
322
|
+
|
|
323
|
+
```typescript
|
|
324
|
+
// In jest.setup.ts
|
|
325
|
+
import { createEntityEquivalentMatcher } from 'nestjs-testfx';
|
|
326
|
+
|
|
327
|
+
expect.extend({
|
|
328
|
+
toBeEntityEquivalent: createEntityEquivalentMatcher(),
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// In tests
|
|
332
|
+
expect(actualUser).toBeEntityEquivalent(expectedUser);
|
|
333
|
+
|
|
334
|
+
expect(savedEntity).toBeEntityEquivalent(expectedData, {
|
|
335
|
+
ignoreFields: ['id', 'createdAt', 'updatedAt'],
|
|
336
|
+
});
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Fixtures
|
|
340
|
+
|
|
341
|
+
Pre-defined static test data.
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
// Define fixtures
|
|
345
|
+
export const ROLE_FIXTURES = {
|
|
346
|
+
ADMIN: { id: 1, name: 'Admin', description: 'Administrator' },
|
|
347
|
+
USER: { id: 2, name: 'User', description: 'Standard user' },
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
export function getAllRoleFixtures() {
|
|
351
|
+
return Object.values(ROLE_FIXTURES);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function getRoleById(id: number) {
|
|
355
|
+
return Object.values(ROLE_FIXTURES).find(r => r.id === id);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Load fixtures
|
|
359
|
+
class FixtureLoader {
|
|
360
|
+
constructor(private manager: EntityManager) {}
|
|
361
|
+
|
|
362
|
+
async loadRoles() {
|
|
363
|
+
for (const role of getAllRoleFixtures()) {
|
|
364
|
+
await this.manager.save(Role, role);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
## Complete Test Example
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
import {
|
|
374
|
+
DatabaseTestHelper,
|
|
375
|
+
EntityComparator,
|
|
376
|
+
} from 'nestjs-testfx';
|
|
377
|
+
import { testDataSource } from '../testing/test-data-source';
|
|
378
|
+
import { UserFactory } from '../testing/factories/user.factory';
|
|
379
|
+
import { OrderFactory } from '../testing/factories/order.factory';
|
|
380
|
+
import { OrderService } from './order.service';
|
|
381
|
+
|
|
382
|
+
describe('OrderService', () => {
|
|
383
|
+
let databaseHelper: DatabaseTestHelper;
|
|
384
|
+
let userFactory: UserFactory;
|
|
385
|
+
let orderFactory: OrderFactory;
|
|
386
|
+
let service: OrderService;
|
|
387
|
+
|
|
388
|
+
beforeAll(async () => {
|
|
389
|
+
databaseHelper = await DatabaseTestHelper.fromDataSource(testDataSource, {
|
|
390
|
+
cleanupOrder: ['OrderItem', 'Order', 'User'],
|
|
391
|
+
dialect: 'postgres',
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
userFactory = new UserFactory(databaseHelper.getDataSource());
|
|
395
|
+
orderFactory = new OrderFactory(databaseHelper.getDataSource());
|
|
396
|
+
|
|
397
|
+
service = new OrderService(
|
|
398
|
+
databaseHelper.getRepository(Order),
|
|
399
|
+
databaseHelper.getRepository(User),
|
|
400
|
+
);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
afterAll(async () => {
|
|
404
|
+
await databaseHelper.close();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
beforeEach(async () => {
|
|
408
|
+
await databaseHelper.cleanDatabase(['OrderItem', 'Order', 'User']);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
describe('createOrder', () => {
|
|
412
|
+
it('should create order for user', async () => {
|
|
413
|
+
// Arrange
|
|
414
|
+
const user = await userFactory.create({ email: 'buyer@example.com' });
|
|
415
|
+
|
|
416
|
+
// Act
|
|
417
|
+
const order = await service.createOrder(user.id, {
|
|
418
|
+
items: [{ productId: 1, quantity: 2 }],
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// Assert
|
|
422
|
+
expect(order.id).toBeDefined();
|
|
423
|
+
expect(order.userId).toBe(user.id);
|
|
424
|
+
expect(order.status).toBe('pending');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('should calculate total correctly', async () => {
|
|
428
|
+
const user = await userFactory.create();
|
|
429
|
+
const order = await service.createOrder(user.id, {
|
|
430
|
+
items: [
|
|
431
|
+
{ productId: 1, quantity: 2, price: 10 },
|
|
432
|
+
{ productId: 2, quantity: 1, price: 25 },
|
|
433
|
+
],
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
expect(order.total).toBe(45); // (2 * 10) + (1 * 25)
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
describe('cancelOrder', () => {
|
|
441
|
+
it('should cancel pending order', async () => {
|
|
442
|
+
const user = await userFactory.create();
|
|
443
|
+
const order = await orderFactory.createPending(user.id);
|
|
444
|
+
|
|
445
|
+
const cancelled = await service.cancelOrder(order.id);
|
|
446
|
+
|
|
447
|
+
expect(cancelled.status).toBe('cancelled');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('should not cancel completed order', async () => {
|
|
451
|
+
const user = await userFactory.create();
|
|
452
|
+
const order = await orderFactory.createCompleted(user.id);
|
|
453
|
+
|
|
454
|
+
await expect(service.cancelOrder(order.id)).rejects.toThrow(
|
|
455
|
+
'Cannot cancel completed order'
|
|
456
|
+
);
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
describe('with transactions', () => {
|
|
461
|
+
it('should rollback on error', async () => {
|
|
462
|
+
const user = await userFactory.create();
|
|
463
|
+
|
|
464
|
+
await expect(
|
|
465
|
+
service.createOrderWithPayment(user.id, {
|
|
466
|
+
items: [{ productId: 1, quantity: 1 }],
|
|
467
|
+
paymentMethod: 'invalid',
|
|
468
|
+
})
|
|
469
|
+
).rejects.toThrow();
|
|
470
|
+
|
|
471
|
+
// Verify no order was created
|
|
472
|
+
const count = await databaseHelper.countEntities(Order, { userId: user.id });
|
|
473
|
+
expect(count).toBe(0);
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
## Factory Manager Pattern
|
|
480
|
+
|
|
481
|
+
For larger projects, use a factory manager to centralize factory access:
|
|
482
|
+
|
|
483
|
+
```typescript
|
|
484
|
+
// src/testing/factories/index.ts
|
|
485
|
+
import { DataSource } from 'typeorm';
|
|
486
|
+
import { UserFactory } from './user.factory';
|
|
487
|
+
import { OrderFactory } from './order.factory';
|
|
488
|
+
import { ProductFactory } from './product.factory';
|
|
489
|
+
|
|
490
|
+
export class FactoryManager {
|
|
491
|
+
public readonly user: UserFactory;
|
|
492
|
+
public readonly order: OrderFactory;
|
|
493
|
+
public readonly product: ProductFactory;
|
|
494
|
+
|
|
495
|
+
constructor(dataSource: DataSource) {
|
|
496
|
+
this.user = new UserFactory(dataSource);
|
|
497
|
+
this.order = new OrderFactory(dataSource);
|
|
498
|
+
this.product = new ProductFactory(dataSource);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Usage in tests
|
|
503
|
+
describe('Service', () => {
|
|
504
|
+
let factories: FactoryManager;
|
|
505
|
+
|
|
506
|
+
beforeAll(async () => {
|
|
507
|
+
factories = new FactoryManager(dataSource);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('test', async () => {
|
|
511
|
+
const user = await factories.user.create();
|
|
512
|
+
const order = await factories.order.createForUser(user.id);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
## Test Data Helper Pattern
|
|
518
|
+
|
|
519
|
+
For complex test scenarios, create a test data helper:
|
|
520
|
+
|
|
521
|
+
```typescript
|
|
522
|
+
// src/testing/helpers/test-data.helper.ts
|
|
523
|
+
import { DatabaseTestHelper } from 'nestjs-testfx';
|
|
524
|
+
import { FactoryManager } from '../factories';
|
|
525
|
+
import { ROLE_FIXTURES } from '../fixtures';
|
|
526
|
+
|
|
527
|
+
export class TestDataHelper {
|
|
528
|
+
constructor(
|
|
529
|
+
private databaseHelper: DatabaseTestHelper,
|
|
530
|
+
private factories: FactoryManager,
|
|
531
|
+
) {}
|
|
532
|
+
|
|
533
|
+
async setupFixtures(manager?: EntityManager) {
|
|
534
|
+
const em = manager || this.databaseHelper.getDataSource().manager;
|
|
535
|
+
for (const role of Object.values(ROLE_FIXTURES)) {
|
|
536
|
+
await em.save(Role, role);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async createTestScenario(name: string) {
|
|
541
|
+
switch (name) {
|
|
542
|
+
case 'e-commerce':
|
|
543
|
+
return this.createEcommerceScenario();
|
|
544
|
+
case 'multi-tenant':
|
|
545
|
+
return this.createMultiTenantScenario();
|
|
546
|
+
default:
|
|
547
|
+
throw new Error(`Unknown scenario: ${name}`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private async createEcommerceScenario() {
|
|
552
|
+
await this.setupFixtures();
|
|
553
|
+
|
|
554
|
+
const admin = await this.factories.user.createAdmin();
|
|
555
|
+
const customers = await this.factories.user.createMany(5);
|
|
556
|
+
const products = await this.factories.product.createMany(20);
|
|
557
|
+
|
|
558
|
+
return { admin, customers, products };
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
## Environment Configuration
|
|
564
|
+
|
|
565
|
+
Create a `.test.env` or `.ci.env` file:
|
|
566
|
+
|
|
567
|
+
```env
|
|
568
|
+
NODE_ENV=test
|
|
569
|
+
DB_HOST=localhost
|
|
570
|
+
DB_PORT=5432
|
|
571
|
+
DB_DATABASE=myapp_test
|
|
572
|
+
DB_USERNAME=postgres
|
|
573
|
+
DB_PASSWORD=postgres
|
|
574
|
+
|
|
575
|
+
# Optional: reduce log noise
|
|
576
|
+
LOG_LEVEL=warn
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
## Best Practices
|
|
580
|
+
|
|
581
|
+
### 1. Clean Tables in Correct Order
|
|
582
|
+
|
|
583
|
+
```typescript
|
|
584
|
+
// Define cleanup order respecting foreign keys
|
|
585
|
+
const cleanupOrder = [
|
|
586
|
+
'OrderItem', // References Order
|
|
587
|
+
'Order', // References User
|
|
588
|
+
'UserRole', // References User and Role
|
|
589
|
+
'User', // Parent
|
|
590
|
+
'Role', // Parent
|
|
591
|
+
];
|
|
592
|
+
|
|
593
|
+
const helper = await DatabaseTestHelper.fromDataSource(dataSource, {
|
|
594
|
+
cleanupOrder,
|
|
595
|
+
});
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### 2. Use Transaction Isolation for Speed
|
|
599
|
+
|
|
600
|
+
```typescript
|
|
601
|
+
beforeEach(async () => {
|
|
602
|
+
await databaseHelper.startTransaction();
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
afterEach(async () => {
|
|
606
|
+
await databaseHelper.rollbackTransaction();
|
|
607
|
+
});
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
### 3. Create Meaningful Factory Methods
|
|
611
|
+
|
|
612
|
+
```typescript
|
|
613
|
+
class OrderFactory extends BaseFactory<Order> {
|
|
614
|
+
// Good: Descriptive, purpose-clear methods
|
|
615
|
+
async createPendingForNewCustomer() { ... }
|
|
616
|
+
async createCompletedWithItems(itemCount: number) { ... }
|
|
617
|
+
async createHighValueOrder(minAmount: number) { ... }
|
|
618
|
+
}
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
### 4. Use Fixtures for Reference Data
|
|
622
|
+
|
|
623
|
+
```typescript
|
|
624
|
+
// Use fixtures for data with known IDs
|
|
625
|
+
const userRole = ROLE_FIXTURES.USER;
|
|
626
|
+
|
|
627
|
+
// Use factories for variable test data
|
|
628
|
+
const user = await userFactory.create({ roleId: userRole.id });
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
## Troubleshooting
|
|
632
|
+
|
|
633
|
+
### "Tests must use a test database"
|
|
634
|
+
|
|
635
|
+
Ensure your `.test.env` has a database name containing 'test' or 'ci':
|
|
636
|
+
|
|
637
|
+
```env
|
|
638
|
+
DB_DATABASE=myapp_test # ✓
|
|
639
|
+
DB_DATABASE=myapp_ci # ✓
|
|
640
|
+
DB_DATABASE=myapp # ✗ Will fail safety check
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
### Foreign Key Constraint Errors
|
|
644
|
+
|
|
645
|
+
Update `cleanupOrder` to delete child tables before parents:
|
|
646
|
+
|
|
647
|
+
```typescript
|
|
648
|
+
const cleanupOrder = [
|
|
649
|
+
'ChildTable', // First
|
|
650
|
+
'ParentTable', // Last
|
|
651
|
+
];
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
### Transaction Already Started
|
|
655
|
+
|
|
656
|
+
Ensure you call `rollbackTransaction()` or `commitTransaction()` before starting a new one:
|
|
657
|
+
|
|
658
|
+
```typescript
|
|
659
|
+
afterEach(async () => {
|
|
660
|
+
await databaseHelper.rollbackTransaction();
|
|
661
|
+
});
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
## License
|
|
665
|
+
|
|
666
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Repository, EntityManager, DataSource, ObjectLiteral } from 'typeorm';
|
|
2
|
+
export interface FactoryOptions {
|
|
3
|
+
save?: boolean;
|
|
4
|
+
manager?: EntityManager;
|
|
5
|
+
count?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface EntityFactory<T> {
|
|
8
|
+
build(overrides?: Partial<T>): T;
|
|
9
|
+
create(overrides?: Partial<T>, options?: FactoryOptions): Promise<T>;
|
|
10
|
+
createMany(count: number, overrides?: Partial<T>, options?: FactoryOptions): Promise<T[]>;
|
|
11
|
+
}
|
|
12
|
+
export declare abstract class BaseFactory<T extends ObjectLiteral> implements EntityFactory<T> {
|
|
13
|
+
protected dataSource: DataSource;
|
|
14
|
+
protected repository: Repository<T>;
|
|
15
|
+
protected entityClass: new () => T;
|
|
16
|
+
constructor(dataSource: DataSource, entityClass: new () => T);
|
|
17
|
+
abstract build(overrides?: Partial<T>): T;
|
|
18
|
+
create(overrides?: Partial<T>, options?: FactoryOptions): Promise<T>;
|
|
19
|
+
createMany(count: number, overrides?: Partial<T>, options?: FactoryOptions): Promise<T[]>;
|
|
20
|
+
protected generateFakeData(): {
|
|
21
|
+
id: number;
|
|
22
|
+
email: string;
|
|
23
|
+
firstName: string;
|
|
24
|
+
lastName: string;
|
|
25
|
+
phone: string;
|
|
26
|
+
address: string;
|
|
27
|
+
city: string;
|
|
28
|
+
state: string;
|
|
29
|
+
postalCode: string;
|
|
30
|
+
country: string;
|
|
31
|
+
name: string;
|
|
32
|
+
description: string;
|
|
33
|
+
createdAt: Date;
|
|
34
|
+
updatedAt: Date;
|
|
35
|
+
boolean: boolean;
|
|
36
|
+
number: number;
|
|
37
|
+
decimal: number;
|
|
38
|
+
datetime: Date;
|
|
39
|
+
text: string;
|
|
40
|
+
url: string;
|
|
41
|
+
priority: "low" | "medium" | "high";
|
|
42
|
+
status: "active" | "inactive" | "pending";
|
|
43
|
+
code: "installation" | "inspection" | "maintenance" | "repair";
|
|
44
|
+
randomMinutes: number;
|
|
45
|
+
};
|
|
46
|
+
protected applyOverrides<K>(baseData: K, overrides?: Partial<K>): K;
|
|
47
|
+
protected createInstance(data: Partial<T>): T;
|
|
48
|
+
protected getRepository(): Repository<T>;
|
|
49
|
+
}
|