DEV Community

Cover image for Writing Integration Tests that Run Inside a Unit-testing Framework like Jest
Andy Jessop
Andy Jessop

Posted on

Writing Integration Tests that Run Inside a Unit-testing Framework like Jest

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.

  1. They're often flaky and difficult to maintain.
  2. They're invariably slow.
  3. 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    \
  /___________________\

Enter fullscreen mode Exit fullscreen mode

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 \
     /________ ____\

Enter fullscreen mode Exit fullscreen mode

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:

  1. Fast to run.
  2. Gives quite high confidence (although not as high as E2E).
  3. 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);
Enter fullscreen mode Exit fullscreen mode

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');
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (7)

Collapse
 
lexlohr profile image
Alex Lohr

Using jest for integration tests is potentially problematic. Jest tests are usually set to have a timeout of 5s. If your app if slightly more complex than the average, this will not suffice. However, setting longer timeouts can lead to instability due to waiting threads.

Collapse
 
andyjessop profile image
Andy Jessop • Edited

Thanks very much for the input. That's absolutely right, but I would suggest that it's not so much of a problem in a setup like this. There is no UI, so rendering speed doesn't delay the test execution.

I suppose that if you're not mocking your API, then you could see long tests if there are network issues, but I would suggest that this is a worse practice than having these types of tests in Jest.

[Edit]: Was there a different scenario you had in mind?

Collapse
 
lexlohr profile image
Alex Lohr

I'm not talking about network issues. I was previously on the team that developed GoToMeeting and one of our team members introduced some integrative jest tests, which were notoriously unreliable, because mocking the complex APIs was rather taxing on the CPU. In the end, we replaced them with testcafé integration tests, which all in all were faster and more reliable. YMMV is all I'm saying.

Thread Thread
 
andyjessop profile image
Andy Jessop

That's good to know, thanks again. I'm going to see if I can reproduce something that will cause timeouts and investigate. The heads-up is much appreciated 👍

Collapse
 
philipjohnbasile profile image
Philip John Basile

Testing is hard. Thank you for helping stretch this out.

Collapse
 
andyjessop profile image
Andy Jessop

And thank you for reading, I'm glad you found it useful.

Collapse
 
okospeter profile image
Peter Thiel

I'm new to integration testing with Jest and interested in your solution.

So would I import your pivot code into the project I am writing test scripts for? Or would I create similar tests in my project that mimic the ones you have described in your pivot code?