Designing for Testability

Learn how to collaborate with developers to build applications that are easy to test — with seams, test IDs, feature flags, and API hooks that make automation reliable and maintainable.

This post is part 4 of the Test Architecture series. If you missed Part 3 — Observability for Testers, start there.


Introduction — Testing Is a Design Problem

The hardest part of test automation isn’t writing tests. It’s testing applications that were not designed to be tested.

An application without test IDs forces you to write brittle CSS selectors. An application without seams forces you to test large, tangled workflows in a single test. An application without feature flags forces you to test in production or coordinate complex deployments to shared staging environments.

These aren’t testing problems — they’re design problems. And the solution isn’t better testing tools. The solution is designing for testability from the start.

Testability is not a QA concern. It’s a development concern. The best test suites are built on applications that were designed with testing in mind. This means:

  • Test IDs for reliable element selection
  • Seams where tests can inject dependencies or bypass slow operations
  • Feature flags to control what code runs in different environments
  • API hooks for data seeding and environment setup

This post will show you how to collaborate with developers to build testability into the application — and how this improves not just testing, but the overall quality and maintainability of the codebase.


Testability Principle 1: Use Test IDs, Not CSS Selectors

The Problem: Brittle Selectors

Most test automation starts like this:

await page.click('.btn-primary.submit-order'); // CSS class selector

Then the designer changes the button style:

- <button class="btn-primary submit-order">Place Order</button>
+ <button class="btn-blue action-btn">Place Order</button>

The test breaks. Not because the functionality changed — because the visual design changed.

The Solution: Test IDs

Add data-testid attributes to elements that tests need to interact with:

<button class="btn-blue action-btn" data-testid="place-order-button">
  Place Order
</button>

Now the test uses the test ID:

await page.click('[data-testid="place-order-button"]');

The designer can change the button style all they want. The test doesn’t break.

:::tip[Test IDs Are a Contract] A data-testid is a contract between the application and the test suite. It says: “This element has a stable identifier for automation purposes.” Developers should treat test IDs as part of the API surface — changing or removing them is a breaking change. :::

Where to Add Test IDs

Not every element needs a test ID. Add them strategically:

  • Actions: Buttons, links, form inputs that users interact with
  • Important content: Headings, error messages, confirmation messages
  • Dynamic lists: Items in a list where you need to select a specific item

Don’t add test IDs to:

  • Decorative elements (icons, dividers)
  • Static text that never changes
  • Elements that tests don’t interact with

Test IDs in Component Libraries

If your team uses a component library (Material-UI, Ant Design, Chakra UI), add test IDs as props:

<Button data-testid="submit-form">Submit</Button>
<TextField data-testid="email-input" label="Email" />
<Alert data-testid="error-message" severity="error">Invalid email</Alert>

Standardize this across the team. Make it part of the component API.


Testability Principle 2: Build Seams for Test Isolation

A seam is a place where you can alter behavior without editing the code. Seams make it possible to test a component in isolation without running the entire system.

Example: Time-Dependent Logic

Untestable code:

function isPromotionActive(): boolean {
  const now = new Date();
  const promotionEnd = new Date('2026-12-31');
  return now < promotionEnd;
}

This function directly calls new Date(). In a test, you can’t control what “now” is. The test will fail after December 31, 2026.

Testable code (with a seam):

function isPromotionActive(now: Date = new Date()): boolean {
  const promotionEnd = new Date('2026-12-31');
  return now < promotionEnd;
}

Now the test can inject a specific date:

test('promotion is active before end date', () => {
  const testDate = new Date('2026-06-01');
  expect(isPromotionActive(testDate)).toBe(true);
});

test('promotion is inactive after end date', () => {
  const testDate = new Date('2027-01-01');
  expect(isPromotionActive(testDate)).toBe(false);
});

Example: External API Calls

Untestable code:

async function getUserProfile(userId: string) {
  const response = await fetch(`https://api.example.com/users/${userId}`);
  return response.json();
}

This directly calls an external API. Tests are slow, flaky, and dependent on network conditions.

Testable code (with dependency injection):

interface ApiClient {
  getUser(userId: string): Promise<User>;
}

async function getUserProfile(userId: string, api: ApiClient) {
  return api.getUser(userId);
}

