TL:DR; Our integration tests should mimic our users' behaviour. Our users don't care how our features are implemented, so neither should our tests.
What is it and why do we have a new acronym?
Everyone in tech loves a good acronym - or at least I hope so with how many the tech community have floating around! Why not add User Driven Testing (UDT) to the list? ๐
I had the idea to write this article after attending two workshops: Kateryna Porshnieva's Comprehensive guide to testing React applications workshop and Sandrina Pereira's Web Accessibility in React Apps workshop. Iโd already been tackling the best way to structure tests for a new React application my company was working on and I kept coming to the same conclusion - our tests were just users of our application... so why don't we write our tests to behave as users?
Why did I write this article?
Note: This blog post uses a React app as an example, but I do believe that the principles of this blog are applicable when coming to testing any application.
If you were trying to figure out how to test your React application, you would probably find yourself reading React's documentation on testing. You would then probably read about React Testing Library (RTL, for the acronym aficionados). You would then probably read RTL's Guiding Principles. And you would then come across a tweet that makes so much sense that you don't know why you didn't think of it before.
At the time, I was setting up a new React app for my company. I now knew what I wanted to achieve: a test suite that mimicked how our users would interact with our app, but it wasn't obvious to me how I could achieve it.
As our app evolved, components were refactored, business logic kept being moved around, and, as a results, our tests were often being rewritten.
"But hang on... When you were doing all these refactors, did the users' behaviour with the application change?" Nope. The users still have the same experience... So why did we have to rewrite our tests?
What was the 'eureka!' moment?
If you're not familiar with RTL, RTL renders the component and all its dependencies to mimic the DOM tree rendered by the browser. That basically means that if a bug is introduced in a dependency of a component being tested, there is a chance the components tests will fail. I know what youโre thinking... "Aleo, if RTL renders a component's children, wouldn't that make the tests... integration tests?" Yes, it would! And that's when it got me thinking - we want our tests to pass if the feature in our app is working, we donโt really care which component is responsible for our feature, do we? So the solution is simple - we want to render our App
component in all our tests! Right?
Well... no. If we render our App
component, we could have to complete prerequisite tasks repeatedly (like navigating to the page the feature is on) before we even start testing the feature in question. Well why can't we come to a middle ground and just render the component that represents our page?
Bringing theory to practice
We've spoken a lot about the theory, but theory can only go so far - let's have a look at an example (you can find the source code on my GitHub here).
For this example, we've been asked to update our company's ticket booking app. When the app was built, the company was only running holding one event at a time. The company now wants to run multiple events and users should be able to book tickets onto all of them. We've been tasked with refactoring the code to allow users to book on as many events as they would like.
What you're starting with
The (oversimplified) app is composed of two components:
- App
- Event
The App
component looks like so:
import React, { FC } from 'react';
import Event from '../Event';
import styles from './App.module.css';
const App: FC = () => {
return (
<div className={styles.container}>
<h1>User Driven Testing</h1>
<Event />
</div>
);
};
export default App;
and the Event
component looks like so:
import React, { FC, useState } from 'react';
import purchaseTickets from '../../requests/purchaseTickets';
import joinClasses from '../../utils/joinClasses';
import styles from './Event.module.css';
const Event: FC = () => {
const [ticketCount, setTicketCount] = useState(0);
const increaseTicketCount = () =>
setTicketCount((currentValue) => currentValue + 1);
const decreaseTicketCount = () => {
if (ticketCount > 0) {
setTicketCount((currentValue) => currentValue - 1);
};
};
const resetTicketCount = () =>
setTicketCount(0);
return (
<div className={styles.container}>
<h2>Super cool event!</h2>
<div className={styles.btnGroup}>
<button
onClick={decreaseTicketCount}
className={joinClasses([styles.btn, styles.btnSecondary])}
>
Remove
</button>
<p>{ticketCount}</p>
<button
onClick={increaseTicketCount}
className={joinClasses([styles.btn, styles.btnSecondary])}
>
Add
</button>
</div>
<div className={styles.btnGroup}>
<button
onClick={resetTicketCount}
className={joinClasses([styles.btn, styles.btnSecondary])}
aria-disabled={!ticketCount}
>
Empty basket
</button>
<button
onClick={() => purchaseTickets(ticketCount)}
className={joinClasses([styles.btn, styles.btnPrimary])}
>
Purchase
</button>
</div>
</div>
);
};
export default Event;
The first thing you notice is that the "Purchase" and "Empty" checkout buttons are in the Event
component. You're aware you're going to have to refactor the code to allow you to add more events.
Before you start refactoring the code, you decide to look at the tests; the App
test are minimal at best:
import { render, screen } from '@testing-library/react';
import App from '.';
describe('App', () => {
test('App loads correctly', () => {
const { container } = render(<App />);
expect(screen.getByRole('heading', { name: /User Driven Testing/i })).toBeVisible();
expect(container.firstChild.children.length).toBe(2);
});
});
but the Event
tests look quite detailed:
import { render, screen } from '@testing-library/react';
import user from '@testing-library/user-event';
import Event from '.';
import purchaseTickets from '../../requests/purchaseTickets';
jest.mock('../../requests/purchaseTickets');
const purchaseTicketsMock = purchaseTickets as jest.Mock;
beforeEach(() => {
purchaseTicketsMock.mockReset();
});
describe('Event', () => {
test('Event loads correctly', () => {
render(<Event />);
expect(screen.getByRole('heading', { name: /Super cool event!/i })).toBeVisible();
expect(screen.getByRole('button', { name: /Remove/i })).toBeVisible();
expect(screen.getByRole('button', { name: /Add/i })).toBeVisible();
expect(screen.getByRole('button', { name: /Empty basket/i })).toBeVisible();
expect(screen.getByRole('button', { name: /Purchase/i })).toBeVisible();
});
test('tickets can be added', () => {
render(<Event />);
const addBtn = screen.getByRole('button', { name: /Add/i });
const ticketTotal = screen.getByText('0');
user.click(addBtn);
expect(ticketTotal).toHaveTextContent('1');
user.click(addBtn);
expect(ticketTotal).toHaveTextContent('2');
});
test('tickets can be reduced down to 0', () => {
render(<Event />);
const addBtn = screen.getByRole('button', { name: /Add/i });
const removeBtn = screen.getByRole('button', { name: /Remove/i });
const ticketTotal = screen.getByText('0');
user.click(addBtn);
expect(ticketTotal).toHaveTextContent('1');
user.click(removeBtn);
expect(ticketTotal).toHaveTextContent('0');
user.click(removeBtn);
expect(ticketTotal).toHaveTextContent('0');
});
test('empty basket button sets ticket count to 0', () => {
render(<Event />);
const addBtn = screen.getByRole('button', { name: /Add/i });
const emptyBasketBtn = screen.getByRole('button', { name: /Empty basket/i });
const ticketTotal = screen.getByText('0');
user.click(addBtn);
expect(ticketTotal).toHaveTextContent('1');
user.click(addBtn);
expect(ticketTotal).toHaveTextContent('2');
user.click(emptyBasketBtn);
expect(ticketTotal).toHaveTextContent('0');
});
test('purchase button calls purchaseTickets', () => {
render(<Event />);
const addBtn = screen.getByRole('button', { name: /Add/i });
const purchaseBtn = screen.getByRole('button', { name: /Purchase/i });
const ticketTotal = screen.getByText('0');
user.click(addBtn);
expect(ticketTotal).toHaveTextContent('1');
user.click(addBtn);
expect(ticketTotal).toHaveTextContent('2');
user.click(purchaseBtn);
expect(purchaseTicketsMock).toHaveBeenCalledWith(2);
});
});
Time for a refactor
You decide to create an EventsGroup
component which will be responsible for rendering all your Event
components and rendering your "Purchase" and "Empty" buttons. Your EventsGroup
component ends up looking something like this:
import React, { FC, useState } from 'react';
import purchaseTickets from '../../requests/purchaseTickets';
import Button from '../../components/Button';
import Event from '../../components/Event';
import styles from './EventsGroup.module.css';
const EventsGroup: FC = () => {
const ticketCountInit = {
superCoolEvent: 0,
anotherAmazingEvent: 0,
}
const [ticketCount, setTicketCount] = useState<Record<string, number>>(ticketCountInit);
const totalTicketCount = Object.values(ticketCount)
.reduce((total, current) => total + current, 0);
const increaseTicketCount = (eventKey: string) => () =>
setTicketCount((currentValue) => ({
...currentValue,
[eventKey]: currentValue[eventKey] + 1,
}));
const decreaseTicketCount = (eventKey: string) => () => {
if (ticketCount[eventKey] > 0) {
setTicketCount((currentValue) => ({
...currentValue,
[eventKey]: currentValue[eventKey] - 1,
}));
};
};
const resetTicketCount = () =>
setTicketCount(ticketCountInit);
return (
<div className={styles.container}>
<Event
eventName="Super Cool Event!"
ticketCount={ticketCount.superCoolEvent}
increaseTicketCount={increaseTicketCount('superCoolEvent')}
decreaseTicketCount={decreaseTicketCount('superCoolEvent')}
/>
<div className={styles.btnGroup}>
<Button
onClick={resetTicketCount}
isAriaDisabled={totalTicketCount <= 0}
variant="secondary"
>
Empty basket
</Button>
<Button
onClick={() => purchaseTickets(totalTicketCount)}
variant="primary"
>
Purchase
</Button>
</div>
</div>
);
};
export default EventsGroup;
And your Event
component ends up looking like this:
import React, { FC } from 'react';
import Button from '../Button';
import styles from './Event.module.css';
interface Props {
eventName: string
ticketCount: number
increaseTicketCount: () => void
decreaseTicketCount: () => void
};
const Event: FC<Props> = ({
eventName,
ticketCount,
increaseTicketCount,
decreaseTicketCount
}) => {
return (
<div className={styles.container}>
<h2>{eventName}</h2>
<div className={styles.btnGroup}>
<Button
onClick={decreaseTicketCount}
variant="secondary"
isAriaDisabled={ticketCount <= 0}
>
Remove
</Button>
<p>{ticketCount}</p>
<Button
onClick={increaseTicketCount}
variant="secondary"
>
Add
</Button>
</div>
</div>
);
};
export default Event;
Amazing! You've refactored your code and it's functionally the same! It looks the same in the browser, you can still add/remove tickets exactly the same, and we can easily add as many events as we like in future! So as far as you're concerned, you're job is done! Right?
Well... we've forgotten an important step: to run our tests. We may have forgotten, but our pipeline hasn't - and our pipeline is not happy. All our tests are failing. But why? The code is functionally the same?
But why didn't our tests pass?
"I would have gotten away with it too, if it weren't for you meddling tests!" ~some Scooby Doo antagonist
So our tests aren't passing, everyone in our team has seen, and we're extra annoyed because the app works exactly the same. How can we fix this? Or better yet, how could we have avoided this?
If we want to fix this, there is a simple solution of updating our unit tests. But should our tests really be breaking if the app works the same?
An alternative (and, by me writing this article, my obvious preferred approach) is to convert our unit tests to integration tests. These integration tests will allow us to verify app functions the way we expect and will allow us to refactor our code in future without risk of breaking our tests (ya know, provided we haven't actually broken anything...).
To do this, we can use the test cases defined in our Event
test file - but instead of rendering our Event
component, we can render our App
container. The tests will all pass now! Yay!
Are these really integration tests?
Now I know what you're thinking, "surely that's just an end-to-end test?". Well, while we are testing the all our code in one test file, we should really be seeing it as only testing one feature of our code. In larger code bases, I would not recommend rendering the App
component in your integration tests - instead, I would recommend rendering the lowest level container responsible for the feature you're testing (in this case, we actually could render EventsGroup
instead, but we didn't have this component at the start).
Location, location, location
Where we store our tests in all down to personal preference. I prefer to keep my integration tests outside my src
folder, some people like to have them within the src
folder, and others prefer to have them next to the component they are rendering in the test.
It really is up to you, but I prefer to keep my src
folder for functional code and unit tests (yes, I do think we should have unit tests on occasion too).
Conclusion
So we set out to refactor a our company's ticket app but broke our tests because they weren't written from a user's perspective. We've seen that if we had written our tests from a user's perspective, we could have made our refactor and our tests would have confidently told us we haven't broken any features of our app.
I do think it's important to still write unit tests for core pieces of logical code (say, any bespoke hooks you write), but the majority of our testing should be done on an integration level for the most return on investment.
Our primary care is how our users interacts with our app. Our users don't care how our features are implemented, so neither should our tests.
Top comments (0)