Skip to content

Testing Standards

This guide covers our testing philosophy, tooling, and conventions for full-stack projects.

Current State

Testing is not consistent across projects. Some projects have solid test suites, others have minimal or no tests. This section defines what we aspire to and the minimum requirements we are working towards. Frontend testing in particular lags behind backend — there is no established equivalent of testing against a design (e.g. visual regression against Figma), though this is an area we want to explore.

Minimum Requirements

Every project must have at minimum:

  • Backend: Integration tests for authentication flows and critical business logic (payments, data mutations)
  • CI gate: Tests run on every PR — failing tests block the merge
  • Bug fixes: A regression test accompanying every bug fix

Everything beyond this is strongly encouraged but depends on the project's scope and timeline.

Testing Philosophy

  • Test behavior, not implementation — tests should verify what the code does, not how it does it
  • Prefer integration tests over unit tests for backend services — they catch more real bugs with less maintenance
  • Every bug fix includes a test — reproduce the bug with a failing test before fixing it
  • Tests are documentation — well-named tests explain the expected behavior of the system

Testing Pyramid

        ┌─────────┐
        │   E2E   │  Few, slow, high confidence
        ├─────────┤
        │ Integr. │  Moderate count, test real interactions
        ├─────────┤
        │  Unit   │  Many, fast, focused on logic
        └─────────┘
Layer What it tests Tools Speed
Unit Pure functions, utilities, isolated logic Jest Fast
Integration Service methods with database, API endpoints Jest + test DB Medium
E2E Full user flows through the browser or API Playwright / Cypress Slow

Backend Testing

Unit Tests

Use for pure business logic, utility functions, and DTOs:

// users.service.spec.ts
describe('UsersService', () => {
    describe('validateEmail', () => {
        it('should accept valid email addresses', () => {
            expect(validateEmail('user@example.com')).toBe(true)
        })

        it('should reject invalid email addresses', () => {
            expect(validateEmail('not-an-email')).toBe(false)
        })
    })
})

Integration Tests

Test NestJS modules with real database connections:

// users.service.integration.spec.ts
describe('UsersService (integration)', () => {
    let service: UsersService
    let module: TestingModule

    beforeAll(async () => {
        module = await Test.createTestingModule({
            imports: [AppModule],
        }).compile()

        service = module.get(UsersService)
    })

    afterAll(async () => {
        await module.close()
    })

    it('should create and retrieve a user', async () => {
        const user = await service.create({
            email: 'test@example.com',
            name: 'Test User',
        })

        const found = await service.findOne(user.id)
        expect(found.email).toBe('test@example.com')
    })
})

API Tests

Test HTTP endpoints end-to-end:

// users.e2e.spec.ts
describe('Users API', () => {
    let app: INestApplication

    beforeAll(async () => {
        const module = await Test.createTestingModule({
            imports: [AppModule],
        }).compile()

        app = module.createNestApplication()
        await app.init()
    })

    it('POST /users should create a user', async () => {
        const response = await request(app.getHttpServer())
            .post('/users')
            .send({ email: 'new@example.com', name: 'New User' })
            .expect(201)

        expect(response.body).toHaveProperty('id')
        expect(response.body.email).toBe('new@example.com')
    })
})

Frontend Testing

E2E Tests

Use Playwright for end-to-end browser testing:

// tests/e2e/login.spec.ts
import { test, expect } from '@playwright/test'

test('user can log in', async ({ page }) => {
    await page.goto('/login')
    await page.fill('[data-testid="email"]', 'user@example.com')
    await page.fill('[data-testid="password"]', 'password123')
    await page.click('[data-testid="submit"]')

    await expect(page).toHaveURL('/dashboard')
    await expect(page.locator('[data-testid="welcome"]')).toContainText('Welcome')
})

Conventions

Test File Naming

  • Unit/integration tests: <name>.spec.ts (co-located with source)
  • E2E tests: tests/e2e/<feature>.spec.ts

Test Naming

Use descriptive describe and it blocks that read as sentences:

describe('UsersService', () => {
    describe('create', () => {
        it('should create a user with valid data', () => { ... })
        it('should throw ConflictException for duplicate email', () => { ... })
        it('should hash the password before storing', () => { ... })
    })
})

Data Test IDs

Use data-testid attributes for E2E selectors instead of CSS classes or element types:

<button data-testid="submit-btn">Submit</button>

Coverage

  • We do not enforce strict coverage thresholds — coverage is a guide, not a goal
  • Focus on testing critical paths: authentication, payments, data mutations
  • New features should include tests for the happy path and key edge cases
  • CI runs all tests on every PR — failing tests block the merge

Tools Summary

Tool Purpose
Jest Unit and integration testing (default)
Playwright E2E browser testing (preferred)
Cypress E2E browser testing (legacy projects)
supertest HTTP endpoint testing in NestJS