React Testing Components

From bibbleWiki
Jump to navigation Jump to search

Introduction

Guiding principle for testing is

Testing Exactly what you are trying to test and nothing more
Never test through a UI component what can be tested some other way
Never test the same thing twice

DOM Overview

Definition

The Document Object Model (DOM) is a programming interface for HTML and XML documents. It represents the page so that programs can change the document structure, style, and content. The DOM represents the document as nodes and objects. That way, programming languages can connect to the page.

A Web page is a document. This document can be either displayed in the browser window or as the HTML source. But it is the same document in both cases. The Document Object Model (DOM) represents that same document so it can be manipulated. The DOM is an object-oriented representation of the web page, which can be modified with a scripting language such as JavaScript.

Document Element

This is one element in a DOM e.g. a div.

innerHTML vs outterHTML

InnerHTML is used for getting or setting a content of the selected whilst the outerHTML is used for getting or setting content with the selected tag. This is a referred to a lot by Web developers so here is a picture to remind people of the difference.
Inner vs outter.png

Testing Rendering

React Testing Library

React Testing Library is one of the most well known. They say,

  • The more your tests resemble the way the software is used, the more confidence they can give you.
  • Encourages rendering components to DOM nodes,
  • Making asserts against DOM nodes


It's API tries to encourage tests to represent how the software is used by the user. For example these tests focus on how the user might access the screen, by label, by text

const r = render(<Message content="Some Stuff" isImportant={true} />);
r.getByLabelText('First Name')
r.getByText('2017-5-13')
r.getByTitle('introduction')
// Frown upon because as a user id are not visible but...
r.getByTestId('target')

Example Test

Here is a full example for testing the Date slider
Date Slider.png
The tests use the getByTestId and render API.

import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect'
import DateSlider from './DateSlider';
import {solToDate, dateToSol} from '../services/sols';

describe('DateSlider', () => {

    describe('render', () => {
        it("should return a container", () => {
            const { container } = render(
              <DateSlider earth_date="2017-5-13" onDateChanged={()=>{}} />
            );
            expect(container).toBeDefined();
            expect(container.outerHTML).toBe("<div><div class=\"Dateslider\"><div class=\"row\"><div class=\"col-12\" style=\"text-align: center;\"><label for=\"date\">Earth Day</label><p class=\"Dateslider-date\" data-testid=\"date-label\">2017-5-13</p></div></div><div class=\"row\"><div class=\"col-3\" style=\"text-align: right;\"><small>2004-01-05</small></div><div class=\"col-6 form-group\"><input data-testid=\"date-slider\" type=\"range\" id=\"date\" class=\"form-control\" min=\"1\" max=\"5746\" value=\"4878\"></div><div class=\"col-3\"><small>2019-09-28</small></div></div></div></div>");
          });

        it('should display the correct date', () => {
            const { getByTestId } = render(<DateSlider earth_date="2017-5-13" onDateChanged={()=>{}} />);
            const date = getByTestId("date-label");
            expect(date).toHaveTextContent("2017-5-13");
        });
        it('should have the correct slider position', () => {
            const { getByTestId } = render(<DateSlider earth_date="2017-5-13" onDateChanged={()=>{}} />);
            const input = getByTestId("date-slider");
            expect(input).toHaveValue(dateToSol("2017-5-13").toString());
        });
    });

    describe('click', () => {
        it('should publish the selected date', () => {
            const fn = jest.fn();
            const { getByTestId } = render(<DateSlider earth_date="2017-5-13" onDateChanged={fn} />);
            
            const input = getByTestId("date-slider");
            fireEvent.change(input, { target: { value: '3877' } });
            
            expect(fn.mock.calls.length).toBe(1);
            expect(fn.mock.calls[0][0]).toBe(solToDate(3877));
        });
    });
});

React Test Utilities

This library provides the function act() which provides a more realistic environment for rendering and updating componenemts.

Example Test Camera (React Test Utilities)

This example is testing a combobox
Test camera.png To use the act API it was noted

  • create a container and make sure you add it to the document
  • jest provides beforeEach and AferEach to prepare and tear down
  • act is the API to render and update components
  • remember the ReactDOM.render requires the container as an argument
  • in the after each we remove the container and reset to null


The tests will consist of testing

  • should render a select element (something selected)
  • the selector renders each of the options
  • it selects the one we ask it to select
import React from 'react';
import ReactDOM from 'react-dom';
import ReactTestUtils from 'react-dom/test-utils';
import CameraSelection from './CameraSelection';

