Explorer
Node.js

Chapter 21: Testing: Unit Tests, Integration Tests, Mocking, and API Testing with Vitest and Supertest

Chapter 21: Testing: Unit Tests, Integration Tests, Mocking, and API Testing with Vitest and Supertest

In software development, unverified code is broken code that hasn't failed yet.

As a backend expands beyond a handful of endpoints, manual testing with Postman or curl becomes an unsustainable bottleneck. Manually verifying 150 routes, 30 middleware guards, and 40 database transactions before every deployment is impossible. Without automated tests, every refactor, dependency upgrade, or database migration carries the risk of silent regressions in production.

Testing in Node.js is not about reaching 100% cosmetic code coverage—it is about building high-confidence verification barriers at every layer of your application.

In this chapter, you will master the testing architecture of production Node.js applications:

  1. The Testing Pyramid and how to balance speed, cost, and confidence.
  2. The modern test runner ecosystem: Vitest vs. Jest.
  3. Unit Testing business logic, DTO schemas, and domain calculations with zero I/O.
  4. Mocking & Test Doubles: Stubs, Spies, and Mocks with vi.fn() and vi.spyOn(), plus how to avoid the over-mocking trap.
  5. Integration Testing against a real PostgreSQL test database with clean isolation and rollback strategies.
  6. API Testing with supertest: Verifying the entire HTTP lifecycle, headers, authentication guards, and response envelopes without opening physical network ports.
  7. Test Fixtures & Factories for clean, deterministic test data.

1. The Testing Pyramid: Balancing Speed and Confidence

The foundational model for software testing is the Testing Pyramid, popularized by Mike Cohn and Martin Fowler. It organizes tests into distinct layers based on execution speed, maintenance cost, and isolation.

TEXT
                 ▲
                / \
               /   \
              / E2E \            High Confidence, High Cost, Slow Execution
             / (UI)  \           (Browser automation, full infrastructure)
            /─────────\
           /    API    \         High Confidence, Medium Speed
          / Integration \        (Supertest, real test DB, full HTTP cycle)
         /───────────────\
        /      Unit       \      Maximum Speed, Lowest Cost, Highly Isolated
       /   Tests (Domain)  \     (Pure functions, DTOs, business calculations)
      /─────────────────────\

The Three Layers Explained

Test Layer What It Tests Execution Speed Dependencies & I/O Confidence Level
Unit Tests Pure functions, domain calculations, DTO validation schemas, utility functions. Blazing fast (1–5ms per test) Zero I/O. No database, no network, no filesystem. Verifies isolated logic; cannot catch integration bugs.
Integration Tests Service + Repository interaction, database queries, transactions, caching layers. Medium (50–200ms per test) Real test database (PostgreSQL), Redis. High. Catches SQL syntax errors, foreign key violations, and race conditions.
API / Contract Tests Full HTTP request/response pipeline via supertest: routing, middleware, auth guards, status codes, response envelopes. Fast to Medium (20–100ms per test) Ephemeral Express app, mocked external APIs (Stripe/S3), real test DB. Very High. Verifies that the client contract remains unbroken.

The "Ice Cream Cone" Anti-Pattern

An inverted pyramid—where an application has 200 slow, flaky End-to-End tests and only 10 unit tests—results in CI pipelines taking 45 minutes to run, frequent false alarms, and developers dreading running tests locally.

The goal is a broad base of fast unit tests, a solid layer of database integration and API tests, and a minimal set of end-to-end smoke tests.


2. Test Runner Selection: Vitest vs. Jest

For years, Jest was the default test runner for Node.js. However, in modern TypeScript backends, Vitest has emerged as the industry's preferred tool.

TEXT
Jest (Legacy Architecture):
  TS Source ──► ts-jest (transpiler) ──► CommonJS Bundle ──► VM execution (High CPU, slow startup)

Vitest (Modern Architecture):
  TS Source ──► Vite/esbuild ──► Native ESM Threads (Near-instant startup, instant HMR watch mode)

Why Modern Backends Prefer Vitest

  1. Native ESM & TypeScript: Runs .ts and ES Module code natively without Babel or cumbersome ts-jest configurations.
  2. Multi-Threaded Performance: Executes test suites concurrently using Node.js worker_threads.
  3. Jest API Compatibility: Drop-in compatible with describe, it, expect, beforeEach, and vi.fn() (the Vitest equivalent of jest.fn()).
  4. Instant Watch Mode: Blazing fast hot-module-reloading (HMR) watch mode during local feature development.

