This article refers to the testing of web applications (front-end and back-end).
Should your app have tests? If yes, should the tests be unit tests, integration tests, end-to-end tests, or a combination of each? If using a combination of the different types of tests, which ones should you prioritize?
These are the questions you're bound to encounter when building an app and these questions are often re-visited as an app evolves. For example, if building a new app, you might start out with writing no tests and only start adding tests once you notice bugs. You might start out by adding end-to-end tests for critical user flows (e.g. login + signup) and only later adding some integration tests.
Unit, Integration, End-to-End test definitions
Let's get on the same page and define what we mean by unit, integration, and end-to-end tests. I'm going to re-use some definitions from Kent C Dodds:
End-to-end: Test the app as a user would by simulating clicks / key presses in your UI. Usually involves spinning up a front-end, back-end, and database to prevent any mocking of the services that you have control over.
Integration: Verify that several units work together in harmony. Could involve an test between your API and database or you can even consider deeply rendering react components using React Testing Library as integration testing.
Learn how to test React components in this article I wrote on the topic.
Unit: Verify that individual, isolated parts work as expected. Often see unit tests for individual functions and testing that a bunch of different inputs yield the expect return value.
Should you have tests in the first place?
Are you a indie hacker starting an app from scratch and don't expect to have many new users using your app when you launch? Then maybe don't waste time on testing something that might not work out.
Are you a large company with many users that rely on your software? Absolutely you should have testing.
In my eyes, there are two main reasons for having tests:
- Ensures that your code behaves as expected
- Ensures that any existing functionality doesn't get broken when introducing new code changes
My personal opinion is that you should have tests UNLESS you're building a side project or are a solo founder / indie hacker. Even then, you should add tests if your app starts gaining a good amount of users. I'm also a big fan of using a typed language like TypeScript in any project to gain the benefits from added type safety.
What types of tests should you have
You'll want to add tests that provide you with the most value. A good way to determine what tests you should add is to examine any recent bugs you've had in your application and then write a test that would have caught that bug.
In most cases, you'll likely find that having an end-to-end or integration test would have caught your issue. In my personal experience, unit tests don't catch as many bugs since they rely often rely on mocking a lot of data which forces you to make a lot of assumptions on how something would work.
Here are examples of bugs that would benefit from different types of tests:
In a "twitter-like" app
A user is unable to submit a tweet.
A cypress end-to-end test that writes a tweet, clicks the "tweet" button, and checks for a success message in the UI.
A user was able to create a twitter handle using invalid characters.
A unit test that tests a function named
validateTwitterHandle and ensures that a falsy value is returned when calling the function with invalid characters.
In a "facebook-like" app
A user is unable to post a message to their friend's timeline.
A cypress end-to-end test that vists a friend's profile, composes and submits a message, and checks for a success message in the UI.
After a user has posted a status update to their own timeline, they need to refresh their page to see their recently updated status.
A react-testing-library integration test that writes out and submits a status and checks that the newly added status shows up in the UI without having to refresh the page.
I opted for an integration test in the above scenario since the problem is purely an issue with the client side code not correctly updating the UI after an action has taken place.
It is not considered an end-to-end test since we would be mocking the API call made once the status is being submitted.
In a "reddit-like" app
A user sees an error toast when trying to upvote another user's comment.
A cypress end-to-end test that clicks the upvote button on another user's comment and checks that the upvote count incremented and no error message appears.
Are unit tests useful?
Yes, unit tests can be useful when you want to thoroughly test how an isolated unit in your app works.
As an example, you might have a utility function that takes a string and transforms it into a URL-friendly slug. A unit test would be useful since you could test that the utility function can handle a bunch of different string inputs, including some edge cases you might not often encounter, but could cause a bug if not properly handled.
You could even consider unit testing your API route handlers and checking that the route handler returns the expected response payload given different request payloads (this can be done with supertest for example).
If testing your API route handlers you may want to mock the return value of some of your utility functions or 3rd party dependencies. You'll also want to mock your database calls and responses (otherwise your tests will become integration tests and you will need to make sure you have a test database running).
One thing I love about unit tests is that they are very fast to run and require little setup. This has two advantages:
- Developers can easily run a command to quickly run the unit tests and quickly iterate on the tests.
- Continuous integration checks can run the unit tests quickly, giving quick feedback to developers about the state of their pull request.
Running tests on continuous integration
Figuring out how to run your tests on continuous integration (CI) machines is important since that is how you will be able enforce that only tested code gets commited to your main version control branch.
Usually, this is how I see tests setup in version control:
Will need to involve a solution like Docker that will allow you to run your app services (e.g. front-end, api, databases) together in an isolated environment. This usually involves building and running your front-end and api images and running database containers that are initialized with seed data that can be used by your tests.
Building images and running containers usually takes up a lot of time and results in end-to-end tests taking a long time to run on CI.
CircleCI has a feature called Docker Layer Caching, which can help reduce image build times by caching your docker layers to an external volume that can be used across different job runs. This feature will cost you some money to use, so something to keep in mind.
A strategy I've seen companies take with regard to running their end-to-end tests, is to not run them on pull requests, but instead run them as part of the continuous delivery/deployment pipelines or run them on a schedule (e.g. run all end-to-end tests once a day). This way you do not need to run your slow end-to-end tests on pull requests, increasing the velocity in which code can be merged.
If interested in learning more about Docker, you can read this article I wrote on the topic.
Depending on what services your integration tests require, you may or may not want to use docker. For example, if you are running integration tests of your API against your database, then you will want to use Docker to spin up a test database. However, if you are running an integration test of your React UI components code using react-testing-library where you mock your API calls, then you will not need Docker.
If using Docker, then the section on end-to-end tests running on CI applies for integration tests. If not running docker, see the section below on running unit tests on CI.
Unit tests can run lightning fast on CI ⚡️ since you will not need to run any services in Docker and CI providers provide good mechanisms for caching installed dependencies across different job runs. All you need to do is install your dependencies and run you tests!
As an example of caching dependencies on CI, say that you're unit testing an API using Jest. Before running the tests, you run an
npm install command in your CI job that will install your API and test dependencies. Depending on your CI provider, you should be able to cache your
node_module dependencies so that they can be re-used in other jobs without needing to re-install everything from scratch using
npm install. You can check out the CircleCI docs on caching to read more about how this works.
If you have many unit tests to run, you may want to consider running them in parallel. This is possible on CircleCI. See this article on how this can be done using
jest-junit on CircleCI.
Since unit tests usually run very quickly, they are good candidates to run on every pull request to help catch any bugs before they get merged into your main branch.
Yes, writing tests takes time, but it can provide you with some peace of mind that your app works and therefore might be worth the investment for you and your team.