
Before React 18 came along, I mainly used Enzyme to test my applications. To keep our applications up to date, we were required to convert all our tests from Enzyme to React Testing Library (RTL), as Enzyme was no longer supported from React 18. I thought I could do this by simply rewriting my tests to use RTL and just switch out the library methods, such as ‘shallow’ rendering to RTL’s equivalent. Shouldn’t take too long, right…?
I stumbled across my first hurdle. RTL didn’t have a shallow render method?! I began to do research and quickly realised that transitioning to RTL wouldn’t be as simple as I first thought.
Whilst Enzyme encourages testing instances of components in isolation of each other, the fundamental principle behind RTL is to test your application how the user would interact with it.
Avoid testing implementation details
One thing I noticed with Enzyme is that whenever I refactored my code, my tests would break and I would have to decipher my tests to see if the application was actually broken or if I needed to update my tests to reflect my refactored implementation.
The behaviour of the application is the same but the test needs updating regardless. Tests are supposed to catch breakages in the application. Why should I have to update my tests even though my application has not broken?
This is the very definition of testing implementation details and is something we should avoid as much as we can.
This post will outline how to make your tests resilient to change, using RTL, and test your application how the user would interact with the webpage.
Implementation detail free testing
Let’s look at an example. We have a property-search-filters
component and a property-results
component which look like this (as you can see, my design skills need some work!).
The rendered components look like this:

Here’s the implementation:
// Page.tsx const Page = () => { const [properties, setProperties] = useState<Property[]>([]); return ( <div> <div> <PropertySearchFilters setProperties={setProperties} /> <PropertyResults properties={properties} /> </div> </div> ); }; // PropertySearchFilters.tsx const PropertySearchFilters = ({ setProperties }: PropertySearchFiltersProps) => { const [filters, setFilters] = useState<Filters>({ postcode: "" }); const updateFilter = (field: string, value: string) => setFilters((oldFilters) => ({ ...oldFilters, [field]: value })); const handleSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); const res = await fetch(`/api/property?postcode=${filters.postcode}`); if (res.ok) { const json = await res.json(); setProperties(json.properties); } }; return ( <div> <form onSubmit={handleSubmit}> <h3>Property Search Filters</h3> <Input label="Postcode" name="postcodeInput" onChange={(e) => updateFilter("postcode", e.target.value)} /> {/* ... Other search filters */} <Button type="submit">Apply</Button> </form> </div> ); };
Here’s the test:
// PropertySearchFilters.spec.tsx test("setProperties is called with the response from the fetch request", async () => { const mockProperties = [ { title: "1 bedroom flat for sale", address: "Fake Lane, Borough, London, W32 28J", image: "https://images.pexels.com/photos/186077/pexels-photo-186077.jpeg?cs=srgb&dl=pexels-binyamin-mellish-186077.jpg&fm=jpg", }, ]; global.fetch = jest.fn().mockResolvedValue({ ok: true, json: () => ({ properties: mockProperties, }), }); const setPropertiesMock = jest.fn(); render(<PropertySearchFilters setProperties={setPropertiesMock} />); await userEvent.type(screen.getByRole("textbox", { name: "Postcode" }), "W32 2BJ"); await userEvent.click(screen.getByRole("button", { name: "Apply" })); expect(setPropertiesMock).toHaveBeenCalledTimes(1); expect(setPropertiesMock).toHaveBeenCalledWith(mockProperties); });
As you can see in the test, we are ensuring that the correct function is called (setProperties
), when the ‘Apply’ button is clicked.
Now, let’s refactor our implementation to modularise the property logic and loosely couple it from the rest of the code to make it more readable and re-usable. We’ll do this through the use of a custom hook.
Our new code looks like this:
// Page.tsx const Page = () => { const { properties, fetchProperties } = usePropertiesState(); return ( <div> <div> <PropertySearchFilters fetchProperties={fetchProperties} /> <PropertyResults properties={properties} /> </div> </div> ); }; // PropertySearchFilters.tsx const PropertySearchFilters = ({ fetchProperties }: PropertySearchFiltersProps) => { const [filters, setFilters] = useState<Filters>({ postcode: "" }); const updateFilter = (field: string, value: string | number) => setFilters((oldFilters) => ({ ...oldFilters, [field]: value })); const handleSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); await fetchProperties(filters); }; return ( <div> <form onSubmit={handleSubmit}> <h3>Property Search Filters</h3> <Input label="Postcode" name="postcodeInput" onChange={(e) => updateFilter("postcode", e.target.value)} /> {/* ... other filter fields */} <Button type="submit">Apply</Button> </form> </div> ); }; // usePropertiesState.ts const usePropertiesState = () => { const [properties, setProperties] = useState<Property[]>({ ([]); const fetchProperties = async (filters: Filters) => { const { postcode } = filters; const res = await fetch(`/api/property?postcode=${postcode}`); if (res.ok) { const json = await res.json(); setProperties(json.properties); } }; return { properties, fetchProperties }; };
Let’s run our test to make sure we haven’t broken anything…
Running…
Failure! Jest is complaining!!!


