Fullstack React - Chapter 8 Notes

Summary

This post contains notes from chapter 8 in Fullstack React - "Unit Testing". The purpose of this chapter is to gain a better understanding of writing unit tests for a React application.

Overview 

In...

JEST

There are many different JavaScript testing libraries. In this book, we learn how to use Jest, which was created by Facebook and like other libraries, provides the following features:
  • A test runner - This is what we execute in the command line. It is responsible for finding tests, running them, and reporting back results to the console.
  • A domain specific language for organizing your tests - These functions help us perform common tasks like orchestrating setup and teardown before and after tests run.
  • An assertion library - The assert functions help us make otherwise complex assertions, like checking equality between JavaScript objects or the presence of certain elements in an array. JEST actually uses the Jasmine library to make these assertions.
Creating a test file
Files that end in *.test.js or *.spec.js will be identified as test files by the Jest test runner.

Assertions
As I said earlier, Jest actually uses Jasmine to make assertions. Jasmine provides a number of different methods to perform assertions.
  • expect() - this method is typically paired with a matcher to verify some condition. For example, in the following code block, we use the toBe matcher to verify the expected condition matches the actual result.

1
2
const a = 1;
expect(a).toBe(1);  // expect(result).toBe(expected)


Matchers
A matcher is a Jasmine method that we call in conjunction with expect to verify some condition. Jasmine provides a number of different matchers:

  • toBe() - the toBe matcher uses triple equals (===) to verify the expected value matches the actual value. === performs an exact value match to make sure the two objects being compared are the exact same object. This is typically used to perform boolean and numerical comparisons.
  • toEqual() - the toEqual matcher can be used to verify two different objects are identical even if they aren't the exact same object. This is typically used to perform comparisons for everything except booleans and numbers.
Structure of a test
A typical test is made up of a describe blocks, spec blocks (it statements), and individual assertions (expect statements).


1
2
3
4
5
6
7
8
describe('My test suite', () => {
    it('`true` should be `true`', () => {
        expect(true).toBe(true);
    });
    it('`false` should be `false`', () => {
        expect(false).toBe(false);
    });
});


  • describe blocks - a describe block is just a way of grouping together specs that are related to a similar feature. describe blocks can have other describe blocks within them. Another benefit to grouping similar tests within a describe block is that we can put common setup code for the tests within that block in the beforeEach method.
  • spec blocks - a spec block starts with the it keyword and holds the individual assertions we want to make
  • assertions - assertions live inside of spec blocks and they are the statements that perform the actual verification (expect statements)
Enzyme
Enzyme is a utility library that was created to help unit test React components. create-react-app already includes the necessary references to start using Enzyme (and Jest), so we just have to define our test files and run npm start to execute the tests. Enzyme provides a number of useful methods. I will talk about each one of those methods from the perspective of this sample code from the book:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import React from 'react';

class App extends React.Component {
  state = {
    items: [],
    item: '',
  };

  onItemChange = (e) => {
    this.setState({
      item: e.target.value,
    });
  };

  addItem = (e) => {
    e.preventDefault();

    this.setState({
      items: this.state.items.concat(
        this.state.item
      ),
      item: '',
    });
  };

  render() {
    const submitDisabled = !this.state.item;
    return(
      <div
        className='ui text container'
        id='app'
      >
        <table className='ui selectable structured large table'>
          <thead>
            <tr>
              <th>Items</th>
            </tr>
          </thead>
          <tbody>
            {
              this.state.items.map((item, idx) => (
                <tr
                  key={idx}
                >
                  <td>{item}</td>
                </tr>
              ))
            }
          </tbody>
          <tfoot>
            <tr>
              <th>
                <form
                  className='ui form'
                  onSubmit={this.addItem}
                >
                <div className='field'>
                  <input
                    className='prompt'
                    type='text'
                    placeholder='Add item...'
                    value={this.state.item}
                    onChange={this.onItemChange}
                  />
                </div>
                <button
                  className='ui button'
                  type='submit'
                  disabled={submitDisabled}
                >
                  Add item
                </button>
                </form>
              </th>
            </tr>
          </tfoot>
        </table>
      </div>
    )
  }
}

export default App;



shallow() - The shallow() method will perform a shallowing rendering of a React component and return a ShallowWrapper object. The ShallowWrapper object has a lot of useful methods that can help us with our assertions, like methods to traverse the shallow DOM.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { shallow } from 'enzyme';

describe('App', () => {
  it('should render to the shallow DOM', () => {
    const wrapper = shallow(
      <App />
    );
  });
});


contains() - The contains method is a method provided by the ShallowWrapper object and it will test that the wrapper object contains an exact match of some component. Note, that we use JSX syntax to define the component it is looking for.


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { shallow } from 'enzyme';

