Testing React Apps using SIFERS
Simple Injectable Functions Explicitly Returning State (SIFERS) is a testing strategy that seeks to improve the developer experience for writing automated tests that are easier to write, maintain, and less flaky.
In this article, you'll learn:
- What are SIFERS
- How to unit test using SIFERS
- Why use SIFERS
What is SIFERS?
SIFERS is an acronym for:
- Simple
- Injectable
- Functions
- Explicitly
- Returning
- State
SIFERS is a replacement for the Jasmine-like API hooks:
beforeAll
andbeforeEach
afterAll
andafterEach
Rather, our tests will contain a single setup()
function that is responsible for explicitly returning the state of our system under test.
The setup()
function is invoked within each test.
Here is an example of a test using SIFERS.
interface SetupOptions {
throwErrorWhenLoading?: boolean
}
function setup(options: SetupOptions) {
const book = {
id: uuid.v4(),
name: 'The Lord of the Rings',
earnings: '100',
description:
'The Lord of the Rings is an epic high fantasy novel written by English author and scholar J. R. R. Tolkien.',
};
BooksApi.deleteAllBooks();
BooksApi.createBook(book);
AuthApi.login(process.env.REACT_API_USERNAME, process.env.REACT_API_PASSWORD);
return { book };
}
test('should work', () => {
// arrange
const { book } = setup();
// act
//assert
});
Let's review this first example of a test using SIFERS.
- First, we may need some options to configure the state of our system under test. As such, we have defined a
SetupOptions
interface. - The
setup()
function is a single (and simple) function that predictably creates and returns the state. - First, we create a new
book
that our tests will use. We'll explicitly return this as part of the state object. - Next, we use the API to delete all of the books and then create the single
book
that we defined. - Next, we authenticate to the API.
- Finally, we return the state object.
Demo
Check out a fully working demo. You can even run the tests using the Jest test runner on codesandbox.
The UserList
Component
Before we dive into writing our tests using SIFERS, let's look at the UserList
component that we are testing.
type Props = {
onToggleUser: (user: User) => void;
selectedUserIds: number[];
users: User[];
};
export default function UserList({
onToggleUser,
selectedUserIds,
users,
}: Props) {
return (
<Box sx={{ width: '100%', maxWidth: 360, bgcolor: 'background.paper' }}>
<List data-testid="user-list">
{users.map((user) => (
<ListItem
key={user.id}
secondaryAction={
<Checkbox
edge="end"
onChange={() => {
onToggleUser(user);
}}
checked={selectedUserIds.indexOf(user.id) > -1}
/>
}
data-testid={`user-${user.id}`}
disablePadding
>
<ListItemAvatar>
<Avatar src={user.avatar} />
</ListItemAvatar>
<ListItemText primary={user.first_name} />
</ListItem>
))}
</List>
</Box>
);
}
The UserList
component is responsible for:
- Rendering a list of users
- Setting the checkbox state of selected users
- Invoking a provided callback function when a user selection is toggled
How to unit test with SIFERS
Let's create a SIFERS for testing the UserList
component above.
interface SetupOptions {
onToggleUser?: (user: User) => void;
}
function setup(options?: SetupOptions) {
const users = [
{
id: 1,
first_name: 'Brian',
last_name: 'Love',
email: 'brian@liveloveapp.com',
avatar: '',
},
{
id: 2,
first_name: 'Mike',
last_name: 'Ryan',
email: 'mike@liveloveapp.com',
avatar: '',
},
] as User[];
const selectedUserIds = [users[0].id];
render(
<UserList
onToggleUser={options?.onToggleUser ?? jest.fn()}
selectedUserIds={selectedUserIds}
users={users}
/>,
);
return {
selectedUserIds,
users,
};
}
Let's review our setup()
function:
- We declare a new
setup()
function at the top of our spec file. - The
setup()
function optionally excepts aSetupOptions
object. This enables our SIFERS to conditionally prepare the state or configure the system under test. - We define an array of mock
users
- We define an array of mock
selectedUserIds
- Render the
UserList
component - Return the state object.
Our setup()
function:
- Performs the necessary setup for our tests
- Renders the
UserList
component - Returns the state of our system.
Further, it may be necessary to return a teardown()
function as a property in order to perform any required cleanup after each test.
First Test
Now, let's look at our first test.
test('should display a list of users', () => {
// arrange
const { users } = setup();
const list = screen.getByTestId('user-list');
// assert
expect(list.children).toHaveLength(users.length);
users.forEach((user) => {
expect(screen.getByText(user.first_name)).toBeTruthy();
});
});
Let's review the test above:
- First, we arrange the test by invoking the
setup()
function that explicitly returns the state, or in this case, the list ofusers
. We then get theList
element using thedata-testid
attribute. - Then, we assert that the list of users matches the length of the
users
provided and that each user is displayed as we expect.
Second Test
Next, let's look at a test that asserts that the UserList
component selects the initially selected users.
test('should select the initially selected users', () => {
// arrange
const { selectedUserIds } = setup();
// assert
selectedUserIds.forEach((id) => {
const el = screen.getByTestId(`user-${id}`);
const checkbox = getByRole(el, 'checkbox');
expect(checkbox.getAttribute('checked')).toBe('');
});
});
Let's review the test above:
- First, we arrange the test by invoking the
setup()
function that explicitly returns the state. In this case, we're only interested in the initial state of the selected users. - Then, we assert that each user's checkbox is checked as we expect.
Third Test
Finally, let's look at the final test that asserts that the UserList
component should select a user.
test('should select a user', () => {
// arrange
const onToggleUser = jest.fn();
const { users } = setup({ onToggleUser });
const el = screen.getByTestId(`user-${users[0].id}`);
const checkbox = getByRole(el, 'checkbox');
// act
fireEvent.click(checkbox);
// assert
expect(onToggleUser).toHaveBeenCalled();
});
Let's review the test above:
- First, we arrange the test by creating a new mock function and assigning it to the
onToggleUser
constant. We then invoke thesetup()
function that explicitly returns the state providing theSetupOptions
parameter with the mock function. We can now get the checkbox for the first user in the list. - Next, we act upon the system by firing the click event on the
checkbox
. - Then, we assert that the
onToggleUser
mock function has been called.
Why use SIFERS?
At this point, you may be wondering, "but, why use SIFERS?" In my experience, SIFERS provides 3 distinct advantages:
- A clear expectation of the state of the system under each test.
- Reduced clutter in
beforeEach
andafterEach
functions. - Reduced opportunity for flaky tests due to stale state.
Next, we'll learn how to migrate an existing Jest test to use SIFERS. In the process, we'll also identify each of these advantages.
Migrating to SIFERS
First, let's look at an example of a test without using SIFERS.
describe('Login Page Component', () => {
const server = setupServer(
rest.get('/login', (req, res, ctx) => {
return res(ctx.json({
id: 123,
firstName: 'Brian',
lastName: 'Love'
}));
}),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('should compile', () => {
const { asFragment } = render(<UserPage />);
expect(asFragment()).toMatchSnapshot();
});
test('should display an error', async () => {
server.use(
rest.get('/greeting', (req, res, ctx) => {
return res(ctx.status(500))
}),
);
render(<UserPage />);
await waitFor(() => {
expect(
screen.queryByRole('error', {name: 'Uh oh. We ran into an error. Try again.'})
).toBeInTheDocument();
});
});
});
Now, let's look at an example of refactoring the test to use SIFERS:
interface SetupOptions {
throwErrorWhenLoadingUser?: boolean;
}
function setup(options: SetupOptions) {
const server = setupServer(
rest.get('/login', (req, res, ctx) => {
if (options?.throwErrorWhenLoadingUser) {
return res(ctx.status(500));
} else {
return res(ctx.json({
id: 123,
firstName: 'Brian',
lastName: 'Love'
}));
}
}),
);
server.listen();
const result = render(<UserPage />);
return {
asFragment: result.asFragment;
teardown: async () => {
server.close();
return Promise.resolve();
}
};
}
test('should compile', () => {
const { asFragment, teardown } = setup();
expect(asFragment()).toMatchSnapshot();
return teardown();
});
test('should display an error', async () => {
const { teardown } = setup({ throwErrorWhenLoadingUser: true });
await waitFor(() => {
expect(
screen.queryByRole('error', {name: 'Uh oh. We ran into an error. Try again.'})
).toBeInTheDocument();
});
return teardown();
});
Let's review the migrated test.
- First, we have a clear expectation of the state given that each test explicitly invokes the
setup()
function that returns the state. We also provide ateardown()
callback function that is used to explicitly - Second, we have reduced the clutter of having multiple lifecycle hooks in our test. We have a single, easy-to-read, and grok,
setup()
function. - Third, we have reduced the flake of our tests. In the case of the “server error” test, we instruct our SIFERS to throw an error loading the user. If we compare this to the previous test, we had to override the behavior of our mock server and rely on resetting the handlers between each test.
Conclusion
Here at LiveLoveApp, we are proponents of using SIFERS for writing unit tests, integration tests, and end-to-end tests. Here are some suggestions:
- If you are experiencing flaky tests, consider refactoring to use SIFERS
- As a team and organization, consider using SIFERS for crafting future tests