Skip to content

Test-Driven Development (TDD) writes a failing test before the production code that makes it pass. On the frontend, that means turning UI behavior, accessibility, and business rules into executable specifications — not treating tests as a final verification step.


  • Each cycle delivers one observable behavior, not a whole feature.
  • Tests that speak in user-visible outcomes stay valid when you swap useState for a form library, a reducer, or a server action.
  • Layers, MSW, and how thin E2E should stay live in Frontend Regression Tests. This note is the design habit: red, then green, then refactor.


1. Red, Green, Refactor

TDD is a tight loop:


  1. Red — Write a small test for the behavior you want. Run it. Confirm it fails for the right reason.
  2. Green — Implement the simplest code that makes it pass.
  3. Refactor — Improve names, structure, and duplication while the tests stay green.

You do not write every test for a feature, then build the feature. You ship one behavior per loop.

A quantity selector in a cart is four cycles, not one suite:


  • Clicking Increase moves quantity from 1 to 2.
  • Clicking Decrease never goes below 1.
  • Changing quantity recalculates the displayed subtotal.
  • The controls remain keyboard-accessible.

Failure: a single test that opens the cart, changes quantity, checks tax, applies a coupon, and checks out. When it fails, you cannot tell which behavior broke.



2. Why Frontend TDD Is Different

Frontend code sits at the intersection of several concerns:


  • Business rules: pricing, eligibility, validation, permissions.
  • User interaction: clicks, keyboard, focus, loading, errors.
  • Rendering: conditional content, responsive layouts, state transitions.
  • Accessibility: semantic elements, labels, focus management, ARIA where necessary.
  • Integration: APIs, storage, analytics, routing, feature flags.

TDD works when tests focus on what users and surrounding systems can observe, not on private implementation: React state, component methods, or exact DOM nesting.

A strong test says:

When the user submits invalid credentials, they see an accessible error and the sign-in request is not sent.

A fragile test says:

The isInvalid state is true and the component has a div with class error-text.

The first survives a move from useState to a form library, a reducer, a server action, or a state machine. The second locks the implementation in place.


Failure: asserting component.state, CSS class lists, or a specific tree of divs. Query by role and accessible name.



3. Walkthrough: a Search Form

A product search form with four requirements:


  • The search button is disabled when the input is empty.
  • Typing a non-empty query enables the button.
  • Submitting trims whitespace before calling the search handler.
  • A user can submit with the Enter key.

Start with the first behavior. The component may not exist yet.


