What concerns should I have when writing tests?
In this article, we'll go over the thought process for testing and implementing a React application that will fetch data from an API and display it. While this is a relatively simple application, it is enough for us to go over some key topics like:
- Preparation before writing tests
- Defining boundaries for testing
- Mocking
What we'll be testing and implementing
Here's a demo of what we'll be building. As you can see, we'll be testing and implementing an application that shows Rick and Morty characters. The characters' information will come from Rick and Morty API.
It's important to mention that before working on an application or feature, it helps to write a test list. By making a test list, we'll have a starting point to write our tests. In a previous article, I went over the process to make a test list, so give if you want to know more.
We'll use the following test list to get us started:
- Shows an empty message when there aren't characters
- Shows one character with expected info
- Shows three characters, each with the expected info
- Shows an error message when there was an error getting characters
However, before we pick a test to implement, there are some ideas we need to go over to make the process easier.
It's hard to make tests for production code we have no idea how to write
If we have no idea how to write the production code, writing tests for it will be hard. So before trying to implement something, it's helpful to have some familiarity with the tools and services we'll be using and have a rough idea of how to use them to solve the problem we have at hand. Otherwise, when we're writing tests, it will be hard to define the boundaries for our tests, and when a test fails, we won't know if the problem is in the tests or the production code.
In situations where we're not familiar with a service or a library, we can create a test and use it as a REPL to make requests to the service or try out the library. For situations where we're exploring libraries that manipulate the DOM, we can try them out by writing a component and rendering it in the browser.
For this application, the only thing we're probably not familiar with is the Rick and Morty API. So before starting, we'd make a couple of requests to get an idea of how it works.
Small steps make tests and production code easier to write
An excellent way to have analysis paralysis is to try to solve too much at once. This is also true for building applications with automated tests. If the test we choose to start with will require a lot of production code to pass, we'll have an unnecessarily complicated time.
The key here is to start with a simple test that is simple to write and simple to make the production code for it to pass. This heuristic usually makes us take small enough steps that allow us to tackle one problem at a time.
One of the critical things to keep in mind while taking small steps is to focus on the current step and forget the others. For example, if we are working on showing an empty message when there aren't characters, we do not worry about showing an error message when there's an error getting characters from the server. We first make the test and production code for the empty message case, and then we make the necessary changes to show an error message when there's an error.
It's particularly important to start small on the first test. We may not realize it, but on that first test, we'll have to make a lot of decisions regarding the design of the module we're building as well as how we are going to test it. It's helpful to start with a simple test not to make our task more complicated than it needs to be.
In situations where no test out of the test list is simple enough, we'll likely be able to decompose those tests into simpler ones. In case we end up producing test cases that are so simple that they don't provide any documentation value or are redundant due to other more complicated tests we end up writing, we can always delete them.
Picking the first test
Out of the test list, the tests that seem more simple are:
- Shows an empty message when there aren't characters
- Shows an error message when there was an error getting characters
To make those tests pass, we only need to render a message to the screen, so the production code is reasonably straightforward. Given both tests are good places to start, we'll just pick the first one.
Defining the boundaries we'll use to test the behavior
To write this first test, we'll have to decide the boundaries we'll use to test the application shows an empty message when the list is empty. In other words, we have to determine the module or component that we will interact with to check the behavior we want to test and implement. Should we test it from a component dedicated to rendering characters, something like <Characters/>
? Or should we test it through somewhere else?
Different people will have different answers to the question above. But one approach that's been working well for me when building web applications is to start from the user perspective. As the tests become too complex and difficult to write, I begin to isolate the behaviors I want to test and test them more directly. Following this approach means that we usually start writing from the page the user would access to use a feature.
What's good about starting from the user perspective and then isolating behavior to reduce complexity in the tests, is that it gives a nice balance between tests that aren't too sensitive to changes in the code structure and tests that aren't too far away from the behavior we're interested in. In other words, it strikes a balance between tests that won't break on every refactor and tests that are straightforward to read and write.
Following the strategy above, we would place the boundary of interaction between tests and production code at the component that represents the page where the Rick and Morty characters will be. Something like <RickAndMortyCharactersPage/>
.
Passing the characters to the page component
The next decision we have to make is how is <RickAndMortyCharactersPage/>
going to have access to the character's data. We know that the characters will come from the Rick and Morty API, and we also know that we don't have control over the behavior of the API. For example, we can't force it to give an error response. This means that we won't be able to create the situations we want to test if we're using the real Rick and Morty API in our tests.
One approach to deal with not controlling the responses from the Rick and Morty API is to mock the interaction with the API. This way, it becomes easy to simulate the situations we want to test. But how exactly should we do it? Should we use something like MirageJS to simulate a server? Should we mock the HTTP client module?
Either making a fake server or mocking the HTTP client would solve the issue of simulating the situation we want to test. However, both approaches force our tests to deal with the details of HTTP interactions. In other words, we would need to do quite a bit of setup to test that given an empty list of characters, we show the right message.
The issue with tests that have complicated setups is that they tend to be hard to write and hard to read. One approach to this problem is to create a function that wraps the HTTP interaction to get the characters and have <RickAndMortyCharactersPage/>
use it. Now we can have whichever response we need for our tests just by mocking the new wrapper function. The signature of the wrapper function would look something like this:
async function fetchCharactersFromServer() {
// Makes request to API and returns characters
}
Making the assertion
From the behavior standpoint, what we want to assert is that when the list of characters is empty, we render a message saying that there aren't characters. So we'll have to search the DOM for the expected message. We also have to keep in mind that fetching the characters from the server is an asynchronous operation, so we'll have to wait for the message to appear.
Dealing with the asynchronous nature of fetching data in this test made me realize that we hadn't written in our test list that we should show the user a loading message while he's waiting for the response with the characters. So at this point, we should add that test to the list.
Taking into account all that we've gone over so far, the test would look like this:
test("Shows empty message when there aren't characters", async function test() {
const fetchCharacters = jest.fn().mockResolvedValueOnce([])
render(<RickAndMortyCharactersPage fetchCharacters={fetchCharacters} />)
expect(
await screen.findByText("There aren't characters to show")
).toBeVisible()
})
But there's one assertion that we're missing in the test above.
Test interactions when using mocks
Whenever we use a mock function, like we just used for our test, we need to make sure that the code under test is calling the mocked function as we intend it to call the real function. We'll also need to make sure that the real version of the function behaves like the mocked version. These kinds of assertions are testing the interactions between modules. It's crucial to test interactions when using mocks since failed interactions give origin to the problem of units that work in isolation but fail to work as a whole.
This means that when we are using mocks, in addition to our tests going over behavior the user can observe, our tests will also cover the interactions between different modules. It's essential to keep those two roles of tests in mind. Otherwise, we'll lose track of what we're trying to test, which leads to frustration when we're trying to write tests.
What this approach means for our test, is it that we'll need to assert that we're calling fetchCharactersFromServer
with the expected arguments.
test("Shows empty message when there aren't characters", async function test() {
const fetchCharacters = jest.fn().mockResolvedValueOnce([])
render(<RickAndMortyCharactersPage fetchCharacters={fetchCharacters} />)
expect(
await screen.findByText("There aren't characters to show")
).toBeVisible()
expect(fetchCharacters).toHaveBeenCalledWith()
})
When we get to testing the fetchCharactersFromServer
, we'll need to write a test for it that proves it can return a Promise with an array when things go well. But more on that later in the article.
A look at the code after making the first test pass
After writing this first test and the production code to make it pass, we ended up with this:
test("Shows empty message when there aren't characters", async function test() {
const fetchCharacters = jest.fn().mockResolvedValueOnce([])
render(<RickAndMortyCharactersPage fetchCharacters={fetchCharacters} />)
expect(
await screen.findByText("There aren't characters to show")
).toBeVisible()
expect(fetchCharacters).toHaveBeenCalledWith()
})
function RickAndMortyCharactersPage({ fetchCharacters }) {
useEffect(
function fetchCharactersOnStart() {
fetchCharacters()
},
[fetchCharacters]
)
return (
<div>
<p>There aren't characters to show</p>
</div>
)
}
Notice that there is only enough production code to make the tests pass. This is a crucial aspect of taking small steps that allow us to focus on the current task.
As we write more tests, the production will change to make the new tests pass, but since we have the older tests in place, we can have confidence that we haven't broken anything.
The remaining tests from the test list
After all the work we had to define the boundaries to test our code during the first test, the remaining tests from the test list are very straightforward. So I'll just highlight some interesting points from them, and we'll go straight into testing the code that fetches data from the server.
Testing for errors is simple
Since we decided to wrap in a function the HTTP call to get characters, we can now easily test error situations by having the promise returned from the mock function reject.
test("shows error message when there's an error fetching characters", async function test() {
const fetchCharacters = jest.fn().mockRejectedValueOnce(new Error())
render(<RickAndMortyCharactersPage fetchCharacters={fetchCharacters} />)
expect(
await screen.findByText("There was an error. Please reload page.")
).toBeVisible()
expect(fetchCharacters).toHaveBeenCalledWith()
})
Since our application doesn't distinguish between errors, we only have a single error test case. But with this approach, testing for other errors would be as simple as changing the error we use when rejecting the promise.
Only the necessary data for the tests
When we wrapped the HTTP call to get the characters, we added a layer of abstraction between the code that renders what we want to show to the user, and the code that fetches the characters. This abstraction layer gives us the flexibility to model the data returned from the server into something that perfectly fits what the view code needs. The results are easier to read and easier to write tests, since the amount of data not relevant to what we're testing, is low.
test("Shows 1 character", async function test() {
const armorthy = {
id: 25,
name: "Armorthy",
status: "Dead",
species: "unknown",
gender: "male",
image: "/mockArmorthyImageUrl",
}
const fetchCharacters = jest.fn().mockResolvedValueOnce([armorthy])
render(<RickAndMortyCharactersPage fetchCharacters={fetchCharacters} />)
await assertCharacterIsVisible(armorthy)
expect(fetchCharacters).toHaveBeenCalledWith()
})
Testing we can get characters from the server
The tests for fetching characters come in two parts. The first one is making sure the function fetchCharactersFromServer
behaves as expected, and the second one that it interacts correctly with the server.
Making sure the module behaves as described
Since we have mocked out the function that fetches characters from the server, we now need to write tests that assert the real function behaves in a way that is compatible with how the mocked function behaves. This is important to make sure that fetchCharactersFromServer
and <RickAndMortyCharactersPage/>
will work well together.
By looking at the code from the previous tests, we can get an idea of the assertions we'll need to write.
// Defines how function should behave when there's an error
const fetchCharacters = jest.fn().mockRejectedValueOnce(new Error())
// Defines how function should behave when all goes well
const characters = [
{
id: 25,
name: "Armorthy",
status: "Dead",
species: "unknown",
gender: "male",
image: "/mockArmorthyImageUrl",
},
// more characters...
]
const fetchCharacters = jest.fn().mockResolvedValueOnce(characters)
From looking at the code above, we know that we'll have to assert thatfetchCharactersFromServer
rejects a promise when something goes wrong, and that it returns an array with characters when things go right. The structure of the characters should be compatible with what the code above specified, of course.
Interacting with the server
The logic for testing the interaction between fetchCharactersFromServer
and the server, is the same we used when we decided to mock the fetchCharactersFromServer
on the previous tests. Since we have no control over the server's responses, we'll mock it out for our tests. But given the server API is already established, we'll need to make sure that our mock behaves as the server would and that we're making the HTTP request as the server expects.
In this article, we've previously talked about two options to mock the server: simulating a server using a library like MirageJS, or mock the HTTP client. Since mocking the HTTP client will make our code break if we decide to change the client, and we don't get any significant benefits from doing so, we'll write the tests for fetchCharactersFromServer
using MirageJS.
A straightforward way to bring the behavior of the fake server close to the real server is to make manual requests to the real server and observe the headers necessary to make the request, as well as the format of the response. Then on the fake server, we can check if the required headers are present and make it return a response with the same structure as the real server would.
In situations where we have access to the code of the server, looking at tests against the endpoints can give a helpful idea of the behavior we have to replicate.
Following what we've just talked, we end up writing the tests below:
import { Server, Response } from "miragejs"
import charactersApiResponse from "./characters-api-response.json"
import { fetchCharactersFromServer } from "../RickAndMortyCharacters"
test("On error fetching characters, rejects promise", async function test() {
const server = new Server({
environment: "test",
routes() {
this.urlPrefix = "https://rickandmortyapi.com"
this.get("/api/character/", () => {
return new Response(500)
})
},
})
await expect(fetchCharactersFromServer()).rejects.toEqual(undefined)
server.shutdown()
})
test("On success fetching characters, returns them", async function test() {
const server = new Server({
environment: "test",
routes() {
this.urlPrefix = "https://rickandmortyapi.com"
this.get("/api/character/", () => {
return charactersApiResponse
})
},
})
const characters = await fetchCharactersFromServer()
expect(characters).toMatchObject([
{
id: 1,
name: "Rick Sanchez",
status: "Alive",
species: "Human",
gender: "Male",
image: "https://rickandmortyapi.com/api/character/avatar/1.jpeg",
},
// more characters...
])
server.shutdown()
})
And this brings us to the last tests of this application.
Putting it all together
The only thing that's left now to make this application work, is to connect the fetchCharactersFromServer
with the <RickAndMortyCharactersPage/>
. We can do that with the code below:
function RickAndMortyCharactersPage({
fetchCharacters = fetchCharactersFromServer,
}) {
//...
}
To ensure the two modules are connected, we could write an E2E test. But given how straightforward the connection between modules is, it's easier to run the application and look at the browser.
You might be wondering where styling the application fits in all of this. Although visuals are a vital part of any web application, the tests we wrote almost only cover the application's behavior. That is, they assert that the application shows the data the user wants to see and that it responds as expected to user interactions. Visual regression testing is an exciting topic that I'd like to cover on a future article, but what's written in this article doesn't apply to it.
Regarding when to style the application using the process of this case study, I think that will mostly depend on the context we're developing. With that said, I like to do it after I've finished implementing a feature or sub-feature. Because by then, I usually have a better sense of how the feature will work, so adapting a design becomes easier.
A closer look into the code
In case you're interested in observing the little details that went into building this application, you can take a look at this repository. There's one commit at least every time a new test passes, so if you look at the commit history, you can get a reasonably detailed view of the process.
Key takeaways
Next time you're faced with writing tests for a React application, remember the following:
- It's easier to write tests for code we have some idea on how to implement. So explore the services and tools you'll be using before starting.
- Small steps will make tests and production code easier to write. So go one test at a time and only worry about making the current test pass.
- Start testing from the user perspective, and as tests become too hard to write, use mocks to isolate the behavior you want to test and bring down the complexity of the tests.
- Whenever we mock a module, we need to test that the code using the mocked module interacts with it as the real module expects to. We also need to test that the real module behaves as the mocked module.
If you enjoyed this article you can follow me on twitter where I share my thoughts about software development and life in general.
Top comments (0)