Testing strategies for a Single Page Application as it relates to state management
In my experience the presence of global state management is a common cause of friction in testing React applications.
Tooling
There are three kinds of environments that we might write tests:
-
Jest/RTL/JSDOM
-
Storybook
-
Cypress/Playwright
- E2E tests against a deployed application
- Component tests
Some disparate caveats and philosophies
1. Writing new code to be testable, and writing tests for an existing codebase with low test coverage are two completely different practices
It's worth keeping in mind that if you're reading an article on 'here's how to write testable' code, as sound and useful as the advice might be, it may be only useful within the context of if you were creating a new project/new module.
Writing tests for some existing, untested code, shouldn't involve (or in practise, should minimise) any 'we need to rewrite the code in order to test it'.
An example of how this distinction might play out, is I would generally give the advice that you shouldn't rely on module mocking as your testing strategy, and you should prefer dependency injection instead (more on this later). However, module mocking can be very a powerful tool in testing a codebase that hasn't implemented a dependency injection pattern.
2. Developers will copy what they already see in the codebase
As a general philosophy for all software development, developers are going to continue with the patterns they already see in the codebase. Questions like 'how do I get this thing from state?' or 'How do I interact with this button in a test?' they'll look at what other code has done, and copy that, and continue to copy that as long as it is being effective in getting their job done.
3. It's easier to write tests if the existing test infrastructure is already set up.
Some testing strategies (eg. using MSW) may involve creating large amounts of boilerplate and/or test data, and once this is done writing tests might be somewhat easy.
But in an untested codebase, creating this large amount of test data and boilerplate maybe an unpalatable and daunting proposition.
4. We're talking about a REST API here
Some of the problems I'll mentioned may be solved with GraphQL. However let's just assume that we have a REST API and we're not about change that.
5. The industry needs to do a better job writing documentation for how to write tests for code using their frameworks.
TanStack does have a dedicated testing section in their docs.
Note though, their examples don't include tests.
6. If I had a codebase with zero tests, I would start with end-to-end tests
E2E tests serve as an implicit test of a bunch of functionality, giving you test coverage of a lot of surface area quickly.
7. Any technical strategy is going to vary on business context
There's a big difference in what your testing strategy will be for a mature 200 person organisation, vs a five person start up, vs a 10,000 person multinational.
The application we are testing
Let's take an application like Github. For the purposes of this exercise, let's ignore all of the git operations involved, which is not something I have given much thought to, and we'll assume that it's otherwise a CRUD app.
When we create an issue or view, here are some of the user interactions available to us.
- When creating an issue there is a title and body textfield to be filled
- The body textfield needs to respond to keyboard shortcuts
- The body textfield needs to respond to pasting files/images in
- In the body textfield if we type
#
it gives us a selector to select other issues/pull requests - In the body textfield if we type
@
it gives us a selector to select users. - On the right hand panel we can assign users, projects, labels etc.
Clearly a non-trivial application, and that kind of thing that we really do want some automated tests on.
In this post we'll explore two ways of writing the code:
First, we use hooks into global state, where the state is used:
function GithubMarkdownEditor(props) {
const availableUsersResult = useUsers();
const availableIssuesAndPrsResult = useIssuesAndPrs();
const addFileToMarkdown = useAddFileToMarkdown();
return <>
implementation here
</>
}
The other approach is that we provide our state via props:
function GithubMarkdownEditor(props) {
const {availableUsers, availableIssuesAndPrs, addFileToMarkdown} = props;
return <>
implementation here
</>
}
How the state management tools suggest you test
The state management library commonly recommend using MSW to Mock API calls, and state that you should include the logic of your state management layer within the unit under test.
See:
Prefer writing integration tests with everything working together. For a React app using Redux, render a <Provider> with a real store instance wrapping the components being tested. Interactions with the page being tested should use real Redux logic, with API calls mocked out so app code doesn't have to change, and assert that the UI is updated appropriately.
Do not try to mock selector functions or the React-Redux hooks! Mocking imports from libraries is fragile, and doesn't give you confidence that your actual app code is working.
I think the reasoning here is sound - often what we want when it comes to testing, isn't just 'when this data exists' - we also want to see the behaviour while the data is loading, etc.
So here's how we might write a test using MSW mocking:
import { rest } from 'msw'
import { setupServer } from 'msw/node'
const server = setupServer(
// Describe the requests to mock.
rest.get('/users', (req, res, ctx) => {
return res(
ctx.json([{
userId: "1",
name: "Fooby"
}]
))
}),
)
beforeAll(() => {
server.listen()
})
afterAll(() => {
server.close()
})
describe(GithubMarkdownEditor, () => {
it("pressing @ will open the users menu", () => {
render(<GithubMarkdownEditor/>);
userEvent.type(screen.getByRole("textbox"), "@");
expect(...)
})
})
One improvement/amendment - MSW not necessary - Service injection instead
One thing I might suggest, is that instead of using MSW to mock API endpoints, we use service functions that we provide via what I call a service injection pattern, which is a dependency injection pattern.
//services/users.ts
async function getUsers() {
// But actually, we have more logic here re: authentication refresh, headers etc
return fetch('/users');
}
// serviceHooks/users.ts
-import {getUsers} from "services/users";
function useUsers() {
+ const {getUsers} = useServices();
return useQuery({
queryKeys: ['users'],
queryFn: async () => {c
const result = await getUsers();
return result;
}
})
}
Below is all of the boilerplate to set up the service injection context provider.
// providers/ServiceProvider
import React, { PropsWithChildren } from "react";
import * as allServices from "../services/index";
export type AllServices = typeof allServices;
// Default behaviour is that each of the services just throw an error
const DEFAULT_SERVICES = Object.keys(allServices).reduce((acc, cur) => {
acc[cur as keyof AllServices] = () => {
throw new Error(`${cur} service not provided`)
}
return acc;
}, {} as AllServices)
const ServiceProviderContext = React.createContext(DEFAULT_SERVICES);
/**
* We can choose to instantiate the services with our own overrides, for testing
*/
export function ServiceProvider(props: PropsWithChildren<Partial<AllServices>>) {
const { children, ...rest } = props;
return <ServiceProviderContext.Provider value={{
...allServices,
...rest
}}>
{props.children}
</ServiceProviderContext.Provider>
}
export const useServices = () => {
return React.useContext(ServiceProviderContext);
};
// App.tsx
import * as allServices from "../services/index";
export function App() {
// In production we use the real services
return <ServiceProvider {...allServices}>
<RestOfTheApp/>
</ServiceProvider>
}
Now we can have a test like this:
describe("Some Component", () => {
it("Happy path", () => {
render(<ServiceProvider getUsers={async () => {
return [
{
userId: "1",
name: "Fooby"
}
]
}}
>
<GithubMarkdownEditor/>
</ServiceProvider>);
})
})
For configuring Redux to accept dependency injection, see this thread here. Permalink
In my opinion this is less cumbersome than setting up MSW and we get the advantages of the TypeScript providing hints at what the return value of each of our service functions should be.
Of course, caveat #1 applies here, if we didn't already have code configured, this this pattern might not work, and that's where MSW could be a very powerful tool.
What about for a more complicated state management?
Whether you use a tool like MSW or you use a service injection pattern, the picture we've currently got is a nice simple blue sky.
But what if our state management is more convoluted?
What if the bit of state management that got the available users looked more like:
function useUsers() {
const {getUsers} = useServices();
+ const getDisplayNameFormat = useDisplayNameFormatLazy();
return useQuery({
queryKeys: ['users'],
queryFn: async () => {
const users = await getUsers();
+ const displayNameFormat = await getDisplayNameFormat();
return users.map((v) => {
+ // some kind of transformation logic based on the displayname format here
})
}
})
}
Then ok, we can write a test like this:
describe("Some Component", () => {
it("Happy path", () => {
render(<ServiceProvider getUsers={async () => {
return [
{
userId: "1",
name: "Fooby"
}
]
}}
+ getDisplayNameFormat={async () => {
+ return "$firstName $lastName"
+ }}
>
<GithubMarkdownEditor/>
</ServiceProvider>);
})
})
But what if it was really convoluted?
function useUsers(projectId: string) {
const {getUsers} = useServices();
const getProjectLazy = useProjectLazy(); // State management hook, comes from GET /project/{id}
const exclusionTags = useExclusionTags(); //State management hook, comes from GET /settings/exclusion-tags
return useQuery({
queryKeys: ['users'],
queryFn: async () => {
const project = await getProjectLazy(projectId);
const projectTags = project.tags;
const projectSubProjects = project.subProjects;
// Also need to fetch the sub projects to find their tags
const otherProjects = Promise.all(projectSubProjects.map((v) => v.getProjectLazy(v)));
// Put into one list and dedupe
const allTags = [...otherProjects.flatMap((v) => v.tags), ...projectTags];
const allTagsDeduped = [...new Set(allTags)];
// Filter out the exclusion tags
const filteredTags = allTagsDeduped.filter((v) => !exclusionTags.data.includes(v));
//Fetch the users, but only show the ones that match on tags
const users = await getUsers()
return users.filter((user) => filteredTags.includes(user.tag));
}
})
}
Writing a test that encompasses all of this logic sounds painful! All just to populate a dropdown when we press the @
key!
The point here is that whatever business logic exists in your state management, will also need to be understand and replicated in your tests using that logic.
At this point, let's address some potential objections.
Q. Why don't you do all of that filtering logic in the getUsers
function.
A. Because we still want to take advantage of our state managements caching ability. We don't want the service functions to be making the dependant API calls if they're already cached.
Q. This should be the responsibility of the backend to just provide the data you need in one query. A. Maybe so, but that doesn't help us in the immediate term.
Q. If you mocked useUsers
then you could return the data you want
A. I'll get onto that next.
At this point, I think we can say, if we can keep our API nice and simple, and be more or less a direct reflection of the data we end up using, then yes, this simplifies our life a lot.
My question for the reader is - in practice is your state management this simple? Or is the convoluted scenario realistic in your codebase?
Module Mocking
So we could use Jest's module mocking API to mock the behaviour of our useUsers
hook directly.
eg. something like
import { useUsers } from "serviceHooks/users";
jest.mock("serviceHooks/users");
const mockedUseUsers = jest.mocked(useUsers);
mockedUseUsers.mockReturnValue({
isLoading: false,
data: [
{
userId: "1",
name: "Fooby"
}
]
});
describe(GithubMarkdownEditor, () => {
it("pressing @ will open the users menu", () => {
render(<GithubMarkdownEditor/>)
userEvent.type(screen.getByRole("textbox"), "@");
expect(...)
})
})
And this would work fine, but:
- It's actually going to be quite cumbersome mocking every property that your state management wants to return. You could probably safely ignore the extra properties... until you can't.
- You're no longer testing things like loading states, or you'd have to declare mock implementations for these as well, and that would be tedious.
- Jest module mocking will only work for Jest. How would you render this component in Storybook?
You can do a kind of module mocking in Storybook by hacking the webpack config, see this part of their documentation. Permalink
The storybook-addon-manual-mocks
addon possibly does the same thing though I haven't used it.
I have used the former technique, and it works, and it allowed me to write Storybooks for components that were otherwise too complicated.
However, one of the big problems I had with it is that relative imports don't work, the module resolution is brittle.
This is my main objection to mocking as a strategy, it can be brittle, and it's too magic. Dependency injection on the hand is as reliable as you design it.
Container and Presentational components
So let's take a different approach.
Instead of using those hooks directly in our component, we pass that data we need in as props.
function GithubMarkdownEditor(props) {
- const availableUsersResult = useUsers();
- const availableIssuesAndPrsResult = useIssuesAndPrs();
- const addFileToMarkdown = useAddFileToMarkdown();
+ const {availableUsers, availableIssuesAndPrs, addFileToMarkdown} = props;
return <>
implementation here
</>
}
Ok, well now this is easy enough, testing these are going to be dead simple.
describe(GithubMarkdownEditor, () => {
it("pressing @ will open the users menu", () => {
render(<GithubMarkdownEditor
availableUsers={[{
userId: "1",
name: "Fooby"
}]}
availableIssuesAndPrs={[]}
addFileToMarkdown={(file) => {
//...
}}
/>)
userEvent.type(screen.getByRole("textbox"), "@");
expect(...)
})
})
And in order to provide the state to this component we create a 'container' component:
export function GitMarkdownEditorContainer() {
const availableUsersResult = useUsers();
const availableIssuesAndPrsResult = useIssuesAndPrs();
const addFileToMarkdown = useAddFileToMarkdown();
if(availableUsersResult.isLoading || availableIssuesAndPrsResult.isLoading){
return null; // Or however you want to handle this
}
return <GithubMarkdownEditor
availableUsers={availableUsersResult.data}
availableIssuesAndPrs={availableIssuesAndPrsResult.data}
addFileToMarkdown={addFileToMarkdown}
/>
}
But what about when this component sits inside another component?
export function NewGithubIssueForm(props) {
return <div className="some-layout-type-stuff">
<div className="more-layout-stuff1">
<GithubIssueTitleContainer/>
</div>
<div className="more-layout-stuff2">
<GithubMarkdownEditorContainer/>
</div>
<div className="more-layout-stuff3">
<GithubIssueSideBarContainer/>
</div>
</div>
}
We'll have the same problem. We can't render this component without having to provide all the state that is required, and for that we'll need to one of the strategies like API mocking, module mocking, or dependency injection.
We could use prop drilling
export function NewGithubIssueForm(props) {
return <div className="some-layout-type-stuff">
<div className="more-layout-stuff1">
<GithubIssueTitle
existingTitle={...}
onChangeTitle={...}
/>
</div>
<div className="more-layout-stuff2">
<GithubMarkdownEditor
availableUsers={props.availableUsers}
availableIssuesAndPrs={props.availableIssuesAndPrs}
addFileToMarkdown={props.addFileToMarkdown}
/>
</div>
<div className="more-layout-stuff3">
<GithubIssueSideBar
availableUsers={...}
availableProjects={...}
//Etc
>
</div>
</div>
}
export function NewGithubIssuePage() {
const availableUsersResult = useUsers();
const availableIssuesAndPrsResult = useIssuesAndPrs();
const addFileToMarkdown = useAddFileToMarkdown();
//etc
return <NewGithubIssueForm
availableUsers = {availableUsersResult.data}
availableIssuesAndPrs={availableIssuesAndPrsResult.data}
addFileToMarkdown={addFileToMarkdown}
/>
}
I'll concede that this example a little redundant as actually it does seem like NewGithubIssueForm
is the top level component. However one can imagine a complex component that sits further down the tree.
And at this point - I would say 'You know what? Prop drilling really isn't so bad if it means that your code is testable'.
But one thing to note is that prop drilling can be potentially bad for performance, as an change to the props at the top of the tree, will cause all items to rerender. This would be particularly bad if you've got prop changes on every key stroke. (And one good reason to prefer uncontrolled components, but that's a different story).
Ah hah! But what about component composition?
Component composition would suggest that rather than passing props through multiple layers, we just pass rendered JSX as children (or in to slots).
export function NewGithubIssueForm(props) {
return <div className="some-layout-type-stuff">
<div className="more-layout-stuff1">
{props.titleSlot}
</div>
<div className="more-layout-stuff2">
{props.bodySlot}
</div>
<div className="more-layout-stuff3">
{props.sidebarSlot}
</div>
</div>
}
export function NewGithubIssuePage() {
const availableUsers = useUsers();
const availableIssuesAndPrs = useIssuesAndPrs();
const addFileToMarkdown = useAddFileToMarkdown();
//etc
return <NewGithubIssueForm
titleSlot={<GithubIssueTitle
existingTitle={...}
onChangeTitle={...}
/>}
bodySlot={<GithubMarkdownEditor
availableUsers={props.availableUsers}
availableIssuesAndPrs={props.availableIssuesAndPrs}
addFileToMarkdown={props.addFileToMarkdown}
/>}
sideBarSlot={
<GithubIssueSideBar
availableUsers={...}
availableProjects={...}
//Etc
>
}
/>
}
With this approach, whether you are doing component composition or not, you do get nice testable components, and one massive god object with all of your state management hooks, which you know, isn't the worst thing in the world.
Note that the the component composition approach isn't going to prevent rerenders, (assuming no components are memoised), the top level object still rerenders when any of the hooks fire, and this causes all components rendered by the top level component to render.
If we're using the containers + slots:
export function NewGithubIssuePage() {
return <NewGithubIssueForm
titleSlot={<GithubIssueTitleContainer/>}
bodySlot={<GithubMarkdownEditorContainer/>}
sideBarSlot={<GithubIssueSideBarContainer/>}/>
}
This will prevent additional renders and we no longer have a god object at the top.
So is container components and slots the answer?
I'm not sure it's practical or realistic to say 'container components will only ever exist at the top of our component tree'.
Take our markdown editor for example, probably we want to use it in a form. It makes sense that we would have a component call SomethingARatherForm
and all of the logic required is in it. Maybe there's some custom logic with refs that relate to the markdown editor, and we just want to contain it in that component. If we require passing the the markdown editor in a slot, then we can no longer keep that logic bundled in nice spot.
Depending on your state management, sometimes you need to have your hooks in side inner components.
For example using Tanstack Query.
In this scenario we have a list of restaurants and we want to have a button on each item to favourite the restaurant.
https://codesandbox.io/p/sandbox/tanstack-query-demo-forked-n83w43?file=%2Fsrc%2FRestaurantList.tsx%3A13%2C23
If we try refactor it to to a container/presentational pattern:
https://codesandbox.io/p/sandbox/tanstack-query-demo-forked-pdssfz?file=%2Fsrc%2FRestaurantList.tsx%3A52%2C17-52%2C36
We can do it, but note that we need to manually add our own loading flags, the result of mutateAsync
won't include the loading flags.
https://github.com/TanStack/query/discussions/4613
Micro Error Boundaries
React's error boundaries can be used to catch runtime errors and display an error message, instead of blowing up the whole app.
The typical use of error boundaries is to put one at the top of the application and show a 'Something went wrong' type message if any runtime error is encountered.
However, we can put an error boundary at any level of the application, and we can put them around our components to have calls into global state.
function UserDisplayByIdInner(props) {
const {userId} = props;
const userResult = useUserById(userId);
if(userResult.isLoading){
return null; // Or however you want to handle this
}
if(userResult.isError){
throw userResult.error
}
return <div>
{userResult.data.username}
</div>
}
export function UserDisplayById(props) {
return <ErrorBoundary>
<UserDisplayByIdInner {...props}>
</ErrorBoundary>
}
Now we can use these components inside a regular component
function GitHubIssueLine(props){
const {issue} = props;
return <div>
<a>{issue.title}</a>
{issue.assigneeId && <UserDisplayById userId={issue.assigneeId}>}
</div>
}
And in our tests, we don't need to provide the requisite global state, we just allow the component to error out, if we're not testing that part of the functionality.
describe(GithubIssueLine, () => {
it("Displays the issue title", () => {
render(<GithubIssueLine issue ={{
issueNumber: "37",
title: "posts/dependency_injecting_when_using-redux",
assigneeId: "user-abc",
tags: ["tag-1"],
comments: ["comment-id1"]
}}/>);
expect(screen.getByText("posts/dependency_injecting_when_using-redux")).toBeInTheDocument();
})
});
Note that Create React App and Storybook does not play nicely with this. Storybook will still detect an error and show its error overlay. See this Github issue.
End To End Tests
Let's now shift tack and discuss end to end tests.
A key characteristic of E2E tests is that they're blackbox tests - they don't know or need to know about how the code is written in order to test it. Your Cypress E2E tests don't need to know if it's a React application, or what state management solution you're using, or whether it's frontend or backend rendered.
(Caveat: Actually it would need to know some implementation details if it need to do API mocking, or was setting things into local storage, etc).
This is a big advantage as this removes a lot of these considerations about 'how do I structure my code such that I can inject the data that the test needs?'.
Here's an overview of how I've been using Cypress E2E tests:
- There is a nightly build of the entire application, which also resets the database
- A script runs that creates some basic resources (eg. an admin user and a regular user) that the tests expect to exist.
- The tests run. The tests will generate the data that they need if don't already exist. For example if we're testing linking one issue to another, the issue will create the first issue as part of its test process. We use a random name generator to ensure that the resources are unique for that test and test run.
- I run the entire test suite twice, once as a admin, once as user. There are certain tests that only run as admin, or only run as user.
- The database doesn't reset between test runs, all the data will continue to accumulate over the day until it resets overnight.
This isn't perfect, but it is working pretty well.
Some downsides of E2E tests:
- These tests aren't going to test failure scenarios (such as 500 errors from the API) without API mocking.
- Because they're running against a real API they're quite slow. My suite takes about an hour to run, but in parallel it would take about 5 minutes.
- Certain scenarios aren't easily testable, where it involves some global configuration that want to be different for one test. (ie. imagine that there's a global settings property that means when you create an issue and popup appears that says 'are you sure?'. We want to have a test for this, but enabling it will cause all the other test flows that don't expect the popup to exist will fail).
Conclusions
Simplifying your data model is going to go a long way to simplifying your testing process.
Pure prop driven components are definitely the easiest to test.
Testing components that otherwise have global state anywhere in their component tree are otherwise going to require knowledge of any business logic inside the state management.
Techniques like prop drilling or component composition can be used to avoid having provide stateful context for components further down the tree.
Error boundaries can used to prune components down the tree that otherwise would require stateful context.
Module mocking can be used to prune the cascade into the state management. Module mocking can be brittle, particularly if your components are being used in multiple contexts.
Questions? Comments? Criticisms? Get in the comments! 👇
Spotted an error? Edit this page with Github