
Writing Testable Front-End Code - Best Practices, Patterns, and Pitfalls (Pt 2)
By Spencer Dangel
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:
- Added
<label>
elements withhtmlFor
attributes – This associates the labels with the inputs, which not only enablesgetByLabelText()
queries in tests, but also improves accessibility for screen readers and keyboard navigation. - Added an accessible button label and
type="submit"
– Using visible text (Log In
) allows us to locate the button viagetByRole()
, andtype="submit"
ensures proper form behavior, including keyboard support and screen reader context. - Removed reliance on class names or structure – The component no longer needs
className
s 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
Latest Posts
We’ve helped our partners to digitally transform their organizations by putting people first at every turn.