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:
| Hook | Runs |
|---|---|
beforeAll / afterAll | once per file (e.g., start/stop a DB) |
beforeEach / afterEach | before/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:testrunner.)
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 (
beforeEachtruncate, 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
appin-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(orreturn) 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
setTimeoutwaits. - 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.