The testing pyramid, adjusted for APIs
- Unit tests (many): Pure business logic, validators, formatters, calculations
- Integration tests (some): Database queries, service interactions, API endpoint behavior
- E2E tests (few): Critical user flows only
What I always unit test
- Business rules: Discount calculations, permission checks, status transitions. These are the core of your application and change frequently.
- Validators: Input validation logic. Every edge case — empty strings, negative numbers, SQL injection payloads.
- Transformers: Functions that convert between shapes (API response to domain model, domain model to database row).
describe('calculateDiscount', () => {
it('applies 10% for orders over $100', () => {
expect(calculateDiscount(150)).toBe(15);
});
it('returns 0 for orders under $100', () => {
expect(calculateDiscount(50)).toBe(0);
});
it('handles exactly $100', () => {
expect(calculateDiscount(100)).toBe(10);
});
});
What I integration test
- API endpoints: Full request/response cycle. Does
POST /usersactually create a user and return 201? - Database queries: Complex queries, especially with joins, aggregations, or pagination. Test against a real database, not mocks.
- Authentication flows: Login, token refresh, permission checks.
describe('POST /api/users', () => {
it('creates a user and returns 201', async () => {
const res = await request(app)
.post('/api/users')
.send({ email: 'test@example.com', name: 'Test' });
expect(res.status).toBe(201);
expect(res.body.data.email).toBe('test@example.com');
});
it('returns 422 for invalid email', async () => {
const res = await request(app)
.post('/api/users')
.send({ email: 'not-an-email', name: 'Test' });
expect(res.status).toBe(422);
expect(res.body.code).toBe('VALIDATION_ERROR');
});
});
What I skip
- Framework behavior: Do not test that NestJS decorators work. Do not test that Express routing works. The framework authors already tested that.
- Simple CRUD: If a service method is just
this.db.findMany()with no logic, the integration test covers it. - Private methods: They are tested through the public API. If a private method is complex enough to test directly, it should probably be a separate function.
The test database
Every integration test suite gets a clean database. I use a setup script that runs migrations and seeds minimal data. Tests run in transactions that roll back after each test — fast and isolated.
When to add E2E tests
Only for flows where a bug means lost revenue or broken trust: signup, checkout, payment processing. These tests are slow and brittle — keep them focused on the critical path.
