A test strategy, not a pile of scripts
A suite of automated tests helps a team only when it is fast, stable and aimed at the risks that matter. This lesson gives you a method to decide which checks to automate, at which level, and how many minutes the whole suite may cost, using Mangrove Market's checkout as the running example.
By the end you can
- Name the four ways a test suite fails a team, and calculate how flaky tests add up
- Compare the test pyramid, the testing trophy and the honeycomb, and say what each level costs and catches
- Push each checkout risk down to the fastest level that can catch it, and decide what not to automate
- Set a budget in CI minutes and track suite time, flake rate and escaped defects
- Write a one-page test strategy for a real feature
The everyday picture
Think about how a good restaurant kitchen controls quality. The cook tastes the sauce at the stove, many times a night. It takes two seconds, and when it is wrong, the cook knows exactly which pot to fix. At the pass, the head chef looks at every finished plate: is the sauce on the right dish, is the food hot, is the side there? That takes longer, and it catches problems no single cook can see. Once a month, the owner sends a mystery diner who books a table, orders a full meal and pays. That costs a whole meal and a week to get the report, and when the report says "dinner was disappointing", nobody knows which station caused it.
No sensible kitchen replaces tasting with a hundred mystery diners. It would be slow, expensive, and every vague report would start an argument. A kitchen with only tasting would still serve cold food on the wrong table. A test strategy is the same choice for software: many fast, precise checks close to the code, a few broader checks where the pieces meet, and a small number of full journeys that see what the customer sees.
Why test suites fail teams
Most teams that start automating do it the obvious way: take the manual test cases and turn each one into a script that clicks through the app. A year later, the suite often has four problems at once.
- It is slow. A suite that takes 45 minutes does not run on every pull request. People batch their changes, so when it fails, five changes are suspects instead of one.
- It is flaky. A flaky test turns red with no bug. After a few false alarms, people press "re-run" without reading the failure.
- It tests the wrong things. Hundreds of checks that the login page has a logo, and none for the discount rule that loses money when it is wrong.
- Nobody trusts red. This is where the first three lead. When a red build is usually noise, a real failure gets merged too. The suite still costs money to run, and it protects nothing.
Flakiness grows faster than people expect, because every test gets its own chance to fail. If a test flakes in 1 run out of 100, a single test is fine. But a run is green only when every test passes, so the chance of a false red is 1 - (1 - p) ** n for n tests that each flake with probability p. With 50 such tests, about 39.5% of runs fail with no bug. With 200 tests, about 87% do. The odds multiply, so a large suite of slightly flaky end-to-end tests is red most of the time. Discipline alone cannot beat that arithmetic. You fix it with fewer tests at the flaky level and more at levels that do not flake.
Quick check
A team has 100 end-to-end tests, each flaking about 1% of the time. Roughly how often is a run red with no real bug?
The run is green only if all 100 tests pass: 0.99 to the power 100 is about 0.37, so about 63% of runs are red. Averaging the rates (1%) is the tempting mistake; the chances combine across tests.
What each level costs and what it catches
A test level is about scope: how much of the real system one test runs. Mangrove's checkout has a customer app (React Native), an order service (NestJS and PostgreSQL), and a payment provider for cards and mobile wallets. Errors, debugging and testing introduced unit and integration tests; this lesson is about choosing between the levels.
- Static checks: TypeScript and a linter read the code without running it. They catch a wrong type or a missing argument in seconds, and never flake.
- Unit tests: call one function, such as
priceOrder(lines, customer), with no network and no database. About a millisecond each. A failure names the exact rule that broke. - API tests (a kind of integration test): start the order service with a real PostgreSQL and call
POST /ordersover HTTP. Roughly 50 to 200 milliseconds each. They catch validation, SQL, transactions and the JSON the service returns. Unit and API tests builds them. - Contract tests: check that the app and the order service still agree on the shape of requests and responses, without running both together. Contract tests covers them.
- End-to-end (E2E) tests: an end-to-end test drives the real app in a browser or on a device, with the real services behind it. Often 20 to 60 seconds each. Only this level sees that the button, the API and the payment page work together. End-to-end tests with Playwright builds them.
| Level | Rough time each | When it fails, it points at | Main cost |
|---|---|---|---|
| Static | seconds, whole repo | a file and line | almost none |
| Unit | ~1 ms | one rule in one function | breaks when you change a function's design |
| API | ~50–200 ms | one endpoint and its data | a real database and clean test data |
| Contract | ~1 s | one field two teams disagree on | a pact file and a shared broker |
| E2E | ~20–60 s | "something in the journey" | environments, waits, flakiness, upkeep |
Read the table as a trade. Going up a level buys confidence that real pieces work together. You pay for it in speed, in isolation (an E2E failure could be the app, the service, the test data or the network), and in maintenance, because broad tests break whenever any part they touch changes. A bug caught by a unit test costs a millisecond and points at one line. Caught by an E2E test, it costs 40 seconds, sometimes a false alarm, and twenty minutes of digging to find the layer.
Pyramid, trophy, honeycomb
People draw the ideal mix of levels as a shape. Three shapes come up in almost every team discussion.
The test pyramid comes from Mike Cohn, who described it in his 2009 book Succeeding with Agile: a wide base of unit tests, a middle layer of service tests, and a thin top of UI tests. Its argument is cost. UI tests are slow, brittle and expensive to write, so keep them few, and get most of your checking from the cheap layer.
The testing trophy, which Kent C. Dodds drew in 2018, adds static checks as the base and makes integration tests the widest part, with a smaller unit layer and a small E2E top. His argument is confidence: tests that use your code the way users do, with fewer mocks, tell you more per test. It grew up in front-end work.
The honeycomb came from Spotify's engineering blog in 2018, for microservices. Most tests should be integration tests that run one service through its API with a real database. A few "implementation detail" tests cover code with its own complex logic. And, ideally, no "integrated" tests, meaning tests that pass or fail depending on whether another service is correct.
The shapes disagree less than they seem. Martin Fowler pointed out in 2021 that much of the argument is about what "unit" means: a test of one function that also runs its real helpers is a unit test to some people and an integration test to others. All three agree on two things: end-to-end tests stay few, and fast tests inside one deployable piece do most of the work. So pick a level for each risk, and let the shape appear by itself. Mangrove's price rules are pure logic with many combinations, so that part of checkout looks like a pyramid. The order service is mostly validation, SQL and wiring, so that part looks like a honeycomb.
Quick check
What do the pyramid, the trophy and the honeycomb all agree on?
The trophy and the honeycomb make integration tests the largest group, so "unit tests are always the largest" is the pyramid's view only. All three keep E2E small and rely on fast tests that stay inside one app or service.
Choosing a level for each risk
Start from risks. A risk is something that could go wrong and would hurt: a wrong price, a double charge, an order lost between the app and the service. For each one, ask where the bug would live, then apply one rule: push each check down to the fastest level that can catch the bug. If the bug can only exist in a pure function, a unit test can catch it, and a slower test adds nothing. If the bug needs the database or the HTTP layer, go up one level. Go to E2E only when the bug needs the real app, the real services and the real network together.
Here is how that plays out for Mangrove's checkout rules. All money is in integer cents (Working with data explains why).
- Delivery fee: 150 cents under a 2,500-cent cart, free from 2,500. This is arithmetic, so it gets unit tests at the boundaries: 2,499 and 2,500 (Designing tests that find bugs shows how to pick them).
- FRESH10: 10% off from 2,000 cents, first order only, never together with the 5% staff discount. Three conditions make many combinations. A unit test table covers them all in milliseconds; through the UI, each combination would cost a minute.
- Quantity 1 to 20 and a cart of 500 to 50,000 cents: the rule itself is a unit test. Whether
POST /ordersreally answers400forqty: 21depends on NestJS validation in the HTTP layer, so one or two API tests check that wiring. - Double tap on Pay: two identical requests must create one order. Only a real database with a unique key can prove it, so it is an API test.
- A renamed field: if the service renames
deliveryFeeCents, app versions already on customers' phones break. That is an agreement between two things released separately: a contract test. - Pay by wallet and see the confirmation: the app, the service and the payment provider's redirect all have to work together. That is one of a few E2E journeys.
The E2E layer then holds a short list of critical journeys: the paths that make money or lose trust if they break, such as sign in, add to cart, pay by card, wallet or cash on delivery, and track the order. Each journey checks the wiring once. The rules inside it are already covered below, so the journey does not need to try every discount.
Quick check
A tester wants to check that FRESH10 is refused when a staff discount is already applied. Which level fits best?
The rule lives in pure pricing logic, so a unit test can catch every way it breaks, in a millisecond, and points at the rule. The API and E2E tests would also catch it, but they pay for a database, users and screens that this bug does not need.
What to leave to people
Automation is worth it when a check will run many times, has a clear test oracle (a way to tell right from wrong without a human), and protects a real risk. When one of the three is missing, a person usually does it better and cheaper.
- No clear oracle: "Is the new slot picker easy to understand?" needs judgement. An exploratory session with a charter (Exploratory testing) finds more here than any script.
- Runs only once or twice: a one-off data migration check, or a festival banner that lives for a week. Writing and maintaining the test costs more than checking it by hand.
- Changes every sprint: a screen still being redesigned. Automate its rules now, and the UI check when the design settles.
- Outside your control: a test that passes only if the payment provider's sandbox is up is an "integrated" test in honeycomb terms. Fake the provider at the API level, and keep one E2E journey against the real sandbox, outside the pull request run if it is unreliable.
Think first: the payment provider's sandbox is down about 2% of the time. Should the wallet journey block every pull request?
Probably not. A 2% outside failure rate would block about one pull request in fifty for reasons nobody on the team can fix, which teaches people to ignore red. Keep the wallet logic in API tests with a fake provider on every pull request. Run the real-sandbox journey on a schedule and before each release, with an owner who reads its failures.
Run the suite like a product
A suite that nobody owns decays like any unowned product. Three habits keep it healthy.
Owners. Every test belongs to a team, written in the folder structure or a code owners file. When a test flakes, its owner gets the ticket, with a deadline.
Code review. Test code gets the same review as product code: clear names, no fixed waits, no shared data between tests, and a reason for its level. The question "could this be one level lower?" saves more minutes than any later speed-up.
A budget in minutes. Decide how long the pull request run may take, then spend it like money. Mangrove's target is 10 minutes. A new E2E journey that adds 40 seconds has to earn them; a new unit test table is almost free. The third diagram shows how the checkout suite spends its budget, stage by stage.
Then measure what tells you whether the suite works:
- Suite time: how long the pull request run takes, at the median and at the slow end (the 90th percentile).
- Flake rate: the share of runs where a test failed and then passed on retry with no code change. Playwright, for example, reports such tests as "flaky" when retries are on. Aim for well under 1% of runs, and fix or quarantine the worst tests first. Stable suites in CI covers how.
- Escaped defects: an escaped defect is a bug found in production. For each one, ask which level should have caught it, then add that test. The pattern of answers tells you which level is weak.
Two popular numbers say little on their own. The number of tests goes up whenever someone adds a slow, duplicated check. Coverage percentage shows which lines ran, not whether anything was checked (Errors, debugging and testing showed why). Both are fine as a map of blind spots. As targets, they reward the wrong work: teams write tests that execute code and assert nothing.
Quick check
After a quarter of automation, which result best shows the checkout suite is working?
Speed, trust and bugs that reach customers are what the suite is for. More tests and higher coverage can both rise while the suite gets slower and catches less.
Build it
Write the test strategy for Mangrove's checkout: map 12 risks to levels, estimate the tests and minutes, make the budget a hard limit in CI, and write the one-page strategy. The next five lessons build each level.
-
Map the risks to levels
List what could go wrong, where the bug would live, and the lowest level that can catch it. Keep it in the repository as
docs/test-strategy.md, so changes to it get reviewed like code.Markdown| # | Risk (what hurts if it breaks) | Bug lives in | Level | Tests | |----|---------------------------------------------------|-------------------------|-------------|-------| | 1 | Delivery fee wrong at 2,499 / 2,500 cents | priceOrder() | Unit | 6 | | 2 | FRESH10: from 2,000, first order, not with staff | priceOrder() | Unit | 16 | | 3 | Staff 5% rounds to the wrong cent | priceOrder() | Unit | 6 | | 4 | Cart 500-50,000 cents, qty 1-20 not enforced | validateCart() | Unit | 10 | | 5 | POST /orders accepts a bad body (no 400) | DTO + ValidationPipe | API | 8 | | 6 | Order row or lines saved wrong in PostgreSQL | OrdersService + SQL | API | 6 | | 7 | Double tap on Pay creates two orders | idempotency key + index | API | 3 | | 8 | Cancel allowed after picking started | cancel rules + status | API | 6 | | 9 | Payment callback does not mark the order paid | webhook handler | API (fake) | 5 | | 10 | Renamed field breaks app versions in use | response shape | Contract | 4 | | 11 | Sign in, cart, slot, pay (card/wallet/COD), track | app + API + provider | E2E | 5 | | 12 | Slot picker confusing on small phones | design | Exploratory | 0 | -
Estimate the minutes
Use rough per-test times and the setup each stage needs. Stages run one after another; inside a stage, tests are shared across workers. The API and contract stages use one shared database here, so they run on one worker.
OutputStage tests each workers setup wall-clock install - - - 90 s 90 s static - - - - 60 s (tsc + eslint) unit 38 ~5 ms 2 15 s ~15 s (19 rounds x 5 ms + start) api 28 ~150 ms 1 40 s ~44 s (Postgres container + app start) contract 4 ~1 s 1 16 s 20 s e2e 5 ~40 s 2 60 s 180 s (3 rounds x 40 s + start) ------------------------------------------------------------- total ~409 s = about 6.8 min budget 10 min: OK If the 140 recorded UI scripts had stayed: e2e 140 ~30 s 4 60 s 35 rounds x 30 s + 60 s = 1,110 s = 18.5 min over budgetThe numbers are estimates, and that is fine. Their job is to show early that a plan cannot fit, before anyone writes 140 browser tests. The first practice task turns this table into a function.
-
Make the budget a hard limit
A budget that only lives in a document drifts. Put it in the tools. In GitHub Actions,
timeout-minutesstops a job that runs too long (GitHub Actions: continuous integration covers the rest of the workflow). In Playwright,globalTimeoutcaps the whole E2E run, andforbidOnlyfails CI if someone left atest.onlybehind.TypeScript// playwright.config.ts: the E2E stage's share of the budget import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './e2e', workers: process.env.CI ? 2 : undefined, // matches the estimate retries: process.env.CI ? 1 : 0, // a pass on retry is reported as flaky, not hidden forbidOnly: !!process.env.CI, // a forgotten test.only fails the run globalTimeout: 4 * 60 * 1000, // milliseconds: the E2E stage gets 4 of the 10 minutes reporter: [['html'], ['junit', { outputFile: 'results/e2e-junit.xml' }]], projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], });YAML# Only the parts that enforce the budget; checkout and setup steps as in dw-16 jobs: checkout-suite: runs-on: ubuntu-latest timeout-minutes: 12 # 10-minute budget plus room for the runner itself steps: # ... checkout, setup-node with npm cache, npm ci ... - run: npm run typecheck && npm run lint - run: npx vitest run # unit tests (*.spec.ts) - run: npx vitest run --config ./vitest.config.api.ts # API tests, starts PostgreSQL in a container - run: npm run test:contract - run: npx playwright testThe two Vitest runs assume unit tests named
*.spec.tsand a separate config for the API tests; Unit and API tests sets them up, and Stable suites in CI splits this job into parallel jobs with reports. -
Write the one-page strategy
One page, so people read it. Each heading answers a question someone will ask in a review.
Markdown# Checkout test strategy (owner: checkout team, reviewed each quarter) ## Scope and risks Customer app checkout and the order service (POST /orders, GET /orders/:id, POST /orders/:id/cancel). Top risks: wrong price, double order, lost payment, app and service out of step. Full risk map: table above. ## Levels and what each owns - Unit: every price and cart rule, as test tables from boundary values - API: validation, persistence, cancel rules, idempotency; real PostgreSQL, payment provider faked - Contract: every field the app reads from the order service - E2E: 5 journeys (card, wallet, COD, cancel, track); no rule checks here - Exploratory: 1 hour per release on new screens, small phones, slow data ## Budget Pull request run under 10 min (today about 7). A new E2E journey needs a reviewer's yes and must say which risk no lower level can catch. ## Data and environments Each test creates its own customer and cart with unique ids. E2E runs against a preview deployment with seeded data, never against shared staging data. ## Ownership and flaky tests Each folder has an owning team. A flaky test is quarantined within a day, with an owner and a fix-by date; quarantine never lasts past two weeks. ## Metrics (shown on the team dashboard) Suite time (median, p90), flake rate (target under 1% of runs), escaped checkout defects per month, each tagged with the level that should have caught it. ## Not automated Visual polish, copy, festival banners, one-off migrations.
Bring it to your team
Ask these in planning, in pull request reviews and when a bug escapes.
- Which risk does this test protect, and could a lower, faster level catch the same bug?
- How long does our pull request run take today, at the median and the slow end, and what is our budget?
- What is our flake rate this month, and who owns each test in quarantine?
- For the last three bugs that reached customers, which level should have caught them?
- Which E2E journeys do we have, and does each one check wiring instead of repeating rules covered below?
- What have we decided not to automate, and who checks it by hand before a release?
Red flags
- Progress reported as "manual cases converted" or "number of tests".
- A coverage target that rises every quarter while escaped defects stay flat.
- "It's flaky, just re-run it" with no ticket and no owner.
- E2E tests that try every discount code through the UI.
- A suite that runs only at night, so failures arrive the next morning with ten suspects.
Practice
Will the suite fit in ten minutes?
Write planSuite(stages, workers, budgetMinutes = 10). Each stage is { level, count, msEach, setupMs, parallel }. Stages run one after another. Inside a stage, tests of equal length are shared across workers, and each worker runs its share one test after another, so the stage takes setupMs + Math.ceil(count / workers) * msEach. A stage with parallel: false runs on one worker. Return { stages: [{ level, ms }], totalMs, withinBudget }, where withinBudget is true when totalMs is at most the budget (in minutes).
function planSuite(stages, workers, budgetMinutes = 10) {
// Starter: adds up every test as if one machine ran them all, and forgets setup
const result = stages.map((s) => ({ level: s.level, ms: s.count * s.msEach }));
const totalMs = result.reduce((sum, s) => sum + s.ms, 0);
return { stages: result, totalMs, withinBudget: totalMs < budgetMinutes };
}
const checkout = [
{ level: 'install', count: 0, msEach: 0, setupMs: 90000 },
{ level: 'static', count: 1, msEach: 60000, setupMs: 0 },
{ level: 'unit', count: 38, msEach: 5, setupMs: 15000 },
{ level: 'api', count: 28, msEach: 150, setupMs: 40000, parallel: false },
{ level: 'contract', count: 4, msEach: 1000, setupMs: 16000, parallel: false },
{ level: 'e2e', count: 5, msEach: 40000, setupMs: 60000 },
];
test('a stage is shared across workers: 5 journeys of 40 s on 2 workers take 3 rounds (120 s) plus 60 s setup', () => {
const plan = planSuite([{ level: 'e2e', count: 5, msEach: 40000, setupMs: 60000 }], 2);
expect(plan.stages[0]).toEqual({ level: 'e2e', ms: 180000 });
});
test('a stage with parallel: false runs on one worker, whatever workers says', () => {
const plan = planSuite([{ level: 'api', count: 28, msEach: 150, setupMs: 40000, parallel: false }], 4);
expect(plan.stages[0].ms).toBe(44200);
});
test('stages run one after another, so totalMs is their sum (checkout plan: 409295 ms)', () => {
const plan = planSuite(checkout, 2);
expect(plan.stages.map((s) => s.level)).toEqual(['install', 'static', 'unit', 'api', 'contract', 'e2e']);
expect(plan.totalMs).toBe(409295);
});
test('the budget is in minutes: about 6.8 minutes fits in 10, but not in 6', () => {
expect(planSuite(checkout, 2).withinBudget).toBe(true);
expect(planSuite(checkout, 2, 6).withinBudget).toBe(false);
});
test('exactly on budget counts as within it', () => {
const plan = planSuite([{ level: 'e2e', count: 1, msEach: 540000, setupMs: 60000 }], 1, 10);
expect(plan.totalMs).toBe(600000);
expect(plan.withinBudget).toBe(true);
});
Show a solution
function planSuite(stages, workers, budgetMinutes = 10) {
const result = stages.map((s) => {
const w = s.parallel === false ? 1 : workers; // shared database: one worker
const rounds = Math.ceil(s.count / w); // each worker runs its share in turn
return { level: s.level, ms: s.setupMs + rounds * s.msEach };
});
const totalMs = result.reduce((sum, s) => sum + s.ms, 0);
return { stages: result, totalMs, withinBudget: totalMs <= budgetMinutes * 60 * 1000 };
}How many flaky tests can you afford?
Write two functions. falseRedRate(flakeRates) takes each test's chance of flaking (0 to 1) and returns the chance that a run is red with no bug, meaning at least one test flakes. maxTestsFor(p, maxRedRate) returns the largest number of tests, each flaking with chance p, that keeps the false-red rate at or below maxRedRate. A test with p of 0 never flakes, so the answer is Infinity.
function falseRedRate(flakeRates) {
// Starter: "1% each, so about 1% overall"
if (flakeRates.length === 0) return 0;
return flakeRates.reduce((sum, p) => sum + p, 0) / flakeRates.length;
}
function maxTestsFor(p, maxRedRate) {
// Starter: assumes the risk grows in a straight line
return Math.floor(maxRedRate / p);
}
test('one test that flakes 1% of the time gives a 1% false-red rate', () => {
expect(falseRedRate([0.01])).toBeCloseTo(0.01, 6);
});
test('50 tests at 1% each make about 39.5% of runs red: the chances multiply, they do not average', () => {
expect(falseRedRate(new Array(50).fill(0.01))).toBeCloseTo(0.395, 3);
});
test('mixed rates [0.05, 0.02, 0] give 1 - 0.95 x 0.98 = 0.069', () => {
expect(falseRedRate([0.05, 0.02, 0])).toBeCloseTo(0.069, 6);
});
test('at 2% flakiness, 34 tests keep false reds at or below 50% (a straight line says 25)', () => {
expect(maxTestsFor(0.02, 0.5)).toBe(34);
});
test('at 0.1% flakiness, 51 tests stay at or below 5%; a test that never flakes has no limit', () => {
expect(maxTestsFor(0.001, 0.05)).toBe(51);
expect(maxTestsFor(0, 0.05)).toBe(Infinity);
});
Show a solution
function falseRedRate(flakeRates) {
// green only if every test passes: multiply the chances of passing
const allPass = flakeRates.reduce((prod, p) => prod * (1 - p), 1);
return 1 - allPass;
}
function maxTestsFor(p, maxRedRate) {
if (p === 0) return Infinity;
let n = 0;
while (1 - Math.pow(1 - p, n + 1) <= maxRedRate) n++;
return n;
}Place three new risks
The checkout team adds three features. For each, name the lowest level that can catch the main risk, and say in one sentence why a lower level cannot. (a) A new rule: cash on delivery is refused for carts over 30,000 cents. (b) The order service starts returning slotLabel instead of slotName, and the app shows the slot on the confirmation screen. (c) A new "repeat last order" button on the home screen that fills the cart through the app and the API.
- (a) is a unit test of the payment and cart rules, with boundary values at 30,000 and 30,001 cents
- (b) is a contract test, because app versions already installed still read the old field
- (c) gets an API test for the new endpoint, and at most one E2E journey for the button's wiring
- Each answer explains why the level below cannot see the bug
Show a model answer
(a) Unit test. The rule is pure logic on the cart total and the payment method, so table rows for 30,000 and 30,001 cents with each payment method catch every way it can break. Static checks cannot know a business rule. Add one API test only if the rule is enforced in a validation layer outside the pure function.
(b) Contract test. The bug is a disagreement between two things released separately: the service changes on Tuesday, while app versions from months ago stay installed. A unit or API test of the service alone passes, because the service is internally consistent. A contract test from the app's side fails the moment the field disappears.
(c) An API test for the "repeat last order" endpoint (right lines, prices recalculated, out-of-stock items handled), because that is where the logic lives. Then at most one E2E journey that taps the button and sees a filled cart, because only the full stack shows that the button calls the right endpoint. The rules inside stay out of the journey.
Check yourself
A team's E2E suite takes 50 minutes and is red on most nights. A manager proposes buying faster CI machines. What should the team try first?
Most of the time and almost all the flakiness come from checks that sit too high. Pushing them down fixes both. Faster machines keep the flakiness, and blanket retries hide real failures. Deleting all E2E tests loses the only check of the full wiring.
Two senior engineers argue: one wants a pyramid, the other a honeycomb for the order service. Reading their tests, you see both write the same thing: tests that start the service with a real database and call its endpoints. What is going on?
Much of the shape debate comes from different meanings of "unit". If both write fast tests of one service through its API, they agree in practice. The honeycomb was in fact drawn for microservices; the trophy is the one that grew up in front-end work.
In one month, three checkout bugs reach customers: a rounding error in the staff discount, a missing 400 for qty: 0, and an old app version crashing on a renamed field. What does this pattern tell you?
Tagging each escaped defect with the level that should have caught it turns a bad month into a specific to-do list. More E2E journeys would be the slowest way to catch any of the three, and coverage cannot see a missing assertion or a field another app depends on.
Flashcards
Key takeaways
- Suites fail teams by being slow, flaky and aimed at the wrong things, until nobody trusts red. Flakiness multiplies with the number of tests.
- Each level trades speed, isolation and upkeep for confidence. The pyramid, trophy and honeycomb all keep E2E few.
- Start from risks, push each check to the fastest level that can catch it, and leave judgement calls to people.
- Run the suite like a product: owners, code review and a budget in minutes, enforced in CI.
- Watch suite time, flake rate and escaped defects; treat test count and coverage as maps of blind spots.
Sources
- Test Pyramid, Martin Fowler, 2012
- The Practical Test Pyramid, Ham Vocke on martinfowler.com, 2018
- The Testing Trophy and Testing Classifications, Kent C. Dodds, 2021
- Testing of Microservices, André Schaffer, Spotify Engineering, 2018
- On the Diverse And Fantastical Shapes of Testing, Martin Fowler, 2021
- Just Say No to More End-to-End Tests, Mike Wacker, Google Testing Blog, 2015
- Retries and TestConfig, Playwright documentation