React Testing Library: Learn by Coding

React Testing Library: Learn by Coding

I’d like to code a simple React “mini jeopardy” game. I will use the React Testing Library to test each of the components. To summarize the game, you get presented with 5 “cues”, click on one and answer it. The game calls the http://www.jservice.io API and makes use of their clues endpoint to return data from the year 1996 in the science category. It picks 5 random valid cues from the first set and sorts them according to their value.

The final game will look like this:

Mini Jeopardy Game

The Setup

First, install bootstrap via npm install bootstrap --save

Your package.json file will look like this:

{
  "name": "mini-jeopardy",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "bootstrap": "^4.4.1",
    "react": "^16.12.0",
    "react-dom": "^16.12.0",
    "react-scripts": "3.3.0"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": "react-app"
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  },
  "devDependencies": {
    "@testing-library/jest-dom": "^4.2.4",
    "@testing-library/react": "^9.4.0",
    "@testing-library/user-event": "^7.1.2"
  }
}

Next, create your components, their test files, and relevant css under the src/components folder: mainclue and answer

For each of the components (defined in the index.js files) define its class. Here is main/index.js:

import React, {Component} from "react";

class Main extends Component {
  render() {
    return(<div></div>)
  }
}

export default Main;

Define the rest of the components (clue/index.jsanswer/index.js) the same exact way. Just replace Main with Clue and Answer.

The First Test

Next, modify your App.js file:

import React from 'react';
import './App.css';
import Main from './components/main'

function App() {
  return (
      <div className="app container-fluid" data-testid="app-container">
        <Main/>
      </div>
  );
}

export default App;

And App.test.js which checks that the app is being rendered correctly

import React from 'react';
import {render} from '@testing-library/react';
import App from './App';

test('loads the app', () => {
  const {getByTestId} = render(<App/>);
  const containerElement = getByTestId('app-container');
  expect(containerElement).toBeInTheDocument();
});

getByTestId is a React Testing Library function and it does what it says it does: It grabs a DOM object from the document via its data-testid attribute. toBeInTheDocument is a jest function. It checks that the element passed into the expect function does in fact exist in the document.

At this point, if you run yarn start to start a development server, you should see a blank page in your browser, without any errors.

Services

Next we write a service to do the API call to grab the data. Create the src/services folder and under it define data/index.js:

class Data {
  constructor() {
    this.api = 'http://www.jservice.io/api/';
  }

  encode(params) {
    return Object.keys(params).map((key) => {
      return encodeURIComponent(key) + '=' + encodeURIComponent(params[key])
    }).join('&');
  }

  async get(params, endpoint = 'clues') {
    try {
      let response = await fetch(
          this.api + endpoint + '?' + this.encode(params),
      );
      return {success: true, data: await response.json()}
    } catch (err) {
      return {success: false, data: []}
    }
  }
}

export default Data

We should also write a different service class to do hold some utility functions like sorting and sampling data. Under services create utils/index.js:

class Utils {
  static sort(data, by, type = 'asc') {
    return data.sort((a, b) => {
      if (type === 'asc') {
        return a[by] - b[by];
      } else {
        return b[by] - a[by];
      }
    })
  }

  static sample(data, n = 5) {
    return data.map(x => ({x, r: Math.random()})).sort((a, b) => a.r - b.r).map(a => a.x).slice(0, n);
  }
}

export default Utils

Components and their Tests

We’re ready to complete the Main (components/main/index.js) component.

import React, {Component} from "react";
import Data from "../../services/data";
import Utils from "../../services/utils";
import Clue from "../clue";

class Main extends Component {

  constructor(props) {
    super(props);
    this.data = new Data();
    this.state = {data: [], success: true}
  }

  async componentDidMount() {
    let fetchedData = await this.data.get({category: 25, min_date: '1996-01-01', max_date: '1996-12-31'});
    let data = this.manipulateData(fetchedData['data']);
    this.setState({
      data,
      success: fetchedData['success']
    });
  }