Now the test can inject a mock API:

test('getUserProfile returns user data', async () => {
  const mockApi: ApiClient = {
    getUser: async (userId) => ({ id: userId, name: 'Test User' })
  };
  
  const profile = await getUserProfile('123', mockApi);
  expect(profile.name).toBe('Test User');
});

:::info[Seams Enable Unit Testing] Seams are essential for unit testing. Without seams, you can’t isolate the code under test from its dependencies. With seams, you can test logic independently of databases, APIs, and external services. :::


Testability Principle 3: Use Feature Flags

Feature flags let you deploy code that isn’t ready for production, test it in isolation, and gradually roll it out to users.

Why Feature Flags Improve Testability

  1. Test in production — deploy incomplete features behind a flag, enable the flag for test accounts only
  2. Parallel development — multiple teams work on different features without blocking each other
  3. Rollback without deployment — if a feature breaks, disable the flag instead of rolling back code

Example: Gradual Rollout

if (featureFlags.isEnabled('new-checkout-flow', user)) {
  return <NewCheckout />;
} else {
  return <OldCheckout />;
}

QA can test the new checkout on staging with the flag enabled. Production users still see the old checkout. When the new checkout is validated, gradually enable the flag for 10%, then 50%, then 100% of users.

Testing Feature-Flagged Code

Without feature flags: You must deploy the new feature to staging, breaking the old feature for everyone else testing on staging.

With feature flags: You enable the flag for specific test accounts. Other testers are unaffected.

Playwright example:

test('new checkout flow works', async ({ page }) => {
  // Enable feature flag for this test
  await page.goto('/feature-flags?enable=new-checkout-flow');
  
  await page.goto('/cart');
  await page.click('[data-testid="checkout-button"]');
  
  // New checkout UI is now visible
  await expect(page.locator('[data-testid="new-checkout-form"]')).toBeVisible();
});

:::warning[Clean Up Old Flags] Feature flags accumulate over time and create technical debt. After a feature is fully rolled out and validated, remove the flag and delete the old code path. Flags that live forever are just conditionals with extra steps. :::


Testability Principle 4: Provide API Hooks for Data Seeding

Tests need data. The worst way to provide data is shared test accounts. The best way is seeding APIs that let tests create data on demand.

Anti-Pattern: Shared Test Data

test('user can view profile', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[data-testid="email"]', 'testuser@example.com'); // shared account
  await page.fill('[data-testid="password"]', 'password123');
  await page.click('[data-testid="login-button"]');
  
  await page.goto('/profile');
  await expect(page.locator('h1')).toHaveText('Test User');
});

Problem: If another test (or developer) modifies testuser@example.com, this test breaks.

Pattern: Seed Data Per Test

Build an API for seeding test data:

// Seed API endpoint (only available in staging/test environments)
POST /api/test/seed-user
{
  "email": "user@example.com",
  "name": "Test User",
  "role": "customer"
}

Response:
{
  "id": "user-abc123",
  "email": "user@example.com",
  "password": "generated-password-xyz"
}

Now the test creates its own user:

test('user can view profile', async ({ page, request }) => {
  // Seed a unique user for this test
  const response = await request.post('/api/test/seed-user', {
    data: { email: `user-${Date.now()}@example.com`, name: 'Test User' }
  });
  const user = await response.json();
  
  await page.goto('/login');
  await page.fill('[data-testid="email"]', user.email);
  await page.fill('[data-testid="password"]', user.password);
  await page.click('[data-testid="login-button"]');
  
  await page.goto('/profile');
  await expect(page.locator('h1')).toHaveText('Test User');
});

Benefits:

  • No shared state
  • Tests can run in parallel
  • No flakiness from data changes

What to Include in Seed APIs

  • Users (with specific roles, permissions, subscription states)
  • Products (with specific prices, inventory levels)
  • Orders (with specific statuses, line items)
  • Organizations (with specific configurations)

:::tip[Seed APIs Are Development Tools Too] Seed APIs aren’t just for tests. Developers use them to quickly set up local environments. Product managers use them to demo features with realistic data. Support teams use them to reproduce customer issues. Invest in good seeding tools — they pay off across the team. :::