The problem with this test is that it tightly couples the test details to the specific implementation details of the component. We have changed the prop
for PropertySearchFilters
to be fetchProperties
, therefore setPropertiesMock
is no longer being called.
If the implementation of the component changes in the future, this test will break even if the functionality remains the same. In other words, the test has given us a false negative. This makes the test fragile and difficult to maintain.
A better approach to testing your application is to write tests that resemble how your user would interact with your application.
The more your tests resemble the way your software is used, the more confidence they can give you.
Kent C. Dodds (@kentcdodds)
Let’s have another go at rewriting this test case and go through the same refactor as above:
test("properties are returned when filters are applied", async () => { const mockProperties = [ { title: "1 bedroom flat for sale", address: "Fake Lane, Borough, London, W32 28J", image: "https://images.pexels.com/photos/186077/pexels-photo-186077.jpeg?cs=srgb&dl=pexels-binyamin-mellish-186077.jpg&fm=jpg", }, ]; global.fetch = jest.fn().mockResolvedValue({ ok: true, json: () => ({ properties: mockProperties, }), }); // GIVEN render(<Page />); // WHEN await userEvent.type(screen.getByRole("textbox", { name: "Postcode" }), "W32 2BJ"); await userEvent.click(screen.getByRole("button", { name: "Apply" })); // THEN expect(screen.getByRole("heading", { name: "1 bedroom flat for sale" })).toBeInTheDocument(); expect(screen.getByText("Fake Lane, Borough, London, W32 28J")).toBeInTheDocument(); });
Running…..
Pass!

In this test case, we have moved the test to page level (Page.spec.tsx
), rather than component level (PropertiesSearchFilters.spec.tsx
) because this is where the property’s state
and setState
live, meaning we don’t have to mock it to test its functionality.
This allows us to test the application the same way the user interacts with the webpage. It is not concerned with any implementation details. It is only concerned with what the user sees:
- The page loads.
- The user types a valid postcode into the postcode input.
- The user clicks the Apply button.
- The user sees the property results (in this case, we assert against the property title and address).
Freedom to refactor
Testing in this way, we are no longer tied down to testing the implementation details of this component so when we make the refactor above and change the implementation details, the test will still pass.
If our application were tested entirely with this approach, we would have absolute confidence to make enormous refactors, if we wish to, knowing that our tests will catch breakages and not changes in implementation.
As a result, we would spend far less time maintaining our tests, which we all don’t like, and more time on building exciting new features.
Tests reflect the acceptance criteria
Testing this way makes tests more readable and reflect the Acceptance Criteria we set for a given piece of work, meaning we have much more direction when writing these tests.
i.e. the acceptance criteria could read:
GIVEN I have loaded the page.
WHEN I type a valid postcode into the postcode input field and click “Apply”.
THEN I should see a list of properties returned.
If our test cases read, “setProperties
is called with the response from the fetch request” and “Correct snapshot is rendered given properties”, this would resemble the Acceptance Criteria far less than, “properties are returned when a valid postcode is input and ‘Apply’ is clicked”.
We should strive to make our test cases readable by our QA engineer!
Conclusion
I appreciate this is a sandboxed example, however, imagine your codebase was tested entirely in this way.
Change is one of the aspects that is inevitable in software engineering.
Ask yourself, how easy would it be to change your code and make large refactors without breaking a lot of your tests?
If the answer is, not easy, then you may significantly benefit from having a look at how you can better rewrite your tests to resemble how the user would interact with your application.
React Testing Library provides fantastic utilities in order to be able to test this way.
Over the long run, you will spend far less time maintaining your tests, deciphering whether the code or tests need updating every time you want to introduce a new feature, refactor your code or fix a bug.