Module 9: Testing & Quality Assurance

The Testing Pyramid in Node.js

A resilient testing strategy strikes a deliberate balance across three layers:

        /  E2E Tests  \       ▲  Slow, Expensive, High Confidence
       / Integration   \      │
      /   API Tests     \     │
     /───────────────────\    │
    /     Unit Tests      \   ▼  Fast, Cheap, Isolated
  • Unit Tests: Verify individual, pure functions or isolated business logic classes in complete isolation. All side effects (I/O, network, database) are mocked or stubbed.
  • Integration & API Tests: Validate that multiple components (routers, middleware, controllers, database models, caching layers) work together correctly using real or ephemeral test databases.
  • End-to-End (E2E) Tests: Exercise the full deployed system from an external client’s perspective.

Unit & Integration Testing: Jest vs. Vitest

Both Jest and Vitest share nearly identical matchers and assertion APIs (describe, it, expect, beforeEach).

  • Jest: The mature industry standard. However, it requires extra toolchains (babel-jest or ts-jest) to execute modern TypeScript or native ES Modules.
  • Vitest: Built on Vite. It natively understands ES Modules, TypeScript, and JSX with zero extra configuration, boots with multi-threaded worker pools, and shares the same configuration pipelines.

Setting Up Vitest for Node.js

Bash

npm install -D vitest

JavaScript

// vitest.config.js
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,               // Exposes describe, it, expect globally
    environment: 'node',         // Uses Node runtime environment (not browser/jsdom)
    coverage: {
      provider: 'v8',            // Native V8 code coverage
      reporter: ['text', 'json', 'html'],
      exclude: ['node_modules/', 'dist/', 'tests/']
    },
    testTimeout: 10000           // 10s timeout for async operations
  }
});

Add test scripts to package.json:

JSON

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

1. Unit Testing: Business Logic Isolation

Unit tests should test edge cases, parameter validations, and deterministic calculations without any network or database activity.

JavaScript

// src/utils/pricing.js
export function calculateOrderDiscount(subtotal, userTier, couponRate = 0) {
  if (subtotal < 0) {
    throw new Error('Subtotal cannot be negative');
  }

  let tierDiscount = 0;
  if (userTier === 'GOLD') tierDiscount = 0.20;
  else if (userTier === 'SILVER') tierDiscount = 0.10;

  const totalDiscountRate = Math.min(tierDiscount + couponRate, 0.50); // Cap discount at 50%
  const finalAmount = subtotal * (1 - totalDiscountRate);

  return Math.round(finalAmount * 100) / 100;
}

JavaScript

// tests/unit/pricing.test.js
import { describe, it, expect } from 'vitest';
import { calculateOrderDiscount } from '../../src/utils/pricing.js';

describe('Pricing Utility: calculateOrderDiscount', () => {
  it('should return the original amount if no tier or coupon is applied', () => {
    const result = calculateOrderDiscount(100, 'STANDARD');
    expect(result).toBe(100);
  });

  it('should apply the correct discount for GOLD tier users', () => {
    const result = calculateOrderDiscount(200, 'GOLD');
    expect(result).toBe(160); // 20% off of 200
  });

  it('should combine tier and coupon discounts accurately', () => {
    const result = calculateOrderDiscount(100, 'SILVER', 0.15);
    expect(result).toBe(75); // 10% + 15% = 25% off
  });

  it('should cap cumulative discounts at exactly 50%', () => {
    const result = calculateOrderDiscount(100, 'GOLD', 0.40); // 20% + 40% = 60% -> capped at 50%
    expect(result).toBe(50);
  });

  it('should throw an explicit error for negative amounts', () => {
    expect(() => calculateOrderDiscount(-10, 'GOLD')).toThrow('Subtotal cannot be negative');
  });
});

2. Integration Testing: Controller & Database Layer

Integration tests execute operations across the database without mocking the query engine. A common best practice is spinning up an ephemeral test database (such as an in-memory database or a dedicated Docker test instance) and wiping state in lifecycle hooks.

JavaScript

// tests/integration/userRepository.test.js
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { prisma } from '../../src/config/db.js';
import { userRepository } from '../../src/repositories/user.repository.js';

describe('Integration: User Repository with Database', () => {
  beforeAll(async () => {
    await prisma.$connect();
  });

  afterAll(async () => {
    await prisma.$disconnect();
  });

  beforeEach(async () => {
    // Truncate tables between tests to guarantee test isolation
    await prisma.order.deleteMany();
    await prisma.user.deleteMany();
  });

  it('should persist a new user and retrieve with relationships', async () => {
    const createdUser = await userRepository.create({
      email: 'test@domain.com',
      name: 'Integration Tester'
    });

    expect(createdUser).toHaveProperty('id');
    expect(createdUser.email).toBe('test@domain.com');

    // Verify record directly via DB client
    const fetched = await prisma.user.findUnique({
      where: { id: createdUser.id }
    });

    expect(fetched).not.toBeNull();
    expect(fetched?.name).toBe('Integration Tester');
  });

  it('should enforce unique constraint errors at database boundary', async () => {
    await userRepository.create({ email: 'duplicate@domain.com', name: 'User 1' });

    await expect(
      userRepository.create({ email: 'duplicate@domain.com', name: 'User 2' })
    ).rejects.toThrow();
  });
});

API Testing: Testing Endpoints with Supertest

Supertest binds to your Express app object and exercises real HTTP operations over an ephemeral local network port without needing you to manually boot the server on a live host port using app.listen().

Bash

npm install -D supertest

Structuring Express for Testability

Never invoke app.listen() directly inside your route definition file (app.js). Export the un-listened app instance, and create a separate entry point (server.js) that boots the listener:

src/
├── app.js      ──▶ Configures middleware & routes (Exported for Supertest)
└── server.js   ──▶ Imports app and calls app.listen(PORT) (Bootstraps production)

Writing End-to-End API Specs

JavaScript

// tests/api/auth.api.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app.js';
import { prisma } from '../../src/config/db.js';

describe('POST /api/v1/auth/register', () => {
  beforeEach(async () => {
    await prisma.user.deleteMany();
  });

  it('should return 201 Created and return a sanitized user entity', async () => {
    const payload = {
      email: 'alex@example.com',
      password: 'StrongPassword123!',
      name: 'Alex Rivera'
    };

    const res = await request(app)
      .post('/api/v1/auth/register')
      .send(payload)
      .set('Accept', 'application/json');

    expect(res.status).toBe(201);
    expect(res.headers['content-type']).toMatch(/json/);
    expect(res.body.status).toBe('success');
    expect(res.body.data.user).toMatchObject({
      email: 'alex@example.com',
      name: 'Alex Rivera'
    });
    // Critical security check: Sensitive credentials must never be in responses
    expect(res.body.data.user).not.toHaveProperty('password');
    expect(res.body.data.user).not.toHaveProperty('passwordHash');
  });

  it('should return 422 Unprocessable Entity when validation fails', async () => {
    const invalidPayload = {
      email: 'not-an-email',
      password: 'short'
    };

    const res = await request(app)
      .post('/api/v1/auth/register')
      .send(invalidPayload);

    expect(res.status).toBe(422);
    expect(res.body.status).toBe('fail');
    expect(res.body.errors).toBeInstanceOf(Array);
    expect(res.body.errors[0]).toHaveProperty('field');
  });

  it('should return 401 Unauthorized when accessing protected route without Bearer token', async () => {
    const res = await request(app)
      .get('/api/v1/users/profile');

    expect(res.status).toBe(401);
    expect(res.body.message).toContain('Authentication required');
  });

  it('should set an httpOnly Secure cookie when logging in', async () => {
    // Seed user
    await request(app).post('/api/v1/auth/register').send({
      email: 'login@test.com',
      password: 'Password123!',
      name: 'Tester'
    });

    const res = await request(app)
      .post('/api/v1/auth/login')
      .send({ email: 'login@test.com', password: 'Password123!' });

    expect(res.status).toBe(200);
    expect(res.body).toHaveProperty('accessToken');

    // Inspect Set-Cookie header
    const cookies = res.headers['set-cookie'];
    expect(cookies).toBeDefined();
    expect(cookies[0]).toMatch(/refreshToken=/);
    expect(cookies[0]).toMatch(/HttpOnly/i);
  });
});

Mocking & Spying

Mocking replaces dependencies that are slow, nondeterministic, costly, or that produce external side effects (credit card processing, third-party emails, AWS buckets).

  • Spy (vi.spyOn): Observes calls, arguments, and return values of a real method while optionally leaving original behavior intact.
  • Stub / Mock (vi.fn): Replaces the target implementation with a fake function returning pre-configured data.
  • Module Mock (vi.mock): Replaces an entire file or npm module with a mocked interface.

1. Mocking Database Queries

When writing unit tests for your service layer, mock the repository or ORM client to avoid requiring a real database connection.

JavaScript

// tests/unit/userService.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { userService } from '../../src/services/user.service.js';
import { userRepository } from '../../src/repositories/user.repository.js';
import { AppError } from '../../src/errors/AppError.js';

// Automatically mock all methods on userRepository
vi.mock('../../src/repositories/user.repository.js');