Testability Principle 5: Make State Transitions Observable

Tests often fail because they assume a state transition has completed when it hasn’t. The application is still loading. The API request is still pending. The animation is still running.

Anti-Pattern: Arbitrary Waits

await page.click('[data-testid="submit-button"]');
await page.waitForTimeout(2000); // hope it's done in 2 seconds
await expect(page.locator('[data-testid="success-message"]')).toBeVisible();

This is flaky. Sometimes 2 seconds is enough. Sometimes it’s not.

Pattern: Wait for Observable State

Expose state transitions in the UI:

<div data-testid="form-container" data-state="idle">...</div>
<div data-testid="form-container" data-state="submitting">...</div>
<div data-testid="form-container" data-state="success">...</div>
<div data-testid="form-container" data-state="error">...</div>

Now the test can wait for the state to change:

await page.click('[data-testid="submit-button"]');
await page.waitForSelector('[data-testid="form-container"][data-state="success"]');
await expect(page.locator('[data-testid="success-message"]')).toBeVisible();

This is deterministic. The test waits exactly as long as needed, no more, no less.


Collaborating with Developers

Testability is a shared responsibility. QA can’t add test IDs or build seeding APIs alone — developers must prioritize testability as part of the feature work.

How to Advocate for Testability

  1. Show the cost of untestable code — measure time spent debugging flaky tests caused by missing test IDs or shared data
  2. Make testability a requirement — add “test IDs added” and “seeding API available” to the definition of done
  3. Pair with developers — sit with a developer while they build a feature and point out testability gaps in real-time
  4. Document standards — write a testability guide for your team (e.g., “All interactive elements must have data-testid”)

Example: Testability Checklist for PRs

## Testability Checklist

- [ ] Test IDs added to all interactive elements
- [ ] Feature flag configured (if applicable)
- [ ] Seeding API endpoint available (if new entity type)
- [ ] State transitions observable (loading, success, error states)
- [ ] No hardcoded timeouts in tests

:::tip[Testability Improves Design] When developers design for testability, they write better code. Seams force loose coupling. Test IDs force semantic HTML. Feature flags force modular architecture. Testability and good design go hand-in-hand. :::


Real-World Example: Redesigning a Checkout Flow

Before: Untestable Checkout

  • No test IDs (tests used .btn-primary.checkout which broke every redesign)
  • No seeding API (tests used shared accounts that other tests modified)
  • No feature flag (new checkout blocked old checkout testing on staging)
  • No observable state (tests used waitForTimeout(3000) and were flaky)

Result: Tests broke every sprint. Maintenance cost exceeded value. Team considered deleting the test suite.

After: Testable Checkout

  • Added data-testid to every button, input, and message
  • Built /api/test/seed-cart to create orders on demand
  • Deployed new checkout behind new-checkout-v2 feature flag
  • Added data-state attributes to form (idle, submitting, success, error)

Result: Tests became reliable. Test suite ran in CI without flakiness. Maintenance cost dropped 80%. Team expanded test coverage.


Conclusion

Testability is not a testing problem — it’s a design problem. The best test suites are built on applications that were designed to be tested.

The principles:

  1. Use test IDs — stable identifiers for interactive elements
  2. Build seams — inject dependencies, control time, mock external services
  3. Use feature flags — test in production, parallel development, gradual rollout
  4. Provide seed APIs — create data on demand, no shared accounts
  5. Make state observable — no arbitrary waits, deterministic transitions

When you design for testability, you don’t just make testing easier — you make the codebase better. Loose coupling. Modular architecture. Clear contracts. Testability is a forcing function for good design.

:::tip[Start Small, Build Trust] If your team is new to designing for testability, start with one feature. Add test IDs. Build a seed API. Show the impact: fewer flaky tests, faster feedback, less maintenance. Then expand. :::

Action for this week: Pick one brittle test that breaks frequently. Identify the root cause: missing test IDs? Shared data? Hardcoded timeouts? Collaborate with a developer to fix the underlying design issue. Measure the impact: does the test become more reliable?


This concludes the Test Architecture series. Next week, we’ll start a new series: AI for QA — exploring how AI-assisted tools fit into test design, maintenance, and risk management. See you in Part 1 — AI-Assisted Test Design.