Testing

Difficulty

Answer: The testing pyramid is a guideline for how to distribute test types by quantity, based on their speed and cost.

        /\        E2E  (few)      slow, brittle, high confidence
       /  \
      /----\      Integration (some)
     /      \
    /--------\    Unit (many)     fast, isolated, cheap

Unit tests (base — many):

  • Test a single function/module in isolation, mocking its dependencies.
  • Fast (milliseconds) and deterministic; pinpoint failures.
test('calculateTotal applies discount', () => {
  expect(calculateTotal(100, 0.1)).toBe(90);
});

Integration tests (middle — some):

  • Test multiple units working together: a route handler + its service + a real (test) database, or a module + an external client.
  • Catch wiring/contract bugs unit tests miss (SQL, serialization, middleware order).

End-to-end tests (top — few):

  • Drive the whole system as a user would (HTTP in, DB and side effects real), sometimes through a browser (Playwright/Cypress).
  • Highest confidence but slow and brittle — keep them for critical happy paths.

Why the shape:

  • Fast unit tests give quick feedback and cheap coverage of logic/edge cases.
  • Too many E2E tests → slow suites and flaky CI.
  • A common modern variant is the "testing trophy," which weights integration tests more heavily because they catch realistic bugs at reasonable cost.

Rule of thumb: most logic covered by unit tests, key flows by integration tests, a handful of E2E for the money paths.

Answer:

Structure — describe groups, test/it cases:

describe('UserService', () => {
  let service;

  beforeEach(() => {           // fresh state per test
    service = new UserService(fakeRepo);
  });

  test('creates a user', async () => {
    const user = await service.create({ name: 'Alice' });
    expect(user.id).toBeDefined();
    expect(user.name).toBe('Alice');
  });

  test('rejects a duplicate email', async () => {
    await expect(service.create({ email: 'taken@x.com' }))
      .rejects.toThrow('already exists');
  });
});

Assertions (Jest matchers):

  • Equality: toBe (===, primitives), toEqual (deep), toStrictEqual.
  • Truthiness: toBeTruthy, toBeNull, toBeDefined.
  • Numbers/strings: toBeGreaterThan, toMatch(/regex/), toContain.
  • Errors/async: toThrow, resolves/rejects.

Lifecycle hooks:

HookRuns
beforeAll / afterAllonce per file (e.g., start/stop a DB)
beforeEach / afterEachbefore/after each test (reset state)

Jest vs Mocha:

  • Jest — batteries-included: runner, assertions (expect), mocking, coverage, snapshots, parallel execution. Common default for Node/React.
  • Mocha — just the runner; pair with Chai (assertions) and Sinon (mocks/stubs/spies). More modular/configurable.
  • (Node also now ships a built-in node:test runner.)

Best practices: keep tests isolated (no shared mutable state — reset in beforeEach), one behavior per test, descriptive names, and avoid depending on test execution order.

Answer: Test doubles stand in for real dependencies so you can test a unit in isolation, deterministically, without hitting databases, networks, or the clock.

The three common kinds:

  • Spy — wraps a real (or empty) function and records calls (arguments, call count) without changing behavior. Use to assert "was this called correctly?"
  • Stub — replaces a function with a canned implementation/return value. Use to control what a dependency returns (e.g., simulate a DB result or an error).
  • Mock — a stub with pre-set expectations that the test verifies (it should be called once, with these args).

In Jest:

// Spy — observe an existing method
const logSpy = jest.spyOn(console, 'log');
doWork();
expect(logSpy).toHaveBeenCalledWith('done');

// Stub/mock a function's return value
const getUser = jest.fn().mockResolvedValue({ id: 1, name: 'Alice' });

// Mock an entire module
jest.mock('./emailClient');
const { sendEmail } = require('./emailClient');
sendEmail.mockResolvedValue({ ok: true });

test('notifies the user', async () => {
  await notify(1);
  expect(sendEmail).toHaveBeenCalledWith('alice@x.com', expect.any(String));
});

When to use — and when not to:

  • Do mock: slow/external dependencies (DB, HTTP, payment APIs), non-determinism (time with fake timers, randomness), and to force error paths that are hard to trigger for real.
  • Don't over-mock: mocking everything tests your mocks, not your code, and makes tests brittle to refactors. Prefer real objects for pure logic, and cover real wiring with integration tests.

Rule: mock at the boundaries (I/O), keep the core logic tested against the real thing.

Answer: Integration tests exercise the real request pipeline — middleware, routing, handlers, and (ideally) a test database — through HTTP. supertest drives your Express app directly.

Export the app separately from the server so tests can import it without listening on a port:

// app.js — build and export the app
const app = express();
app.use(express.json());
app.use('/users', usersRouter);
module.exports = app;

// server.js — only this file calls listen()
require('./app').listen(3000);

Test with supertest:

const request = require('supertest');
const app = require('./app');

describe('POST /users', () => {
  test('creates a user and returns 201', async () => {
    const res = await request(app)
      .post('/users')
      .send({ name: 'Alice', email: 'alice@x.com' })
      .expect('Content-Type', /json/)
      .expect(201);

    expect(res.body).toMatchObject({ name: 'Alice' });
    expect(res.body.id).toBeDefined();
  });

  test('rejects invalid input with 400', async () => {
    await request(app).post('/users').send({}).expect(400);
  });
});

Good practices:

  • Use a dedicated test database (or an in-memory/containerized one), reset between tests (beforeEach truncate, or wrap each test in a rolled-back transaction).
  • Test the contract: status codes, response shape, validation errors, auth (send/omit tokens), and edge cases — not just the happy path.
  • Mock only truly external third parties (payment gateways, email); keep your own DB real so you catch query/serialization bugs.
  • supertest calls app in-process (it starts an ephemeral server), so no port management or flakiness from a separately running server.

Answer:

Async tests — make the runner wait:

// async/await (preferred)
test('fetches a user', async () => {
  const user = await getUser(1);
  expect(user.name).toBe('Alice');
});

// Assert a rejection
test('throws on missing user', async () => {
  await expect(getUser(999)).rejects.toThrow('Not found');
});
  • The common bug is a test that doesn't await the promise — it passes even when the assertion would fail, because the test finishes before the async work. Always await (or return) the promise.

Timers — don't actually wait: Use fake timers to control time deterministically (fast, no flakiness):

jest.useFakeTimers();

test('debounce fires once after 300ms', () => {
  const fn = jest.fn();
  const debounced = debounce(fn, 300);
  debounced(); debounced();
  jest.advanceTimersByTime(300);  // simulate time passing
  expect(fn).toHaveBeenCalledTimes(1);
});

This tests timeouts, intervals, and debounce/throttle instantly.

Avoiding flaky tests:

  • No real network/clock — mock external calls, use fake timers instead of setTimeout waits.
  • No shared state — reset mocks and data in beforeEach; don't depend on test order.
  • Deterministic data — seed randomness, fix "now" (fake timers / injected clock).
  • Await everything — unhandled async is the #1 cause of intermittent failures.
  • Assert on outcomes/state, not on arbitrary sleep durations.

Error paths: force failures by stubbing a dependency to throw/reject, then assert the code handles it (returns the right status, logs, retries, rolls back). Error branches are exactly where bugs hide, so test them explicitly.