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 is SIFERS?
SIFERS is an acronym for:
SIFERS is a replacement for the Jasmine-like API hooks:
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.
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:
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: '[email protected]',
avatar: '',
},
{
id: 2,
first_name: 'Mike',
last_name: 'Ryan',
email: '[email protected]',
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:
Our?setup()?function:
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:
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:
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:
Why use SIFERS?
At this point, you may be wondering, "but, why use SIFERS?" In my experience, SIFERS provides 3 distinct advantages:
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.
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: