---
title: 'The Test Pyramid'
source: 'https://academia.sh/en/courses/integration-testing/test-pyramid'
course: 'Integration, Contract and End-to-End Testing'
language: en
updated: '2026-08-23T14:25:14+00:00'
license: 'CC BY-SA 4.0'
---

# The Test Pyramid

Running the same borrowing flow at the unit, integration, and end-to-end levels; showing the defect class each level catches and misses; and measuring the cost per level with query, request, and setup counts.

The previous topic built four checks: whether the request and response matched the
schema, whether the consumer's expectation ran against the provider, whether a schema
change was breaking, and whether the mock server stayed faithful to the contract. What
they share is this: each looks at **a single boundary**. A member borrowing a book fits
none of those boundaries — the rule lives in one place, the record in another, and the
response the interface sees in a third.

Classifying the levels was done in the Quality and Testing Fundamentals course; what is
asked here is not the classification but the **distribution**. The answer to how many
tests belong at which level is not a preference: the cost of running each level, the
defect class it catches, and the cost of catching the same defect at more than one level
are all measured, and the distribution follows from these three numbers.

## The Same Flow, Three Levels

The borrowing flow is split into three parts: the eligibility rule is a pure function,
the record is a real database, and the outer surface is an HTTP interface. The rule
places a limit on the number of open loans.

```js
// rule.mjs — version 1: loan eligibility rule
export const LIMIT = 5;

export function isEligible({ openLoans, daysOverdue, membership, bookStatus }) {
  if (membership !== 'active') return { eligible: false, reason: 'membership-not-active' };
  if (bookStatus !== 'shelved') return { eligible: false, reason: 'book-not-shelved' };
  if (daysOverdue > 0) return { eligible: false, reason: 'has-overdue-loan' };
  if (openLoans > LIMIT) return { eligible: false, reason: 'loan-limit-exceeded' };
  return { eligible: true, reason: 'eligible' };
}
```

The repository layer turns the decision into real rows. Every query is counted; this
counter is the lesson's measure of cost.

```js
// repository.mjs — catalog and loan records over node:sqlite
import { DatabaseSync } from 'node:sqlite';
import { isEligible } from './rule.mjs';

export const counter = { query: 0 };

export function setupRepository() {
  const db = new DatabaseSync(':memory:');
  db.exec(`CREATE TABLE member (member_no TEXT PRIMARY KEY, membership TEXT, days_overdue INTEGER);
           CREATE TABLE book (book_no TEXT PRIMARY KEY, status TEXT);
           CREATE TABLE loan (member_no TEXT, book_no TEXT UNIQUE)`);
  return db;
}

export function borrowBook(db, memberNo, bookNo) {
  const read = (s, ...p) => { counter.query += 1; return db.prepare(s).all(...p); };
  const write = (s, ...p) => { counter.query += 1; db.prepare(s).run(...p); };
  const [member] = read('SELECT membership, days_overdue FROM member WHERE member_no = ?', memberNo);
  const [book] = read('SELECT status FROM book WHERE book_no = ?', bookNo);
  const [{ n }] = read('SELECT COUNT(*) AS n FROM loan WHERE member_no = ?', memberNo);
  const decision = isEligible({
    openLoans: n, daysOverdue: member.days_overdue, membership: member.membership, bookStatus: book.status,
  });
  if (decision.eligible === false) return decision;
  write('INSERT INTO loan (member_no, book_no) VALUES (?, ?)', memberNo, bookNo);
  write("UPDATE book SET status = 'loaned' WHERE book_no = ?", bookNo);
  return decision;
}
```

The outer surface has two parts: a small function that maps the decision to a status
code, and the server that answers the request.

```js
// statuscode.mjs — version 1: decision mapped to an HTTP status code
export const statusCode = (decision) => (decision.eligible ? 201 : 409);
```

