Stop Testing Implementation Details in Your Frontend Code
Testing Frontend Best PracticesAs developers, we all want to write good tests. But sometimes, without realizing it, we write tests that make our lives harder instead of easier. Today, let’s talk about one of the biggest testing mistakes: testing implementation details.
What Are Implementation Details?
Think of implementation details as the “behind-the-scenes” stuff in your code. It’s like the engine of a car, users don’t care how the engine works internally, they just care that the car moves when they press the gas pedal.
Simple rule: If a user (either an end-user or a developer using your component) doesn’t directly interact with it, see it, or need to know about it, then it’s probably an implementation detail.
Why Testing Implementation Details Is Bad
There are two main problems with testing implementation details:
- Your tests break when you refactor code (even when the app still works fine)
- Your tests pass when your app is actually broken
Let’s see this in action with a simple example.
Example: A Counter Component
Let’s say we have a simple counter component:
// Counter.js
import React, {useState} from 'react';
function Counter() {
const [count, setCount] = useState(0);
const increment = () => {
setCount(count + 1);
};
const decrement = () => {
setCount(count - 1);
};
return (
<div>
<h2>Count: {count}</h2>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
}
export default Counter;
The Wrong Way: Testing Implementation Details
Here’s how many developers (including myself in the past) would test this:
// Counter.test.js - BAD EXAMPLE
import {render} from '@testing-library/react';
import Counter from './Counter';
test('increment function increases count state', () => {
const wrapper = render(<Counter />);
const component = wrapper.container.firstChild;
// Testing internal state - BAD!
expect(component.state.count).toBe(0);
// Calling internal methods directly - BAD!
component.increment();
expect(component.state.count).toBe(1);
});
test('component has increment and decrement methods', () => {
const wrapper = render(<Counter />);
const component = wrapper.container.firstChild;
// Testing that internal methods exist - BAD!
expect(typeof component.increment).toBe('function');
expect(typeof component.decrement).toBe('function');
});
What’s wrong with this approach?
- Testing internal state directly: We’re checking
component.state.count
, but users never see or care about this internal state variable - Calling methods directly: We’re calling
component.increment()
directly instead of simulating how a real user would interact (clicking a button) - Testing method existence: We’re checking if methods exist, but this doesn’t tell us if the component actually works for users
- Fragile tests: If we rename
increment
tohandleIncrement
or change the state structure, these tests break even though the component still works perfectly
Problem 1: Tests Break During Refactoring
Let’s say we want to refactor our counter to use a reducer instead of useState:
// Counter.js - Refactored version
import React, {useReducer} from 'react';
function counterReducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return {value: state.value + 1};
case 'DECREMENT':
return {value: state.value - 1};
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, {value: 0});
const handleIncrement = () => {
dispatch({type: 'INCREMENT'});
};
const handleDecrement = () => {
dispatch({type: 'DECREMENT'});
};
return (
<div>
<h2>Count: {state.value}</h2>
<button onClick={handleIncrement}>+</button>
<button onClick={handleDecrement}>-</button>
</div>
);
}
export default Counter;
What happens? Our tests break! 💥
- There’s no
count
state anymore (it’sstate.value
now) - There’s no
increment
method anymore (it’shandleIncrement
now) - The component works exactly the same for users, but our tests fail
Why this proves implementation details testing is bad:
This refactoring is a perfect example of why testing implementation details hurts productivity. We improved our code’s internal structure (using useReducer for better state management), but our tests punish us for it. The component still:
- Shows “Count: 0” initially
- Increments when + button is clicked
- Decrements when - button is clicked
- Looks and behaves identically to users
Yet our tests fail because they were coupled to internal implementation details that changed. This forces developers to spend time fixing tests instead of adding features or fixing real bugs.
Problem 2: Tests Pass When App Is Broken
Let’s say someone accidentally breaks the button click handlers:
// Counter.js - BROKEN VERSION
function Counter() {
const [count, setCount] = useState(0);
const increment = () => {
setCount(count + 1);
};
const decrement = () => {
setCount(count - 1);
};
return (
<div>
<h2>Count: {count}</h2>
{/* OOPS! Forgot onClick handlers */}
<button>+</button>
<button>-</button>
</div>
);
}
Our implementation detail tests still pass because:
- The
increment
anddecrement
functions still exist - The state still works when we call them directly
- But users can’t actually use the counter because buttons don’t work!
Why this is dangerous:
This scenario shows the worst-case problem with implementation detail testing false confidence. Our tests give us a green checkmark, making us think everything works, but users can’t actually use our app!
The tests pass because:
- We’re testing that methods exist (they do)
- We’re calling methods directly (they work when called directly)
- We’re checking internal state (it updates correctly when methods are called)
But we’re not testing the critical connection between user actions (clicking buttons) and the methods being called. This is exactly the kind of bug that slips into production and breaks the user experience.
The Right Way: Test Like a User
Here’s how we should test our counter:
// Counter.test.js - GOOD EXAMPLE
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';
test('user can increment and decrement the counter', async () => {
const user = userEvent.setup();
render(<Counter />);
// Check initial state by looking at what user sees
expect(screen.getByText('Count: 0')).toBeInTheDocument();
// Interact like a real user would
await user.click(screen.getByText('+'));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
await user.click(screen.getByText('+'));
expect(screen.getByText('Count: 2')).toBeInTheDocument();
await user.click(screen.getByText('-'));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
What makes this approach better?
- Tests real user interactions: We click actual buttons like a real user would, not call methods directly
- Verifies what users see: We check the displayed text (“Count: 1”) instead of internal state variables
- Tests the complete flow: From user action (click) → internal logic → visual feedback, ensuring the whole chain works
- Implementation agnostic: This test works whether we use
useState
,useReducer
, class components, or any other implementation - Catches real bugs: If buttons aren’t wired up correctly, this test will fail
- Survives refactoring: We can completely rewrite the internal logic and this test will still pass as long as the behavior is correct
Why This Approach Is Better:
- Refactor-friendly: This test passes whether we use
useState
,useReducer
, or even a completely different state management approach - Catches real bugs: If someone breaks the button handlers, this test will fail
- Tests user behavior: We’re testing what users actually care about, can they click buttons and see the count change?
Another Example: Login Form
Let’s look at a more complex example:
// LoginForm.js
import React, {useState} from 'react';
function LoginForm({onLogin}) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
try {
await onLogin({email, password});
} catch (error) {
console.error('Login failed:', error);
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type='email'
placeholder='Email'
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type='password'
placeholder='Password'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type='submit' disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</button>
</form>
);
}
export default LoginForm;
Wrong Way: Testing Implementation Details
// LoginForm.test.js - BAD EXAMPLE
test('handleSubmit function sets loading state', () => {
const mockOnLogin = jest.fn();
const wrapper = render(<LoginForm onLogin={mockOnLogin} />);
// Testing internal state - BAD!
expect(wrapper.state.isLoading).toBe(false);
// Calling internal methods directly - BAD!
wrapper.instance().handleSubmit({preventDefault: jest.fn()});
expect(wrapper.state.isLoading).toBe(true);
});
What’s wrong with this login form test?
- Testing internal state: We’re checking
wrapper.state.isLoading
directly, but users don’t care about a state variable, they care about seeing “Logging in…” text - Calling methods directly: We’re calling
handleSubmit
directly with a fake event, bypassing the actual form submission process - Missing user interaction: We’re not testing if the form actually submits when a user clicks the button
- Incomplete test: We’re not verifying that the
onLogin
function gets called with the right data - Fragile: If we rename the state variable or method, this test breaks even if the component works perfectly
Right Way: Test User Behavior
// LoginForm.test.js - GOOD EXAMPLE
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
test('user can submit login form', async () => {
const user = userEvent.setup();
const mockOnLogin = jest.fn();
render(<LoginForm onLogin={mockOnLogin} />);
// User types their credentials
await user.type(screen.getByPlaceholderText('Email'), 'user@example.com');
await user.type(screen.getByPlaceholderText('Password'), 'password123');
// User clicks submit
await user.click(screen.getByText('Login'));
// Check that onLogin was called with correct data
expect(mockOnLogin).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123'
});
});
test('shows loading state when submitting', async () => {
const user = userEvent.setup();
const slowOnLogin = () => new Promise((resolve) => setTimeout(resolve, 100));
render(<LoginForm onLogin={slowOnLogin} />);
await user.type(screen.getByPlaceholderText('Email'), 'user@example.com');
await user.type(screen.getByPlaceholderText('Password'), 'password123');
await user.click(screen.getByText('Login'));
// User should see loading state
expect(screen.getByText('Logging in...')).toBeInTheDocument();
});
Why these login form tests are much better:
First test - Form submission:
- Real user workflow: Type email → type password → click submit button (exactly what users do)
- Tests actual behavior: Verifies the
onLogin
function receives the correct data - No implementation details: We don’t care about state variables or internal methods
- Complete integration: Tests the entire flow from user input to function call
Second test - Loading state:
- Tests user-visible feedback: Checks for “Logging in…” text that users actually see
- Realistic scenario: Uses a slow async function to simulate real API calls
- User perspective: Focuses on what the user experiences during loading
- Implementation independent: Works regardless of how loading state is managed internally
These tests will continue to pass even if we:
- Rename state variables or methods
- Switch from
useState
touseReducer
- Change the internal logic completely
- Refactor the component structure
But they will fail if we break something users care about, like the form not submitting or the loading state not showing.
Simple Test Writing Process
- Think like a user: What would a real person do with this component?
- Write the steps: “User types email, user clicks submit, user sees success message”
- Turn steps into test: Use testing library to simulate those exact user actions
- Verify the outcome: Check what the user would see or experience
Conclusion
Remember: The more your tests resemble the way your software is used, the more confidence they can give you.
When you test implementation details, you’re creating tests that:
- Break when you refactor (even when nothing is actually broken)
- Don’t catch real bugs that affect users
- Make your codebase harder to maintain
When you test user behavior instead, you get:
- Tests that survive refactoring
- Tests that catch real problems
- Confidence that your app actually works for users
Your tests should be your safety net, not your burden. Focus on testing what users care about, and your tests will serve you much better in the long run.