The Case for a Testing Trophy, Not a Pyramid
In an interview with J.B. Rainsberger on TestingJavaScript.com, he offered a memorable metaphor for test strategy: throwing paint at a wall might cover most of it, but you'll never hit the corners without a brush. The point is that different testing tools exist for different jobs—choosing the right one is a matter of matching the tool to the task.
This thinking led to the Testing Trophy, a concept that visualizes the recommended focus and trade-offs of four test types. The trophy also demonstrates a subtle but critical point: while the classic testing pyramid favors a broad base of unit tests, a balanced strategy gives more weight to integration tests, which offer more confidence per test than lower-level tests alone.
The four levels of the trophy define the test types, from most to least comprehensive:
- End to End (E2E): A helper robot that behaves like a user to click around the app and verify that it functions correctly. Sometimes called "functional testing" or e2e.
- Integration: Verifies that several units work together in harmony.
- Unit: Verifies that individual, isolated parts work as expected.
- Static: Catches typos and type errors as you write the code.
The relative size of each level is a guide to how much effort you should invest in each type, in general.
Testing Levels in Practice
End to End
E2E tests run the entire application—both frontend and backend—and interact with it just like a user would, typically using a tool like Cypress. These tests are the closest to real user behavior.
import { generate } from 'todo-test-utils'
describe('todo app', () => {
it('should work for a typical user', () => {
const user = generate.user()
const todo = generate.todo()
// here we're going through the registration process.
// I'll typically only have one e2e test that does this.
// the rest of the tests will hit the same endpoint
// that the app does so we can skip navigating through that experience.
cy.visitApp()
cy.findByText(/register/i).click()
cy.findByLabelText(/username/i).type(user.username)
cy.findByLabelText(/password/i).type(user.password)
cy.findByText(/login/i).click()
cy.findByLabelText(/add todo/i)
.type(todo.description)
.type('{enter}')
cy.findByTestId('todo-0').should('have.value', todo.description)
cy.findByLabelText('complete').click()
cy.findByTestId('todo-0').should('have.class', 'complete')
// etc...
// My E2E tests typically behave similar to how a user would.
// They can sometimes be quite long.
})
})
Integration
Integration tests render components with the real providers used in the app (the `render` method from a custom `test/app-test-utils` module handles this). The principle is to mock as little as possible—typically only:
- Network requests, using MSW.
- Components responsible for animation.
These tests often have global configuration, such as automatically resetting all mocks between tests, to keep them reliable. A test-utils file can be set up following the React Testing Library setup docs.
import * as React from 'react'
import { render, screen, waitForElementToBeRemoved } from 'test/app-test-utils'
import userEvent from '@testing-library/user-event'
import { build, fake } from '@jackfranklin/test-data-bot'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { handlers } from 'test/server-handlers'
import App from '../app'
const buildLoginForm = build({
fields: {
username: fake((f) => f.internet.username()),
password: fake((f) => f.internet.password()),
},
})
// integration tests typically only mock HTTP requests via MSW
const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterAll(() => server.close())
afterEach(() => server.resetHandlers())
test(`logging in displays the user's username`, async () => {
// The custom render returns a promise that resolves when the app has
// finished loading (if you're server rendering, you may not need this).
// The custom render also allows you to specify your initial route
await render(<App />, { route: '/login' })
const { username, password } = buildLoginForm()
userEvent.type(screen.getByLabelText(/username/i), username)
userEvent.type(screen.getByLabelText(/password/i), password)
userEvent.click(screen.getByRole('button', { name: /submit/i }))
await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i))
// assert whatever you need to verify the user is logged in
expect(screen.getByText(username)).toBeInTheDocument()
})
Unit
Unit tests isolate a single function or component, mocking its dependencies to focus on its own behavior. They're the bread and butter of the testing pyramid.
import '@testing-library/jest-dom/extend-expect'
import * as React from 'react'
// if you have a test utils module like in the integration test example above
// then use that instead of @testing-library/react
import { render, screen } from '@testing-library/react'
import ItemList from '../item-list'
// Some people don't call these a unit test because we're rendering to the DOM with React.
// They'd tell you to use shallow rendering instead.
// When they tell you this, send them to https://kcd.im/shallow
test('renders "no items" when the item list is empty', () => {
render(<ItemList items={[]} />)
expect(screen.getByText(/no items/i)).toBeInTheDocument()
})
test('renders the items in a list', () => {
render(<ItemList items={['apple', 'orange', 'pear']} />)
// note: with something so simple I might consider using a snapshot instead, but only if:
// 1. the snapshot is small
// 2. we use toMatchInlineSnapshot()
// Read more: https://kcd.im/snapshots
expect(screen.getByText(/apple/i)).toBeInTheDocument()
expect(screen.getByText(/orange/i)).toBeInTheDocument()
expect(screen.getByText(/pear/i)).toBeInTheDocument()
expect(screen.queryByText(/no items/i)).not.toBeInTheDocument()
})
Everyone calls this a unit test, and they're right.
// pure functions are the BEST for unit testing and I LOVE using jest-in-case for them!
import cases from 'jest-in-case'
import fizzbuzz from '../fizzbuzz'
cases(
'fizzbuzz',
({ input, output }) => expect(fizzbuzz(input)).toBe(output),
[
[1, '1'],
[2, '2'],
[3, 'Fizz'],
[5, 'Buzz'],
[9, 'Fizz'],
[15, 'FizzBuzz'],
[16, '16'],
].map(([input, output]) => ({
title: `${input} => ${output}`,
input,
output,
})),
)
Static
Static analysis tools run while you code and catch typos and type errors before your tests ever execute.
// can you spot the bug?
// I'll bet ESLint's for-direction rule could
// catch it faster than you in a code review 😉
for (var i = 0; i < 10; i--) {
console.log(i)
}
const two = '2'
// ok, this one's contrived a bit,
// but TypeScript will tell you this is bad:
const result = add(1, two)
The Real Goal: Confidence
Why do we write tests? Not just to satisfy a PR check, but to gain confidence—the assurance that a current change won't break a feature in production. The choice of which tests to write, and how many, should be driven by how much confidence each test provides relative to its cost.
The Trade-off of Every Test Level
Every test type comes with a balance of three key trade-offs, visualized with arrows moving up the trophy.
Cost
Higher on the trophy means higher cost. This isn't just the actual money to run tests in a CI environment, but also the engineering time to write and maintain them. Tests with more points of failure are more likely to break, requiring more time to analyze and fix them.
Speed
Higher on the trophy typically means slower tests. A test running more actual code (like a full app) takes longer than a unit test that mocks thousands of lines of dependencies with only a few.
Confidence
The crucial counterbalance to cost and speed. If those were the only factors, unit tests would dominate. However, the key principle is: the more your tests resemble the way your software is used, the more confidence they can give you. Lower-level tests simply can't cover everything. Static analysis can't test business logic, and unit tests can't verify proper interaction with a real dependency.
Conversely, higher-level tests can be overkill for edge cases. A full E2E test for a form validation edge case is a lot of setup when an integration test would do; an integration test for a coupon calculator is similarly disproportionate when a unit test is more direct.
The higher up the trophy, the more confidence each test provides—but the less code it covers relative to its cost, requiring more tests to reach the same level of coverage. Each level covers a different category of bugs.
In the end, the boundary between test types is less important than the outcome. Whether you call a test "integration" or "unit," the only question that matters is: are you confident that shipping your changes satisfies the business requirements? Using a mix of static, unit, integration, and E2E tests—each at its proper scale—is how we get there.