```js
// service.mjs — HTTP interface for the loan flow
import { createServer } from 'node:http';
import { borrowBook } from './repository.mjs';
import { statusCode } from './statuscode.mjs';

export function setupServer(db) {
  return createServer((request, response) => {
    let body = '';
    request.on('data', (chunk) => { body += chunk; });
    request.on('end', () => {
      const { memberNo, bookNo } = JSON.parse(body);
      const decision = borrowBook(db, memberNo, bookNo);
      response.writeHead(statusCode(decision), { 'content-type': 'application/json' });
      response.end(JSON.stringify(decision));
    });
  });
}
```

Four tests exercise the same flow at three levels: one tests only the rule, one tests
the rule together with the database, and two test the path the user sees.

```js
// levels.test.mjs — the same loan flow tested at three levels
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isEligible } from './rule.mjs';
import { setupRepository, borrowBook } from './repository.mjs';
import { setupServer } from './service.mjs';

const seed = () => {
  const db = setupRepository();
  db.prepare("INSERT INTO member VALUES ('U-17', 'active', 0)").run();
  db.prepare("INSERT INTO member VALUES ('U-31', 'suspended', 0)").run();
  db.prepare("INSERT INTO book VALUES ('K-90', 'shelved')").run();
  return db;
};

const send = async (body) => {
  const server = setupServer(seed());
  await new Promise((done) => server.listen(0, done));
  const response = await fetch(`http://127.0.0.1:${server.address().port}/loan`,
    { method: 'POST', body: JSON.stringify(body) });
  const payload = await response.json();
  server.close();
  return { status: response.status, reason: payload.reason };
};

test('unit: the fifth open loan reaches the limit', () => {
  const base = { daysOverdue: 0, membership: 'active', bookStatus: 'shelved' };

  assert.equal(isEligible({ ...base, openLoans: 4 }).eligible, true);
  assert.equal(isEligible({ ...base, openLoans: 5 }).reason, 'loan-limit-exceeded');
});

test('integration: a loan row is written and the book status returns', () => {
  const db = seed();

  assert.equal(borrowBook(db, 'U-17', 'K-90').eligible, true);
  const row = db.prepare('SELECT member_no, book_no FROM loan').get();
  assert.equal(`${row.member_no} ${row.book_no}`, 'U-17 K-90');
  assert.equal(db.prepare("SELECT status FROM book WHERE book_no = 'K-90'").get().status, 'loaned');
});

test('end-to-end: an eligible request returns approval', async () => {
  assert.deepEqual(await send({ memberNo: 'U-17', bookNo: 'K-90' }), { status: 201, reason: 'eligible' });
});

test('end-to-end: a suspended membership is rejected', async () => {
  assert.deepEqual(await send({ memberNo: 'U-31', bookNo: 'K-90' }),
    { status: 409, reason: 'membership-not-active' });
});
```

## The Boundary Comparison Defect

The first version of the rule has the boundary comparison wrong: if the limit is five
open loans, a member with five should not be able to take a sixth. Writing
`openLoans > LIMIT` grants the sixth and stops only at the seventh.

```bash
node --test --test-reporter=tap levels.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
```

```
not ok 1 - unit: the fifth open loan reaches the limit
ok 2 - integration: a loan row is written and the book status returns
ok 3 - end-to-end: an eligible request returns approval
ok 4 - end-to-end: a suspended membership is rejected
# tests 4
# pass 3
# fail 1
```

The unit level caught the defect. What stands out in the same run is the other three
lines: with the defect still in the code, the integration and end-to-end tests **stayed
green**. The reason is a simple choice — those two tests run the happy path, a member
with zero open loans. Catching the boundary defect at an upper level would have required
preparing a member with five open loans there too; the cost of that is the subject of
the next section.

The fix is in a single comparison.

```js
// rule.mjs — version 2: boundary comparison fixed
export const LIMIT = 5;

export function isEligible({ openLoans, daysOverdue, membership, bookStatus }) {
  if (membership !== 'active') return { eligible: false, reason: 'membership-not-active' };
  if (bookStatus !== 'shelved') return { eligible: false, reason: 'book-not-shelved' };
  if (daysOverdue > 0) return { eligible: false, reason: 'has-overdue-loan' };
  if (openLoans >= LIMIT) return { eligible: false, reason: 'loan-limit-exceeded' };
  return { eligible: true, reason: 'eligible' };
}
```

```bash
node --test --test-reporter=tap levels.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
```

```
ok 1 - unit: the fifth open loan reaches the limit
ok 2 - integration: a loan row is written and the book status returns
ok 3 - end-to-end: an eligible request returns approval
ok 4 - end-to-end: a suspended membership is rejected
# tests 4
# pass 4
# fail 0
```

## The Defect Only the Upper Level Sees

There is also a defect class running the other way: one with no counterpart at a lower
level. The status code of a rejected loan request is one of these — for the rule it is a
single value, "not eligible"; for the repository it is not writing a record; but
for the client calling the interface, that value is a status code.

```js
// statuscode.mjs — version 2: rejection now returns a success code
export const statusCode = (decision) => (decision.eligible ? 201 : 200);
```

```bash
node --test --test-reporter=tap levels.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
```

```
ok 1 - unit: the fifth open loan reaches the limit
ok 2 - integration: a loan row is written and the book status returns
ok 3 - end-to-end: an eligible request returns approval
not ok 4 - end-to-end: a suspended membership is rejected
# tests 4
# pass 3
# fail 1
```

The rule and repository tests stay green; the defect is in no value they see. The fix is
to reverse the mapping.

```js
// statuscode.mjs — version 3: rejection returns a conflict code again
export const statusCode = (decision) => (decision.eligible ? 201 : 409);
```

```bash
node --test --test-reporter=tap levels.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
```

```
ok 1 - unit: the fifth open loan reaches the limit
ok 2 - integration: a loan row is written and the book status returns
ok 3 - end-to-end: an eligible request returns approval
ok 4 - end-to-end: a suspended membership is rejected
# tests 4
# pass 4
# fail 0
```

That the mapping was pulled out into its own function is no accident here: because
`statusCode` is a separate function, a unit test can be written for it, and that defect
is never again left to the end-to-end level. The pyramid is a design pressure before it
is a counting rule — a check that can be pushed down is pushed down.

## Cost

The rule checks four conditions: membership, book status, overdue days, and the
open-loan count. Seeing the boundary behavior needs three values of open loans (four,
five, six); the other three conditions have two values each. Twenty-four cases in total.
The measurement below runs the same twenty-four cases at three levels and counts the
cost per level.

```js
// cost.mjs — the cost of running the same case set at three levels
import { isEligible } from './rule.mjs';
import { setupRepository, borrowBook, counter } from './repository.mjs';
import { setupServer } from './service.mjs';

const cases = [];
for (const membership of ['active', 'suspended']) {
  for (const bookStatus of ['shelved', 'loaned']) {
    for (const daysOverdue of [0, 3]) {
      for (const openLoans of [4, 5, 6]) cases.push({ membership, bookStatus, daysOverdue, openLoans });
    }
  }
}

const measurement = { setup: 0, request: 0 };
const prepare = (c) => {
  const db = setupRepository();
  db.prepare('INSERT INTO member VALUES (?, ?, ?)').run('U-17', c.membership, c.daysOverdue);
  db.prepare('INSERT INTO book VALUES (?, ?)').run('K-90', c.bookStatus);
  measurement.setup += 2;
  for (let i = 0; i < c.openLoans; i += 1) {
    db.prepare('INSERT INTO loan VALUES (?, ?)').run('U-17', `E-${i}`);
    measurement.setup += 1;
  }
  return db;
};

const s = (n, g) => String(n).padStart(g);
const measure = async (name, component, run) => {
  measurement.setup = 0;
  measurement.request = 0;
  counter.query = 0;
  const start = performance.now();
  await run();
  const duration = performance.now() - start;
  console.log(`${name.padEnd(13)}${s(component, 11)}${s(cases.length, 6)}${s(measurement.setup, 9)}`
    + `${s(counter.query, 7)}${s(measurement.request, 9)}`);
  return duration;
};

