July 2, 2025

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

By Spencer Dangel

i
Series Navigation: This is Part 2 of a two-part series on writing testable front-end code. If you haven't read Part 1, I recommend starting there - it covers foundational concepts like test types, separating concerns, and tooling you'll see referenced here.

Use Stable Selectors

One of the most common mistakes in front-end testing is relying on brittle selectors - things like DOM structure, class names, or IDs that aren't intended for testing. These make tests fragile, noisy, and overly sensitive to layout or styling changes.

A better approach is to use semantic queries that reflect how users interact with your UI - like roles, labels, and accessible names.

Let’s look at a simple login form:

❌ Bad Example

Here’s a form component - and a test that relies on class names and structure:

// LoginForm.tsx
export function LoginForm() {
    const [username, setUsername] = useState('');
    const [password, setPassword] = useState('');

    return (
        <form className="login-form">
            <input
                type="text"
                className="input username"
                value={username}
                onChange={(e) => setUsername(e.target.value)}
            />
            <input
                type="password"
                className="input password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
            />
            <button className="submit-btn">Login</button>
        </form>
    );
}
// LoginForm.spec.tsx
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

test('submits login credentials', async () => {
    const user = userEvent.setup();
    const { container } = render(<LoginForm />);

    const usernameInput = container.querySelector('.username');
    const passwordInput = container.querySelector('.password');
    const button = container.querySelector('.submit-btn');

    await user.type(usernameInput!, 'admin');
    await user.type(passwordInput!, 'password');
    await user.click(button!);
});

This test works - but it’s fragile: the test is tightly coupled to class names and assumes knowledge of internal structure - not behavior or intent.

✅ Good Example

Instead, let’s write the component and the test in a way that aligns with how real users interact with the form - not how it happens to be styled.

Updated Component

// LoginForm.tsx
import { useState } from 'react';

export function LoginForm() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = (e: React.FormEvent) => {
      e.preventDefault();
      
      // In a real app, you might call an API here
      // await loginService.login(username, password);
  };

  return (
      <form onSubmit={handleSubmit}>
          <label htmlFor="username">Username</label>
          <input 
            id="username" 
            type="text" 
            name="username" 
            value={username}
            onChange={(e) => setUsername(e.target.value)}
          />

          <label htmlFor="password">Password</label>
          <input 
            id="password" 
            type="password" 
            name="password" 
            value={password}
            onChange={(e) => setPassword(e.target.value)}
          />

          <button type="submit">Log In</button>
      </form>
  );
}

What changed?

We made three key improvements:

  1. Added <label> elements with htmlFor attributes – This associates the labels with the inputs, which not only enables getByLabelText() queries in tests, but also improves accessibility for screen readers and keyboard navigation.
  2. Added an accessible button label and type="submit" – Using visible text (Log In) allows us to locate the button via getByRole(), and type="submit" ensures proper form behavior, including keyboard support and screen reader context.
  3. Removed reliance on class names or structure – The component no longer needs classNames to be testable, and the test doesn't depend on layout or styling.

Updated Test

// LoginForm.spec.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

test('submits login credentials', async () => {
    const user = userEvent.setup();
    render(<LoginForm />);

    await user.type(screen.getByLabelText(/username/i), 'admin');
    await user.type(screen.getByLabelText(/password/i), 'password');
    await user.click(screen.getByRole('button', { name: /log in/i }));
});

This test now:

  • Uses semantic queries that match real user interaction
  • Works regardless of layout or CSS changes
  • Encourages accessibility best practicesby reinforcing proper form structure and keyboard operability

Always prefer getByLabelText, getByRole, or getByText when possible - they lead to more meaningful, less fragile, and more accessible code. When semantic selectors aren’t feasible (e.g., in a dynamic dropdown), fall back to data-testid - but use it intentionally, not by default.

Write Pure Utility Functions

Utility functions are the easiest code to test - or at least, they should be. But when you sneak in global state, side effects, or randomness, even a simple helper can become hard to reason about.

A pure function should always return the same output for the same input, with no dependencies on time, randomness, or external state.

