Design for Testability, Not After
"We Can't Test This" Is a Design Problem, Not an Implementation One
Flaky E2E tests. Unit tests so full of mocks nobody can tell what they actually verify. Most of these problems don't start when the test code gets written — they start earlier, at design time. No selector on the element under test. Current time hardcoded deep inside a function. An external API call buried three layers into a component. Once implementation is done, these are expensive to fix, and the people writing tests end up stacking workarounds on top of workarounds.
Testability isn't something you retrofit later — it's a property you decide during design. Here are three concrete design decisions that consistently pay off.
1. Fix Your data-testid Naming Convention Before You Build
Targeting elements by CSS class or text content breaks every time styling or copy changes. But adding data-testid attributes ad hoc just trades one problem for another — inconsistent names that nobody can trace back to a specific test.
A convention that actually holds up in practice is simple:
- Use a three-part pattern:
{screen}-{element type}-{identifier}(e.g.bug-report-form-submit-button) - For repeated elements like list rows or cards, append a unique ID (e.g.
bug-report-row-{id}) - For modals or toasts that render differently by state, include the state name (e.g.
save-toast-errorvssave-toast-success)
Decide this before implementation starts, and whoever writes the tests can guess the right selector without reading the component code.
2. Make Time and Randomness Injectable
Logic like "expired after 24 hours" or "rate-limited after 3 retries within an hour" tends to get written with direct calls to Date.now() or Math.random(). That makes tests either produce a different result every run, or fail depending on what time they happen to execute.
The fix at design time is small: let the time and randomness source be passed in from outside the function.
// Hard to test
function isExpired(token) {
return token.expiresAt < Date.now();
}
// Testable
function isExpired(token, now = Date.now()) {
return token.expiresAt < now;
}Using a default argument means production call sites don't need to change at all — only tests need to pass a fixed value. The same principle applies on the Rails side: avoid embedding Time.current directly, and keep it in a form that can be frozen for tests.
3. Draw One Mock Boundary for External APIs
When external API calls are scattered across components and service classes, every test ends up mocking a different layer, and it's easy to miss one — resulting in a "test" that quietly hits a real external API.
The standard fix is to consolidate the boundary into a single adapter layer:
- Route all external communication through a dedicated class or module (e.g.
GithubIssueClient) - Business logic depends only on that adapter's interface, never on the raw HTTP client
- Tests only ever need to substitute that one adapter
This fixes the mock boundary at exactly one place across the whole codebase, so a new team member never has to guess where to intercept a call.
Three Things to Check in a Design Review
Keep this as a checklist for design reviews on new screens or features — it prevents costly rework after implementation:
- Is there a naming policy for
data-testidon the key interactive elements? - Can time- and randomness-dependent logic be injected from outside?
- Do all external API calls go through a single adapter layer?
Each of these is cheap to build in from the start. Retrofitting any of them later means touching a wide swath of existing code — which is exactly why they're worth confirming during design review.
Testability in Practice with Bugoon
Bugoon's widget lets reporters annotate the exact element on a screenshot and records the interaction steps before filing a GitHub Issue. When the element in that report already carries a clear data-testid, adding a regression test for it after the fix becomes straightforward — the bug report and the test that guards against its return can point at the same element. That's a direct payoff of naming conventions decided during design, not after the fact.
Streamline bug reporting for your team.
Bugoon is free to get started. Add one line of code to your site and transform how your team handles bugs.
Get Started