Tic-Tac-Toe Component Tests

Moving from end-to-end to component and unit tests

Let's take React Tic-Tac-Toe example by someone named D Abramov. He must be somewhat important person, since this example was included in the React Tutorial. You can find my version of the game at bahmutov/react-tic-tac-toe-example.

Tic-tac-toe game

The project uses react-scripts to bundle and serve the application. The application itself only has 3 files in the src folder

1
2
3
4
5
6
repo/
package.json
src/
index.jsx
app.jsx
app.css

The application file src/app.jsx has a few components: Square, Board, Game and a function calculateWinner.

Main application file

E2E vs component tests

Usually, I consider end-to-end tests the most effective way to confirm the application works. The Cypress test interacts with the loaded application like a real user, and any error in its logic, code, bundling, or deployment is likely to be caught.

cypress/integration/spec.js
1
2
3
4
5
6
7
8
describe('Tic-tac-toe', () => {
it('plays', function () {
cy.visit('http://localhost:3000')
cy.get('.square').eq(0).click() // X
cy.get('.square').eq(1).click() // O
// more commands until one player wins
})
})

The tests are nice, but what if we want to concentrate on the Square component? Maybe we want to refactor it, maybe we want to see how it looks with different styles or props, there could be lots of reasons we want to really stress this component in isolation from the main application.

Component tests

By adding cypress-react-unit-test to the repo we can easily write component tests. Let's confirm the Square component shows the value passed via a prop. For now we can simply export Square from the app.jsx and import it in the test file, and we can place the spec file alongside the component.

src/app.jsx
1
2
3
4
5
6
7
export function Square(props) {
return (
<button className="square" onClick={props.onClick}>
{props.value}
</button>
);
}
src/Square.spec.js
1
2
3
4
5
6
7
8
9
10
import React from 'react'
import { mount } from 'cypress-react-unit-test'
import { Square } from './app'

describe('Square', () => {
it('renders value', () => {
mount(<Square value="X" />)
cy.contains('.square', 'X')
})
})

Square test

We import the component and mount it, and then use Cypress commands to interact with the live component. Let's confirm it calls the passed prop on click. Ordinarily one would write a new unit test to separate the value test from the click test. But Cypress has built-in time traveling debugger, records movies on CI, takes screenshots on failures - you don't need to make component tests tiny just to have a good debugging experience. So I will continue expanding the same test.

src/Square.spec.js
1
2
3
4
5
6
7
8
9
10
11
import React from 'react'
import { mount } from 'cypress-react-unit-test'
import { Square } from './app'

describe('Square', () => {
it('renders value', () => {
mount(<Square value="X" onClick={cy.stub().as('click')} />)
cy.contains('.square', 'X').click()
cy.get('@click').should('be.called')
})
})

The test passes.

Square click test

Styles

The square component looks like a regular button, not like a board cell in the real game. This is because we only mounted the markup and never applied any styles to it. There are multiple options for styling components during tests, but the simplest is just to import the application's CSS from the spec file.

src/Square.spec.js
1
2
3
4
5
6
7
8
9
10
11
12
import React from 'react'
import { mount } from 'cypress-react-unit-test'
import { Square } from './app'
import './app.css'

describe('Square', () => {
it('renders value', () => {
mount(<Square value="X" onClick={cy.stub().as('click')} />)
cy.contains('.square', 'X').click()
cy.get('@click').should('be.called')
})
})

styled Square

We can import './app.css' because the specs are bundled using the same Webpack config as the application; if you can import a resource from the application code, you should be able to import it from the component test file.

Mocking imports

We have passed cy.stub to the component. This stub comes from Sinon.js included with Cypress. It works great when stubbing individual functions or a method on an object. But what about mocking ES6 module exports and imports?

The game component determines the winner by calling calculateWinner function. The exact details are unimportant, but the code around it looks like this:

Game calls calculateWinner

From the component test, we cannot reach and overwrite a private function calculateWinner. But we could move it into an external module and import it.

src/utils.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export function calculateWinner(squares) {
const lines = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
];
for (let i = 0; i < lines.length; i++) {
const [a, b, c] = lines[i];
if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
return squares[a];
}
}
return null;
}
src/app.jsx
1
2
import {calculateWinner} from './utils'
// use calculateWinner to determine the winner

Now let's write a component test for the Game component - and mock the ES6 module import.

src/Game.spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import React from 'react'
import { mount } from 'cypress-react-unit-test'
import { Game } from './app'
import './app.css'
// import the module with exports we want to mock
import * as utils from './utils'

describe('Game', () => {
it('declares winner', () => {
cy.stub(utils, 'calculateWinner').returns('X')
mount(<Game />)
cy.contains('Winner: X')
})
})

The test passes - note how Winner: X is immediately displayed, even before the first move is played 😀

Help X win right away by mocking the ES6 module import

Unit tests

We can write more end-to-end and component tests, using built-in code coverage as a guide. But what about the above function calculateWinner? Do we only indirectly test it via component tests? No. We should also directly test it using unit tests.

src/utils.spec.js
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
import { calculateWinner } from './utils'
describe('calculateWinner', () => {
const _ = undefined
const x = 'X'
const o = 'O'
it('calls no winner for empty board', () => {
expect(calculateWinner(
[
_, _, _,
_, _, _,
_, _, _,
]
)).to.equal(null)
})

it('calls winner for X', () => {
expect(calculateWinner(
[
_, _, x,
x, o, x,
_, o, x,
]
)).to.equal(x)
})

it('calls winner for O', () => {
expect(calculateWinner(
[
_, _, o,
x, o, x,
o, o, x,
]
)).to.equal(o)
})
})

We exercise different data inputs rather than code paths to make sure the function works correctly. The tests do not have a GUI, but the Command Log still shows useful information.

Unit tests

If there is an error, the code frame immediately shows the problem. For example if we change the last assertion to )).to.equal(x) we get the precise source code location

Error location

More info

For more reasons behind component testing, read My Vision for Component Tests blog posts, and visit the cypress-react-unit-test repo.