Writing Effective Tests for Modern Web Apps
Testing modern web apps can be challenging. There are many variations of tests — unit, integration, acceptance, end-to-end — and knowing which ones to use can be difficult. This article will cover patterns I use to test my web apps using unit and integration tests. The integration tests will closely simulate a real user’s journey through the app but will have no dependencies on external systems.
This means the tests will run quickly and be less brittle than end-to-end tests, but will also provide a high level of confidence that your app is working correctly. The patterns used in these tests are very flexible and allow your tests to be decoupled from the implementation details of your code. This means you can refactor your code without creating a “false negative”, where the code is correct but the test fails due to some tight coupling between the test and how the code was implemented.
Important: This article will cover high-level patterns and testing strategies. It will not be a step-by-step guide on writing tests. The examples will use libraries like Jest, Testing Library, jest-fetch-mock and more, but I will only mention the relevant pieces and leave the details to the docs for each respective library.
The Example App
To start, let’s say that we are building a user authentication flow for our web application. This involves showing the user an input field on the first screen for the user’s email. If we find that the user’s email exists in our system, we show them a password input field. If not, we show them a join form. The user will start on the /lookup
view, and then end up on either the /join
or /challenge
view depending on whether or not they have an account.
This provides us with a fairly simple user experience that can still benefit from both unit and integration tests. We’ll leave some of the implementation details out (like the UI) and focus on how we would test this application effectively to ensure it works as expected.
Mocking HTTP Requests the Right Way
To start, we know that we will need to make an API call to look up a user in our system by their email address. Let’s make a basic module to handle making that API call for us.
Let’s put the file at src/app/api/lookup/index.ts
. It may look something like this:
Under the hood, the Http
object from @core/http
is just a utility class that handles common HTTP logic for our application. Things like setting up the base API domain based on environment variables, setting common HTTP headers, parsing the response, firing analytic events, etc...
When it comes to mocking, generally it’s best to mock at the “edge” of the thing you are testing. For a web application, this means you can mock actual HTTP requests instead of mocking a code module. If you don’t do this, I can personally attest to how brittle mocking modules can be. I’ve built up large, complex tests using something like jest module mocking. I then used a collaboration verification style of testing to basically assert “Did moduleA
call moduleB
with the correct arguments?”.
It was like building a house of cards. Instead of validating app functionality, tests were asserting how the code was structured. This leads to some of the most useless, tightly-coupled tests you could possibly imagine. The slightest code change would cause tests to start triggering false negatives.
The main idea is that how the code is structured isn’t the thing we want to test. At the end of the day, we care that the app functions correctly. This means that we expect an HTTP request to be sent in the correct format and that our app correctly handles all the various responses that could come back.
By mocking the HTTP request itself, the underlying code is free to be refactored without failing tests that shouldn’t fail. Let’s look at how we would write a unit test of this module by looking at how we approach mocking HTTP requests.
Use a Single, Global Mock Object
Mocking HTTP calls in tests is a very common pattern. However, it is easy to do this in a way that becomes messy and prone to errors.
As your application grows, there is a good chance that you might make the same API call from different places in your code. If you redefine the HTTP mock in individual test suites, you increase the chance for bugs.
For example, what happens when the API response changes? You have to update each test that mocked that response and hope you catch all of the places it is used. If you happen to miss a place where the mock is incorrect, that test is asserting on incorrect data.
To address this, we use a single, global mock for each of our API calls. Per our convention, all of our modules that make API calls are located in the api
directory. For each of those modules, we can add a __mocks__
directory that contains a module with the “definition” for that network call.
The mock object includes both the response and the request payloads, as well as any of the variations that are needed for testing. You can use user-friendly keys for the uses cases like userNotFound
to make it easier to see what was being mocked. Here is an example mock for the lookup
module we defined earlier. This mock would be located at src/app/api/lookup/__mocks__/lookup.ts
.
Now, we can use this global mock for both unit and integration tests.
Writing a Unit Test for the API module
Unit tests are a great way to ensure that individual modules are functioning as expected. These tests don’t provide as much value in ensuring the entire app functions correctly, but they can help developers build components effectively.
As an example, let’s write a unit test for the lookup
API module. This example unit test would be located in src/app/api/lookup/__tests__/index.spec.ts
.
The above example only tests a single flow for the lookup
module, but it should give you an idea of the basic structure. The global mock is used in combination with jest-fetch-mock
to mock the underlying HTTP request. Now you can do assertions on the module’s response and verify the structure of the HTTP request.
Writing an Integration Test
Unit tests are nice, but to really test our application, we want to have integration tests.
Why are integration tests valuable? They validate that individual modules interact correctly with each other. It is very easy to incorrectly tie modules together with mistakes in code. I have personally made mistakes in the past that involved writing code that simply did not work, even though the unit tests all passed.
After all, a single module in isolation usually does nothing for a user. In the end, it’s a collection of many modules or pieces of code interacting together that provide benefit to the user.
Having said all of that, integration tests are inherently more complex than unit tests. There are more pieces of code under test, and as a developer, you still have to decide how to group functionality into an integration test. I’ve found that one nice rule of thumb is to look at it from the user’s perspective. In the case of our example app, we can effectively write integration tests for each view in our app.
From the diagram above, we can write integration tests for the major flows for the lookup
view. In general, it will look like this:
- Start on the
lookup
view - Set up the mocks for the HTTP request for the
/user/credential_available/v1
call - Type in an email for the user and click submit
- Assert that the expected things happened in your app
Notice this plan makes very few assumptions about the underlying code. We have a starting point, and we know we have to mock an API call, but we are triggering all of the behavior just like a user would. Let’s dig a bit more into simulating user interaction.
Simulating User Interaction
When writing integration tests, it used to be tricky to simulate user behavior. But interacting with your application like a user would is an important factor to having tests that provide value.
This is the guiding principle for the Testing Library. One of the big benefits of this strategy is that you can ensure that all of your UI pieces are correctly hooked up to the code. I’ve run into bugs in my career where I was testing a user flow by manually invoking code that I assumed would be invoked on a button press, but I had unfortunately made a mistake and never actually hooked the button up correctly to invoke the code.
Using something like the user-event library can help us simulate user behavior in a straightforward way.
While this works great, there is a small drawback. For each event, we have to grab the element
to perform the action on. This is simple to do, but what happens as your test suite grows? The same thing that can happen with the mocking of HTTP requests. If you grab the same element in multiple tests (or even test suites), but then you have to change the HTML structure of that element, you break a lot of tests.
This once again creates a coupling between our tests and implementation details in the code. In this case, it’s the structure of the HTML. Let’s see if we can decouple that in a scalable way.
Using a Page Model
In order to decouple our tests from the HTML structure of the underlying app, I recommend using a Page Model. By creating an interface over your HTML, the test can then rely on that interface instead of knowing about the structure of the app’s HTML. These page models can be stored with the view under the __tests__
directory for each view.
Let’s look at an example page model for the lookup
view located at src/app/components/views/Lookup/__tests__/LookupPage.ts
:
This page is relatively simple, but you can see how this would become much larger for a complex page. You can even extend this page model by having each model extend a base PageModel
class like the above example. That base PageModel
class can contain base functionality that might be consistent across all views. An example of this might be a method to assert if an error is visible if you implemented errors consistently across your app.
Putting It All Together
Now let’s take all of the individual pieces we’ve covered and put it all together for an integration test for the lookup
view.
This test is a little bigger, but most of it should look familiar by now. One of the new pieces is the use of @reach/router
to handle routing for our app. The renderWithRouter
method is a utility method from the @testing-library
docs. This allows us to assert that the app is correctly routing to pages as expected. Just like with our unit tests, we are using the global mock and jest-fetch-mock
to mock our HTTP requests.
Now that we’ve mocked the edge of our application (HTTP requests) and the minimal amount that we need in order to assert functionality (reach router mocking), we can simulate our user behavior and interact with our app. We use the LookupPage
page model class to interact with the HTML, and the user-event
library to simulate user behavior to interact with our page.
Because the HTTP request was mocked to “find” the user in our system, the user should then be redirected to the /challenge
view. That is our main assertion in this test. While this seems simple, we have just tested many modules working together correctly from the user’s point of view. We did this with minimal mocking, and our test will be incredibly resilient to any code refactors that may happen.
Conclusion
Using the techniques described in this article, you can create flexible tests that give you a high degree of confidence in your app. To recap the main points:
- Stop mocking at the code level and mock at the network level
- Use a single object as the source of truth for a network call’s mock
- Use a consistent project structure for tests, mocks, and models for maintainability
- Use a Page Model/Object pattern for more flexible interactions between tests and HTML elements
- Simulate real user behavior in your tests to give you higher confidence in your tests
- Integration tests can provide massive amounts of value for web apps while still being implementation-agnostic
I’ll add the caveat that not all of these techniques make sense for every project. There will always be different use cases, but there is a good chance you can find value from these techniques, even if you don’t always use them all.
Thanks for reading, and please let me know if you have any feedback or thoughts.