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 kind of software testing that check how well different parts of the codebase work together. They help to find and fix problems that might not show up during unit testing, such as wrong data or communication errors between functions or system components.

They also help to make sure that the software meets the system requirements and is ready to be delivered to the users.

For web-based software, integration tests often use E2E tests in a browser or a similar environment. This means loading the application and testing its functionality by simulating user actions and checking the outcomes.

The benefit of these tests is that they give you a lot of confidence that your application works as intended. You have a bot that acts like a user and gives you feedback on every code change. That's awesome.

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    \
  /___________________\

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:

  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);

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!

No comments:

Post a Comment