tsx
import { render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"

import { SearchForm } from "./search-form"

describe("SearchForm", () => {
  it("disables submission when the query is empty", () => {
    render(<SearchForm onSearch={vi.fn()} />)

    expect(
      screen.getByRole("button", { name: /search/i })
    ).toBeDisabled()
  })
})

Implement only enough to satisfy that assertion:


tsx
import { useState } from "react"

type SearchFormProps = {
  onSearch: (query: string) => void
}

export function SearchForm({ onSearch }: SearchFormProps) {
  const [query, setQuery] = useState("")

  return (
    <form>
      <label htmlFor="product-search">Search products</label>

      <input
        id="product-search"
        value={query}
        onChange={(event) => setQuery(event.target.value)}
      />

      <button type="submit" disabled={!query.trim()}>
        Search
      </button>
    </form>
  )
}

Next cycle: a valid query enables the button.


tsx
import userEvent from "@testing-library/user-event"

it("enables submission after the user enters a query", async () => {
  const user = userEvent.setup()

  render(<SearchForm onSearch={vi.fn()} />)

  await user.type(
    screen.getByRole("textbox", { name: /search products/i }),
    "wireless headphones"
  )

  expect(screen.getByRole("button", { name: /search/i })).toBeEnabled()
})

Then specify submission:


tsx
it("submits a trimmed query", async () => {
  const user = userEvent.setup()
  const onSearch = vi.fn()

  render(<SearchForm onSearch={onSearch} />)

  await user.type(
    screen.getByRole("textbox", { name: /search products/i }),
    "  wireless headphones  "
  )

  await user.click(screen.getByRole("button", { name: /search/i }))

  expect(onSearch).toHaveBeenCalledWith("wireless headphones")
})

The missing implementation is now obvious:


tsx
export function SearchForm({ onSearch }: SearchFormProps) {
  const [query, setQuery] = useState("")

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()

    const normalizedQuery = query.trim()

    if (normalizedQuery) {
      onSearch(normalizedQuery)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="product-search">Search products</label>

      <input
        id="product-search"
        value={query}
        onChange={(event) => setQuery(event.target.value)}
      />

      <button type="submit" disabled={!query.trim()}>
        Search
      </button>
    </form>
  )
}

Enter-to-submit is a fourth cycle: type, press Enter, assert onSearch. Because the handler is on the form, that test often passes without extra code — confirm it anyway.

The tests never mention useState. They would still pass under React Hook Form, a state machine, or a server-oriented form flow. They specify behavior through accessible controls.


Failure: writing all four tests, then implementing the whole form. Diagnose one red assertion at a time.



4. Choose the Test Level

Match the test level to the risk and scope of the behavior.


Test levelBest forExample
UnitPure logic and transformationsPrice calculation, form validation, permission checks
ComponentUser-visible behavior of one component or featureModal opening, invalid form feedback, pagination
IntegrationMultiple components and service boundariesCheckout with mocked payment and inventory APIs
End-to-endCritical real-browser workflowsSign-up, login, checkout, account recovery

Keep most behavior in fast unit and component tests. Use end-to-end tests for a small number of critical journeys.

A common TypeScript frontend setup:


  • Vitest for fast unit and component tests — this is where the TDD loop lives.
  • React Testing Library for interaction-focused component tests.
  • Mock Service Worker (MSW) for HTTP at the network boundary, not mocked hooks.
  • Playwright or Cypress for browser-level journeys. The published playbook on this site uses Cypress; Playwright fills the same slot. Details: Frontend Regression Tests.

Failure: proving the same fact at every layer. If Vitest already shows the coupon rejects, Cypress does not need to re-prove the math.



5. What to Test First

TDD pays off on code with meaningful behavior or expensive regression risk.


Pure domain logic

Extract rules from UI components. A checkout surface should not own tax, discounts, or entitlement checks.


ts
export function calculateDiscount(
  subtotal: number,
  discountPercent: number
): number {
  if (subtotal <= 0 || discountPercent <= 0) {
    return 0
  }

  return Math.round(subtotal * (discountPercent / 100))
}

These functions are fast, easy to read, and independent of the UI framework.


Form behavior and validation

Forms have clear input-output contracts:


  • Required fields, formatting, and normalization.
  • Async validation and server-side error handling.
  • Submission, loading, and disabled controls.
  • Accessible error announcements.

Specify that an API error appears to the user, not that an internal error variable changed.


State transitions

Complex UI gets easier when you name the states first:


  • Idle → loading → success.
  • Idle → loading → failure → retrying.
  • Editing → saving → saved.
  • Editing → saving → conflict resolution.

Useful for onboarding, payments, uploads, approvals, and AI-assisted interfaces where streaming, retry, and partial failure must be explicit.


Accessibility behavior

Accessibility is a product requirement, not a post-launch audit.


  • A control has an accessible name.
  • A dialog receives focus when opened.
  • A keyboard user can complete the interaction.
  • An error is exposed to assistive technology.
  • Fields are associated with labels.

Prefer getByRole, getByLabelText, and getByText. Use a test id only when no meaningful accessible query exists.


Failure: TDD-ing CSS class names, pixel offsets, or a data-testid on every node. Those tests describe markup, not behavior.



6. Common Mistakes

Testing implementation details

Do not inspect React state, call component methods, or assert exact markup.

Instead of:

tsx
expect(component.state.isMenuOpen).toBe(true)

test:

tsx
await user.click(screen.getByRole("button", { name: /open menu/i }))

expect(
  screen.getByRole("menu", { name: /account options/i })
).toBeVisible()

The second is closer to the user and survives an implementation change.


Over-mocking

Mocking every hook, child, and utility produces tests of your mocks. Prefer real components together. Mock at meaningful boundaries:


  • HTTP requests.
  • Browser APIs missing from the test runtime.
  • Payments, email, authentication, or third-party SDKs.
  • Time, randomness, and other external side effects.

MSW intercepts at the network layer. The frontend keeps its real data-fetching code; tests control the response.


Writing large tests before implementation

A test with ten interactions and twenty assertions is hard to diagnose. Ask: what is the smallest observable behavior I can implement next?


Treating snapshots as primary tests

Snapshots can catch unexpected rendering changes. They rarely explain whether a feature works. Large snapshots become noise and get approved without review.

Use explicit interaction assertions for behavior. Keep snapshots small and intentional, if you use them at all.


Ignoring asynchronous states

Modern UI is rarely synchronous. Test loading, empty, success, failure, retry, cancellation, and stale data on purpose.

For a data-driven dashboard, that usually includes:


  • Initial loading indicator.
  • Successful data rendering.
  • Empty state.
  • API failure message and retry.
  • Disabled interactions while a mutation is pending.
  • Correct behavior when a response arrives after navigation or input has changed.

Failure: only asserting the happy path. The loading spinner and the retry button are the behavior users hit when the network is unkind.



7. TDD and Component Design

TDD often reveals too many responsibilities. If a test needs routing, auth, global stores, feature flags, API mocks, locale providers, and browser APIs before it can check a simple behavior, the code is overly coupled.

A more maintainable split:


text
features/
  checkout/
    domain/
      calculateTotal.ts
      validateCoupon.ts
    api/
      checkoutClient.ts
    components/
      CouponForm.tsx
      CheckoutSummary.tsx
    hooks/
      useCheckout.ts

Not every widget needs this shape. Business rules should be independently testable, UI should expose clear behavior, and external dependencies should sit at boundaries.

In a multi-tenant SaaS product, test tenant policy as pure logic, then test the UI’s response to allowed, denied, or restricted. That is safer than scattering authorization across visual components.


Failure: a 200-line CheckoutPage test whose setup is longer than the assertions. Extract the rule; test the widget against a result.



8. Team Workflow and When Not to Force It

TDD succeeds when it fits daily engineering rather than becoming ceremony.


  1. Clarify the user-facing behavior and acceptance criteria.
  2. Write one failing test for the highest-value behavior.
  3. Implement the smallest change that makes it pass.
  4. Refactor production code and test setup.
  5. Repeat until the important paths are covered.
  6. Add or update an end-to-end test only for the critical customer journey.
  7. Run the relevant tests locally; let CI run the broader suite.

Code review should ask more than “are there tests?”


  • Does the test describe an important user or business behavior?
  • Would it fail if the feature regressed?
  • Does it use accessible, user-oriented queries?
  • Is it coupled to a private implementation choice?
  • Are failure, loading, and edge states considered?
  • Can it run reliably in CI?

TDD is a tool, not a measure of engineering worth. It is less useful when exploring visual direction, rapidly prototyping, or dealing with highly uncertain requirements.

A productive sequence is often:


  1. Prototype the interface quickly.
  2. Learn from product, design, or user feedback.
  3. Stabilize the behavior.
  4. Add tests before refactoring, extending, or shipping broadly.

For pixel-level visual requirements, complement TDD with visual regression testing and design review. For complex browser workflows, complement component tests with a small E2E set. For performance-sensitive work, pair tests with performance budgets and real-user monitoring.


Failure: blocking a spike because there is no failing test yet. Explore first; lock the behavior before it becomes load-bearing.



Takeaway

The value of frontend TDD is not test count or 100% coverage. It is confidence in change.


  • Design from observable behavior outward: define what the user must accomplish, prove it with a failing test, implement it simply, improve the code beneath it.
  • Keep the TDD loop in Vitest and Testing Library. Keep E2E thin. Extract domain rules so they do not live inside JSX.
  • When you can reorganize components, replace state management, or migrate an API client without silently breaking a workflow, the tests are doing their job.

Failure: a green suite that would still pass if the user could no longer complete the task.


Recap Q&A