Installing and Configuring Vitest

BASH
pnpm add -D vitest supertest @types/supertest
TS
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,               // Enables global describe, it, expect
    environment: 'node',         // Node.js execution environment
    include: ['src/**/*.test.ts', 'src/**/*.spec.ts'],
    testTimeout: 10000,          // 10s timeout for DB integration tests
    hookTimeout: 15000,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: ['node_modules/', 'dist/', '**/*.dto.ts'],
    },
  },
});

Add the test scripts to package.json:

JSON
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage"
  }
}

3. Unit Testing: Business Logic & DTO Validation

Unit tests must have zero external I/O. If a test touches a database, hits an HTTP port, or writes to disk, it is an integration test, not a unit test.

Testing Domain Logic

Let's test an e-commerce order calculation engine that computes item totals, applies tiered discount codes, and calculates sales tax:

TS
// src/features/order/order-calculator.ts
export interface OrderItem {
  priceCents: number;
  quantity: number;
}

export class OrderCalculator {
  public static calculateSubtotal(items: OrderItem[]): number {
    return items.reduce((sum, item) => sum + item.priceCents * item.quantity, 0);
  }

  public static applyDiscount(subtotalCents: number, couponCode?: string): number {
    if (!couponCode) return subtotalCents;

    const normalized = couponCode.trim().toUpperCase();
    if (normalized === 'SAVE10') {
      return Math.round(subtotalCents * 0.9); // 10% off
    }
    if (normalized === 'SAVE50') {
      return Math.round(subtotalCents * 0.5); // 50% off
    }

    return subtotalCents;
  }

  public static calculateTax(taxableCents: number, taxRate: number): number {
    if (taxRate < 0 || taxRate > 1) {
      throw new RangeError('Tax rate must be between 0.0 and 1.0');
    }
    return Math.round(taxableCents * taxRate);
  }
}

Now, write the unit test suite in order-calculator.spec.ts:

TS
// src/features/order/order-calculator.spec.ts
import { describe, it, expect } from 'vitest';
import { OrderCalculator, OrderItem } from './order-calculator';

describe('OrderCalculator (Unit Tests)', () => {
  describe('calculateSubtotal', () => {
    it('should return 0 when item list is empty', () => {
      const result = OrderCalculator.calculateSubtotal([]);
      expect(result).toBe(0);
    });

    it('should correctly sum prices multiplied by their quantities', () => {
      const items: OrderItem[] = [
        { priceCents: 1000, quantity: 2 }, // 2000
        { priceCents: 500, quantity: 3 },  // 1500
      ];
      const result = OrderCalculator.calculateSubtotal(items);
      expect(result).toBe(3500);
    });
  });

  describe('applyDiscount', () => {
    it('should apply 10% discount for SAVE10 coupon', () => {
      const result = OrderCalculator.applyDiscount(10000, 'SAVE10');
      expect(result).toBe(9000);
    });

    it('should handle case insensitivity and whitespace in coupon codes', () => {
      const result = OrderCalculator.applyDiscount(10000, '  save10  ');
      expect(result).toBe(9000);
    });

    it('should return unmodified subtotal for invalid coupon code', () => {
      const result = OrderCalculator.applyDiscount(10000, 'INVALID_CODE');
      expect(result).toBe(10000);
    });
  });

  describe('calculateTax', () => {
    it('should compute rounded tax amount accurately', () => {
      // 1000 * 0.0825 = 82.5 -> rounds to 83 cents
      const tax = OrderCalculator.calculateTax(1000, 0.0825);
      expect(tax).toBe(83);
    });

    it('should throw a RangeError when tax rate is negative or exceeds 1.0', () => {
      expect(() => OrderCalculator.calculateTax(1000, -0.05)).toThrow(RangeError);
      expect(() => OrderCalculator.calculateTax(1000, 1.5)).toThrow(RangeError);
    });
  });
});

Unit Testing DTOs and Validation Schemas

Recall our BaseDto pattern from Chapter 9. We must verify that:

  1. Valid payloads succeed and strip unexpected properties (Mass Assignment defense).
  2. Malformed payloads throw validation errors with field-specific paths.
TS
// src/features/auth/auth.dto.spec.ts
import { describe, it, expect } from 'vitest';
import { RegisterUserDto } from './auth.dto';

