/writing
Test Layers for a Design System
· 3 min read
A design system has a different testing problem than an app. Its components rarely hold business logic. What they have instead is an API other teams build against, a token cascade that has to hold however someone composes them, and a prop space too large for anyone to enumerate by hand.
So I built a stack of test layers, each one aimed at a different boundary.
Type tests catch API drift
Every stable component ships a type-tests file next to it, covering props that should compile and props that should fail with a specific error.
The negative cases turned out to be the more useful half. They lock in the guarantees. Someone who passes a renamed value gets a type error and a clear path forward, rather than a silent runtime fallback.
An API surface snapshot tracks the public shape
A generated file lists every exported component, every prop and its type. CI regenerates it on each build and diffs it against the committed copy. If the surface moved, the diff lands in the failure message, so the reviewer sees exactly which prop was added, removed or retyped.
That snapshot doubles as the source for tier promotion. A component cannot move up a tier until its surface is captured.
Story-level guards catch broken token cascades
Stories run interaction tests through Storybook’s play function. The interesting piece is a small helper that reads computed styles and checks each one resolved to a real value.
Every design-token variable falls back to transparent. So when the cascade works, the property lands on a real color. When the chain breaks, because of a missing token, a typo, or an element rendered outside a theme scope, it lands on transparent and the test fails with the property name.
The guard catches broken cascades. It does not catch hardcoded colors, which need a separate lint rule against literal color values in component CSS.
Property tests cover the prop space
Another layer renders every stable component across its whole prop space: every union branch, every enum value, every boolean combination. If any combination throws, the framework shrinks it to the smallest failing case and prints the seed.
The point is not that every combination looks right. It is that none of them throw. A component that breaks on a rare combination almost never shows up in a hand-written story, and property tests find it on the first run.
What it covers
Sixty-eight components sit behind these layers, with a hundred and ten test files and seventy-two type-test files between them. The counts matter less than the fact that promotion is gated on them: a component cannot move up a tier until its surface is captured and its property tests pass.
That is the part I would keep. Not the layers themselves, but tying them to promotion, so the tests are a precondition for calling something stable rather than a chore to catch up on afterwards.
What this catches that unit tests do not
Unit tests check what a component does. These layers check what it promises: a stable type contract, an enumerated public API, tokens that actually cascade, and a prop space that never throws.
More writing
Cutting Backend CI by 76%
A Django test pipeline went from a median of 18.5 minutes to 4.5. Almost none of it came from making tests faster. The last part was a connection budget.
· 6 min read
Audit Scripts for a Design System
How a style guide became a suite of audit scripts behind one umbrella command, grouped by what they protect, with parallel execution and an explicit drift-resolution workflow.
· 3 min read
Building Type-Safe APIs with OpenAPI
How the dashboard API integration layer became its own product: OpenAPI as the single source of truth across multiple consuming teams.
· 4 min read