loading...

TDD in React using Jest — beginner tutorial

devkhadka profile image Dev Khadka ・12 min read

Overview

In this tutorial we’ll get started with using Jest library to test react application. This tutorial will cover following topics

  • Setup react project which includes jest library
  • How to write test using jest
  • Some common jest matchers
  • Concept of mocking and how to do it using jest
  • UI testing of react using react testing library
  • Finally I will also add reference where you can get in depth knowledge

For grasping above topics we’ll create a demo application which lists restaurants which can be filtered by distance from a center location. We’ll use TDD approach to build this application and give you simple exercise along the way to play with.

Jest Tutorial Screenshot

Prerequisite

You need to

  • be familiar with javascript
  • have some understanding of react like (JSX, Function based components, few hooks like useState, useEffect, useMemo). I will try to explain them as we use it

The codes are in Github with separate branch for each step. You can directly jump into any branch and play around.

If you are familiar with setting up react app you can directly skip to “List Restaurants

Setup New React Project

You need nodejs installed before you can continue

  • Create a new folder named “jest-tutorial” and cd into that folder
cd /path/to/jest-tutorial
  • Run “create-react-app” command
# if create-react-app doesn't exists npx will install it and run
npx create-react-app .
  • Now you can run your app in browser. You should see a spinning react native logo in browser
npm start
  • press “ctrl+c” to stop the server in terminal

Lets Check Some Important Files

  • package.json — below is a part of the package json file. It lists project dependencies and commands that you can run
"dependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.5.0",
"@testing-library/user-event": "^7.2.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-scripts": "3.4.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
  • index.js — It is entry point for the app, it mounts the “App” component to element with id “root” in “public/index.html” file
ReactDOM.render(
<React.StrictMode>
   <App />
</React.StrictMode>,
document.getElementById('root')
);
  • App.js — It is the root component for our application. We can think of a react application as a tree where “App” component is root and it and its descendants can have one or more components as branches.
import './App.css';
function App() {
return (
<div className="App">
    ...
</div>
);
}
export default App;

Some Explanations

  • It imports “./App.css” as a global css file
  • “App” function returns JSX which is HTML like syntax in Javascript (What is JSX?)
  • It exports “App” component to be used in other files

Basic Layout

  • Replace content of “App.css” file
  • replace whole content of App.css file with css in following gist. This css includes basic styling for our demo application.
.App {
  display: flex;
  flex-direction: column;
  height: 100vh;
  color: white;
  overflow: hidden;
}

.App-header {
  background-color: #282c34;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
  border-bottom: 1px solid rgb(143, 143, 143, 30);
}

.App-content {
  padding-top: 16px;
  background-color: #40444d;
  display: flex;
  flex-direction: column;
  flex-grow: 1;
  overflow: scroll;
}

.App-content form{
  margin: 16px 10% 16px 10%
}

.App-content input {
  box-sizing: border-box;
  width: 100%;
  height: 32px;
  font-size: 20px;
  padding: 4px;
}


.App-content ul {
  box-sizing: border-box;
  margin: 16px 10% 16px 10%;
  padding: 0px;
}

.App-content li {
  box-sizing: border-box;
  width: 100%;
  font-size: 20px;
  padding: 16px;
  list-style-type: none;
  background-color:  #282c34;
  border-bottom: 1px solid rgb(143, 143, 143, 30);
}