describe('RegisterUserDto Validation', () => {
  it('should parse valid registration data and strip malicious extra fields', () => {
    const rawPayload = {
      email: 'user@example.com',
      password: 'StrongPassword123!',
      role: 'SUPER_ADMIN', // Attacker trying to elevate privileges
    };

    const validated = RegisterUserDto.validate(rawPayload);

    expect(validated.email).toBe('user@example.com');
    expect(validated.password).toBe('StrongPassword123!');
    // 🔒 Verify role was stripped by Zod
    expect((validated as any).role).toBeUndefined();
  });

  it('should fail when email is invalid', () => {
    const rawPayload = {
      email: 'not-an-email',
      password: 'StrongPassword123!',
    };

    expect(() => RegisterUserDto.validate(rawPayload)).toThrow();
  });

  it('should fail when password is shorter than minimum length', () => {
    const rawPayload = {
      email: 'user@example.com',
      password: '123',
    };

    expect(() => RegisterUserDto.validate(rawPayload)).toThrow();
  });
});

4. Mocking & Test Doubles: Spies, Stubs, and Mocks

When testing services that interact with external boundaries (e.g., sending an email via SendGrid, charging a credit card via Stripe, or pushing an image to AWS S3), you should never make real network calls in automated tests.

Instead, we substitute real implementations with test doubles.

Test Double Terminology

TEXT
┌─────────────────────────────────────────────────────────────┐
│                       TEST DOUBLES                          │
├───────────────┬─────────────────────────────────────────────┤
│ Dummy         │ Passed around but never actually used.       │
│ Stub          │ Returns canned, predefined responses.       │
│ Spy           │ Records calls, arguments, and return values. │
│ Mock          │ Pre-programmed with expectations to assert.  │
└───────────────┴─────────────────────────────────────────────┘

Mocking with Vitest: vi.fn() and vi.spyOn()

Suppose we have a NotificationService that dispatches welcome emails:

TS
// src/features/user/user.service.ts
import { ApiError } from '../../common/errors/api-error';

export interface EmailSender {
  sendWelcomeEmail(to: string, name: string): Promise<boolean>;
}

export interface UserRepository {
  findByEmail(email: string): Promise<any>;
  create(data: any): Promise<any>;
}

export class UserService {
  constructor(
    private readonly userRepo: UserRepository,
    private readonly emailSender: EmailSender
  ) {}

  public async register(email: string, name: string) {
    const existing = await this.userRepo.findByEmail(email);
    if (existing) {
      throw ApiError.conflict('Email is already registered.');
    }

    const newUser = await this.userRepo.create({ email, name });
    
    // Asynchronous notification (must not throw or halt registration)
    await this.emailSender.sendWelcomeEmail(email, name).catch(() => {});

    return newUser;
  }
}

Now, write the unit test using mock dependencies:

TS
// src/features/user/user.service.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserService, UserRepository, EmailSender } from './user.service';
import { ApiError } from '../../common/errors/api-error';

describe('UserService (with Mocks)', () => {
  let userRepoMock: UserRepository;
  let emailSenderMock: EmailSender;
  let userService: UserService;

  beforeEach(() => {
    // 1. Create clean mocks before each test
    userRepoMock = {
      findByEmail: vi.fn(),
      create: vi.fn(),
    };

    emailSenderMock = {
      sendWelcomeEmail: vi.fn().mockResolvedValue(true),
    };

    userService = new UserService(userRepoMock, emailSenderMock);
  });

  it('should register a new user and dispatch a welcome email', async () => {
    // Arrange: Mock findByEmail to return null (no user exists)
    vi.mocked(userRepoMock.findByEmail).mockResolvedValue(null);
    vi.mocked(userRepoMock.create).mockResolvedValue({
      id: 'usr_123',
      email: 'ada@lovelace.org',
      name: 'Ada Lovelace',
    });

    // Act
    const result = await userService.register('ada@lovelace.org', 'Ada Lovelace');

    // Assert: User creation
    expect(result.id).toBe('usr_123');
    expect(userRepoMock.findByEmail).toHaveBeenCalledWith('ada@lovelace.org');
    expect(userRepoMock.create).toHaveBeenCalledOnce();

    // Assert: Email sender was triggered with correct arguments
    expect(emailSenderMock.sendWelcomeEmail).toHaveBeenCalledWith(
      'ada@lovelace.org',
      'Ada Lovelace'
    );
  });

  it('should throw a 409 Conflict if email is already taken and NOT send email', async () => {
    // Arrange: User already exists
    vi.mocked(userRepoMock.findByEmail).mockResolvedValue({ id: 'existing_id' });

    // Act & Assert
    await expect(
      userService.register('existing@test.com', 'Existing User')
    ).rejects.toThrow(ApiError);

    // Verify create and email were NEVER invoked
    expect(userRepoMock.create).not.toHaveBeenCalled();
    expect(emailSenderMock.sendWelcomeEmail).not.toHaveBeenCalled();
  });
});

