loading...
Cover image for What I've Learned About React Testing So Far

What I've Learned About React Testing So Far

christiankastner profile image Christian ・6 min read

Recently, in my hunkered down and quarantined state, I've been trying to dive into React testing. From what I'd read, it's a great way to have confidence in the apps you deploy and know for sure that they're functioning correctly. They also exist at a crucial point in Continuous Integration and Continuous Deployment (CI/CD). With github actions you can have an automated workflow that tests, builds, and deploys your code on every git push command. Fireship.io made a great youtube video about it here

This seems SO much better than having to manually deploy a code base whenever I wanna make a change. However, this requires that your tests be taken seriously and demonstrate that the code works as it's supposed to. This can be cumbersome and annoying as it hamstrings short term speed. But having to check the same routine app functions again and again is going to be a much larger waste of time in the long run.

Testing Like A User Would

Now this testing for confidence philosophy is great and all, but it doesn't really make it any clearer how to write tests themselves. This was my origin point in test land and it took me towards Kent C. Dobbs, the React testing messiah himself. Dobbs wrote the React Testing Library under the assumption that the most effective tests utilize details seen by the user and do not break if the code is restructured. If your tests fail because you restructured your code, then that's an implementation detail and not related to how your user will actually interact with the test.

Plus tests that break because you changed how state is declared or what have you are extremely annoying and aren't reflective of why to use tests in the first place. So react testing library is built such that nodes are identified by text, input nodes identified by label text, and if you have no other recourse for picking out a node, then using a data test id in the jsx element.

Component state might be a good thing to know the internal workings of an app, but it is not important for testing what the user will see or click on.

Unit and Integration Tests

After reading about Dobbs, I was even more on board with the idea of testing. However, I'd still not really gotten into how a test is written. I'd gotten a little bit closer into the testing realm by reading about the difference between unit and integration tests, which was definitely more tangible. However, I'd found that demonstrations of unit tests (testing a single chunk of code) were much more plentiful on the web than integration tests.

But this from the Dobbs man says that integration tests are what the main chunk of testing base should be. Writing a million unit tests with mock function event handler props is definitely one way to handle testing a code base. But relying more and more on fake functions doesn't seem to demonstrate the ways in which React components interact with one another.

Obviously unit tests can be important, but testing larger components that rely on forms, button presses, and dynamic rendering seem much more important than testing the heck out of a single presentation card component.

Testing Pyramid

The idea here is that integration tests will have the most bang for their buck in terms of demonstrating function and offering confidence.

Finally, Some Code

After so much frustration hearing about the importance of integration tests but not getting good material to write my own tests from, I followed along with Dobbs's frontened masters workshop about React testing. It seriously helped me get the hang of good testing that offers some confidence. Below is a test for whether the login works and if a jwt token is saved to localStorage.

import App from '../App'
import { render, fireEvent, wait } from '@testing-library/react'
import React from 'react'
import {api} from "../services/api"

    beforeEach(() => {
        window.localStorage.removeItem('token')
    })

    it("Lets a user login to an account", async () => {
        const fakeUser = {
            email: "chris@hotmail.com",
            name: "Chris Stephens",
            password: "Boomgoesthedynamite"
        }

        const { container, getAllByPlaceholderText, getByText, getByTestId } = render(<App/>)

        fireEvent.click(getByText("Log in"))

        const emailInputs = getByPlaceholderText("Email")
        const nameInputs = getByPlaceholderText("Name")
        const passwordInputs = getByPlaceholderText("Password")

        emailInputs.value = fakeUser.email
        nameInputs.value = fakeUser.name
        passwordInputs.value = fakeUser.password

        fireEvent.click(getByTestId("login-btn"))

        await wait(() => expect(window.localStorage.getItem("token")).toBeDefined())

        expect(api.auth.login).toHaveBeenCalledTimes(1)
        expect(window.localStorage.getItem("token")).toBe("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c")

        fireEvent.click(getByText("Log out"))

        expect(window.localStorage.getItem("token")).toBeFalsy()
    })

Ok so there's a lot going on in this test but I'll try to go through it one step at a time. We start with importing some necessary modules like the App component I have, some important functions from React testing library: render for rendering our component, fireEvent for simulating event handlers, and wait for async calls.

The next important thing to note is that I'm importing an api module that contains all the different calls to my backend in Rails. It's important to contain all this logic in one module because Jest, create react app's default test-runner, has the capability to mock modules for testing. What that means is that I can write fake functions that would simulate the actual calls I'd be making to a backend server.

However, it's required that wherever this mocked module is, there must be "mocks" directory in the same directory as our mocked module. It's also imperative that the modules are named the same. For ex. mocking my api module in the src directory means that I'll create another api module in a mocks directory i've created in the src directory. See here if you need more detail.

The specific function that my app was using to request from my api was using the function "api.auth.login(...)" using login details as an argument. That means I'll replace this function with my own fake jest function in my mocks directory. For me it looked like this:

const login = jest.fn(() => Promise.resolve({jwt: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"}))

export const api = { 
    auth: {
        login
    }
}

And importantly you must declare that you want a module mocked to jest using

jest.mock("...path")

I put this in my setupTests.js file since then it'll be run in the whole test suite.

This is largely dependent on how you set up your code but for me I was my App component was expecting a promise to which it would call ".then(json => json.jwt" to. So I created a mock jest function to return just that, a promise that resolves into an object with a jwt token.

My app then takes that jwt token and stores it in localStorage. Which brings us to the test itself.

First we render the App component using object destructuring to get the getByText and getByPlaceholderText react testing library queries. Refer to their docs if you want the whole suite of queries.

Then I find the button on the render that triggers a login form render right here:

fireEvent.click(getByText("Log in"))

Now the hypothetical DOM should have a form where we can grab the nodes holding form inputs with getByPlaceholderText and fill the values with some fake data I'd written at the beginning of the test:

const emailInputs = getByPlaceholderText("Email")
        const nameInputs = getByPlaceholderText("Name")
        const passwordInputs = getByPlaceholderText("Password")

        emailInputs.value = fakeUser.email
        nameInputs.value = fakeUser.name
        passwordInputs.value = fakeUser.password

Next we click the submit button

fireEvent.click(getByTestId("login-btn"))

Unfortunately I had numerous places where I'd used the text "log in" so had to use a data-testid. This triggers the submit event handler and would normally send data to the backend. However, jest will use the fake function to give back a promise to be resolved. The app will use this resolved promise to store in localStorage. Which means we can test for this value. However, since the promise is async we'll have to wait for the resolve. "wait" is a nice feature in React Testing Library where we can wait for the promise to resolve or, in my case, for the token to be stored. This is where this line comes in:

await wait(() => expect(window.localStorage.getItem("token")).toBeDefined())

Wait can take in an expect function which is quite nice to take advantage. Finally, I run my assertion. This is that the localStorage should have an item "toke" that matches the jwt string in my mocks fake function. Which looks like:

expect(window.localStorage.getItem("token")).toBe("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c")

I also wanted to test whether we would also be logged out properly upon clicking the log out button which is my last two lines of code.

fireEvent.click(getByText("Log out"))

        expect(window.localStorage.getItem("token")).toBeFalsy()

I hope this has been somewhat helpful. I know that it was at times extremely frustrating to not have a grasp on how to even set up a test that would be useful. The code for this is located in one of my project repos here.

Posted on Mar 27 by:

christiankastner profile

Christian

@christiankastner

Software engineer particularly interested in creative coding and machine learning.

Discussion

markdown guide