describe('CameraSelection', () => {

    describe('rendering', () => {
        let container,select;
        const cameras = {
            BC: "Big camera",
            LC: "Little camera"
        };
        beforeEach(() => {
            container = document.createElement('div');
            document.body.appendChild(container);
            ReactTestUtils.act(()=>{
                ReactDOM.render(<CameraSelection camera="LC" cameras={cameras} onCameraSelected={()=>{}} />, container);
            });
            select = container.querySelector('select');
          });
          
          afterEach(() => {
            document.body.removeChild(container);
            container = null;
          });

        it("should render a select element", () => {
            expect(select).toBeDefined();
        });
        it("should have 2 options", ()=>{
            expect(select.length).toBe(2);
        });
        it("should have LC (Little Camera) selected", ()=>{
            expect(select.value).toBe("LC");
            expect(select.selectedOptions[0].text).toBe('Little camera');
        });
    });
});

Test Render

Test Render does not use the DOM but instead test the javaScript objects. One of the great points which was made is you can identify the component is too complex when looking at testing and it may show opportunity to refactor unnecessarily complete code.

Example Test (Test Render)

Here we create an instance of the component and then look for if the checkbox is checked. It did appear to rely on data-testid prior to writing of testing.

            describe('none selected', ()=>{
                it('should select no rovers', ()=> {
                    const none = {spirit: false, opportunity: false, curiosity: false};
                    const tr = TestRenderer.create(<RoverSelector roversActive={none} rovers={rovers} roverSelection={none} onRoverSelection={()=>{}} />);
                    const inputs = tr.root.findAllByProps({"data-testid": 'rover-selected'});
                    inputs.forEach((input) => {
                        expect(input.props.checked).toBe(false);
                    });
                });
            });

Testing Component Events

Approach

For test events take four steps

  • Set props and render
  • Find elements you need
  • Trigger events on elements
  • Make assertions against events produced

Testing Library

We can use this to test events. Here is a simple component which increments a count on press.

import React, { useState } from 'react';

const Counter = () => {
    const [count, setCount] = useState(0);

    return (
        <p onClick={()=> { setCount(count+1); }}>
           {count} ah ah ah
        </p>
    );
};
export default Counter;

Example Test Counter (Testing Library)

Put this here to not scare people and show simple testing is easy.

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

describe('Counter', () => {

    it("should start at zero", () => {
        const { queryByText } = render(
            <Counter />
        );
        const paragraph = queryByText(/ah ah ah/);
        expect(paragraph).toBeTruthy();
        expect(paragraph.textContent).toBe('0 ah ah ah');
    });

    it("should increment on click", () => {
        const { queryByText } = render(
            <Counter />
        );
        const paragraph = queryByText(/ah ah ah/);
        expect(paragraph.textContent).toBe('0 ah ah ah');
        fireEvent.click(paragraph);
        expect(paragraph.textContent).toBe('1 ah ah ah');
        fireEvent.click(paragraph);
        expect(paragraph.textContent).toBe('2 ah ah ah');
    });
});

Asyc Testing and Jest

Testing Asynchronous code with Jest requires test methods to return a promose or they will always pass. Here is the counter again but using a timeout to simulate Async

import React, { useState } from 'react';

const CounterAsync = () => {
    const [count, setCount] = useState(0);

    return (
        <p onClick={()=> { setTimeout(()=> { setCount(count+1); }, 1000)}}>
           {count} ah ah ah
        </p>
    );
};

export default CounterAsync;

Example Test Counter Async (Testing Library)

To test async we need to wait for the results. We do this by using the wait function and await.

import React from 'react';
import { render, fireEvent, wait } from '@testing-library/react';
import CounterAsync from './Counter.async';

describe('Counter', () => {

    it("should start at zero", () => {
        const { queryByText } = render(
            <CounterAsync />
        );
        const paragraph = queryByText(/ah ah ah/);
        expect(paragraph).toBeTruthy();
        expect(paragraph.textContent).toBe('0 ah ah ah');
    });

    it("should increment on click", async () => {
        const { queryByText } = render(
            <CounterAsync />
        );
        const paragraph = queryByText(/ah ah ah/);
        await wait(() => {
            expect(paragraph.textContent).toBe('0 ah ah ah');
        });
        fireEvent.click(paragraph);
        await wait(() => {
            expect(paragraph.textContent).toBe('1 ah ah ah');
        });
        fireEvent.click(paragraph);
        await wait(() => {
            expect(paragraph.textContent).toBe('2 ah ah ah');
        });
    });
});

Mocking

Jest provides mocking functions. Mocks functons

  • can be called but don't return anything
  • can record they were called and the arguments passed
const f = jest.fn();

f(1,2,3)
f('a')

expect(f.mock.calls).toEqual(
   [
      [1,2,3],
      ['a']
   ]
)

We can provide an implement and test that e.g.

const f = jest.fn((a,b) => {a + b});
expect(f(4,8)).toBe(12);
expect(f.mocks.calls).toEqual(
   [
      [4,8]
   ]
)

We can fake the results to