The Over-Mocking Anti-Pattern

[!WARNING] Do not mock what you do not own, and do not mock internal implementation details.

If a test mocks the database, the ORM, the query builder, and three internal helper functions, the test is no longer testing your application—it is testing your mocks.

When database queries, constraints, and relationships need verification, use a real test database in an integration test.


5. Integration Testing with a Real Test Database

An in-memory fake database (like SQLite in memory or mock repositories) cannot replicate PostgreSQL-specific features:

  • jsonb containment and indexing (@>)
  • Trigram fuzzy text search (pg_trgm)
  • Native concurrency isolation levels and row locking (FOR UPDATE)
  • Foreign key constraints (ON DELETE RESTRICT)

The Test Database Setup Pattern

To run safe, isolated database integration tests:

  1. Maintain a separate test database: DATABASE_URL=postgresql://localhost:5432/consistcode_test.
  2. Apply migrations before the test suite begins.
  3. Clean up tables between test runs to ensure tests do not leak state to each other.
TEXT
Vitest Global Setup
        │
        ▼
Connect to PostgreSQL Test DB
        │
        ▼
Run Migrations (`drizzle-kit migrate`)
        │
┌───────┴────────────────────────┐
│ For each test suite:           │
│   1. TRUNCATE tables (clean)   │
│   2. Insert seed fixtures      │
│   3. Execute real SQL queries  │
│   4. Assert database state     │
└───────┬────────────────────────┘
        │
        ▼
Close DB Connection Pool

Implementing Table Truncation Between Tests

TS
// src/test-utils/db-test-helper.ts
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from '../../packages/database/src/db/index';

const testDbUrl = process.env.TEST_DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/consistcode_test';

export const testSql = postgres(testDbUrl, { max: 5 });
export const testDb = drizzle(testSql, { schema });

/**
 * Truncates all application tables in cascade order to guarantee a fresh slate.
 */
export async function cleanDatabase() {
  await testSql`
    TRUNCATE TABLE 
      submissions,
      problem_test_cases,
      problems,
      users
    RESTART IDENTITY CASCADE;
  `;
}

Integration Test Example: Testing Atomic Row Decrement

TS
// src/features/product/inventory.integration.spec.ts
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import { testDb, testSql, cleanDatabase } from '../../test-utils/db-test-helper';
import { products } from '../../packages/database/src/db/practice';
import { eq, sql } from 'drizzle-orm';

describe('Inventory Service (Database Integration)', () => {
  beforeEach(async () => {
    await cleanDatabase();
  });

  afterAll(async () => {
    await testSql.end();
  });

  it('should atomically decrement stock and prevent negative inventory under concurrent load', async () => {
    // 1. Seed initial product with stock = 5
    const [inserted] = await testDb.insert(products).values({
      name: 'Algorithm Handbook',
      stock: 5,
      priceCents: 2999,
    }).returning();

    // 2. Perform atomic conditional update: decrement 3 units
    const result = await testDb
      .update(products)
      .set({
        stock: sql`${products.stock} - 3`,
      })
      .where(sql`${products.id} = ${inserted.id} AND ${products.stock} >= 3`)
      .returning();

    expect(result).toHaveLength(1);
    expect(result[0].stock).toBe(2);

    // 3. Attempt to decrement 5 units when only 2 remain (should fail to match WHERE condition)
    const failedResult = await testDb
      .update(products)
      .set({
        stock: sql`${products.stock} - 5`,
      })
      .where(sql`${products.id} = ${inserted.id} AND ${products.stock} >= 5`)
      .returning();

    expect(failedResult).toHaveLength(0); // No rows updated!

    // Verify stock is still 2 in DB
    const [current] = await testDb.select().from(products).where(eq(products.id, inserted.id));
    expect(current.stock).toBe(2);
  });
});