Let’s look at a common example: generating a randomized greeting.

❌ Bad Example

// greetings.ts
export function getRandomGreeting() {
    const greetings = ['Hi', 'Hello', 'Hey there'];
    const index = Math.floor(Math.random() * greetings.length);
    return greetings[index];
}

You can’t write a reliable test for this - the output changes every time you call it. You’d have to test against all possible values or manipulate Math.random, which is messy and brittle.

✅ Good Example

Instead, we make the randomness explicit by injecting it:

// getRandomGreeting.ts
export function getRandomGreeting(random: () => number) {
    const greetings = ['Hi', 'Hello', 'Hey there'];
    const index = Math.floor(random() * greetings.length);
    return greetings[index];
}

Now the function is fully testable, and the behavior is predictable:

// getRandomGreeting.spec.ts
import { getRandomGreeting } from './getRandomGreeting';

test('returns the first greeting', () => {
    const alwaysZero = () => 0;
    expect(getRandomGreeting(alwaysZero)).toBe('Hi');
});

test('returns the second greeting', () => {
    const fixedRandom = () => 0.4; // between index 1 and 2
    expect(getRandomGreeting(fixedRandom)).toBe('Hello');
});

You could even wire it up like this in production code:

getRandomGreeting(Math.random);

Keep your utility functions pure and pass in external behavior. If your function uses time, randomness, or global state, consider injecting those values instead.

Test the Right Thing at the Right Level

When working on front-end features, it's easy to default to writing big integration tests that render entire pages and try to assert everything at once. But just because a component can be tested as a whole doesn’t mean it always should.

Take a checkout page, for example. It likely includes:

  • A cart summary
  • A payment form
  • A terms and conditions checkbox
  • A review and submit section

If each of these components is already tested in isolation - with clear props, accessible selectors, and predictable behavior - then testing the full CheckoutPage becomes much simpler. You only need to verify that:

  • The correct sections render in the right order
  • The interactions between them work as expected (e.g. submitting the full flow)

Rather than writing a single massive test that covers all logic and edge cases, focus your effort where it matters:

  • Test individual components for their specific behavior
  • Write lightweight integration tests for critical workflows
  • Use end-to-end tests only to cover high-value flows like checkout, onboarding, or login

The best tests are the smallest ones that still give you confidence. Test small components with unit tests. Compose them with light integration tests. Use full-page tests when necessary - but don’t let them become your default.

Bonus: Use MSW for Integration Testing

Sometimes, you can't separate concerns as cleanly as you'd like. Maybe a third-party package wraps the logic. Maybe a legacy component handles its own data fetching. Or maybe - for good reason - you need to test the actual integration with the network layer.

That’s where Mock Service Worker (MSW) comes in.

What is MSW?

MSW (Mock Service Worker) intercepts network requests at the network layer during your tests (or dev environment), and returns mocked responses - just like a real backend.

Unlike mocking fetch or axios directly, MSW works at the browser or Node level:

  • No need to mock implementation details
  • Your code still behaves exactly like it would in production
  • You test real behavior - not stubs

When to Use It

  • You want to test a component that must fetch data on its own
  • You want realistic responses for integration or UI testing
  • You don’t want to tightly couple tests to internal logic
  • You need consistent, predictable server responses without spinning up a backend

Let’s revisit the original UserList example - but assume that for architectural reasons, it must load users internally:

// UserListWithFetch.tsx
import { useEffect, useState } from 'react';
import { User } from '../interfaces/user';

