Writing Integration Tests that Run Inside a Unit-testing Framework like Jest
Andy Jessop
Posted on May 21, 2023
What are Integration Tests and Why are they Important?
Integration tests are a type of software testing where individual units of the codebase are combined and tested as a group. This type of testing is essentially done to expose faults in the interaction between integrated units.
They're important because:
They expose interface issues that may not be apparent during unit testing. This is things like incorrect data types or values being passed between functions or miscommunication between different parts of the system.
They can verify system requirements, ensuring the overall product is ready for delivery.
In software created for the web, integration tests usually take the form of E2E tests in a browser or browser-like environment. This is essentially loading up your application and verifying functionality by clicking around and asserting behaviours.
The great thing about these kinds of test is that they give you extremely high confidence that your application works as expected. You effectively have a bot clicking around your app and giving you the thumbs-up on every merge. Fantastic.
However...this comes at a cost!
There are a few main issues with E2E tests.
- They're often flaky and difficult to maintain.
- They're invariably slow.
- They're (more) difficult to debug.
What this means in practice is that we don't write enough of these types of tests, often sticking just to the happy path that your users take through the app. We'll get a high confidence in the critical behaviours, but then there's often a gulf of functionality that goes untested.
You've probably heard of a testing pyramid. It might look something like this:
^
/ \
/ E2E \
/_______\
/ \
/Integration\
/_____________\
/ \
/ Unit Tests \
/___________________\
The unit tests form the bottom of the pyramid. These are numerous, fast and cheap to run, and their debuggability (does that word exist?) is fantastic. But the confidence they give you that the app actually works as expected is minimal.
E2E at the top gives the highest confidence, but less coverage (unless you want to be waiting 30+ minutes for CI), and highest cost for maintenance.
So what about the integration tests? In web, we often ignore these all-together. After all, how do you test a web app as a complete system without running it in a browser (E2E)? The reality is that we generally run more of a distorted hourglass shape, like this:
^
/ \
/ E2E \
/_______\
\ /
\ /
/_____\
/ \
/ \
/Unit Tests \
/________ ____\
Ok ok, that's a pretty terrible representation, but you get the point. We have a gaping void that needs to be filled with something that is:
- Fast to run.
- Gives quite high confidence (although not as high as E2E).
- Is easily debuggable.
How can we achieve integration testing without a UI?
We can construct our apps in a way that is headless, where the app itself works without needing to render anything to the DOM. This is what I'm doing with the Pivot framework. An app is created without anchoring to a DOM element, like this:
export const app = headless(services, slices, subscriptions);
I don't have the space-time here to go into all the details of how a Pivot app works, but the gist of it is that everything, including routing (crucially) is part of the state management, and so the application can run simply by spinning up the store, firing actions, and testing the state.
I will delve deeper into Pivot itself in future articles, but for now, let's look at what it means for our tests. Below is an example of an integration test. It runs in Vitest, not in Cypress, and it doesn't test the state of any DOM elements. Instead, it tests the internal state of the application.
What this means is that, yes, we have less confidence than with an E2E test, but it does still give us much more confidence than unit tests. It fills the gulf. And what's more, these types of integration test are almost as fast as unit tests, and give the same level of debuggability - i.e. stepping through code from within your IDE.
const app = headless(services, slices, subscriptions);
const project = findProjectByName('pivot');
describe('integration', () => {
describe('router', () => {
beforeEach(async () => {
await app.init();
await app.getService('router');
const auth = await app.getService('auth');
await auth.login('user@user.com', 'password');
});
it('should visit project page', async () => {
visit(`/projects/${project.uuid}`);
const state = await app.getSlice('router');
expect(state.route?.name).toEqual('project');
});
});
});
By the way, the visit
utility is simulating a page navigation in the same way that it works in a browser, by modifying the history, and emitting a popstate
event:
export function visit(url: string) {
history.pushState(null, '', url);
const popStateEvent = new PopStateEvent('popstate', {
bubbles: true,
cancelable: true,
state: null,
});
window.dispatchEvent(popStateEvent);
}
So, in the test we're initialising the app and router, logging into the app, visiting an authenticated route, then asserting that the current route is correct.
You can imagine what this looks like in E2E - I'm sure you've done this sort of thing many times. The main difference here is that this test takes just a few milliseconds to run.
But what confidence does it give us? Well, we know that the login system works on a superficial level, and we know that the router is listening to the popstate
event and navigating us to a page. And we know that the logic that allows an authenticated user to visit this page is working.
That's pretty good already, because changes to both the router and the login system will cause this to fail.
Let's add a test to test that an unauthenticated user cannot access this route:
it('should navigate to notFound if unauthorized', async () => {
const auth = await app.getService('auth');
const router = await app.getService('router');
await auth.logout();
router.navigate({ name: 'project', params: { id: project.uuid } });
const route = await app.waitFor(selectRoute, (route) => route?.name === 'notFound');
expect(route?.name).toEqual('notFound');
});
Great! Now we know that the auth system really works. And we also now know that we can navigate using the internal router
API.
Conclusion
I think this kind of testing is a bit of a sweet spot, as it gives us a very high confidence that the app's business logic works, and it's so simple and fast to write that it means we can really extend the meaningful test coverage of our apps.
Of course, there is still the question of UI testing, but this isn't meant to replace any existing strategies, just to augment them.
By not coupling the initialisation of our application to our UI framework, we're liberated from its shackles and have more flexibility in testing. And more than likely, we end up with cleaner code, but that's a story for another time.
Happy testing!
Note that Pivot is still in its very early stages, and is not yet published. I'm reworking lots of ideas, mostly surrounding declarative data fetching and more on integration testing.
Posted on May 21, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
May 21, 2023