6. End-to-End API Testing with Supertest

Supertest allows you to test HTTP endpoints by passing your Express app directly into it. Supertest binds the app to an ephemeral, in-memory local socket—no open port on localhost:3000 is needed, preventing port collisions during parallel test runs.

The Supertest Execution Flow

TEXT
Supertest `request(app)`
          │
          ▼
Constructs HTTP Request in RAM
          │
          ▼
Executes Complete Express Pipeline:
  ├─ Global Middleware (Helmet, CORS, JSON Parser)
  ├─ Route Matching & Validation Middleware
  ├─ Authentication & RBAC Guard
  ├─ Controller & Service Invocation
  └─ Global Error Handling Middleware
          │
          ▼
Supertest Asserts HTTP Status, Headers, & Body

Comprehensive Supertest Suite: Auth & Resource Endpoints

TS
// src/features/problem/problem.api.spec.ts
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import express from 'express';
import jwt from 'jsonwebtoken';
import { problemRouter } from './problem.routes';
import { errorHandler } from '../../common/middleware/error.middleware';
import { cleanDatabase, testSql } from '../../test-utils/db-test-helper';

// Initialize ephemeral test application
const app = express();
app.use(express.json());
app.use('/api/v1/problems', problemRouter);
app.use(errorHandler); // Mount central error middleware!

const JWT_SECRET = 'test_super_secret_signing_key_32_chars!';

// Helper to generate test auth tokens
function generateTestToken(userId: string, role: string = 'user'): string {
  return jwt.sign({ sub: userId, role }, JWT_SECRET, { expiresIn: '1h' });
}

describe('Problem API Endpoints (Supertest)', () => {
  beforeEach(async () => {
    await cleanDatabase();
  });

  afterAll(async () => {
    await testSql.end();
  });

  describe('GET /api/v1/problems', () => {
    it('should return 200 OK with paginated response envelope', async () => {
      const response = await request(app)
        .get('/api/v1/problems')
        .query({ page: 1, limit: 10 });

      expect(response.status).toBe(200);
      expect(response.headers['content-type']).toMatch(/json/);
      expect(response.body).toHaveProperty('success', true);
      expect(response.body).toHaveProperty('data');
      expect(response.body).toHaveProperty('meta');
      expect(Array.isArray(response.body.data)).toBe(true);
    });
  });

  describe('POST /api/v1/problems', () => {
    it('should return 401 Unauthorized when Authorization header is missing', async () => {
      const response = await request(app)
        .post('/api/v1/problems')
        .send({
          title: 'Two Sum',
          difficulty: 'EASY',
        });

      expect(response.status).toBe(401);
      expect(response.body.success).toBe(false);
      expect(response.body.message).toMatch(/unauthorized/i);
    });

    it('should return 403 Forbidden when user does not have ADMIN role', async () => {
      const userToken = generateTestToken('usr_regular', 'student');

      const response = await request(app)
        .post('/api/v1/problems')
        .set('Authorization', `Bearer ${userToken}`)
        .send({
          title: 'Two Sum',
          difficulty: 'EASY',
          description: 'Solve two sum in O(n)',
        });

      expect(response.status).toBe(403);
      expect(response.body.success).toBe(false);
    });

    it('should return 400 Bad Request when request body fails DTO schema validation', async () => {
      const adminToken = generateTestToken('usr_admin', 'admin');

      const response = await request(app)
        .post('/api/v1/problems')
        .set('Authorization', `Bearer ${adminToken}`)
        .send({
          // Missing required 'title' and invalid difficulty
          difficulty: 'IMPOSSIBLE',
        });

      expect(response.status).toBe(400);
      expect(response.body.success).toBe(false);
      expect(response.body.errors).toEqual(
        expect.arrayContaining([
          expect.objectContaining({ field: 'title' }),
          expect.objectContaining({ field: 'difficulty' }),
        ])
      );
    });

    it('should return 201 Created and return the created resource for valid admin requests', async () => {
      const adminToken = generateTestToken('usr_admin', 'admin');

      const validPayload = {
        title: 'Reverse Linked List',
        slug: 'reverse-linked-list',
        difficulty: 'EASY',
        description: 'Reverse a singly linked list in O(n) time.',
      };

      const response = await request(app)
        .post('/api/v1/problems')
        .set('Authorization', `Bearer ${adminToken}`)
        .send(validPayload);

      expect(response.status).toBe(201);
      expect(response.body.success).toBe(true);
      expect(response.body.data).toHaveProperty('id');
      expect(response.body.data.slug).toBe('reverse-linked-list');
    });
  });
});

7. Test Fixtures and Factories

Hardcoding literal objects in every test file leads to brittle tests. If you add a required field like emailVerifiedAt to the user schema, 80 test files will immediately break.

Instead, use the Factory Pattern with deterministic defaults:

TS
// src/test-utils/factories/user.factory.ts
import crypto from 'node:crypto';

export interface UserAttributes {
  id?: string;
  email?: string;
  name?: string;
  role?: 'student' | 'instructor' | 'admin';
  isActive?: boolean;
}

export function buildUserFixture(overrides: UserAttributes = {}) {
  const uniqueId = crypto.randomUUID();

  return {
    id: overrides.id ?? `usr_${uniqueId}`,
    email: overrides.email ?? `user_${uniqueId.substring(0, 8)}@example.com`,
    name: overrides.name ?? 'Ada Lovelace',
    role: overrides.role ?? 'student',
    isActive: overrides.isActive ?? true,
    createdAt: new Date(),
    updatedAt: new Date(),
  };
}

Now, tests specify only the properties relevant to the specific behavior being tested:

TS
// Clean, maintainable test using factory:
const suspendedUser = buildUserFixture({ isActive: false });
const adminUser = buildUserFixture({ role: 'admin' });

8. Continuous Integration & Coverage Thresholds

Code coverage measures which lines, branches, functions, and statements are exercised by your test suite.

In Vitest, coverage is collected via V8's native profiling hooks:

BASH
pnpm test --coverage

Sample Output:

TEXT
 % Coverage report from v8
-----------------------|---------|----------|---------|---------|-------------------
File                   | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
-----------------------|---------|----------|---------|---------|-------------------
All files              |   94.12 |    88.88 |   92.30 |   94.12 |                   
 order-calculator.ts   |     100 |      100 |     100 |     100 |                   
 user.service.ts       |   95.23 |    83.33 |     100 |   95.23 | 42-43             
 auth.dto.ts           |     100 |      100 |     100 |     100 |                   
-----------------------|---------|----------|---------|---------|-------------------

Setting Quality Gates in CI

You can configure Vitest to fail the build if coverage drops below acceptable thresholds:

TS
// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      thresholds: {
        statements: 85,
        branches: 80,
        functions: 85,
        lines: 85,
      },
    },
  },
});

9. Production Testing Checklist & Summary

CODE
┌────────────────────────────────────────────────────────────────────────────┐
│                    PRODUCTION BACKEND TESTING CHECKLIST                    │
├────────────────────────────────────────────────────────────────────────────┤
│ [ ] Fast Unit Tests: Domain math, business rules, and DTO schemas execute  │
│     in milliseconds with zero external I/O.                                │
│                                                                            │
│ [ ] Realistic Integration Tests: Real PostgreSQL instance used for testing │
│     queries, JSONB operations, and concurrency; no SQLite fakes.          │
│                                                                            │
│ [ ] Supertest Isolation: Express app tested without opening live network   │
│     ports (`app.listen()`), eliminating port contention in CI.             │
│                                                                            │
│ [ ] Error Path Coverage: Tests verify 400 (validation), 401 (unauthorized),│
│     403 (forbidden), and 404 (not found) responses, not just happy paths. │
│                                                                            │
│ [ ] Fixture Factories: Clean builder functions maintain test data without  │
│     brittle hardcoded IDs or schema coupling.                              │
│                                                                            │
│ [ ] Ephemeral Cleanup: Database tables truncated between test suites       │
│     guaranteeing 100% test isolation.                                      │
│                                                                            │
│ [ ] Automated CI Pipeline: Tests run automatically on every pull request,  │
│     blocking merges on test failures or coverage regression.               │
└────────────────────────────────────────────────────────────────────────────┘

In the next chapter, we will examine Chapter 22: Security: OWASP Top 10 for Node.js Backends, securing our endpoints against NoSQL/SQL injections, Cross-Site Scripting (XSS), Cross-Site Request Forgery (CSRF), rate limiting, and HTTP security headers with Helmet.js.

Finished this lesson?

Mark this chapter complete to update your learning streak and unlock the next lesson.