export function UserListWithFetch() {
    const [users, setUsers] = useState<User[]>([]);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        void (async () => {
            const res = await fetch('/api/users');
            const data = await res.json();
            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 doesn’t take props. It doesn't expose any hooks. You can’t easily inject anything - but you still want to test it.

Testing with MSW

First, define an MSW handler for the /api/users endpoint:

// testServer.ts
import { rest } from 'msw';
import { setupServer } from 'msw/node';

export const server = setupServer(
    rest.get('/api/users', (req, res, ctx) => {
        return res(
            ctx.status(200),
            ctx.json([
                { id: '1', firstName: 'John', lastName: 'Smith' },
                { id: '2', firstName: 'Jane', lastName: 'Smith' },
            ])
        );
    })
); 

Set up the server for your tests:

// setupTests.ts (called from jest config)
import '@testing-library/jest-dom';
import { server } from './testServer';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

We also need to configure the node-fetch polyfills:

// setupFetch.ts
const nodeFetch = require('node-fetch');

// Polyfill fetch for Node.js environment
(global as any).fetch = nodeFetch;

// Polyfill Response, Request, Headers for MSW/node-fetch compatibility
if (typeof (global as any).Response === 'undefined') {
  (global as any).Response = nodeFetch.Response;
}
if (typeof (global as any).Request === 'undefined') {
  (global as any).Request = nodeFetch.Request;
}
if (typeof (global as any).Headers === 'undefined') {
  (global as any).Headers = nodeFetch.Headers;
}

// Polyfill BroadcastChannel for MSW compatibility
if (typeof (global as any).BroadcastChannel === 'undefined') {
  (global as any).BroadcastChannel = class {
    constructor() {}
    postMessage() {}
    close() {}
    addEventListener() {}
    removeEventListener() {}
  };
}

// Polyfill TextEncoder and TextDecoder for MSW/node-fetch compatibility
if (typeof (global as any).TextEncoder === 'undefined') {
  const { TextEncoder, TextDecoder } = require('util');
  (global as any).TextEncoder = TextEncoder;
  (global as any).TextDecoder = TextDecoder;
} 

Then write your test:

// UserListWithFetch.spec.tsx
import { render, screen } from '@testing-library/react';
import { UserListWithFetch } from './UserListWithFetch';

test('renders users from the API', async () => {
    render(<UserListWithFetch />);
    
    expect(screen.getByText(/loading/i)).toBeInTheDocument();

    expect(await screen.findByText('John Smith')).toBeInTheDocument();
    expect(screen.getByText('Jane Smith')).toBeInTheDocument();
}); 

No need to mock fetch. No need to modify the component. MSW intercepts the request and provides the response - just like your real backend would.

Why MSW Is Worth Learning

  • You can write realistic integration tests without touching component logic
  • You reduce coupling between your tests and your implementation
  • You simulate API behavior without modifying the code you're testing

MSW is especially useful when you're testing third-party components, legacy code, or UI libraries where you can’t change how data is fetched - but still need to verify behavior.

Wrapping Up: Build for Testability

Testable code doesn’t happen by accident - it happens by design.

If there's one theme running through everything in this post, it's that the best tests are the result of intentionally structured code. You don't need a fancy framework or a perfect setup - you just need to make your code easier to observe, easier to control, and easier to reason about.

Here’s a quick recap of what we’ve covered:

  • Separate concerns so that data logic and UI rendering don’t fight for space in your components
  • Mock behavior at the boundaries, not everywhere - over-mocking hides real bugs
  • Avoid global side effects in rendering - inject time, randomness, or config where needed
  • Use stable selectors (getByRole, getByLabelText, etc.) so your tests reflect real user interaction
  • Write pure functions that are predictable and easy to test in isolation
  • Don’t test too much at once - test small units, compose with lightweight integration tests
  • Use MSW to test components that depend on network requests without rewriting them

Not everything needs to be mocked. Not everything needs to be split up. The real goal is balance: write your code in a way that makes testing feel like a natural next step - not a separate phase of development.

Good tests are a byproduct of good design. And testable code is maintainable code. If you structure your components with testing in mind, the tests will come easier, run faster, and give you confidence that your UI is actually working the way you think it is.

All of the examples from this article are available in a minimal React app here: https://github.com/seven-hills-technology/testable-frontend-code-examples.

Fair warning - the app itself is intentionally barebones. The focus is on showcasing components and patterns that support effective testing, not on building a polished UI.

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.

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

A practical guide to writing testable front-end code, mocking strategies, and applying best practices to ensure your codebase stays clean, maintainable, and covered.

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.