describe('App', () => {
  it('should have the th "Items"', () => {
    const wrapper = shallow(
      <App />
    );

    expect(wrapper.contains(<th>Items</th>)).toBe(true);
  });
});


containsMatchingElement() - With contains, we are looking for a exact match of some component including things like css classes and other properties. Sometimes that is overkill, so to get around this, we can use the containsMatchingElement

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { shallow } from 'enzyme';

describe('App', () => {
  it('should have the th "Items"', () => {
    const wrapper = shallow(
      <App />
    );

    expect(wrapper.containsMatchingElement(<input />)).toBe(true);
  });
});


find() - The find() method will search for items matching a selector and return an array of all items that match that selector. There are a number of different selectors, but the book only shows us how to use css selectors. 

first() - After we get our list of items, we can use the first() method to get a ShallowWrapper object of the first item in the list.

props() - Once we have a ShallowWrapper object, we can view the properties of that object (HTML element or React component) using the props() method.

The following code shows an example of all 3 methods.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { shallow } from 'enzyme';

describe('App', () => {
  it('should have a disbled button"', () => {
    const wrapper = shallow(
      <App />
    );

    const button = wrapper.find('button').first();

    expect(button.props().disabled).toBe(true);
  });
});


beforeEach()
- This method let's us run code before each it block is executed. This is a useful place for setting up a "fresh" context before each test so we are starting with a clean slate, as opposed to working with the leftover state from the previous test.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { shallow } from 'enzyme';

describe('App', () => {
  let wrapper;

  beforeEach(() => {
    wrapper = shallow ( <App /> );
  });

  it('This is the first test and it will have a "fresh" wrapper context from beforeEach."', () => {

    const button = wrapper.find('button').first();

    expect(button.props().disabled).toBe(true);
  });

  it('This is the second test and it will also have a "fresh" wrapper context from beforeEach."', () => {

    const button = wrapper.find('button').first();

    expect(button.props().disabled).toBe(true);
  });
});


afterEach() - Similar to beforeEach, this method will run after each test is executed. This is a good place to clear up any mocks.

simulate() - This method let's us simulate some kind of interaction with our code. The following example simulates a text change to the input field. Also of interest is the way we nest describe blocks so that we can put setup code for the test in a beforeEach block.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { shallow } from 'enzyme';

describe('App', () => {
  let wrapper;

  beforeEach(() => {
    wrapper = shallow ( <App /> );
  });

  // Nested describe so we can have a new beforeEach for setup code
  describe('the user populates the input', () => {
    const item = 'Pizza';

    beforeEach(() => {
      const input = wrapper.find('input').first();

      input.simulate('change', { target: { value: item }});
    });

    it('should update the state property item', () => {
      expect(wrapper.state().item).toEqual(item);
    });

    it('should enable button', () => {
      const button = wrapper.find('button').first();

      expect(button.props().disabled).toBe(false);
    });

    // Nested describe so we can have a new beforeEach for setup code
    describe('and then clears the input', () => {
      beforeEach(() => {
        const input = wrapper.find('input').first();

        input.simulate('change', { target: { value: '' }});
      });

      it('should disable button', () => {
        const button = wrapper.find('button').first();

        expect(button.props().disabled).toBe(true);
      });
    });
  })
});


Mocking
Jest provides a mocking framework that we can use to simulate method calls.

To mock a class, we must import that class at the top of our file. Then we use the jest.mock() method to initialize the mock. Once the mock has been initialized, any calls to methods in the mocked class will be intercepted and mocked by the mocking framework.

The following code block contains examples of a lot of the things we learned in this chapter including:

  • Setting up a mock (the Client class)
  • Mocking a function
  • Setting up and tearing down tests in beforeEach and afterEach
  • Asserting different values
  • Finding elements in the component
  • Simulating events (button clicks, text changes)
  • Nested tests that follow a flow

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
/* eslint-disable no-unused-vars */
import { shallow } from 'enzyme';
import React from 'react';
import FoodSearch from '../src/FoodSearch';

// Import this class because we are going 
// to mock the functions in this class
import Client from '../src/Client';

// Initialize the mock so calls to functions in this class
// are intercepted
jest.mock('../src/Client');

