
Writing Testable Front-End Code - Best Practices, Patterns, and Pitfalls (Pt 1)
By Spencer Dangel
Why Testability Matters
Most developers don't understand that testability starts at the design phase, before you even write your first test. Code that’s hard to test is usually hard to understand, hard to debug, and hard to change.
The best time to think about testing is before you write your first test. By structuring your components and logic with testability in mind, you make it easier to validate behavior, catch bugs early, and refactor with confidence. Tests become faster to write, more reliable, and less brittle - and your codebase becomes more maintainable in the long run.
Types of Tests
Before we take a look at writing testable code, it’s worth reviewing the different types of tests you’ll typically see in a front-end project. I like to use Mike Cohn’s Test Pyramid as a mental model for thinking about automated tests.
The pyramid breaks tests into three layers:
- Unit Tests – test isolated pieces of logic (e.g., functions, hooks, or components)
- Integration Tests – test how multiple units work together (e.g., component + API + state)
- End-to-End (E2E) or UX Tests – simulate full user interactions across the entire system
The shape of the pyramid isn’t just visual - it represents cost and speed. Unit tests form the base because they’re fast, cheap, and reliable. They should make up the bulk of your test suite.
Integration tests sit in the middle. These are great for catching contract or wiring bugs between components or services - for example, checking that a form correctly saves user input to local storage.
At the top are E2E tests, which simulate real user behavior (e.g., logging in, navigating a dashboard). These are slower, more brittle, and harder to maintain, so use them sparingly to validate only the most critical flows.
The takeaway? Focus on writing lots of fast, isolated tests - and only a few full-system ones.
Tooling: What We'll Use in This Guide
The examples in this guide are written using React, since it's one of the most widely used front-end libraries - and also one where testability challenges often come up. If you're using another UI framework like Vue or Svelte, many of the concepts and patterns here will still apply with slight syntax differences.
We'll primarily focus on unit and integration tests - where the bulk of your automated testing should live - and use the following tools throughout:
Jest
– our test runner and assertion library@testing-library/react
– for rendering React components and simulating user behaviorMSW
– (Mock Service Worker) for mocking API calls at the network level
These tools are designed to work well together and emphasize testing behavior rather than implementation details, which aligns perfectly with our focus on designing for testability.
A quick note on @testing-library/react
: it plays a key role in our examples by promoting a testing style that reflects how users actually interact with your UI. Instead of selecting elements by .className
or relying on component internals, it encourages you to:
- Query elements by text content, roles, or labels
- Simulate user actions like typing, clicking, and submitting forms
- Make assertions against the rendered DOM - just like a real browser environment
This approach naturally leads to better accessibility and more resilient tests, since you're writing tests that focus on what the user sees and does, not how the component is implemented.
For full end-to-end (E2E) testing, tools like Cypress and Playwright are excellent choices. They simulate full user workflows in a real browser and are useful for validating critical paths like logins, checkouts, and onboarding flows. If you're looking for a more visual, low-code approach to E2E or regression testing, tools like Katalon Studio can help non-developers build test scenarios without having to mess with code. While powerful in certain workflows, they’re out of scope for this post.
Why Code Design Affects Testability
Good tests don’t just depend on good testing tools - they depend on good code design. Tests aren’t magic. If your code is tightly coupled, unpredictable, or hard to isolate, writing meaningful tests becomes painful (or impossible).
Testability comes down to two core abilities:
- The ability to observe what your code is doing (inputs, outputs, and side effects)
- The ability to control its behavior (injecting dependencies, mocking boundaries)
Code that lacks these qualities is often:
- Tightly coupled: everything depends on everything else
- Impure: full of side effects like time, randomness, or network calls
- Obscure: logic is buried in UI components or hard to reach
You don’t fix this with better assertions - you fix it by designing code that’s modular, predictable, and decoupled.
In the next sections, we’ll walk through common front-end scenarios and show how you can refactor code to make it easier to test - using simple patterns like dependency injection, separation of concerns, and pure functions.
Separating Concerns
It’s common to build components that do everything - they fetch data, manage local state, and render UI. But the more a component tries to do, the harder it is to test.
Let’s say we want to display a list of users from an API. Here’s a typical approach:
❌ Bad Example
// Users.tsx
import { useEffect, useState } from 'react';
import { organizationService } from '../../services/organization.service';
import { User } from '../../interfaces/user';
export function Users() {
const [users, setUsers] = useState<User[]>();
const [loading, setLoading] = useState(true);
useEffect(() => {
void (async () => {
setLoading(true);
const data = await organizationService.fetchOrganizationUsers('org-123');
setUsers(data);
setLoading(false);
})();
}, []);
if (loading) {
return <div>Loading...</div>;
}
return (
<ul>
{users?.map((user) => (
<li key={user.id}>{user.firstName} {user.lastName}</li>
))}
</ul>
);
}
This component works - but it’s hard to test:
- You have to mock the
organizationService
API just to render it - There’s no way to test the rendering in isolation from the fetch logic
- The component is doing two jobs: fetching and displaying
✅ Good Example
To make this component easier to test, we'll apply a key principle: separate data logic from component behavior.
We’ll move the API call and loading state into a custom hook, useOrganizationUsers
, and keep the Users
component responsible for rendering the list or a loading indicator.
This gives us a clean boundary:
- The hook handles data fetching
- The component handles conditional rendering
- We can mock the hook in tests to simulate different states
// useOrganizationUsers.ts
import { useEffect, useState } from 'react';
import { organizationService } from '../services/organization.service';
import { User } from '../interfaces/user';
export function useOrganizationUsers(orgId: string) {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
void (async () => {
setLoading(true);
const data = await organizationService.fetchOrganizationUsers(orgId);
setUsers(data);
setLoading(false);
})();
}, [orgId]);
return { users, loading };
}
// Users.tsx
import { useOrganizationUsers } from './useOrganizationUsers';
export function Users() {
const { users, loading } = useOrganizationUsers('org-123');
if (loading) {
return <div>Loading...</div>;
}
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.firstName} {user.lastName}</li>
))}
</ul>
);
}
🧪 Test Example (Mocking the Hook)
We want to verify that:
- The loading indicator is shown when
loading
istrue
- The users list is rendered when
loading
isfalse
To do that, we’ll mock the hook response:
// Users.spec.tsx
import { render, screen } from '@testing-library/react';
import { Users } from './Users';
jest.mock('./useOrganizationUsers', () => ({
useOrganizationUsers: jest.fn(),
}));
import { useOrganizationUsers } from './useOrganizationUsers';
const mockedUseOrganizationUsers = useOrganizationUsers as jest.Mock;
test('shows loading state', () => {
mockedUseOrganizationUsers.mockReturnValue({
users: [],
loading: true,
});
render(<Users />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
test('shows user list when loaded', () => {
mockedUseOrganizationUsers.mockReturnValue({
users: [
{ id: '1', firstName: 'John', lastName: 'Smith' },
{ id: '2', firstName: 'Jane', lastName: 'Smith' },
],
loading: false,
});
render(<Users />);
expect(screen.getByText(/John Smith/)).toBeInTheDocument();
expect(screen.getByText(/Jane Smith/)).toBeInTheDocument();
});
This pattern makes your component:
- Simple to test in isolation
- Free from real network calls in tests
- Predictable - you control all input via mocks
Designing components with hook boundaries like this gives you maximum flexibility: you can mock logic in tests, reuse it in other places, or replace it entirely if you want to switch to a different backend.
Avoid Side Effects in Rendering
Sometimes it seems harmless to use global values like Date.now()
or Math.random()
directly inside a component - after all, the browser gives them to us for free. But if your component’s output depends on something that changes every time it runs, it becomes much harder to write reliable tests.
Let’s say we want to display a time-based greeting based on the current hour. Here’s how that often gets written:
❌ Bad Example
// Greeting.tsx
export function Greeting() {
const hour = new Date().getHours();
const message = hour < 12 ? 'Good morning' : 'Good afternoon';
return <h1>{message}</h1>;
}
This works fine in production, but it’s not testable. You can’t write a predictable test for the greeting unless you’re willing to manipulate the system clock - and nobody wants to do that.
✅ Good Example
Instead, let’s inject the time-related behavior using a simple function prop. The component will never call new Date()
directly, and tests can pass in whatever behavior they want.
// Greeting.tsx
export function Greeting({ getHour }: { getHour: () => number }) {
const hour = getHour();
const message = hour < 12 ? 'Good morning' : 'Good afternoon';
return <h1>{message}</h1>;
}
🧪 Test Example
// Greeting.spec.tsx
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';
test('shows "Good morning" before noon', () => {
render(<Greeting getHour={() => 9} />);
expect(screen.getByText(/good morning/i)).toBeInTheDocument();
});
test('shows "Good afternoon" after noon', () => {
render(<Greeting getHour={() => 15} />);
expect(screen.getByText(/good afternoon/i)).toBeInTheDocument();
});
Now your component is completely deterministic. You’re testing its behavior - not the current time.
This pattern works great for anything that’s:
- Time-based (
Date
, timers) - Randomized (
Math.random
) - Global/environmental (like feature flags,
window.location
, etc.)
Design your components so that you control the behavior in tests. The less they rely on ambient state, the easier they are to verify.
Avoid Over-Mocking
It’s tempting to mock everything when writing tests - services, hooks, child components, even props. And sometimes mocking is necessary. But mocking too much can hurt your test suite more than it helps.
Over-mocking leads to:
- False confidence – your test passes even if the real implementation is broken
- Brittle coverage – changing a component’s internal behavior breaks tests that don’t need to care
- Lack of integration – your tests stop verifying how parts of the system work together
Let’s look at a common example: mocking a child component in a parent test.
❌ Bad Example
// UserList.tsx
import { User } from '../interfaces/user';
import { UserRow } from './UserRow';
interface UserListProps {
users: User[];
}
export function UserList({ users }: UserListProps) {
return (
<ul>
{users.map((user) => (
<UserRow key={user.id} user={user} />
))}
</ul>
);
}
In our test, we mock the child:
// UserList.spec.tsx
jest.mock('./UserRow', () => ({
UserRow: () => <li data-testid="mocked-row">Mocked Row</li>,
}));
test('renders the correct number of user rows', () => {
const users = [
{ id: '1', firstName: 'John', lastName: 'Smith' },
{ id: '2', firstName: 'Jane', lastName: 'Smith' },
];
render(<UserList users={users} />);
expect(screen.getAllByTestId('mocked-row')).toHaveLength(2);
});
This test technically passes - but it tells you nothing about whether UserRow
renders the actual user name, or even accepts the right props.
✅ Good Example
In most cases, you’re better off using the real component. If the component is slow or flaky, fix that component - not the test.
// UserList.spec.tsx
import { render, screen } from '@testing-library/react';
import { UserList } from './UserList';
test('renders user names', () => {
const users = [
{ id: '1', firstName: 'John', lastName: 'Smith' },
{ id: '2', firstName: 'Jane', lastName: 'Smith' },
];
render(<UserList users={users} />);
expect(screen.getByText(/John Smith/)).toBeInTheDocument();
expect(screen.getByText(/Jane Smith/)).toBeInTheDocument();
});
test('renders correct number of user rows', () => {
const users = [
{ id: '1', firstName: 'John', lastName: 'Smith' },
{ id: '2', firstName: 'Jane', lastName: 'Smith' },
{ id: '3', firstName: 'Bob', lastName: 'Johnson' },
];
render(<UserList users={users} />);
// We can test that the UserRow components are rendered with correct test IDs
expect(screen.getByTestId('user-row-1')).toBeInTheDocument();
expect(screen.getByTestId('user-row-2')).toBeInTheDocument();
expect(screen.getByTestId('user-row-3')).toBeInTheDocument();
// And verify the content is rendered correctly
expect(screen.getByText('John Smith')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
expect(screen.getByText('Bob Johnson')).toBeInTheDocument();
});
This test verifies:
- The list renders
- The
UserRow
component renders the correct names - There’s no need to mock anything
You don’t need to mock a component unless you want to isolate very specific behavior for a performance-critical or visual test. Most of the time, you should prefer real behavior over mocks. It gives you higher confidence and better coverage with less code.
That’s it for Part 1! In the next post, we’ll take a look at more strategies for writing testable front-end code - including how to use stable selectors, write pure utilities, structure integration-friendly components, and make the most of tools like MSW.
Frequently Asked Questions
Latest Posts
We’ve helped our partners to digitally transform their organizations by putting people first at every turn.