@studiosonrai/nestjs-testfx
v1.2.0
Published
Shared testing utilities for NestJS + TypeORM applications
Downloads
305
Readme
nestjs-testfx
Shared testing utilities for NestJS + TypeORM applications. This package provides a robust foundation for writing API tests with database integration.
Features
- DatabaseTestHelper: Transaction-based test isolation and database cleanup
- BaseFactory: Abstract factory pattern for generating test data with Faker.js
- EntityComparator: Deep entity comparison with custom Jest matchers
- Fixtures: Pre-defined static test data with loader utilities
- Jest Setup Template: Ready-to-use Jest configuration with safety checks
Installation
npm install nestjs-testfx --save-devPeer Dependencies
Ensure you have these peer dependencies installed:
npm install --save-dev \
@faker-js/faker \
@nestjs/common \
@nestjs/config \
@nestjs/testing \
@nestjs/typeorm \
jest \
typeormQuick Start
1. Set Up Jest Configuration
Copy the Jest setup template to your project:
// src/testing/jest.setup.ts
import { config } from 'dotenv';
import { createEntityEquivalentMatcher } from 'nestjs-testfx';
config({ path: '.test.env' });
jest.setTimeout(30000);
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error('Tests must run with NODE_ENV=test');
}
});
expect.extend({
toBeEntityEquivalent: createEntityEquivalentMatcher(),
});Update your jest.config.js:
module.exports = {
setupFilesAfterEnv: ['<rootDir>/src/testing/jest.setup.ts'],
// ... other config
};2. Create Your DataSource
// src/testing/test-data-source.ts
import { DataSource } from 'typeorm';
export const testDataSource = new DataSource({
type: 'postgres', // or 'mssql', 'mysql'
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432'),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
entities: ['src/**/*.entity.ts'],
synchronize: false,
});3. Create Factories for Your Entities
// src/testing/factories/user.factory.ts
import { DataSource } from 'typeorm';
import { BaseFactory } from 'nestjs-testfx';
import { User } from '../../entities/user.entity';
export class UserFactory extends BaseFactory<User> {
constructor(dataSource: DataSource) {
super(dataSource, User);
}
build(overrides?: Partial<User>): User {
const fake = this.generateFakeData();
return this.createInstance(this.applyOverrides({
email: fake.email,
firstName: fake.firstName,
lastName: fake.lastName,
isActive: true,
createdAt: new Date(),
}, overrides));
}
async createAdmin(overrides?: Partial<User>): Promise<User> {
return this.create({
role: 'admin',
isActive: true,
...overrides,
});
}
}4. Write Your First Test
// src/users/users.service.spec.ts
import { DatabaseTestHelper } from 'nestjs-testfx';
import { testDataSource } from '../testing/test-data-source';
import { UserFactory } from '../testing/factories/user.factory';
import { UsersService } from './users.service';
describe('UsersService', () => {
let databaseHelper: DatabaseTestHelper;
let userFactory: UserFactory;
let service: UsersService;
beforeAll(async () => {
databaseHelper = await DatabaseTestHelper.fromDataSource(testDataSource, {
cleanupOrder: ['Order', 'User'],
dialect: 'postgres',
});
userFactory = new UserFactory(databaseHelper.getDataSource());
service = new UsersService(databaseHelper.getRepository(User));
});
afterAll(async () => {
await databaseHelper.close();
});
beforeEach(async () => {
await databaseHelper.cleanDatabase(['User']);
});
it('should find user by email', async () => {
const user = await userFactory.create({ email: '[email protected]' });
const found = await service.findByEmail('[email protected]');
expect(found).toBeDefined();
expect(found?.id).toBe(user.id);
});
});API Reference
DatabaseTestHelper
Manages database connections and provides test isolation utilities.
import { DatabaseTestHelper } from 'nestjs-testfx';
// Create from existing DataSource
const helper = await DatabaseTestHelper.fromDataSource(dataSource, {
cleanupOrder: ['OrderItem', 'Order', 'User'],
dialect: 'postgres', // 'mssql' | 'postgres' | 'mysql'
});
// Transaction-based isolation
await helper.startTransaction();
const manager = helper.getTransactionManager();
await manager.save(User, userData);
await helper.rollbackTransaction(); // Undo all changes
// Clean specific tables
await helper.cleanDatabase(['User', 'Order']);
// Query helpers
const exists = await helper.entityExists(User, { email: '[email protected]' });
const count = await helper.countEntities(User, { isActive: true });
// Execute in separate transaction (auto-commits)
const user = await helper.inNewTransaction(async (manager) => {
return manager.save(User, { email: '[email protected]' });
});
// Close connection
await helper.close();BaseFactory
Abstract base class for entity factories.
import { BaseFactory, FactoryOptions } from 'nestjs-testfx';
class ProductFactory extends BaseFactory<Product> {
constructor(dataSource: DataSource) {
super(dataSource, Product);
}
build(overrides?: Partial<Product>): Product {
const fake = this.generateFakeData();
return this.createInstance(this.applyOverrides({
name: fake.name,
price: fake.decimal,
inStock: true,
}, overrides));
}
// Convenience methods
async createOutOfStock(overrides?: Partial<Product>): Promise<Product> {
return this.create({ inStock: false, ...overrides });
}
}
// Usage
const factory = new ProductFactory(dataSource);
// Build without saving
const product = factory.build({ name: 'Widget' });
// Create and save
const saved = await factory.create({ name: 'Gadget' });
// Create multiple
const products = await factory.createMany(10, { inStock: true });
// Create within transaction
const product = await factory.create(
{ name: 'Test' },
{ manager: transactionManager }
);
// Build without saving
const instance = await factory.create({}, { save: false });Available Fake Data
generateFakeData() provides:
| Property | Description |
|----------|-------------|
| id | Random integer (1-999999) |
| email | Random email address |
| firstName | Random first name |
| lastName | Random last name |
| phone | Random phone number |
| address | Random street address |
| city | Random city name |
| state | Random state/province |
| postalCode | Random zip/postal code |
| country | Random country name |
| name | Random company name |
| description | Random paragraph |
| createdAt | Random past date |
| updatedAt | Random recent date |
| boolean | Random boolean |
| number | Random integer (1-1000) |
| decimal | Random float (0-100, 2 decimals) |
| datetime | Random future date |
| text | Random sentences |
| url | Random URL |
| priority | 'low' | 'medium' | 'high' |
| status | 'active' | 'inactive' | 'pending' |
EntityComparator
Deep entity comparison with custom options.
import { EntityComparator } from 'nestjs-testfx';
// Basic comparison
const result = EntityComparator.compare(actual, expected);
console.log(result.areEqual); // true/false
// Ignore auto-generated fields
const result = EntityComparator.compare(savedUser, {
email: '[email protected]',
firstName: 'John',
}, {
ignoreFields: ['id', 'createdAt', 'updatedAt'],
});
// Custom comparators
const result = EntityComparator.compare(entity1, entity2, {
customComparators: {
// Compare dates by day only
scheduledDate: (a, b) =>
new Date(a).toDateString() === new Date(b).toDateString(),
// Compare prices within tolerance
price: (a, b) => Math.abs(a - b) < 0.01,
},
});
// Compare subset of fields
const result = EntityComparator.compareSubset(fullEntity, {
email: '[email protected]',
name: 'John',
});
// Format for error messages
if (!result.areEqual) {
console.log(EntityComparator.formatComparisonResult(result));
}Jest Custom Matcher
// In jest.setup.ts
import { createEntityEquivalentMatcher } from 'nestjs-testfx';
expect.extend({
toBeEntityEquivalent: createEntityEquivalentMatcher(),
});
// In tests
expect(actualUser).toBeEntityEquivalent(expectedUser);
expect(savedEntity).toBeEntityEquivalent(expectedData, {
ignoreFields: ['id', 'createdAt', 'updatedAt'],
});Fixtures
Pre-defined static test data.
// Define fixtures
export const ROLE_FIXTURES = {
ADMIN: { id: 1, name: 'Admin', description: 'Administrator' },
USER: { id: 2, name: 'User', description: 'Standard user' },
};
export function getAllRoleFixtures() {
return Object.values(ROLE_FIXTURES);
}
export function getRoleById(id: number) {
return Object.values(ROLE_FIXTURES).find(r => r.id === id);
}
// Load fixtures
class FixtureLoader {
constructor(private manager: EntityManager) {}
async loadRoles() {
for (const role of getAllRoleFixtures()) {
await this.manager.save(Role, role);
}
}
}Complete Test Example
import {
DatabaseTestHelper,
EntityComparator,
} from 'nestjs-testfx';
import { testDataSource } from '../testing/test-data-source';
import { UserFactory } from '../testing/factories/user.factory';
import { OrderFactory } from '../testing/factories/order.factory';
import { OrderService } from './order.service';
describe('OrderService', () => {
let databaseHelper: DatabaseTestHelper;
let userFactory: UserFactory;
let orderFactory: OrderFactory;
let service: OrderService;
beforeAll(async () => {
databaseHelper = await DatabaseTestHelper.fromDataSource(testDataSource, {
cleanupOrder: ['OrderItem', 'Order', 'User'],
dialect: 'postgres',
});
userFactory = new UserFactory(databaseHelper.getDataSource());
orderFactory = new OrderFactory(databaseHelper.getDataSource());
service = new OrderService(
databaseHelper.getRepository(Order),
databaseHelper.getRepository(User),
);
});
afterAll(async () => {
await databaseHelper.close();
});
beforeEach(async () => {
await databaseHelper.cleanDatabase(['OrderItem', 'Order', 'User']);
});
describe('createOrder', () => {
it('should create order for user', async () => {
// Arrange
const user = await userFactory.create({ email: '[email protected]' });
// Act
const order = await service.createOrder(user.id, {
items: [{ productId: 1, quantity: 2 }],
});
// Assert
expect(order.id).toBeDefined();
expect(order.userId).toBe(user.id);
expect(order.status).toBe('pending');
});
it('should calculate total correctly', async () => {
const user = await userFactory.create();
const order = await service.createOrder(user.id, {
items: [
{ productId: 1, quantity: 2, price: 10 },
{ productId: 2, quantity: 1, price: 25 },
],
});
expect(order.total).toBe(45); // (2 * 10) + (1 * 25)
});
});
describe('cancelOrder', () => {
it('should cancel pending order', async () => {
const user = await userFactory.create();
const order = await orderFactory.createPending(user.id);
const cancelled = await service.cancelOrder(order.id);
expect(cancelled.status).toBe('cancelled');
});
it('should not cancel completed order', async () => {
const user = await userFactory.create();
const order = await orderFactory.createCompleted(user.id);
await expect(service.cancelOrder(order.id)).rejects.toThrow(
'Cannot cancel completed order'
);
});
});
describe('with transactions', () => {
it('should rollback on error', async () => {
const user = await userFactory.create();
await expect(
service.createOrderWithPayment(user.id, {
items: [{ productId: 1, quantity: 1 }],
paymentMethod: 'invalid',
})
).rejects.toThrow();
// Verify no order was created
const count = await databaseHelper.countEntities(Order, { userId: user.id });
expect(count).toBe(0);
});
});
});Factory Manager Pattern
For larger projects, use a factory manager to centralize factory access:
// src/testing/factories/index.ts
import { DataSource } from 'typeorm';
import { UserFactory } from './user.factory';
import { OrderFactory } from './order.factory';
import { ProductFactory } from './product.factory';
export class FactoryManager {
public readonly user: UserFactory;
public readonly order: OrderFactory;
public readonly product: ProductFactory;
constructor(dataSource: DataSource) {
this.user = new UserFactory(dataSource);
this.order = new OrderFactory(dataSource);
this.product = new ProductFactory(dataSource);
}
}
// Usage in tests
describe('Service', () => {
let factories: FactoryManager;
beforeAll(async () => {
factories = new FactoryManager(dataSource);
});
it('test', async () => {
const user = await factories.user.create();
const order = await factories.order.createForUser(user.id);
});
});Test Data Helper Pattern
For complex test scenarios, create a test data helper:
// src/testing/helpers/test-data.helper.ts
import { DatabaseTestHelper } from 'nestjs-testfx';
import { FactoryManager } from '../factories';
import { ROLE_FIXTURES } from '../fixtures';
export class TestDataHelper {
constructor(
private databaseHelper: DatabaseTestHelper,
private factories: FactoryManager,
) {}
async setupFixtures(manager?: EntityManager) {
const em = manager || this.databaseHelper.getDataSource().manager;
for (const role of Object.values(ROLE_FIXTURES)) {
await em.save(Role, role);
}
}
async createTestScenario(name: string) {
switch (name) {
case 'e-commerce':
return this.createEcommerceScenario();
case 'multi-tenant':
return this.createMultiTenantScenario();
default:
throw new Error(`Unknown scenario: ${name}`);
}
}
private async createEcommerceScenario() {
await this.setupFixtures();
const admin = await this.factories.user.createAdmin();
const customers = await this.factories.user.createMany(5);
const products = await this.factories.product.createMany(20);
return { admin, customers, products };
}
}Environment Configuration
Create a .test.env or .ci.env file:
NODE_ENV=test
DB_HOST=localhost
DB_PORT=5432
DB_DATABASE=myapp_test
DB_USERNAME=postgres
DB_PASSWORD=postgres
# Optional: reduce log noise
LOG_LEVEL=warnBest Practices
1. Clean Tables in Correct Order
// Define cleanup order respecting foreign keys
const cleanupOrder = [
'OrderItem', // References Order
'Order', // References User
'UserRole', // References User and Role
'User', // Parent
'Role', // Parent
];
const helper = await DatabaseTestHelper.fromDataSource(dataSource, {
cleanupOrder,
});2. Use Transaction Isolation for Speed
beforeEach(async () => {
await databaseHelper.startTransaction();
});
afterEach(async () => {
await databaseHelper.rollbackTransaction();
});3. Create Meaningful Factory Methods
class OrderFactory extends BaseFactory<Order> {
// Good: Descriptive, purpose-clear methods
async createPendingForNewCustomer() { ... }
async createCompletedWithItems(itemCount: number) { ... }
async createHighValueOrder(minAmount: number) { ... }
}4. Use Fixtures for Reference Data
// Use fixtures for data with known IDs
const userRole = ROLE_FIXTURES.USER;
// Use factories for variable test data
const user = await userFactory.create({ roleId: userRole.id });Troubleshooting
"Tests must use a test database"
Ensure your .test.env has a database name containing 'test' or 'ci':
DB_DATABASE=myapp_test # ✓
DB_DATABASE=myapp_ci # ✓
DB_DATABASE=myapp # ✗ Will fail safety checkForeign Key Constraint Errors
Update cleanupOrder to delete child tables before parents:
const cleanupOrder = [
'ChildTable', // First
'ParentTable', // Last
];Transaction Already Started
Ensure you call rollbackTransaction() or commitTransaction() before starting a new one:
afterEach(async () => {
await databaseHelper.rollbackTransaction();
});License
MIT