  manipulateData(data) {
    data = data.filter(d => d['invalid_count'] == null && d['value'] != null);
    data = Utils.sample(data);
    data = Utils.sort(data, 'value');
    return data
  }

  createQuestions() {
    return this.state.data.map((data, index) => {
      return <Clue key={index} question={data['question']} answer={data['answer']} value={data['value']}/>
    })
  }

  dataErrorMessage() {
    if (!this.state.success) {
      return <div className='alert alert-danger'>Oops! Something went wrong :(</div>
    }
  }

  render() {
    return (
        <div className='pt-4'>
          <h2>Category: Science!</h2>
          {this.dataErrorMessage()}
          <div className='card-deck'>
            {this.createQuestions()}
          </div>
        </div>
    );
  }
}

export default Main;

In componentDidMount lifecycle method, we call the API via our data service and pass the data to manipulateData to sort and sample via the utils service. We then set the state which causes React to re-render the component.

We’ve got a problem! Anytime a test is ran, including App.test.js, the data service will get called and the live endpoint is hit. This makes our tests very slow and flakey. We need to “mock” the data to ensure that the tests are quick and are ran the same every time. Let’s rewrite App.test.js

import React from 'react';
import {render} from '@testing-library/react';
import App from './App';
import Data from "./services/data";

jest.mock("./services/data");

beforeEach(()=>{
  Data.mockImplementation(() => {
    return {
      get: async () => {
        return await new Promise(resolve => {resolve({success: true, data: []})})
      },
    };
  });
})

test('loads the app', () => {
  const {getByTestId} = render(<App/>);
  const containerElement = getByTestId('app-container');
  expect(containerElement).toBeInTheDocument();
});

So, what’s happening in this test? First, we use jest.mock to actually mock the service. This means that anytime during the test, the Data service is called it’s is not ran as is, but what we define via Data.mockImplementation is called. In there, we return a function get, named the same as the Data service’s get function, but with a different definition. We “mock” the returned data: {success: true, data: []}

Now if you run App.test.js, we don’t hit the real API, but the fake data is returned and used in the App render.

Next, let’s complete the test for the Main component:

import React from 'react';
import {render, waitForElement} from '@testing-library/react';
import Main from './index';
import Data from "../../services/data";

jest.mock("../../services/data");

describe('successfully loads', () => {

  const data = [
    {
      "id": 1,
      "answer": "answer 1",
      "question": "question 1",
      "value": 100,
      "invalid_count": null,
    },
    {
      "id": 2,
      "answer": "answer 2",
      "question": "question 2",
      "value": 300,
      "invalid_count": null,
    },
    {
      "id": 3,
      "answer": "answer 3",
      "question": "question 3",
      "value": 200,
      "invalid_count": null,
    },
    {
      "id": 4,
      "answer": "answer 4",
      "question": "question 4",
      "value": 100,
      "invalid_count": null,
    },
    {
      "id": 5,
      "answer": "answer 5",
      "question": "question 5",
      "value": 500,
      "invalid_count": null,
    },
    {
      "id": 6,
      "answer": "answer 6",
      "question": "question 6",
      "value": null,
      "invalid_count": null,
    },
    {
      "id": 7,
      "answer": "answer 7",
      "question": "question 7",
      "value": 200,
      "invalid_count": 1,
    }
  ];

  beforeEach(()=>{
    Data.mockImplementation(() => {
      return {
        get: async () => {
          return await new Promise(resolve => {resolve({success: true, data: data})})
        },
      };
    });
  })

  test('loads header', () => {
    const {getByText} = render(<Main/>);
    const containerElement = getByText('Category: Science!');
    expect(containerElement).toBeInTheDocument();
  });

  test('loads clues successfully', async () => {
    const {findAllByTestId} = render(<Main/>);

    const clueElements = await waitForElement(() =>
        findAllByTestId('clue')
    );

    const clueValueElements = await waitForElement(() =>
        findAllByTestId('clue-value')
    );

    const clueValues = clueValueElements.map(value => value.innerHTML);

    expect(clueElements).toHaveLength(5);
    expect(clueValues).not.toContain('');
    expect(isAscending(clueValues)).toBe(true);
  });
});

describe('fails to load', () => {
  test('fails to load clues', async () => {
    Data.mockImplementation(() => {
      return {
        get: async () => {
          return await new Promise(resolve => {resolve({success: false, data: []})})
        },
      };
    });
    const {findByText} = render(<Main/>);
    const fail = await waitForElement(() =>
        findByText('Oops! Something went wrong :(')
    );
    expect(fail).toBeInTheDocument()
  });
});

function isAscending(arr) {
  return arr.every((x, i) => {
    return i === 0 || x >= arr[i - 1];
  });
}

In our first test, we do the same exact thing as we did in App.test.js to mock the data, except we return some data to actually test the component. We grab the elements via findAllByTestId method. This returns an array of elements with a certain id. We then make sure we have 5 cues, none of the values are empty, and they are sorted in an ascending order. We also test the case were we don’t return anything via the second test. In that case we look for the words “Oops! Something went wrong :(“. Simple, eh?

Onto the next component: Cue

import React, {Component} from "react";
import './index.css'
import Answer from "../answer";

class Clue extends Component {

  constructor(props) {
    super(props);
    this.question = props.question;
    this.value = props.value;
    this.answer = props.answer;
    this.handleClick = this.handleClick.bind(this);

    this.state = {
      toggled: false
    }
  }

  handleClick(e) {
    e.preventDefault();
    this.setState({
      toggled: true
    });
  }

  cssClasses() {
    let classes = {
      card: 'card bg-primary',
      value: 'card-body front text-center',
      question: 'card-body back text-center hidden',
      answer: 'hidden'
    };
    if (this.state.toggled) {
      classes = {
        card: 'card bg-warning',
        value: 'card-body front text-center hidden',
        question: 'card-body back text-center',
        answer: ''
      };
    }
    return classes;
  }

  render() {
    let classes = this.cssClasses();
    return (
        <div className={classes.card} onClick={this.handleClick} data-testid='clue'>
          <p className={classes.value} data-testid='clue-value'>{this.value}</p>
          <p className={classes.question} data-testid='clue-question'>{this.question}</p>
          <span className={classes.answer} data-testid='clue-answer'>
            <Answer answer={this.props.answer}/>
          </span>
        </div>
    )
  }

}

export default Clue;

In Cue we render the “cue card” with the value visible and the cue itself hidden. After the user clicks on a card, we update the state via the handleClick function. We then grab the correct set of CSS classes via the cssClasses method to hide value and show the cue.

The index.css file is very simple:

.card {
    min-height: 300px;
    cursor: pointer;
    position: relative;
}

.hidden {
    display: none;
}

The test for Cue (or question) looks like this:

import React from 'react';
import {render, fireEvent} from '@testing-library/react';
import Clue from './index';

describe('successfully loads', () => {
  test('renders correctly', () => {
    const {getByText} = render(<Clue answer='answer 1' value='100' question='question 1' />);
    const questionElement = getByText('question 1');
    const valueElement = getByText('100');

    expect(questionElement).toBeInTheDocument();
    expect(valueElement).toBeInTheDocument();
  });

  test('user clicks on clue', () => {
    const {getByText, getByTestId} = render(<Clue answer='answer 1' value='100' question='question 1' />);

    expect(getByText('question 1')).toHaveClass('hidden');
    expect(getByText('100')).not.toHaveClass('hidden');
    expect(getByTestId('clue-answer')).toHaveClass('hidden');

    fireEvent.click(getByText('100'));

    expect(getByText('question 1')).not.toHaveClass('hidden');
    expect(getByTestId('clue-answer')).not.toHaveClass('hidden');
    expect(getByText('100')).toHaveClass('hidden');
  });
});

In the first test, we make sure the cue or the question exists in the document and so is the value element.

The second test, makes sure that by default the value is not hidden, and the question and the answer text box are hidden. We the simulate a click on the cue card via fireEvent.click and then we check that the question and answer text box are NOT hidden but the value is.

Finally, we have our Answer component:

import React, {Component} from "react";

class Answer extends Component {

  constructor(props) {
    super(props);
    this.answer = props.answer.toLowerCase();
    this.state = {value: '', correct: false, answered: false};

    this.handleChange = this.handleChange.bind(this);
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    let userAnswer = this.state.value.toLowerCase();
    this.setState({
      answered: true,
      correct: this.answer === userAnswer
    });
  }

  handleChange(e) {
    this.setState({value: e.target.value});
  }

  correctAnswer() {
    return (
        <div className="text-center text-success p-4" data-testid='correct_answer'>
          Correct Answer!
        </div>
    );
  }

  wrongAnswer() {
    return (
        <div className="text-center text-danger p-4" data-testid='wrong_answer'>
          Wrong Answer :(
        </div>
    );
  }

  defaultAnswer() {
    return (
        <div className="input-group p-md-4 pb-4" data-testid='default_answer'>
          <input type="text" className="form-control" placeholder="Type your answer..." value={this.state.value}
                 onChange={this.handleChange} data-testid='input'/>
          <div className="input-group-append">
            <button className="btn btn-primary" onClick={this.handleClick}>Go!</button>
          </div>
        </div>
    );
  }

  render() {
    let items = this.defaultAnswer();
    if (this.state.answered && this.state.correct) {
      items = this.correctAnswer();
    } else if (this.state.answered && !this.state.correct) {
      items = this.wrongAnswer();
    }
    return items
  }
}

export default Answer

We render the default answer first, which is just a text box and a button. After the user types their answer (tracked via handleChange) and clicks the button (handled via handleClick) we compare the cue answer passed down as a prop and what the user submits. If the user answer is correct, we execute correctAnswer, otherwise, wrongAnswer is rendered.

And the test:

import React from 'react';
import {render, fireEvent} from '@testing-library/react';
import Answer from './index';

describe('successfully loads', () => {
  test('renders correctly', () => {
    const {getByTestId, queryByTestId} = render(<Answer answer='answer 1' />);
    const defaultAnswerElement = getByTestId('default_answer');
    const wrongAnswerElement = queryByTestId('wrong_answer');
    const correctAnswerElement = queryByTestId('correct_answer');


    expect(defaultAnswerElement).toBeInTheDocument();
    expect(wrongAnswerElement).toBe(null);
    expect(correctAnswerElement).toBe(null);
  });

  test('user answers correctly', () => {
    const {getByText, getByTestId} = render(<Answer answer='answer 1' />);
    const input = getByTestId('input');

    fireEvent.change(input, {target: { value: 'answer 1' }});
    fireEvent.click(getByText('Go!'));

    const correctAnswerElement = getByTestId('correct_answer');
    expect(correctAnswerElement).toBeInTheDocument();
  });

  test('user answers wrongly', () => {
    const {getByText, getByTestId} = render(<Answer answer='answer 1' />);
    const input = getByTestId('input');

    fireEvent.change(input, {target: { value: 'wrong answer' }});
    fireEvent.click(getByText('Go!'));

    const wrongAnswerElement = getByTestId('wrong_answer');
    expect(wrongAnswerElement).toBeInTheDocument();
  });
});

The first test, makes sure everything is rendered correctly and the default text (text box and submit button) is there. The second test gives a correct answer via fireEvent.change and submits it via fireEvent.click. Then we make sure that the correctAnswerElement is rendered and the third test does the opposite of the second test.

That’s it! We’ve created a simple React app that demonstrates how to write your test suite using the React Testing Library.

Page content