describe('FoodSearch', () => {
  let wrapper;

  // Set onFoodClick to a mocked function because we don't really
  // care about what it does. We are strictly testing the FoodSearch components
  // so we just need to make sure it is invoked when a food item is clicked
  const onFoodClick = jest.fn();

  beforeEach(() => {
    // Create a shallow wrapper using Enzyme, so we can reference this 
    // throughout our tests
    wrapper = shallow(
      <FoodSearch onFoodClick={onFoodClick} />
    );
  });

  afterEach(() => {
    // Clear mocks here so counts to function calls are reset to 0 between tests
    Client.search.mockClear();
    onFoodClick.mockClear();
  });

  it('should not display the remove icon', () => {
    // Try to find the remove element by the class names 
    // and confirm it doesn't not exist
    expect(wrapper.find('.remove.icon').length).toBe(0);
  });

  it('should display zero rows', () => {
    // Try to find rows in the body of the food table
    // and confirm there aren't any
    expect(wrapper.find('tbody tr').length).toBe(0)
  });

  describe('user populates search field', () => {
    const value = 'brocc';

    beforeEach(() => {
      // Find the input element (search box)
      const input = wrapper.find('input').first();

      // Change the text in the search box
      input.simulate('change', {
        target: { value: value }
      });
    });

    it('should update state property searchvalue', () => {
      // Confirm the state().searchValue was updated when the search
      // box input was changed
      expect(wrapper.state().searchValue).toEqual(value);
    });

    it('should display the remove icon', () => {
      // Find the remove element by the class names
      // and confirm it does exist
      expect(wrapper.find('.remove.icon').length).toBe(1);
    });

    it('should call `Client.search() with `value`', () => {
      // Get the arguments for the first time the search method was called
      const invocationArgs = Client.search.mock.calls[0];

      // Assert that the first argument is 'brocc'
      expect(invocationArgs[0]).toEqual(value);
    });

    describe('and API returns results', () => {
      // Setup a result for the search mock
      const foods = [
        {
          description: 'Broccolini',
          kcal: '100',
          protein_g: '11',
          fat_g: '21',
          carbohydrate_g: '31',
        },
        {
          description: 'Broccoli rabe',
          kcal: '200',
          protein_g: '12',
          fat_g: '22',
          carbohydrate_g: '32',
        }
      ];

      beforeEach(() => {
        // Get the arguments for the first call to the search mock,
        // So we can grab the callback function and invoke it
        const invocationArgs = Client.search.mock.calls[0];

        // Get a hold of the call back function
        const cb = invocationArgs[1];

        // Invoke the callback function and return the search results
        cb(foods);

        // Tell the React components to update
        wrapper.update();
      });

      it('should set the state property foods', () => {
        // Assert that the results are stored in the state().foods property
        expect(wrapper.state().foods).toEqual(foods);
      });

      it('should display two rows', () => {
        // Assert that 2 rows were added (1 for each food item)
        expect(wrapper.find('tbody tr').length).toEqual(2);
      });

      it('should render the description of first food', () => {
        // Assert that the html of the rendered component contains the
        // description of the first food item
        expect(wrapper.html()).toContain(foods[0].description);
      });

      it('should render the description of second food', () => {
        // Assert that the html of the rendered component contains the description
        // of the second food item
        expect(wrapper.html()).toContain(foods[1].description);
      });

      describe('then user clicks food item', () => {
        beforeEach(() => {
          // Simulate a click of the first food item
          const foodRow = wrapper.find('tbody tr').first();

          // Simulate a click event on the food row
          foodRow.simulate('click');
        });

        it('should call prop onFoodClick with food', () => {
          const food = foods[0];

          // Assert that the onFoodClick was called with the 
          // first food item object
          expect(onFoodClick.mock.calls[0]).toEqual([ food ]);
        });
      });

      describe('then user types more', () => {
        const value = 'broccx';

        beforeEach(() => {
          // Get the input textbox (search textbox)
          const input = wrapper.find('input').first();

          // Change the value in the search box to 'broccx'
          input.simulate('change', {
            target: { value: value }
          });
        });

        it('should update state property searchvalue', () => {
          // Assert that the state().searchValue property was updated to 'broccx'
          expect(wrapper.state().searchValue).toEqual(value);
        });

        it('should display the remove icon', () => {
          // Find the remove button by class names
          // and confirm it exists
          expect(wrapper.find('.remove.icon').length).toBe(1);
        });

        describe('and API returns no results', () => {
          beforeEach(() => {
            // Get the arguments for the second call to the search mock,
            // So we can grab the callback function and invoke it
            const secondInvocationArgs = Client.search.mock.calls[1];

            // Get a hold of the call back function
            const cb = secondInvocationArgs[1];

            // Invoke the callback function and return the search results
            cb([]);

            // Tell the React components to update
            wrapper.update();
          });

          it('should set the state property foods', () => {
            // Assert that the state().foods property is empty now
            // that we don't have any results
            expect(wrapper.state().foods).toEqual([]);
          });

          it('should not render any results', () => {
            // Assert that the food item table doesn't have any
            // rows in its body
            expect(wrapper.find('tbody tr').length).toEqual(0);
          });
        });
      });
    });
  });
});

No comments:

Post a Comment

} else { }