describe('User Service Unit Tests', () => {
  beforeEach(() => {
    vi.clearAllMocks(); // Clear call counts and return values between tests
  });

  it('should throw an AppError if the user already exists', async () => {
    // Arrange: Configure findByEmail to return an existing user
    userRepository.findByEmail.mockResolvedValue({
      id: 'usr_1',
      email: 'exists@domain.com'
    });

    // Act & Assert
    await expect(
      userService.registerUser({ email: 'exists@domain.com', password: 'password123' })
    ).rejects.toThrow(AppError);

    // Verify create was never triggered
    expect(userRepository.create).not.toHaveBeenCalled();
    expect(userRepository.findByEmail).toHaveBeenCalledTimes(1);
    expect(userRepository.findByEmail).toHaveBeenCalledWith('exists@domain.com');
  });

  it('should successfully hash the password and persist a new user', async () => {
    // Arrange
    userRepository.findByEmail.mockResolvedValue(null);
    userRepository.create.mockImplementation(async (data) => ({
      id: 'usr_new',
      ...data,
      createdAt: new Date()
    }));

    // Act
    const result = await userService.registerUser({
      email: 'new@domain.com',
      password: 'PlainPassword',
      name: 'New User'
    });

    // Assert
    expect(result).toHaveProperty('id', 'usr_new');
    expect(result.email).toBe('new@domain.com');
    // Ensure repository received a hashed string, not the plain password
    expect(userRepository.create).toHaveBeenCalledWith(
      expect.objectContaining({
        email: 'new@domain.com',
        password: expect.not.stringMatching('PlainPassword')
      })
    );
  });
});

2. Mocking Network Calls (MSW & Fetch/Axios Mocks)

Third-party APIs should never be called during test execution. Mock Service Worker (MSW) intercepts outgoing HTTP requests at the network transport level, making it library-agnostic (works with native fetch, axios, and got).

Bash

npm install -D msw

JavaScript

// tests/mocks/handlers.js
import { http, HttpResponse } from 'msw';

export const handlers = [
  // Intercept outgoing POST requests to third-party payment gateway
  http.post('https://api.stripe.com/v1/payment_intents', async ({ request }) => {
    const auth = request.headers.get('Authorization');
    
    if (!auth || !auth.includes('sk_test_valid')) {
      return new HttpResponse(null, { status: 401 });
    }

    return HttpResponse.json({
      id: 'pi_test_12345',
      status: 'succeeded',
      amount: 5000,
      currency: 'usd'
    });
  }),

  // Intercept Currency Conversion API
  http.get('https://api.exchangerates.io/v1/latest', () => {
    return HttpResponse.json({
      rates: { EUR: 0.92, GBP: 0.78 }
    });
  })
];

JavaScript

// tests/setupTests.js (Registered in vitest.config.js setupFiles)
import { beforeAll, afterAll, afterEach } from 'vitest';
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers.js';

export const server = setupServer(...handlers);

beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); // Throw if code calls an unmocked external API
afterEach(() => server.resetHandlers()); // Reset runtime overrides
afterAll(() => server.close());

3. Spying & Fake Timers

Spying on Methods and Built-ins

Use vi.spyOn to assert that internal utility methods or logging agents were notified with the expected metadata without altering their execution:

JavaScript

import { logger } from '../../src/utils/logger.js';
import { metrics } from '../../src/utils/metrics.js';

it('should increment metrics and log warning when cache misses occur', async () => {
  const logSpy = vi.spyOn(logger, 'warn');
  const metricSpy = vi.spyOn(metrics, 'increment').mockImplementation(() => {});

  await getCachedData('key_missing');

  expect(metricSpy).toHaveBeenCalledWith('cache.miss', 1);
  expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Cache miss encountered'));

  // Restore original non-mocked implementation
  metricSpy.mockRestore();
});
Controlling Time with Fake Timers

When testing rate-limiters, TTL expirations, or recurring background queues, never use real delays like setTimeout in test assertions. Fake timers fast-forward system time synchronously.

JavaScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SessionManager } from '../../src/services/session.manager.js';

describe('Session TTL Expiration', () => {
  beforeEach(() => {
    vi.useFakeTimers(); // Intercept system Date, setTimeout, setInterval
  });

  afterEach(() => {
    vi.useRealTimers(); // Restore real system clock
  });

  it('should invalidate active sessions after 15 minutes of inactivity', () => {
    const sessionManager = new SessionManager();
    sessionManager.createSession('session_1');

    expect(sessionManager.isValid('session_1')).toBe(true);

    // Fast-forward virtual system clock by 14 minutes
    vi.advanceTimersByTime(14 * 60 * 1000);
    expect(sessionManager.isValid('session_1')).toBe(true);

    // Advance 2 more minutes (16 minutes total -> TTL expired)
    vi.advanceTimersByTime(2 * 60 * 1000);
    expect(sessionManager.isValid('session_1')).toBe(false);
  });
});

Test Suite Execution Summary Matrix

ToolPrimary PurposeScopeKey Methods / Mechanics
Vitest / JestTest runner & assertion harnessUnit, Integrationdescribe(), it(), expect(), toBe(), toMatchObject()
SupertestHTTP assertion agentREST API Endpointsrequest(app).get(), .send(), .set(), .expect(200)
MSW (Mock Service Worker)Outgoing HTTP interceptionExternal Serviceshttp.get(), http.post(), HttpResponse.json()
vi.fn() / vi.mock()Dependency replacementStubs & FakesmockResolvedValue(), mockImplementation(), toHaveBeenCalledWith()
vi.useFakeTimers()Clock manipulationTimeouts & Expirationsvi.advanceTimersByTime(), vi.setSystemTime()