console.log(`${'level'.padEnd(13)}${s('component', 11)}${s('case', 6)}${s('setup', 9)}${s('query', 7)}${s('request', 9)}`);

const unit = await measure('unit', 1, () => {
  for (const c of cases) isEligible(c);
});

const integration = await measure('integration', 2, () => {
  for (const c of cases) borrowBook(prepare(c), 'U-17', 'K-90');
});

const endToEnd = await measure('end-to-end', 3, async () => {
  for (const c of cases) {
    const server = setupServer(prepare(c));
    await new Promise((done) => server.listen(0, done));
    await fetch(`http://127.0.0.1:${server.address().port}/loan`,
      { method: 'POST', body: JSON.stringify({ memberNo: 'U-17', bookNo: 'K-90' }) });
    measurement.request += 1;
    server.close();
  }
});

const ratio = (a, b, threshold) => (a / b > threshold ? 'yes' : 'no');
console.log(`is integration duration more than 10x the unit duration: ${ratio(integration, unit, 10)}`);
console.log(`is end-to-end duration more than 100x the unit duration: ${ratio(endToEnd, unit, 100)}`);
```

```
level          component  case    setup  query  request
unit                   1    24        0      0        0
integration            2    24      168     74        0
end-to-end             3    24      168     74       24
is integration duration more than 10x the unit duration: yes
is end-to-end duration more than 100x the unit duration: yes
```

Duration depends on the machine and the load, so the output prints a ratio threshold
rather than raw milliseconds. In this run, the twenty-four cases took 0.02 ms at the
unit level, 1.85 ms at the integration level, and 31.79 ms at the end-to-end level; on
another machine all three numbers change, their ordering does not. The run-independent
quantities are in the table: the same twenty-four cases produce no query and no request
at the unit level, while the upper two levels produce 168 setup rows and 74 flow
queries, and the topmost level adds 24 network requests on top of that. The component
column is also a cost: however many parts a level requires to be up at the same time,
that many sources of failure it carries.

Repeating the same claim at three levels adds up these costs. If the twenty-four cases
were repeated at every level, that would be seventy-two tests; when the rule changes one
day, three separate files get updated and three runs get brought back to green.
Repetition catches no new defect class in return: the upper levels read the same
decision, with a transport layer stacked on top.

The shape of the pyramid is the sum of these two observations. At the lower level a case
counts as free, so the case set is consumed there. At an upper level, every case buys
setup, queries, and requests; that is why only defect classes **with no counterpart at a
lower level** are tested there — the wiring between parts, the mapping in the transport
layer, the integrity of the path the user sees. The reverse shape, consuming the case
set at the top, catches the same defects far more expensively and far more slowly.

## Summary

- The borrowing flow ran at three levels: the rule as a pure function, the record with a
  real database, the outer surface with an HTTP interface.
- Only the unit level caught the boundary comparison defect; the upper two levels stayed
  green because they ran the happy path.
- Only the end-to-end level caught the status-code mapping defect; the rule and
  repository tests never see that value.
- The cost of the twenty-four cases rose from 0 queries and 0 requests to 242 queries and
  24 requests, depending on the level; the duration ratio in this run exceeded a hundred
  times the unit level.
- The pyramid is not a counting rule but the result of two measures: the case set is
  consumed where its cost is low, and only the defect classes with no lower-level
  counterpart are left for the upper level.

## Next Step

The end-to-end tests here drove the HTTP interface: a request was sent, a status code
was read. The surface the member actually sees, though, is a page — with a search box, a
book card, and a button on it. A test that drives that surface has to say which element
to touch with a **selector** and wait for the element to be ready. The next lesson
measures these two decisions: how many structural changes a test survives when the
selector type changes, and how the wait policy changes the drop rate.