const f = jest().fn()
f.mockReturnValueOnce(12).mockReturnValue(0);
// Subsequent calls will then be 0 e.g.
expect(f()).toBe(12);
expect(f()).toBe(0);
expect(f()).toBe(0);
expect(f()).toBe(0);

Back to Date Slider Example

Not we can fire the event using the a mock function.

  • Use jest.fn to capture the event parameters when date changes
  • We get the input slider using getByTestId
  • fire the event change with fireEvent.change

Using React Testing Library

    describe('change', () => {
        it('should publish the selected date', () => {
            const fn = jest.fn();
            const { getByTestId } = render(
                <DateSlider earth_date="2017-5-13" onDateChanged={fn} />
            );
            
            const input = getByTestId("date-slider");
            fireEvent.change(input, { target: { value: '3877' } });
            
            expect(fn.mock.calls).toEqual([
                [solToDate(3877)]
            ]);
        });
    });

Using the Test Renderer

describe('DateSlider', () => {

    describe('change', () => {
        it('should publish the selected date', () => {
            const fn = jest.fn();
            const tr = TestRenderer.create(
                <DateSlider earth_date="2017-5-13" onDateChanged={fn} />
            );

            const input = tr.root.findByProps({"data-testid": "date-slider"});
            TestRenderer.act(() => { 
                input.props.onChange({ target: { value: '3877' } }); 
            });
            
            expect(fn.mock.calls).toEqual([
                [solToDate(3877)]
            ]);
        });
    });

});

Testing States and Effects

Nice demonstration using a simple idea of a list we have two components one with a list with state and one with a list with reducer.

Using List with State

Implementation

import React, {useState} from 'react';

export const inputPlaceholder = "Enter new item";

const List = () => {
    const [newItem, setNewItem] = useState('');
    const [items, setItems] = useState([]);

    const handleSubmit = (e) => {
        setItems(items.concat([newItem]));
        setNewItem('');
        e.preventDefault();
    };

    return (
        <div>
            <h2>List</h2>
            <ul>
                {items.map((item) => <li key={item}>{item}</li>)}
            </ul>
            <form onSubmit={handleSubmit} data-testid='form'>
                <div className="form-group">
                    <input 
                        type="text" 
                        value={newItem} 
                        onChange={(e) => {setNewItem(e.target.value)}} 
                        className="form-control" 
                        id="newItem" 
                        placeholder={inputPlaceholder} />
                </div>
                <input type="submit" value="Add" className="btn btn-primary" />
            </form>
        </div>
    );
};

export default List;

Test

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

describe('List', () => {

    describe('adding items', () => {

        it('should add items', ()=>{
            const { getByText, getByPlaceholderText, getByTestId } = render(
                <List />
            );
            const input = getByPlaceholderText(inputPlaceholder);
            const form = getByTestId('form');

            fireEvent.change(input, {
                target: { value: 'one'}
            });
            fireEvent.submit(form);

            const firstItem = getByText('one');
            expect(firstItem).toBeDefined();
            expect(firstItem.tagName).toBe('LI');
            expect(input.value).toBe('');

            fireEvent.change(input, {
                target: { value: 'two'}
            });
            fireEvent.submit(form);

            const secondItem = getByText('two');
            expect(secondItem).toBeDefined();
            expect(secondItem.tagName).toBe('LI');
            expect(secondItem.parentNode.childElementCount).toBe(2);
            expect(input.value).toBe('');
        });
    });

});

Using List with Reducer

Implementation

import React, {useReducer} from 'react';

export const inputPlaceholder = "Enter new item";
export const defaultState = {
    newItem: '',
    items: []
};

export const reducer = (state, action) => {
    switch (action.type) {
        case 'newItemChange':
            return {
                ...state,
                newItem: action.value
            };
        case 'add':
            return {
                items: state.items.concat([state.newItem]),
                newItem: ''
            };
        default: return state;
    }
};

const ListReducer = () => {
    const [state, dispatch] = useReducer(reducer, defaultState);

    const handleSubmit = (e) => {
        e.preventDefault();
        dispatch({
            type: 'add'
        });
    };

    return (
        <div>
            <h2>List</h2>
            <ul>
                {state.items.map((item) => <li key={item}>{item}</li>)}
            </ul>
            <form onSubmit={handleSubmit} data-testid='form'>
                <div className="form-group">
                    <input 
                        type="text" 
                        value={state.newItem} 
                        onChange={(e) => {dispatch({type: 'newItemChange', value: e.target.value});}} 
                        className="form-control" 
                        id="newItem" 
                        placeholder={inputPlaceholder} />
                </div>
                <input type="submit" value="Add" className="btn btn-primary" />
            </form>
        </div>
    );
};

export default ListReducer;

Test

import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import ListReducer, {inputPlaceholder, reducer, defaultState} from './ListReducer';

