At ClassDojo, our frontend web apps are large single-page applications running in React and Redux. Millions of teachers, parents, and students use these apps every day, and we want to ensure a quality experience for them. However, effectively testing frontend apps is hard.
We have a good number of unit tests, but found that apart from a few complicated components with lots of stateful interactions, these tests are tedious to write and usually ineffective at preventing bugs. This is because most of our frontend bugs have occurred in the boundary between components or layers of our app – for example, between the API and the data layer, the data layer and our components, or between a parent and child component. These boundaries are explicitly not tested by standard unit tests, which mock out external dependencies. It’s always very frustrating to find that an API change or refactor had broken our app, when all of our unit tests continue to pass.
To address this issue, we decided to look into integration testing our frontend apps. The standard way to do this is with a browser automation tool like Selenium. However, these tools are legendary for their difficulty of use and unreliability. Tests written this way are brittle, breaking when class names change or if API calls or renders take too long. We wanted an easier, more efficient method. Luckily, React and Redux provided us the tools to do so.
Difficulties
The primary difficulty when writing integration tests for web apps is that the browser provides only a limited set of options for interacting with your app. Unit tests can mock out dependencies, wait for promises to resolve, and inspect internal app state as needed, but browser automation tools can only access what appears on the screen.
For example, consider the process of testing the signup flow – ensuring that at each step, the correct information is displayed on the screen. To properly test this flow, we would like to be able to inspect what has been rendered at several points: the initial state, the state after inputting information, the state after the user clicks “sign up”, and the state after the request succeeds or fails. If the only input we receive is what displays on the screen, it’s hard to know what assertions we should make at which times.
Our method
There are a few components of our integration tests: initializing and rendering the app, tracking API requests, writing multi-stage tests, and managing the router. I’ll review each of them in turn.
Enzyme, Mocha and JSDom
AirBnB has created the excellent testing tool Enzyme to make assertions about React components. In our case, we mount our entire React app in Enzyme using its mount
method, which fully renders a tree of React components and properly registers component lifecycle hooks and event handlers. We can then use Enzyme to traverse the rendered tree and make assertions about what’s present.
We create and mount a new app instance before each test, to remove any accumulated state. Luckily, Redux makes this very easy, since it’s standard practice to create a new store as part of your app root component’s creation. However, if your app has any singleton modules, you’ll need to reset them manually.
Enzyme’s mount
method requires that window
and document
objects be available in the global scope. While this can be accomplished by running your tests in a real browser (using Karma and PhantomJS, for example), this would require running Webpack to compile and build our application before every test run. This is cumbersome and time-consuming.
Instead, we use JSDom to create a mock document, and run our tests in Node using Mocha. Note that we don’t actually mount anything in our mock document; it’s simply there to provide document
and window
objects. And babel-register allows Mocha to run the same compiled code Webpack will bundle into our app. While there are some differences some between node and the browser even after a pass through Babel’s compiler, Webpack papers over most of them, and clever use of if (__TESTING__)
blocks can handle the rest. So far, we haven’t had any major issues from test/browser environment inconsistencies.
Sample initialization code for Enzyme and JSDom is below:
1 2 3 4 5 6 7 |
|
Tracking API Calls
As mentioned above, waiting for the results of asynchronous operations is a common difficulty when writing frontend integration tests. Our solution is to wrap all of our app’s API calls with code that allows us to hook into their completion. The code to do this looks something like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
|
mocha-steps
Integration tests are often long, requiring a series of steps that each depend on previous results. To make writing these tests cleaner, we’ve used the mocha-steps library, which allows you to write tests that will halt if any step fails. An example test would look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
Routing
If you’re using React-Router to control your app’s navigation, congratulations! Page transitions will work just fine. If you want to set the page manually (for example, at the beginning of a test), you’ll want to do the following:
1 2 3 |
|
Note that React-Router’s API has changed substantially between versions; we’re on v3, so if you’re using a different version, method names may be different.
The API
We manually start a local instance of our API before starting the test suite. An instance is also kept running on our CI server. Before each test, we make a special request to the API that causes it to reset its testing database fixtures.
Putting it all together
We mount our app using Enzyme, Mocha, and JSDom, setting the initial route using react-router
and resetting our API’s database before each test. We execute a series of test steps using mocha-steps
. When a step needs to wait for an API call to finish, it simply calls the waitForApiCallsToFinish
function and waits for that promise to resolve. We use Enzyme to trigger events and make assertions about the what’s being shown.
Example test
Here’s an example test – in this case, for a user signing up. The code is relatively long, but quite easy to read and write.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
|
Shortcomings
While we’ve had great success with our integration tests, we’ve found a few shortcomings that they do present.
First, since we’re using JSDom as our document, we don’t have the ability to test in multiple browsers. Luckily, babel’s generated code tends to work in all browsers that we support (IE9+), and you can use eslint to keep you from using unsupported browser methods.
Second, this approach to frontend testing doesn’t catch visual bugs in your CSS or HTML. A different approach is needed to test visual changes.
Third, non-API side effects can present some difficulties. Our wrapped API module allows tests to wait for calls to complete. However, to test other asynchronous side effects – for example, writing to local storage – we would need to write a similar wrapper that allows us to wait for the effect’s completion.
Conclusions
We’ve gotten a lot of benefit out of writing integration tests for our React apps. They’ve caught a lot of bugs and edge cases before reaching production, without requiring that much additional time to write.