NestJS E2E Testing Workflow
This skill guides Test-Driven Development for NestJS serverless APIs with Clean Architecture.
When to Use
- •Adding new API endpoints
- •Modifying existing functionality or fixing bugs
- •Implementing Use Cases or Repositories
- •Changing business logic
Before Starting
Check project-specific test setup:
# Find test configuration find . -name "jest*.config.*" -o -name "jest.config.*" # Find test setup files find . -path "*/test/*" -name "setup*.ts" # Find existing test examples find . -name "*.e2e-spec.ts" | head -5 # Find test fixtures find . -path "*/test/*" -name "*fixture*" # Find mock implementations find . -path "*/test/*" -name "*mock*"
Review existing test files to understand:
- •Project test file structure
- •Available fixtures and helper functions
- •Mock configurations
- •Authentication patterns
Test Strategy
E2E Tests Only (No Unit Tests)
This workflow focuses on E2E integration tests:
| Test Type | Use | Reason |
|---|---|---|
| Unit Test | NO | Low ROI for overhead |
| E2E Integration Test | YES | Validates complete API flow |
| Browser E2E | NO | Serverless API only |
Test Coverage Scope
HTTP Request → Controller → UseCase → Repository → Database
↑ ↓
└──────────────── Response ←──────────────────────┘
- •Uses real NestJS app instance
- •Uses real database (test DB)
- •Only mocks external services (auth providers, SMS, storage, etc.)
TDD Workflow
Step 1: Write Tests First (RED)
Convert requirements to test cases BEFORE writing any implementation:
// test/{domain}.e2e-spec.ts
describe('Order Integration', () => {
let fixtures: TestFixtures;
beforeAll(() => {
fixtures = new TestFixtures(getDb());
});
beforeEach(async () => {
await clearDatabase(getDb());
});
describe('POST /app/orders', () => {
it('creates an order with valid data', async () => {
// Arrange: Setup test data using fixtures
const user = await fixtures.createUser();
const product = await fixtures.createProduct();
// Act: Call API
const response = await getRequest()
.post('/app/orders')
.set('x-user-id', String(user.id))
.send({
productId: product.id,
quantity: 2,
});
// Assert: Verify result
expect(response.status).toBe(201);
expect(response.body.productId).toBe(product.id);
});
it('returns 404 when product does not exist', async () => {
const user = await fixtures.createUser();
const response = await getRequest()
.post('/app/orders')
.set('x-user-id', String(user.id))
.send({ productId: 99999 });
expect(response.status).toBe(404);
expect(response.body.code).toBe('ProductNotFound');
});
});
});
Step 2: Run Tests (Verify FAILURE)
pnpm test:e2e -- --testPathPattern="order" # Tests MUST fail at this point (not implemented yet)
Step 3: Implement (GREEN)
Write minimal code to pass the tests, following Clean Architecture:
- •Domain Entity (
core/domain/entities/) - •Repository Interface (
core/domain/repositories/) - •Domain Errors (
core/domain/errors/) - •Use Case (
app/application/use-cases/) - •Repository Implementation (
core/infrastructure/persistence/) - •Mapper (
core/infrastructure/persistence/drizzle/mappers/) - •Controller + DTOs (
app/presentation/) - •Module Registration (
app/modules/)
Step 4: Run Tests (Verify SUCCESS)
pnpm test:e2e -- --testPathPattern="order" # All tests MUST pass now
Step 5: Refactor (IMPROVE)
With passing tests as safety net:
- •Remove duplication
- •Improve naming
- •Optimize performance
- •Ensure lint passes
Step 6: Run Full Suite
pnpm test:e2e pnpm lint
Test Naming Convention
Success Cases: Use Active Verbs
it('creates a new order', async () => { ... });
it('retrieves product list with pagination', async () => { ... });
it('updates order status to shipped', async () => { ... });
it('filters products by category', async () => { ... });
Failure Cases: "returns {status} when..."
it('returns 404 when product does not exist', async () => { ... });
it('returns 400 when required field is missing', async () => { ... });
it('returns 401 when request is not authenticated', async () => { ... });
it('returns 403 when user lacks permission', async () => { ... });
it('returns 409 when resource already exists', async () => { ... });
Edge Cases: Specify Condition
it('returns empty array when no products exist', async () => { ... });
it('returns empty array when page exceeds total', async () => { ... });
it('caps size at maximum when exceeded', async () => { ... });
Required Tests (MUST Write)
1. Happy Path (Success Flow)
At least ONE success case per endpoint:
describe('POST /app/products', () => {
it('creates a new product', async () => {
const response = await getRequest()
.post('/app/products')
.send({ name: 'Product', price: 1000 });
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
id: expect.any(Number),
name: 'Product',
});
});
});
2. Business Rule Validation
Test domain logic is correctly enforced:
it('returns 400 when changing COMPLETED order to PENDING', async () => {
const order = await fixtures.createOrder({ status: 'COMPLETED' });
const response = await getRequest()
.patch(`/app/orders/${order.id}/status`)
.send({ status: 'PENDING' });
expect(response.status).toBe(400);
expect(response.body.code).toBe('InvalidStatusTransition');
});
3. Authentication/Authorization (For Protected Endpoints)
describe('Authentication/Authorization', () => {
it('returns 401 when not authenticated', async () => {
const response = await getRequest().get('/app/users/me');
expect(response.status).toBe(401);
});
it('returns 403 when accessing another user resource', async () => {
const otherUser = await fixtures.createUser();
const response = await getRequest()
.get(`/app/users/${otherUser.id}/orders`)
.set('x-user-id', String(currentUser.id));
expect(response.status).toBe(403);
});
});
4. Required Field Validation (ONE Representative Case)
Don't test every field - test ONE representative case:
it('returns 400 when name is missing', async () => {
const response = await getRequest()
.post('/app/products')
.send({ price: 1000 }); // name missing
expect(response.status).toBe(400);
});
5. Resource Existence
it('returns 404 when product does not exist', async () => {
const response = await getRequest().get('/app/products/999999');
expect(response.status).toBe(404);
});
Unnecessary Tests (DO NOT Write)
1. Framework-Guaranteed Functionality
// BAD: class-validator handles this
it('returns 400 when name is not a string', ...);
it('returns 400 when price is negative', ...);
it('returns 400 when email format is invalid', ...);
// BAD: NestJS ParseIntPipe handles this
it('returns 400 when id is not a number', ...);
2. Individual Field Validation for Every Field
// BAD: Testing each field separately
it('returns 400 when name is missing', ...);
it('returns 400 when brandId is missing', ...);
it('returns 400 when price is missing', ...);
// GOOD: ONE representative case
it('returns 400 when required field is missing', ...);
3. Redundant CRUD Tests
// BAD: Testing same logic repeatedly
it('retrieves 1 product', ...);
it('retrieves 2 products', ...);
it('retrieves 10 products', ...);
// GOOD: Representative cases only
it('retrieves product list', ...);
it('returns empty array when no products exist', ...);
4. Implementation Details
// BAD: Testing internal implementation
it('calls Repository.save() once', ...);
it('refreshes cache', ...);
// GOOD: Test external behavior only
it('creates the product', ...);
5. Type Checking
// BAD: TypeScript validates at compile time
it('response.id is number type', ...);
it('response.createdAt is string type', ...);
Test Structure
Describe Block Organization
describe('Product API', () => {
describe('GET /app/products', () => {
describe('Success', () => {
it('retrieves product list', ...);
it('filters by category', ...);
it('applies pagination', ...);
});
describe('Failure', () => {
it('returns 400 when page is invalid', ...);
});
});
describe('POST /app/products', () => {
describe('Success', () => {
it('creates a new product', ...);
});
describe('Failure', () => {
it('returns 404 when category does not exist', ...);
it('returns 400 when required field is missing', ...);
});
});
});
Arrange-Act-Assert Pattern
it('filters products by category', async () => {
// Arrange: Setup test data
const category = await fixtures.createCategory({ name: 'Electronics' });
await fixtures.createProduct({ categoryId: category.id });
await fixtures.createProduct({ categoryId: null }); // Different category
// Act: Call API
const response = await getRequest()
.get('/app/products')
.query({ categoryId: category.id });
// Assert: Verify result
expect(response.status).toBe(200);
expect(response.body.total).toBe(1);
});
Minimum Tests Per Endpoint
| Endpoint Type | Min Tests | Composition |
|---|---|---|
| List (GET) | 2-3 | Success 1, Empty 1, Filter 1 (if applicable) |
| Detail (GET) | 2 | Success 1, 404 1 |
| Create (POST) | 2-3 | Success 1, Required missing 1, Business rule violation 1 |
| Update (PATCH/PUT) | 2-3 | Success 1, 404 1, Business rule violation 1 |
| Delete (DELETE) | 2 | Success 1, 404 1 |
Response Verification Scope
MUST Verify
// Status code
expect(response.status).toBe(200);
// Core business fields
expect(response.body.id).toBe(product.id);
expect(response.body.status).toBe('AVAILABLE');
// List response structure
expect(response.body.total).toBe(1);
expect(response.body.data).toHaveLength(1);
// Error codes (custom domain errors)
expect(response.body.code).toBe('ProductNotFound');
DON'T Over-Verify
// BAD: Verifying every field
expect(response.body.name).toBe('...');
expect(response.body.categoryId).toBe(1);
expect(response.body.price).toBe(1000);
expect(response.body.createdAt).toBe('...');
expect(response.body.updatedAt).toBe('...');
// GOOD: Verify structure and key fields
expect(response.body).toMatchObject({
id: expect.any(Number),
name: 'Product Name',
});
Test Fixtures Pattern
Fixture Design Principles
// test/fixtures/test-fixtures.ts
export class TestFixtures {
constructor(private readonly db: DatabaseService) {}
// Basic creation with sensible defaults
async createEntity(overrides?: Partial<EntityInsert>) {
const data = {
field1: 'default value',
field2: 100,
...overrides,
};
const [result] = await this.db.insert(entityTable).values(data).$returningId();
return { id: result.id, ...data };
}
// Creation with required dependencies
async createChildEntity(parentId: number, overrides?: Partial<ChildInsert>) {
const data = {
parentId,
name: 'default name',
...overrides,
};
const [result] = await this.db.insert(childTable).values(data).$returningId();
return { id: result.id, ...data };
}
// State-specific factory methods
async createActiveUser() {
return this.createUser({ status: 'ACTIVE' });
}
async createSuspendedUser() {
return this.createUser({ status: 'SUSPENDED' });
}
}
Database Cleanup Pattern
// Clear all test data between tests
export async function clearDatabase(db: DatabaseService) {
await db.execute(sql`SET FOREIGN_KEY_CHECKS = 0`);
// Delete in dependency order (child → parent)
await db.execute(sql`TRUNCATE TABLE child_table`);
await db.execute(sql`TRUNCATE TABLE parent_table`);
await db.execute(sql`SET FOREIGN_KEY_CHECKS = 1`);
}
Note: Check your project's test/fixtures/ directory for available fixture methods and update clearDatabase when adding new tables.
Mock Patterns
Mock Structure
Mocks should be centralized and reusable:
// test/mocks/{service}.mock.ts
export const createMockService = () => ({
method1: jest.fn().mockResolvedValue({ success: true }),
method2: jest.fn().mockResolvedValue(null),
});
// Reset mocks between tests
beforeEach(() => {
jest.clearAllMocks();
});
Common Mock Scenarios
// External API mocks
export const createMockExternalApi = () => ({
call: jest.fn().mockResolvedValue({ data: 'response' }),
});
// Auth provider mocks
export const createMockAuthProvider = () => ({
validateToken: jest.fn().mockResolvedValue({ userId: 1, valid: true }),
createUser: jest.fn().mockResolvedValue({ id: 'ext-123' }),
});
// Storage service mocks
export const createMockStorage = () => ({
upload: jest.fn().mockResolvedValue({ url: 'https://cdn.example.com/file.jpg' }),
delete: jest.fn().mockResolvedValue(undefined),
});
Applying Mocks in Test Setup
// test/setup.ts
moduleFixture = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(EXTERNAL_SERVICE)
.useValue(createMockExternalService())
.overrideProvider(AUTH_PROVIDER)
.useValue(createMockAuthProvider())
.compile();
Note: Check your project's test/mocks/ directory for available mocks and test/setup.ts for mock configuration.
Authentication Helpers Pattern
Request Helper Pattern
// test/helpers/request.helper.ts
// For user-authenticated requests
export function withUserHeaders(request: Request, userId: number) {
return request.set('x-user-id', String(userId));
}
// For admin-authenticated requests
export function withAdminHeaders(request: Request, adminId: string) {
return request.set('authorization', `Bearer mock-token-${adminId}`);
}
// Usage
const response = await withUserHeaders(
getRequest().get('/app/orders'),
user.id,
);
Note: Check your project's test/helpers/ directory for available authentication helpers.
Test Execution
Common Commands
# Run all tests
pnpm test:e2e
# Run specific file
pnpm test:e2e -- --testPathPattern="{domain}"
# Run specific test by name
pnpm test:e2e -- -t "creates a new order"
# Watch mode (development)
pnpm test:e2e -- --watch
# Run with specific project (if configured)
pnpm test:e2e -- --selectProjects app
pnpm test:e2e -- --selectProjects admin
Anti-Patterns to Avoid
1. Testing Implementation Details
// BAD: Testing internal state
const spy = jest.spyOn(repository, 'save');
await request.post('/products').send({ ... });
expect(spy).toHaveBeenCalledTimes(1);
// GOOD: Test external behavior
const response = await request.post('/products').send({ ... });
expect(response.status).toBe(201);
2. Hardcoded IDs
// BAD: Using hardcoded IDs
it('retrieves product', async () => {
const response = await getRequest().get('/app/products/1');
expect(response.status).toBe(200);
});
// GOOD: Use fixture-created data
it('retrieves product', async () => {
const product = await fixtures.createProduct();
const response = await getRequest().get(`/app/products/${product.id}`);
expect(response.status).toBe(200);
});
3. Test Dependencies
// BAD: Tests depend on order
it('creates product', async () => { createdId = response.body.id; });
it('retrieves created product', async () => { get(`/products/${createdId}`); });
// GOOD: Each test is independent
it('retrieves product', async () => {
const product = await fixtures.createProduct();
const response = await getRequest().get(`/products/${product.id}`);
});
4. Vague Assertions
// BAD: Vague assertions
expect(response.body).toBeTruthy();
expect(response.body.data.length).toBeGreaterThan(0);
// GOOD: Specific assertions
expect(response.body.total).toBe(3);
expect(response.body.data).toHaveLength(3);
expect(response.body.data[0].name).toBe('Expected Name');
5. Missing Database Cleanup
// BAD: Data leaks between tests
beforeEach(async () => {
// No cleanup
});
// GOOD: Clean slate for each test
beforeEach(async () => {
await clearDatabase(getDb());
});
New Feature Development Flow
1. Understand Project Structure
# Review existing test patterns find . -name "*.e2e-spec.ts" | head -3 | xargs head -50
2. Create Test File Following Project Conventions
// test/{domain}.e2e-spec.ts
import { clearDatabase, TestFixtures } from './fixtures/test-fixtures';
import { getDb, getRequest } from './setup';
describe('{Domain} Integration', () => {
let fixtures: TestFixtures;
beforeAll(() => {
fixtures = new TestFixtures(getDb());
});
beforeEach(async () => {
await clearDatabase(getDb());
});
// Write test cases
});
3. Write Tests (They Will FAIL)
pnpm test:e2e -- --testPathPattern="{domain}"
# Verify tests fail
4. Implement Clean Architecture Layers
Follow your project's directory structure for:
- •Domain Entity
- •Repository Interface
- •Domain Errors
- •Use Case
- •Repository Implementation
- •Mapper
- •Controller + DTOs
- •Module Registration
5. Verify Tests Pass
pnpm test:e2e -- --testPathPattern="{domain}"
# All tests should pass
6. Run Lint and Full Suite
pnpm lint pnpm test:e2e
Test Checklist Before Commit
Writing
- • Does this test validate business value?
- • Is this already guaranteed by the framework?
- • Does this duplicate another test?
After Writing
- • Does the test name clearly describe success/failure?
- • Does it follow Arrange-Act-Assert pattern?
- • Is the verification scope appropriate?
- • Does clearDatabase run in beforeEach?
- • Are all test data created via fixtures?
Remember: No code without tests. Tests are the specification, documentation, and safety net. Write tests FIRST, then implement.