describe('ListReducer', () => {

    describe('adding items', () => {
        it('should add items', ()=>{
            const { getByText, getByPlaceholderText, getByTestId } = render(
                <ListReducer />
            );
            const input = getByPlaceholderText(inputPlaceholder);
            const form = getByTestId('form');

            fireEvent.change(input, {
                target: { value: 'one'}
            });
            fireEvent.submit(form);

            const firstItem = getByText('one');
            expect(firstItem).toBeDefined();
            expect(firstItem.tagName).toBe('LI');
            expect(input.value).toBe('');

            fireEvent.change(input, {
                target: { value: 'two'}
            });
            fireEvent.submit(form);

            const secondItem = getByText('two');
            expect(secondItem).toBeDefined();
            expect(secondItem.tagName).toBe('LI');
            expect(secondItem.parentNode.childElementCount).toBe(2);
            expect(input.value).toBe('');
        });
    });

    describe('reducer', () => {
        
        describe('starting state', () => {
            it('should have no items', () => {
                const state = reducer(defaultState, {type: 'does not matter'});
                expect(state.items).toEqual([]);
            });
            it('should have an empty new item input', () => {
                const state = reducer(defaultState, {type: 'does not matter'});
                expect(state.newItem).toEqual('');
            });
        });
        
        describe('typing characters', () => {
            it('should add the characters to the newItem property', () => {
                let state = reducer(defaultState, {type: 'newItemChange', value: 'a'});
                expect(state.newItem).toEqual('a');
                state =  reducer(defaultState, {type: 'newItemChange', value: 'ab'});
                expect(state.newItem).toEqual('ab');
            });
            it('should not change the items array', () => {
                let state = reducer(defaultState, {type: 'newItemChange', value: 'a'});
                expect(state.items).toEqual([]);
                state =  reducer(defaultState, {type: 'newItemChange', value: 'ab'});
                expect(state.items).toEqual([]);
            });
        });
        
        describe('add items', () => {
            it('should add the items', () => {
                let state = reducer(
                    { items: [], newItem: 'aurora'},
                    {type: 'add'});
                expect(state.items).toEqual(['aurora']);
                state = reducer(
                    { ...state, newItem: 'borealis'},
                    {type: 'add'});
                expect(state.items).toEqual(['aurora', 'borealis']);
            });
            it('should clear newItem', () => {
                let state = reducer(
                    { items: [], newItem: 'aurora'},
                    {type: 'add'});
                expect(state.newItem).toEqual('');
                state = reducer(
                    { ...state, newItem: 'borealis'},
                    {type: 'add'});
                expect(state.newItem).toEqual('');
            });
        });
    });

});

Integration Testing

When Redux or other state management is used then the testing of the components becomes harder to test. Integration testing is perhaps a better choice. So below is an example of how you might approach this. Render the app, find the controls and test the expectation. Fire Events and then repeat.

import { render, fireEvent, wait } from '@testing-library/react';
import { App } from './App';
import { dateToSol } from './services/sols';

describe('App', () => {

    describe('initial render', () => {
        it('should select Sep 28 2019', () => {
            const { queryByTestId } = render(
                App
            );
            const dateLabel = queryByTestId('date-label');
            const sliderInput = queryByTestId('date-slider');
            expect(dateLabel).toBeDefined();
            expect(dateLabel.textContent).toBe('2019-09-28');
            expect(sliderInput).toBeDefined();
            expect(sliderInput.value).toBe(dateToSol('2019-09-28').toString());
        });
    });

    describe('selecting a pre-curiosity day and changing criteria', () => {
        it('should show the correct number of images', async () => {
            jest.setTimeout(10000);
            const day = '2007-11-28';
            const renderResult = render(
                App
            );
            const dateLabel = renderResult.queryByTestId('date-label');
            const sliderInput = renderResult.queryByTestId('date-slider');
            const checkboxes = renderResult.getAllByTestId("rover-selected");
            fireEvent.change(
                sliderInput,
                { target: { value: dateToSol(day).toString() } }
            );

            await wait(() => {
                expect(dateLabel.textContent).toBe('2007-11-28');
                expect(sliderInput.value).toBe(dateToSol('2007-11-28').toString());
                const images = renderResult.getAllByTestId('rover-image');
                expect(images.length).toBe(8);
            });

            // deselect opportunity
            fireEvent.click(checkboxes[1]);
            await wait(() => {
                const images = renderResult.getAllByTestId('rover-image');
                expect(images.length).toBe(2);
            });

            // select navigation camera
            const cameraSelect = renderResult.queryByTestId('camera-select');
            fireEvent.change(
                cameraSelect,
                { target: { value: 'NAVCAM' } }
            );
            await wait(() => {
                const images = renderResult.getAllByTestId('rover-image');
                expect(images.length).toBe(27);
            });
        });
    });
});