Contract and visual regression tests
Tests that guard the edges between teams: schema checks and consumer-driven contracts stop an API change that would break a client, and visual tests catch pixel changes nobody meant, from one button to a whole design system.
Two bugs no other test caught
Picture a travel booking site on a Friday afternoon. Two teams ship small changes. Both changes pass every unit test, every integration test, and the five end-to-end tests that run on each pull request. By Monday, both have hurt real users.
The first change is on the backend. The bookings team renames the field price to totalPrice in the GET /bookings/:id response, and updates its own tests. The web app still reads booking.price, gets undefined, and shows "NaN" on the checkout page. Its tests pass too, because they use a hand-written fake response that still has price.
The second change is in the shared design system. A developer changes the default line-height of the Text component from 1.5 to 1.4 to tighten a marketing page. Every component test passes: the text is there, the roles are right, the clicks work.
But on the checkout page, the price line now sits 3 pixels higher, and the "Pay now" button wraps its label onto two lines in German. Nobody looked at the German page.
Why the usual tests miss them
Both bugs live at an edge, where one team's output becomes another team's input: the API between backend and frontend, and the screen between your CSS and the user's eyes. Unit and integration tests fake the other side. End-to-end tests cross the edges, but they are few, and they check text and behavior, not pixels.
- A fake drifts from the real thing. A test that mocks the API checks your code against your memory of the API. When the real API changes, the fake does not.
- Assertions do not see layout.
expect(button).toBeVisible()passes when the button overlaps the footer or has white text on white. DOM tests read the tree, not the rendered picture. - Nobody checks every combination. 120 components, times 3 themes, 4 viewports, and 2 text directions, is 2,880 screens. No person looks at that on every pull request.
Two families of tests, one idea
This lesson covers two families of tests that guard those edges. They share one idea: write down what the other side expects, as a file in version control, and fail the build when a change breaks it.
- Contract and schema tests guard the API edge. A schema check compares the new API description (an OpenAPI file or a GraphQL schema) with the old one and flags breaking changes. A consumer-driven contract goes further: each client records the exact requests it sends and the fields it reads, and the provider proves it still serves them.
- Visual regression tests guard the screen edge. They screenshot a page or component, compare it with an approved baseline image, and show a person the difference to decide whether it was meant.
The [API contracts and errors](/system-design/concepts/api-contracts-and-errors) lesson covered the basics: what a contract is, a first Pact test, can-i-deploy, and runtime validation with zod. This lesson goes further: running these tests across teams and in CI, keeping them fast and quiet, reviewing their output, and paying for them. It closes the testing module, after [testing strategy](/system-design/concepts/testing-strategy), [unit and integration tests](/system-design/concepts/unit-and-integration-tests), and [end-to-end tests](/system-design/concepts/e2e-tests).
The mental model: a recorded expectation and a judge
Think of a tailor who keeps a paper pattern of a customer's suit. Every new suit is laid on the pattern before it leaves the shop, so a sleeve 2 cm short shows at once, with no customer in the room. Contract and visual tests work the same way: the pattern is a file, and the build lays every change on it.
Every test in this lesson has the same three parts, whatever the tool:
- A recorded expectation. A file that says what is correct today: the published schema or the consumer's contract file for an API, the baseline screenshot for a screen. It lives in git or in a service, with a version and an owner.
- A comparison. A program that checks the new output against the recorded one:
oasdiffcompares two OpenAPI files, a Pact verifier replays requests against the real API, a pixel diff compares two images. - A judge. Something that decides whether a difference is allowed. For schemas, rules decide (removing a field is breaking, adding one is not). For screenshots, a person decides, because only a person knows if a 3 pixel shift was the goal or a bug.
Rules judge contracts, people judge pixels
This is the biggest difference between the two families, and it drives how you run them. A machine can judge a schema change with near certainty: if a field the client reads is gone, the change is breaking. So contract checks can block a merge on their own.
A screenshot difference cannot be judged by a machine alone. A new brand color is a huge diff and a correct change; a 1 pixel gap between a label and its input is a tiny diff and maybe a bug. So visual tests open a review, and a person approves or rejects each change. The approved image becomes the new baseline.
| Question | Contract and schema tests | Visual regression tests |
|---|---|---|
| What is recorded? | An OpenAPI or GraphQL schema, or a consumer's contract file | A baseline PNG per story, page, browser, and viewport |
| Who compares? | A schema diff tool or a contract verifier | An image diff, pixel by pixel or perceptual |
| Who judges? | Rules: breaking or not | A person: intended or not |
| How fast? | Seconds, no browser | About a second per screenshot, in a real browser |
| What makes it noisy? | Rarely noisy; wrong rules or stale contracts | Fonts, anti-aliasing, animations, live data |
| Typical result | Merge blocked with a list of breaking changes | A review page with before, after, and diff images |
Check your understanding · Exercise 1
Which of these differences can a machine safely judge on its own, with no person approving? Select all that apply.
Schema checks in CI
A schema check is the cheapest contract test there is. Most APIs already publish a description of themselves: an OpenAPI file for a REST API, or the schema of a GraphQL API. A schema check compares the description on the pull request with the one on the main branch, lists every change, and fails the build if any change would break an existing client.
It needs no browser, no running server, and no work from the client teams, and it runs in seconds. That makes it the first thing to add, before consumer-driven contracts.
What counts as breaking
The rule is about direction. A client sends requests and reads responses, so a change is breaking if it asks more of requests or promises less in responses. Making requests looser or responses richer is safe.
| Change | Breaking? | Why |
|---|---|---|
| Remove or rename a response field | Yes | A client that reads it gets undefined |
| Make a response field optional or nullable | Yes | A client that assumed it is always there can crash on null |
Change a field's type, number to string | Yes | Math and formatting break, often silently |
| Add a new required request field or parameter | Yes | Old clients do not send it and get a 400 |
| Add a value to a response enum | Often | A switch with no default case misses it; tools differ on warning or failing |
| Add an optional response field | No | Clients ignore fields they do not read |
| Add an optional request parameter | No | Old clients just do not send it |
| Make a required request field optional | No | Old clients still send it |
The enum row is the tricky one. Adding "refunded" to a status enum looks safe, but a client with switch (status) and no default branch will render nothing for that booking. Some tools, such as GraphQL Inspector, put this in a middle class, "dangerous", that shows up in review without blocking.
OpenAPI diff in a pull request
For REST, an open source tool such as oasdiff compares two OpenAPI files and sorts each change into errors (definitely breaking), warnings (possibly breaking), and info. A CI job takes the main branch's file, generates the new one, and runs the diff. The job fails on errors and prints the warnings into the pull request.
# CI step on every pull request to the API repo
git show origin/main:openapi.yaml > base.yaml
npm run generate:openapi # writes openapi.yaml from the code
oasdiff breaking base.yaml openapi.yaml --fail-on ERR
# example output
# error [response-property-removed] at openapi.yaml
# in API GET /bookings/{id}
# removed the required property 'price' from the response
# with the '200' status
Two details make this work. The schema must be generated from the code (or the code from the schema): a hand-edited YAML file drifts and gives false comfort. And the frontend must consume the same file, with types generated by a tool such as openapi-typescript, so its build breaks on the same change.
GraphQL schema checks with real usage
GraphQL has an advantage: every client query lists exactly the fields it reads. So a GraphQL schema check can ask more than "is this change breaking in theory?". It can ask "does any real client query use the field I am removing?".
- Static diff. A tool such as GraphQL Inspector compares two schema files and labels each change breaking, dangerous, or safe.
- Diff against known operations. Collect every query the frontend apps ship (many teams extract them at build time as persisted queries) and check the new schema against that list. A field that no shipped query reads can be removed, even though the static diff calls it breaking.
- Diff against traffic. Hosted platforms such as Apollo GraphOS check a schema change against the operations seen in production over a recent window. This catches old mobile app versions still in use, which a list from the current code would miss.
Making a breaking change safely
Sometimes you must rename price to totalPrice. The check does not forbid that; it forces you to do it in steps, a pattern called expand and contract.
- Expand. Add
totalPricenext topriceand markpriceasdeprecated: truein the schema. The check passes, because adding is safe. - Migrate. Every client switches to
totalPriceand ships. Usage data or the contract broker shows when nobody readspriceany more. - Contract. Remove
price. The check flags it as breaking, so you add an explicit, reviewed exception (an ignore file or an approval step, depending on the tool) that links to the usage data.
Check your understanding · Exercise 2
Your schema check runs on the API's pull requests. Which of these changes should it fail? Select all that apply.
Consumer-driven contracts across teams
A schema check knows what the API promises, but not what each client uses. A consumer-driven contract adds that: each client records the requests it makes and the fields it reads, and the provider proves on every change that it still serves them. The [API contracts lesson](/system-design/concepts/api-contracts-and-errors) showed one Pact test; this chapter runs it across five teams without it becoming a chore.
The cast
- Consumers: the web app, the iOS app, the Android app, a backend-for-frontend, each with its own repo and release schedule.
- Provider: the bookings API. One repo, deploys several times a day.
- The broker: a shared service (the open source Pact Broker, or the hosted PactFlow) that stores every contract, every verification result, and which version of each app is deployed where.
can-i-deploy reads the stored results.The loop, step by step
- The consumer records. The web app's tests call a Pact mock server, and each test records one interaction: the request, a provider state ("booking 42 exists"), and the minimal response shape it needs.
- The consumer publishes. CI uploads the contract to the broker, tagged with the git commit and branch:
web-appversiona1b2c3, branchmain. - The broker triggers the provider. If the contract changed, a broker webhook starts a verification job in the provider's CI.
- The provider verifies. The provider's CI starts the real API with a test database, replays each request, checks each response, and publishes the result:
bookings-api f9e8d7verifiesweb-app a1b2c3. - Both sides ask before deploying. Each deploy job runs
can-i-deploy, which checks the verification results against the versions already in that environment. If any pair is unverified or failed, the deploy stops. - Both sides record deployments. After a deploy, the job tells the broker that
web-app a1b2c3is now in production, so the nextcan-i-deploychecks against the right versions.
# in the web app's deploy job, before deploying
pact-broker can-i-deploy \
--pacticipant web-app --version $GIT_SHA \
--to-environment production
# after a successful deploy
pact-broker record-deployment \
--pacticipant web-app --version $GIT_SHA \
--environment production
Provider states: the part that takes work
A recorded interaction says "given booking 42 exists, GET /bookings/42 returns a booking with a numeric price". The provider must be able to make that true on demand. So the provider writes a small state handler for each named state: a function that seeds the test database with booking 42.
This is where most of the cost lives: ten consumers can name 60 states between them. Agree on a short shared list ("a confirmed booking exists", "the user has no bookings") so the provider team is not writing seed functions all week.
When a consumer needs something new
Say the web app starts reading a new field, cancellationDeadline, that the API has not built yet. The new contract would fail provider verification and block the API team's builds, which is backwards: the API team did nothing wrong.
Pact brokers handle this with pending pacts, which the provider turns on in its verification settings. A contract the provider has never passed is marked pending, so its failure is reported but does not fail the provider's build. Once the provider passes it, it becomes a normal, blocking contract. Meanwhile, can-i-deploy still stops the web app from deploying code that reads a field production does not send.
Decision Which contract approach should a team adopt?
What we pick: Start with schema checks on every API: they are cheap and catch most breaks. Add consumer-driven contracts where several independently deployed clients share one API and a break is expensive, such as checkout or a mobile app. A single team shipping the API and the web app together from one repo usually needs only shared generated types.
Check your understanding · Exercise 3
The web app publishes a contract that reads a new field the bookings API has not built. What should happen? Select all that apply.
Screenshot diffing: how the comparison works
A visual test does four things: render a page or component in a real browser, take a PNG screenshot, compare it with the approved baseline PNG, and fail if they differ by more than a set amount. The hard part is the comparison, because two screenshots of the same unchanged button are often not identical, pixel for pixel.
Pixel diff with a color threshold
The simplest comparison walks both images pixel by pixel and computes a color distance for each pair. A pixel whose distance is above a per-pixel threshold counts as changed and is painted red in a diff image. If more pixels changed than the pixel budget allows, the test fails.
Popular libraries such as pixelmatch, and Playwright's built-in comparison, measure the distance in the YIQ color space, which weights brightness the way eyes do, so a small change in blue counts for less than the same change in green. Playwright's toHaveScreenshot takes the two knobs directly. threshold runs from 0 (strict) to 1 (lax) and defaults to 0.2. maxDiffPixels or maxDiffPixelRatio sets the budget, which defaults to zero, so by default any changed pixel fails the test.
Anti-aliasing: the main source of noise
Anti-aliasing is how a screen draws a smooth curve or a diagonal edge on a grid of square pixels: the edge pixels get in-between colors, part text and part background. Text gets the most of it. The exact in-between colors depend on the font file, the font rendering engine, the operating system, the GPU, and the screen's pixel ratio.
So the same Button with the same CSS, captured on a Mac laptop and on a Linux CI machine, differs along the edge of every letter. For a 120 by 40 button with a 6 letter label, that can be a few hundred pixels, every one of them noise.
Diff libraries fight this in two ways. pixelmatch tries to detect anti-aliased pixels (an edge pixel with both brighter and darker neighbors) and, by default, leaves them out of the count. And the per-pixel threshold lets small color shifts pass. Both help, and both can hide real bugs: a 1 pixel border that turned a shade darker looks a lot like anti-aliasing.
Perceptual diff
A perceptual diff asks whether a person would notice. Instead of single pixels, it compares small windows of the image for brightness, contrast, and structure; a well-known measure of this kind is SSIM, the structural similarity index. A blurry edge that moved by a fraction of a pixel scores as "the same"; a gap that opened between two boxes scores as "different".
Hosted visual testing services advertise their own perceptual or AI-assisted comparisons. Their methods are not public, so judge them on your own screenshots during a trial, not on their claims.
| Question | Pixel diff with threshold | Perceptual diff |
|---|---|---|
| What it compares | Each pixel's color, one at a time | Small windows: brightness, contrast, structure |
| Ignores anti-aliasing | Partly, with detection and a threshold | Mostly, by design |
| Catches a 1 px border color change | Yes, if threshold is low | Sometimes not |
| Explaining a failure | Easy: these red pixels changed | Harder: a score went below a cut-off |
| Where you find it | Playwright, pixelmatch, most open source tools | Some open source options, most paid services |
Kill the noise at the source
Tuning thresholds is the last step, not the first. Every bit of noise you remove at the source lets you keep thresholds strict, and strict thresholds catch real bugs. In order of payoff:
- One rendering machine. Take every screenshot, locally and in CI, inside the same Docker image, with the same browser build and the same fonts installed. Playwright publishes official images for this. Baselines made on a developer's Mac rarely match a Linux CI run, pixel for pixel.
- Fonts loaded before capture. Wait for
document.fonts.ready, and self-host the font files so a slow font CDN cannot swap in a fallback font mid-capture. - Nothing that moves. Turn off CSS animations and transitions, hide the text caret, and pause videos and carousels. Playwright's
animations: "disabled"does much of this. - Nothing that changes. Fix the clock, seed random values, and serve fixed API data.
- Mask what you cannot freeze. Paint a solid box over ads, maps, user avatars, and third-party embeds with the
maskoption. A masked area never counts as changed, so mask as little as you can.
import { test, expect } from "@playwright/test";
test("checkout summary", async ({ page }) => {
await page.clock.setFixedTime(new Date("2026-03-01T10:00:00Z"));
await page.goto("/checkout?fixture=two-items");
await page.evaluate(() => document.fonts.ready);
await expect(page.getByTestId("summary")).toHaveScreenshot("summary.png", {
animations: "disabled",
mask: [page.getByTestId("promo-banner")],
threshold: 0.2, // per-pixel color tolerance, 0 to 1
maxDiffPixels: 20, // small budget for leftover noise
});
});
Lab: noise versus a real bug
The lab below shows a diff map of a card after a pull request. The avatar is live data and differs on every run unless masked, and the text edges carry anti-aliasing noise from the CI machine's font hinting. Two scenarios add a real bug: a price moved down 2 pixels, or a bottom border a shade lighter. Tune the settings and watch for a red build with no bug (a false positive) and a green build with a bug (a missed bug).
Check your understanding · Exercise 4
Visual tests pass on every developer's laptop but fail on CI for about half the components, always along text edges. What fixes the cause rather than the symptom? Select all that apply.
Storybook-driven tests
Page-level screenshots have a problem: a page mixes 30 components, live data, and layout, so when it differs, you hunt for the cause. Component-level screenshots fix that, but you need a way to render every component in every state, alone, with fixed data. Storybook is the most common tool for that.
A story is a named, rendered state of one component: Button / Primary, Button / Disabled, DatePicker / Range selected in Arabic. Teams write stories anyway, to build and document components, so a design system with 400 stories already has 400 test cases. Storybook-driven testing runs checks on every story, automatically.
Three checks on every story
- Render and interaction tests. Each story renders in a real browser; a story that throws fails. A story can also have a
playfunction that clicks, types, and asserts, like a small integration test you can watch step by step in the Storybook UI. - Accessibility checks. The accessibility addon runs axe on each story and reports missing labels, low contrast, wrong ARIA, and duplicate ids, as warnings or as failures.
- Visual snapshots. Each story is screenshotted after its
playfunction has run and diffed against its baseline. That is how states like "open dropdown" or "error shown" get covered.
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect } from "storybook/test";
import { CurrencyInput } from "./CurrencyInput";
const meta = {
component: CurrencyInput,
args: { label: "Amount", currency: "EUR" },
} satisfies Meta<typeof CurrencyInput>;
export default meta;
type Story = StoryObj<typeof meta>;
export const TooLarge: Story = {
play: async ({ canvas, userEvent }) => {
const input = canvas.getByLabelText("Amount");
await userEvent.type(input, "1000000");
await userEvent.tab();
await expect(input).toHaveAttribute("aria-invalid", "true");
await expect(canvas.getByRole("alert")).toHaveTextContent(/at most/i);
},
};
play steps check behavior, axe checks the error state's accessibility, and the visual snapshot is taken after the error is showing.Running stories in CI
Storybook offers two ways to run stories as tests. Since Storybook 9, its docs recommend the Vitest addon for projects built with Vite and describe the older test runner as superseded.
- The Vitest addon (
@storybook/addon-vitest). It turns each story into a Vitest test that runs in a real browser through Vitest's browser mode, with Playwright driving Chromium by default. Stories then run in the same command, watch mode, and coverage report as your other unit tests. It needs a Vite-based Storybook framework. - The test runner (
@storybook/test-runner). It visits every story of a running or published Storybook in a Playwright-driven browser, runs itsplayfunction, and reports failures, using Jest underneath. Hooks let you add an axe check or a screenshot after each story. It remains the option for Storybooks built with Webpack.
Decision Where should the visual snapshots of your stories run?
What we pick: A small app with a few dozen key screens does well with Playwright snapshots in one Docker image. A design system with hundreds of stories, many reviewers, and several browsers usually pays for a hosted service, because the review UI keeps people looking at diffs. Either way, run interaction and accessibility checks on your own CI.
Check your understanding · Exercise 5
Your Select component looked right when closed, but its open dropdown list was cut off by a parent with overflow: hidden. How could Storybook-driven tests have caught it? Select all that apply.
Testing a design system
A design system is the extreme case for both families of tests. Dozens of teams import it, so one bad change ships to every product at once. And its "API" has two faces, both of them contracts: the pixels users see, and the props, types, and CSS tokens developers use.
Three layers of checks
- Visual: every variant, every theme. A
Buttonwith 4 variants, 3 sizes, 2 themes, and 2 text directions is 48 screens; forty such components is close to 2,000. Snapshots are the only way that gets covered at all. - Accessibility: every story. axe on every story, plus
playtests for keyboard behavior: doesEscapeclose the menu, does focus return to the trigger. Deque's 2021 study of its own audit data found that automated checks caught about 57 percent of accessibility issues by volume, so screen reader output and much else still need a person. - API surface: every export. The list of exported components, their prop types, and the CSS custom properties (design tokens) are a contract with every consumer. Removing a prop or renaming a token is a breaking change, exactly like removing an API field.
Generate the matrix, do not hand-write it
Writing 48 Button stories by hand is slow, and they drift. Instead, write one "all variants" story per component that renders a grid of every combination, and snapshot it in each theme. One screenshot per theme covers every variant, and a diff shows at a glance which cells changed.
Add hostile cases to the grid: a label 3 times longer than normal (German text often runs about 30 percent longer than English), right-to-left text, 200 percent zoom, and forced-colors (Windows high contrast) mode. These catch the bugs real products hit and happy-path stories miss.
Guarding the API surface
The pixel tests do not notice when a prop is renamed, because the stories are updated in the same pull request. So the design system needs a schema check of its own, on its TypeScript types.
- An API report. A tool such as Microsoft's API Extractor writes the public type signatures into a text file checked into git. Any pull request that changes the public API changes that file, so the reviewer sees it and can ask "is this a major version?".
- Type tests. Small tests with Vitest's
expectTypeOf(ortsd) pin the types consumers rely on:variantaccepts"primary",onChangereceives a string. A refactor that widens or narrows them fails the test. - Token diffs. Tokens such as
--color-text-mutedare read by product CSS the library cannot see. Treat the token list like an API schema: diff it, and treat removals and renames as breaking. - Semantic versioning with change notes. Removing a prop or token is a major version; tools such as Changesets make each pull request say whether it is a patch, minor, or major change.
Test inside real products before release
The last check borrows the idea of consumer-driven contracts. Before publishing, install the release candidate into two or three real product apps and run their visual and end-to-end suites. The products show the components under real CSS, data, and layouts, which is where a z-index change that hides a product's sticky header shows up.
Check your understanding · Exercise 6
A design system pull request renames the Button prop kind to variant and updates every story. Every visual and accessibility check passes. What should catch the problem for product teams? Select all that apply.
Baselines, review, and cost
A visual test is only as good as the person who approves its diffs. If reviewing is slow or confusing, people click "approve all" and the test becomes a ritual, so the workflow around baselines matters as much as the diff algorithm.
Where baselines live
- In git, next to the test. Playwright writes baselines into a folder beside each test file, with the project (browser) and platform in the file name, for example
summary-chromium-linux.png. Changes show up in the pull request; thousands of PNGs make the repo heavy, so teams often store them with Git LFS. - In a hosted service. The service keeps a baseline per story and per branch, linked to commits. The repo stays small, and review happens in the service's web UI, which reports a status back to the pull request.
Baselines follow branches
Two pull requests open at the same time can each change the header. If both compare against main, the second to merge shows the first one's change as its own diff, which confuses reviewers. Good setups compare each branch with the baselines of the commit it branched from; when a pull request merges, its approved images become main's new baselines.
Reviewing a diff well
- Read the pull request first. A diff outside the area it meant to change is the suspicious one.
- Approve per component, not per build. "Approve all" on 140 changes hides the one that was a bug. A change that big came from a token or base style; sample it as in the design system chapter.
- Name an owner. The design system team approves changes to shared components; each product team approves its own pages. A diff nobody owns gets approved by whoever is in a hurry.
What it costs
Visual tests cost CI minutes, money (hosted services charge per snapshot), and reviewer time. The snapshot count multiplies fast: stories times browsers times viewports times themes, times every push to a pull request.
Take a design system with 400 stories, checked in 2 browsers, 2 viewports, and 2 themes: 3,200 snapshots per run. With 25 pushes a day across all pull requests, that is 80,000 snapshots a day, or about 1.7 million in a month of 21 working days. At one second each, that is 22 hours of browser time a day. The calculator below lets you try your own numbers.
Three levers bring it down without losing much safety:
- Only what changed. Snapshot only stories whose component files, or their imports, changed in the pull request. Some hosted services do this from the build's dependency graph; with your own setup, a script can map changed files to stories. Run the full set on main every night as a backstop.
- Fewer combinations per pull request. Run one browser, one viewport, and one theme on each push, and the full matrix on main or before a release. Most visual bugs show in every combination.
- Fewer, bigger images. One "all variants" grid per component is one snapshot instead of 48, and one diff to review.
Check your understanding · Exercise 7
Your visual test bill tripled after you added a dark theme and a tablet viewport. Which changes cut cost without losing much safety? Select all that apply.
Failure modes and how to spot them
Both families fail quietly: the checks stay green, or go red so often that nobody reads them, and the bugs ship anyway. Here are the common ways that happens:
| Failure | How you notice | Fix |
|---|---|---|
| Rubber-stamp approvals | Diffs approved in seconds; a visual bug reaches production with an approved baseline | Smaller snapshots, owners per area, and a rule against approving more than one component's changes in one click |
| Flaky snapshots | The same commit passes and fails on re-run | Find the moving part: animation, font load, lazy image, clock. Fix it; do not retry |
| Baselines made on the wrong machine | Every snapshot fails after someone updates baselines locally | Update baselines only through the CI Docker image, never on a laptop |
| Masking creep | Masks cover more and more of each page over time | Review masks like code; prefer fixing data over masking it |
| Hand-written schema drifts | The schema check passes, but the server returns something else | Generate the schema from code, and validate real responses against it in tests |
| Over-strict contracts | Provider builds fail when a booking's name changes from Ana to Ben | Use type matchers (any string, any integer) in contracts, not exact values |
| Stale consumers in the broker | can-i-deploy blocks on an app that was retired months ago | Record undeployments, and remove retired consumers from the broker |
| Untested old clients | Schema check passes against today's code, but an old mobile version crashes | Check against production usage, and keep deprecated fields for a set period |
The most common contract mistake: recording too much
A consumer test that records the whole response, with exact values, turns the contract into a snapshot of the test database. Every harmless provider change, such as a new default name or an extra list item, fails verification, and after a few weeks the provider team asks to turn contracts off.
Record only the fields the consumer code reads, and match on type or format, not exact value: price is any integer, checkIn is a date string, rooms is an array with at least one item of this shape. A contract should fail only when a change would break the consumer.
How teams use them in practice
The most common pattern: product apps keep a few dozen toHaveScreenshot checks on key screens such as checkout and sign-up; design systems run snapshots and axe on every story; large GraphQL APIs check schemas against recent client operations; and teams with mobile apps use Pact with can-i-deploy, because a broken mobile release can take days to replace on users' phones.
Think first
A visual test of the search results page fails on about 1 run in 5, always with a small diff in the top-right corner. What do you check first?
A failure in the same spot on some runs is a timing problem, not a rendering one. Look at what lives in that corner: often an avatar from a CDN, a notification badge, or a lazy image that sometimes finishes loading before the capture.
Fix the cause: serve a fixed local avatar in tests, wait for that image to load before capturing, or as a last resort mask that one element. Do not add retries: they hide the flake and the real breaks with it.
Using it in a system design interview
Contract and visual tests rarely get a whole interview. They come up in the last ten minutes, under "how do you ship this safely?", or when the design has a shared API, several clients, or a component library. A sentence or two then shows you have run real systems with many teams.
When to bring it up
- Several clients share an API (web, iOS, Android, partners): schema checks in the API's CI and consumer-driven contracts with a deploy gate.
- A design system or shared component library is part of the design: visual snapshots per story, axe on every story, an API report, and a canary release into one product.
- The UI is the product (checkout, a pricing page, an editor toolbar): a few visual snapshots of key screens in one Docker image.
If you have a whiteboard, draw the edges, not the tools: a box per team, arrows for the API and the shared components, and a short label on each arrow saying which check guards it. That one picture answers "how do teams ship without breaking each other?" faster than a list of tool names.
How deep to go by level
| Level | What a good answer includes |
|---|---|
| Junior | Names the two gaps (mocks drift, DOM tests do not see layout) and says what each test compares |
| Mid | Schema checks with generated types; screenshot tests with fixed data and one rendering environment; knows threshold and masking trade-offs |
| Senior | Consumer-driven contracts with a broker and can-i-deploy; expand and contract for renames; baseline review ownership; Storybook as the source of test cases |
| Staff | Chooses per edge by cost of a break; plans usage-based schema checks for old mobile clients; budgets snapshot cost; measures the real-bug rate of visual failures |
Summary
- Contract and visual tests guard the edges between teams: the API between backend and frontend, and the screen between CSS and the user. Unit and end-to-end tests leave those edges mostly unchecked.
- Every such test is a recorded expectation, a comparison, and a judge. Rules can judge schema changes; only people can judge screenshot diffs.
- Start with schema checks in the API's CI, generated from code: a change is breaking if it asks more of requests or promises less in responses. Rename with expand and contract.
- Consumer-driven contracts add what each client really uses. A broker, provider states, pending pacts, and
can-i-deploywith recorded deployments make them work across teams. - Screenshot noise comes mostly from fonts and anti-aliasing. Fix it at the source with one Docker image, loaded fonts, frozen time and data, then keep thresholds strict and masks small.
- Storybook stories are ready-made test cases: run
playinteraction tests, axe, and visual snapshots on each one. - A design system needs three layers: visual matrices per theme, accessibility on every story, and an API report plus type tests for its props and tokens.
- Visual tests are only as good as their review. Keep snapshots small and owned, run fewer combinations per pull request, and track how many failures were real bugs.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
The bookings API renamed price to totalPrice and the checkout broke, though every test passed. Design the checks that stop this happening again.
The frontend's tests used a hand-written mock that still had price, so nothing compared the frontend's expectation with the real API. I would close that gap in layers.
First, generate the OpenAPI schema from the API code, and run a schema diff on every API pull request that fails on breaking changes like a removed field. Generate the frontend's types from the same schema, so the rename also fails the frontend build, and validate mock responses against it so mocks cannot drift.
Second, because web and mobile both read this API and deploy separately, add consumer-driven contracts: each client records the fields it reads, the API verifies them in CI, and both sides run can-i-deploy before each deploy.
The rename itself then goes through expand and contract: add totalPrice, migrate clients, watch usage drop to zero, then remove price with a reviewed exception.
Question 2
Your visual tests fail on most pull requests and reviewers approve without looking. What do you change?
First measure: what share of failures were real bugs? If it is low, the problem is noise, and the fix is at the source. Take every screenshot in one Docker image so fonts and anti-aliasing match, wait for fonts, disable animations, freeze the clock and data, and mask only what truly cannot be fixed.
Then make diffs reviewable: snapshot components or regions instead of whole pages, give each area an owner, and do not allow approve-all across components.
Keep the threshold at or below the default and the pixel budget small. Raising them to get green builds makes the tests blind to the small layout bugs they exist for.
Question 3
How would you test a design system used by 30 product teams?
Stories are the base: every component in every important state, plus generated all-variants grids and hostile cases such as long text, right-to-left, and zoom.
On each story I run three checks: interaction tests in play functions for keyboard and focus behavior, axe for accessibility, and visual snapshots per theme reviewed by the design system team.
The props, types, and tokens are a contract too: an API report checked into git, type tests, and semantic versioning, so a renamed prop is a visible major change.
Before release, I install the candidate into two or three product apps and run their suites, since products use components in ways the stories do not.
Question 4
When would you not use consumer-driven contracts?
When one team owns the API and the only client and ships them together from one repo. Shared generated types and an integration test cover that edge for far less work.
Also when the provider is a third party you cannot run in CI. There you validate their responses at runtime with a schema and alert on mismatches instead.
Contracts pay off when several clients deploy independently from the provider, and a break is costly, as with a mobile app that takes days to update.
Question 5
Pixel diff or perceptual diff for visual tests?
A pixel diff with a YIQ color threshold and anti-aliasing detection is predictable and easy to explain: these pixels changed. It catches small color changes, like a border that got a shade darker, but it is sensitive to rendering noise.
A perceptual diff compares regions the way a person would, so it ignores most anti-aliasing noise, but it can miss subtle changes and its scores are harder to reason about.
I would start with a pixel diff in a stable rendering environment, because removing noise at the source beats hiding it. I would try a perceptual or hosted comparison only if noise remains after that, and judge it on our own screenshots.
Practice it
These design problems lean on contract and visual regression tests.
References
- Playwright: Visual comparisons toHaveScreenshot, baselines per platform, and updating snapshots.
- Pact documentation Consumer-driven contracts, provider states, pending pacts, and can-i-deploy.
- Storybook: How to test UIs with Storybook Interaction, accessibility, and visual tests on stories.
- Storybook: Vitest addon Runs stories as Vitest tests in a real browser; the recommended runner for Vite-based projects.
- oasdiff Open source OpenAPI diff and breaking change detection.
- GraphQL Inspector Schema diffs labelled breaking, dangerous, or safe.
- pixelmatch A small pixel diff library with anti-aliasing detection.
- API Extractor API reports that make public TypeScript API changes visible in review.
Read next
- API contracts, versioning, and errorsThe written agreement between the frontend and the API: a typed schema both sides are checked against, rules for changing it without breaking old clients, and one error shape the UI can always understand, including for partial failures and safe retries.
- A frontend testing strategyHow to split a frontend's tests between static checks, unit, integration, and end-to-end tests, so they catch the bugs that matter without making every change slow.
- End-to-end testsTests that drive a real browser through your real app like a user would: what to cover, how to keep them reliable, where test data comes from, and how to run them in CI.
- Unit and integration testsFast tests that check what users see and do: Testing Library queries, user-event, async UI without flakiness, mocking the network with MSW, and testing hooks, forms, and timers without coupling tests to how the code is built.
- Testing accessibilityHow a team proves a UI works for people who use keyboards, screen readers, zoom, and voice control: layers of linting, automated scans, role-based unit tests, manual keyboard and screen reader passes, and real users, measured against WCAG.
- Design systemsA shared set of design tokens, accessible components, patterns, docs, and rules that many teams build on, so a company ships one consistent product instead of many slightly different ones, and can change its look in one place.