June 27, 2025

Writing Testable Front-End Code - Best Practices, Patterns, and Pitfalls (Pt 1)

By Spencer Dangel

i
Series Update: Due to the amount of information being covered, this post has turned into a two part series. Keep an eye out for the next part coming in a few days!

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 behavior
  • MSW – (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 is true
  • The users list is rendered when loading is false

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

No items found.

Latest Posts

We’ve helped our partners to digitally transform their organizations by putting people first at every turn.

2/7/2025
Writing Testable Front-End Code - Best Practices, Patterns, and Pitfalls (Pt 2)

Continuing our guide to testable front-end code with advanced patterns, real-world examples, and the traps that even experienced devs miss.

23/6/2025
Can You Trust Your MCP Server?

Think your MCP server is safe? One poisoned tool could quietly turn it into a data-leaking backdoor.

20/6/2025
Why Fractional AI Leadership Might Be The Smartest Move Your Business Can Make

Most companies don’t need a full-time AI exec—they need smart, fractional leadership that aligns AI with real business goals.

2/6/2025
Cracking the Code: Fixing Memory Leaks and File Corruption in React Native GCP Uploads

Struggling with large file uploads in React Native? We hit memory leaks and corrupted files over 2GB—then fixed it by building native modules. Here’s how.

16/5/2025
From Coders to Conductors: How AI is Helping Us Build Smarter, Faster, and Better Software

How AI Is Changing the Way We Build Software: Our developers are using AI tools like GitHub Copilot to move faster and smarter—shifting from manual coding to strategic prompting and editing. Learn how this evolving approach is helping us deliver high-quality software in less time.

13/5/2025
Why Government Tech Falls Short, And What We Can Do About It

The RFP process is broken. Here's how public sector teams can get better outcomes by partnering earlier, focusing on users, and rethinking how government tech gets built.

6/1/2025
Growing Junior Developers in Remote and AI-Enabled Environments

Nurturing junior developers in today’s remote and AI-driven workplace is essential for long-term success, yet it comes with unique challenges. This article explores practical strategies to help junior talent thrive.

2/12/2024
The Power of Discovery: Ensuring Software Project Success

Effective discovery is crucial in software development to prevent budget overruns and project delays. By conducting discovery sprints and trial projects, businesses can align goals, define scope, and mitigate risks, ensuring successful outcomes.

29/1/2023
Native vs. React Native For Mobile App Development

In this article, we address the advantages and disadvantages of native apps and compare them to those of React Native apps. We will then propose one example of a ‘good fit’ native app and a ‘good fit’ React Native app. The article concludes with a general recommendation for when you should build your application natively and when to do so in React Native.

15/1/2021
Azure Security Best Practices

Adoption of cloud services like Microsoft Azure is accelerating year over year. Around half of all workloads and data are already in a public cloud, with small businesses expanding rapidly and expecting up to 70% of their systems to be in a public cloud within the next 12 months. Are you sure your data is secure?

19/10/2020
High Cohesion, Low Coupling

In this short article I would like to show you one example of High Cohesion and Low Coupling regarding Software Development. Imagine that you have a REST API that have to manage Users, Posts and Private Message between users. One way of doing it would be like the following example: As you can see, the […]

6/12/2019
How to Find a Software Development Company

You’ve identified the need for new software for your organization. You want it built and maintained but don’t have the knowledge, time, or ability to hire and manage a software staff. So how do you go about finding a software development company for your project? Step 1: Search for Existing Software The first step in […]

19/11/2019
3 Common Problems with Custom Software Development

Custom software is a great way to increase efficiency and revenue for your organization. However, creating custom software means more risk for you. Here are a few common problems to avoid when building your next mobile or web app. 1. Cost Overrun One of the biggest challenges of custom software development is gathering requirements. The process […]

3/11/2019
Staff Augmentation vs. Project-based Consulting

So, you want to build some software. But where do you start? Maybe you’re not ready to take on the large task of hiring a team internally. Of all the options out there for building your software, two of the most common are staff augmentation and project-based consulting. So what’s best for you, staff augmentation […]

28/10/2019
Agile Isn’t the Problem

Failed implementing agile in your organization? Agile isn't the problem.

10/9/2019
Should you hire software developers?

Are you ready to hire software developers? It might be worth more investigation.

29/8/2019
How long does a project take?

Breaking down how we work and what goes into each project.

19/8/2019
Observability of Systems

Solve your next production issue with less headache and better insight.

28/6/2019
Web vs Mobile: What’s Right for You?

How to use empathy to drive decisions around the platform for your future application.

17/6/2019
5 Tricks To Help Developers with Design

Developers tend to struggle with design, but there are a few quick changes that can make your software shine.

29/10/2018
Why should you use a G Suite Resller?

As of February 2018, Google had 4 million businesses using G Suite for email and file storage, collaborating on documents, video conferencing and more.