.App-link {
  color: #61dafb;
}
  • Replace the JSX in “App.js” replace all JSX content ( and its contents) with following
    <div className="App">
        <header className="App-header">
            <h2>Welcome to Jest Tutorial</h2>
        </header>
        <div className="App-content">
        </div>
    </div>
    

    List Restaurants

    Lets start by listing restaurants in UI. For that we need list of restaurants, which we may need to fetch from an api and then display it in UI. It sounds bit complex, if we try to implement all the functionality at once it will be complex to implement and hard to debug.

    In TDD we build application in small increments. We first plan the next incremental feature to be implemented, then we write test code to validate that the implementation works as expected, with some given preconditions and inputs. Then only we write code for the feature

    App Component

    Start here by checking-out “1-skeleton” branch

    Implementation Steps

    We’ll implement the “List Restaurants” feature in following steps

    • Instead of directly showing list in “App” component we’ll create “Restaurants” component which will be included in “App” component. This will separate the responsibility and make it more testable.
    • “Restaurants” component will take list of restaurants as input and display it

    Test Cases for App Component

    Now lets write test cases for above steps.

    App Component
        - Should call "fetchRestaurants" function to get restaurants
        - Should render "Restaurants" component with result from "fetchRestaurants"
    

    Lets write the first unit test, for that lets create a “tests” folder in “src” and move “src/App.test.js” in it. It is common practice to put tests under “tests” folder.

    Now replace content of “App.test.js” with following code

    import React from 'react';
    import { render } from '@testing-library/react';
    import App from '../App';
    describe("App Component", ()=>{
        it('Should call "fetchRestaurants" function to get restaurants', ()=>{
            fail("not implemented")
        })
    })
    

    Some explanation

    • “npm test” runs the jest command, which will look for js files inside tests or *.test.js or *.specs.js files and runs tests inside it one at a time in not particular order
    • “describe” is function provided by jest which will be available without import when running test with jest. It is used to group similar tests.
    • “it” is also function available in test environment it represents a single test case. Here we intentionally wrote test to fail.

    Command to Run Test

    npm test
    

    it should show result ‘Failed: “not implemented”’ in the console

    Using Mock for Testing

    • If you notice, the test above depends on a function called “fetchRestaurants”. Do we have to implement the function first? No, here is why
    • If we try to implement another functionality while working on one it will complicate things, which is against TDD principals
    • If we use real “fetchRestaurants” in test then when “fetchRestaurants” fails in future, testing depending on it will also fail. It will make pin-pointing the problem harder

    So what is the solution for it?

    Solution is to make a fake “fetchRestaurants” function which will return the value we need for testing, this is called mocking.

    Lets see it in action

    import React from 'react';
    import { render } from '@testing-library/react';
    import App from '../App';
    import Restaurants from '../Restaurants'
    import {fetchRestaurants} from '../utils'
    import * as fixtures from '../fixtures'
    import { act } from 'react-dom/test-utils';
    
    // First mock whole '../Restaurants' and '../utils'
    // By default it will mock all the functions in module to return undefined
    jest.mock('../Restaurants')
    jest.mock('../utils')
    
    // Provide fake return values for the functions
    Restaurants.mockReturnValue(null)
    // we want fetchRestaurants to return promise that resolves to fixtures.dummyRestaurants
    fetchRestaurants.mockResolvedValue(fixtures.dummyRestaurants)
    
    describe("App Component", ()=>{
    
      // function passed to before each is called before running each test
      // It is used to setup pre-condition for each test
      beforeEach(()=>{
        // mockClear clears call history of the mock function
        Restaurants.mockClear()
        fetchRestaurants.mockClear()
      })
    
      it('Should call "fetchRestaurants" function to get restaurants', async ()=>{
        await act(async () => {
          render(<App />)
        })
        expect(fetchRestaurants).toBeCalled()
      })
    
      it('Should render "Restaurants" component with result from "fetchRestaurants"', async ()=>{
        await act(async () => {
          render(<App />)
        })
        expect(Restaurants.mock.calls[1][0]).toEqual({list: fixtures.dummyRestaurants})
      })
    })
    

    Some Explanations

    • “jest.mock(modulepath)” will modifies the original model by hooking into the import functionality. This is called monkey patching. Any other modules imported in this test file will also see the modified module.
    • So when “App” component see “Restaurants” component in its JSX it will use mock “Restaurants” instead of real one. This gives us chance to monitor how it is being used, like what property being passed.
    • “render” function renders the components in a virtual DOM implemented by “jest-dom” so that the test can be run without a browser
    • We need to wrap render inside “async act(async ()=>{})” because we are updating state in useEffect function which will update state and trigger UI update
    • “expect” function gives us access to variety of matcher that can be used to check if certain condition is satisfied in test.

    Steps to Make the Tests Pass

    At this point your test will fail, to make the test to pass you have to do following changes step by step which will take your test little further in each change

    • Create file “src/Restaurants.js ” and add code below
    export default function Restaurants() {
    }
    
    • create file “src/utils.js” and add code below
    export function fetchRestaurants() {
    }
    
    • create file “src/fixtures.js” and add code below
    export const dummyRestaurants = "Dummy Restaurants"
    Add code below before return in App.js. Dont forget to import fetchRestaurants in the file
    useEffect(()=>{
        fetchRestaurants()
    })
    
    • change App function in App.js to look like below. Don’t forget to import “Restaurants”
    import React, { useEffect, useState } from 'react';
    import './App.css';
    import { fetchRestaurants } from './utils';
    import Restaurants from './Restaurants';
    
    function App() {
      const [restaurants, setRestaurants] = useState(null)
      useEffect(()=>{
        fetchRestaurants()
          .then(setRestaurants)
          .catch(()=>console.log("error in fetching"))
      }, [])
    
      return (
        <Restaurants list={restaurants}/>
      );
    }
    
    export default App;
    

    Some Explanations

    • callback of “useEffect” is called before each render of App component if values in second parameter changed. Values in second parameter must be a prop or state, empty array means it will run for 1st time only. We are calling “fetchRestaurants” before each render and calling “setRestaurants” function with value resolved by promise to update restaurants. This will re-render Restaurants component by updating list prop
    • You tests should pass now. Now lets move on to testing “Restaurant Component”

    Exercise: Add a test case to do snapshot test of the component. You can get some detail here

    Hint: Object returned by render function will have “baseElement” property. you can call “expect(baseElement).toMatchSnapshot()” which will create snapshot of html rendered for first time and test “baseElement” against the saved snapshot from next time. It will prevent accidental change in UI.

    Exercise: Handle error case of fetchRestaurants.

    Hint: Resolve object with structure {data: …} for success and {error: …} for error and check condition App component to show or hide error message element

    Restaurants Component

    Start here by checking-out “2-App-Component” branch

    Implementation Steps for Restaurants Component

    • Restaurants component will receive restaurant list as “list” prop and render it by looping through each restaurant
    • It will take distance in a input field and filter the restaurants within the distance. To implement this feature we need a function to calculate distance, which is not implemented yet, so for doing the test we need to mock it.

    Test Cases for Restaurants Component

    Restaurants Component
        - should render restaurants passed to it
        - should be able to filter restaurants by distance from the center
    

    The test cases should look like shown below

    import React from 'react'
    import {render, fireEvent} from '@testing-library/react'
    import Restaurants from '../Restaurants'
    import * as fixtures from '../fixtures'
    import {calculateDistance} from '../utils'
    
    jest.mock('../utils')
    describe("Restaurants Component", ()=>{
        it("should render restaurants passed to it", ()=>{
            // render function returns a handle 
            const {getAllByText} = render(<Restaurants list={fixtures.dummyRestaurants}/>)
            // get elements matching regex
            expect(getAllByText(/Restaurant\d/).length).toBe(5)
        })
    
        it("should be able to filter restaurants by distance from center", ()=>{
            const {queryAllByText, getByTestId} = render(<Restaurants list={fixtures.dummyRestaurants}/>)
    
            // following block set five different return value for five calls to calculateDistance
            calculateDistance
                .mockReturnValueOnce(30)
                .mockReturnValueOnce(110)
                .mockReturnValueOnce(80)
                .mockReturnValueOnce(60)
                .mockReturnValueOnce(300)
    
            const inpDistance = getByTestId('inpDistance')
            // fire change event on inpDistance to set distance
            fireEvent.change(inpDistance, {target:{value: 100}})
    
            expect(queryAllByText(/Restaurant\d/).length).toBe(3)
        })
    })
    

    Some Explanation

    In short, we interact with rendered DOM using handle returned by “render” function. We can also fire different event on DOM element by using “fireEvent” object. Like we used “change” event to trigger filter and check that list is filtered . More details are on comments in code.

    Steps to Make Test Pass

    • Enter code below to “Restaurants.js” file for layout
    import React from 'react'
    export default function Restaurants({list}) {
       return <div className="App">
            <header className="App-header">
                <h2>Restaurants</h2>
            </header>
            <div className="App-content">
            </div>
        </div>
    }
    
    • Create “distance” state by adding following line above “return” const [distance, setDistance] = useState(null)
    • Add the code block below before “return” line in “Restaurants” function. It will create a memorized value “filteredList” which is changed when either “list” or “distance” state changes
    const filteredList = useMemo(()=> {
        return filterWithinDistance(list, distance)
    }, [list, distance])
    
    • To render “filteredList” insert code below inside “App-content” div in JSX. This should make first test pass
    {
        filteredList && filteredList.map((restaurant, i)=>
            <li key={restaurant.id}>{restaurant.name}</li>
        )
    }
    
    • In “utils.js” add following function
    export function calculateDistance(location){
    }
    
    • Add “filterWithinDistance” function below the “Restaurants” function at the bottom of page. Don’t forget to import “calculateDistance” from “utils”
    function filterWithinDistance(restaurants, distance) {
        return distance?
            restaurants.filter(restaurant=> calculateDistance(restaurant.location) <= distance):
            restaurants
    }
    
    • Now add the following “form” in JSX above “ul” element
    <form onSubmit={(e)=>e.preventDefault()}>
        <input onChange={(e)=> setDistance(e.target.value*1)}
            data-testid="inpDistance"
            placeholder="Enter distance in meters"/>
    </form>
    

    Now all of your tests should pass.

    Excercise: Handle the case when restaurant list is empty or null by showing a message in UI

    Hint: In test, render “Restaurant” component with list property “null” and “[]” then verify that you can find element containing the message text. In “Restaurant” component, conditionally show message or list based on “list” prop

    Excercise: Show calculated distance in each restaurant list item.

    Hint: modify “filterWithinDistance” to return restaurants with calculated distance and show it in UI. In test verify that mocked distance is show in the rendered UI

    Implement “fetchRestaurants”

    Start here by checking-out “3-Restaurants-Component” branch

    Test Cases for fetchRestaurants

    fetchRestaurants
        - should call fetch api with correct parameters
        - should return response on fetch success
        - should return empty array on fetch error
    

    The test codes should look like

    import {fetchRestaurants, RESTAURANTS_URL} from '../utils'
    import * as fixtures from '../fixtures'
    
    
    jest.spyOn(global, 'fetch')
    
    describe('fetchRestaurants', ()=>{
        beforeEach(()=>{
            global.fetch.mockClear()
            global.fetch.mockResolvedValue({text: ()=>JSON.stringify(fixtures.dummyRestaurants)})
        })
        it('should call fetch api with correct parameters', ()=>{
            fetchRestaurants()
            expect(global.fetch).toBeCalledWith(RESTAURANTS_URL)
        })
    
        it("should return response on fetch success", async ()=>{
            const restaurants = await fetchRestaurants()
            expect(restaurants).toEqual(fixtures.dummyRestaurants)
        })
    
        it("should return null on fetch error", async ()=>{
            global.fetch.mockRejectedValue("some error occured")
            const restaurants = await fetchRestaurants()
            expect(restaurants).toEqual([])
        })
    })
    

    Some Explanations

    • ‘fetch’ is a global variable so we used “jest.spyOn” function to mock
    • ‘fetch’ property of “global” object. “global” object is equal to “window” object in browser.
    • “mockResolvedValue” sets mimic value resolved by fetch by passing object with text function.
    • “mockRejectedValue” mimics the error case in fetch

    Steps to Make the Test Pass

    • Add “RESTAURANTS_URL” constant in “utils.js” file
    export const RESTAURANTS_URL = "https://gist.githubusercontent.com/devbkhadka/39301d886bb01bca84832bac48f52cd3/raw/f7372da48797cf839a7b13e4a7697b3a64e50e34/restaurants.json"
    

    fetchDistance function should look like below

    export async function fetchRestaurants() {
        try{
            const resp = await fetch(RESTAURANTS_URL)
            const respStr = await resp.text()
            return JSON.parse(respStr)
        }
        catch(e) {
            console.log(e)
            return []
        }
    }
    

    Some Explanations

    • We are getting the restaurants list for git raw url which returns text response. So we are using “text” property of “resp”.
    • We are parsing response string to javascript object

    Implement Calculate Distance

    Start here by checking-out “4-fetch-restaurants” branch

    Test Cases for calculateDistance

    calculateDistance
        - should return distance in meters from center to a location given in degree
    

    Test code for calculateDistance should look like below. Add it at the bottom of utils.test.js file

    describe('calculateDistance', ()=>{
    it('should return distance in meters from center to a location given in degree', ()=>{
        const testLocationPairs = [
            [ 40.76404704,-73.98364954],
            [ 26.212754, 84.961525],
            [27.699363, 85.325500],
            [ -11.166805, 38.408597],
        ]
        const expectedDistances = [12109725, 168479, 1181, 6647488]
        const calculatedDistances = testLocationPairs.map((location)=>{
            return calculateDistance(location)
        })
        // Test calculated values with in 1km range of expected value
        expect(calculatedDistances.map(d=>Math.floor(d/100)))
            .toEqual(expectedDistances.map(d=>Math.floor(d/100)))
        })
    })
    

    Steps to Make the Test Pass

    • Add constants below at top of utils.js file
    export const CENTER_LOCATION = [27.690870, 85.332701]
    const EARTH_RADIUS_KM = 63710
    const PI_RADIAN_IN_DEGREE = 180
    Add following code for calculating distance
    export function calculateDistance(location){
        const [x1, y1] = convertCoordinateFromDegreeToRadian(location)
        const [x2, y2] = convertCoordinateFromDegreeToRadian(CENTER_LOCATION)
        const term1 = Math.sin((x2-x1)/2)**2
        const term2 = Math.sin((y2-y1)/2)**2 * Math.cos(x1) * Math.cos(x2)
        const distance = 2*EARTH_RADIUS_KM*Math.asin(Math.sqrt(term1+term2))
        return distance * 100
    }
    function convertCoordinateFromDegreeToRadian(point) {
        const [x, y] = point
        return [x*Math.PI/PI_RADIAN_IN_DEGREE, y*Math.PI/PI_RADIAN_IN_DEGREE]
    }
    

    We are using haversine distance formula to calculate distance in earth’s surface

    Excercise: Validate the location parameter, Latitudes range from 0 to 90. Longitudes range from 0 to 180

    Hint: verify that passing invalid value throws error using “expect(function).toThrow()”



    Your tests should pass now. You can check in browser if it works or not by running “npm start”



    I will appreciate any feedback, question and criticism. Your small encouragement means a lot, please don’t forget to clap like.

    References

Posted on by:

devkhadka profile

Dev Khadka

@devkhadka

Tech Enthusiast with interest in Data Science, Web Technologies, Dev Ops, Programming

Discussion

markdown guide