I’m going to do an experiment on a branch to see if I can make the interface for testing a little easier to read. I want something bit more like assert_select in Rails.
Let’s see now. Pretending it’s magic…
test('shows the first blog post', async () => {
const component = await mount(<Application />)
check(component).has(
{
'.site-name': 'Blogging',
'.post .title': 'React on Rails',
'.post .body': 'I can use React with Rails.',
}
)
})
Or, how about this?
test('also shows the first blog post', async () => {
const component = await mount(<Application />)
assert_select(component, '.site-name', 'Blogging')
assert_select(component, '.post .title', 'React on Rails')
assert_select(component, '.post .body', 'I can use React with Rails.')
})
assert_select allows nesting. Maybe something like this?
test('shows the first blog post with nesting', async () => {
const component = await mount(<Application />)
assert_select(component, '.site-name', 'Blogging')
assert_select(component, '.post', match => {
assert_select(match, '.title', 'React on Rails')
assert_select(match, '.body', 'I can use React with Rails.')
})
})
Let’s try the simple (non-nested) version for a while to see how it feels. Here’s a quick implementation.
export function assert_select(component, selector, expectation) {
const selected = component.find(selector)
switch(typeof(expectation)) {
case 'string':
expect(selected.text()).toEqual(expectation)
break
case 'number':
expect(selected.length).toEqual(expectation)
break
default:
expect(expectation).toEqual('string or number')
break
}
}
Let’s see how it goes. I’ll extract this into a ReactHelper along with a wrapper function for mount() that I will call display().
The application test fails because it is trying to make a network request and, as everyone knows, we don’t want to make network requests in unit tests.
I suppose I could change the Application.test to use shallow() instead of mount() but then we wouldn’t actually be testing very much — and we’ll have to deal with this network request problem eventually.
Axios comes with an API for mocking network requests but that all feels a bit low-level to me.
What I’d like to do is refactor the code so that it is easy to switch out the network layer entirely when we are testing. Let’s introduce an API layer with a function to fetch posts.
We’ll start by extracting a method fetchPost(), then convert it to be an async function and await the response in componentDidMount(). Then, finally, we’ll move the function to its own file.
I’ll just show the final result.
// posts/api.js
import * as axios from 'axios'
export function fetchPost(id) {
const request = {
url: `http://localhost:3000/posts/${id}.json`
}
return axios(request).then(response => response.data)
}
async componentDidMount() {
const post = await fetchPost(1)
this.setState({post})
}
EXPLAIN PROMISES
EXPLAIN ASYNC
We’re not done yet because I’d like to introduce one more level of indirection. I want to abstract away the call to the server partly because this code will end up being used in a bunch of places and partly because that’s where I want to introduce my cleavage point for mocking.
All problems in computer science can be solved by another level of indirection.
// remote/server.js
import * as axios from 'axios'
export const server = {send}
function send(url) {
const request = {url}
return axios(request).then(response => response.data)
}
// posts.api.js
import {server} from 'remote/server'
export function fetchPost(id) {
return server.send(`/posts/${id}.json`)
}
There. That looks much nicer and now we are finally ready to mock out that server connection In Application.test.jsx.
import {Application} from 'application/Application'
import {server} from 'remote/server'
describe('The application', () => {
server.send = jest.fn()
beforeEach( () => {
const post = {
id: 1,
title: 'React on Rails',
body: 'I can use React with Rails.',
}
server.send.mockReturnValue(post)
})
test('shows the first blog post', async () => {
const component = await mount(<Application />)
expect(component.find('.site-name').text()).toEqual('Blogging')
expect(component.find('.title').text()).toEqual('React on Rails')
expect(component.find('.body').text()).toEqual('I can use React with Rails.')
expect(server.send).toBeCalledWith('/posts/1.json')
})
})
I find it hard to do test first with UI components because I’m always in such a rush to see what the UI will look like. But test last is better than no test, so let’s test now.
Jest is a unit testing library for Javascript and Enzyme provides an adapter that lets you interact with React components from your test code. We’ll install them using the Yarn package manager.
yarn add -dev jest
yarn add -dev enzyme enzyme-adapter-react-16
Let’s create a test for our Application component.
import React from 'react'
import {mount} from 'enzyme'
import {configure} from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'
configure({ adapter: new Adapter() })
import {Application} from 'application/Application'
describe('The application', () => {
test('shows the first blog post', () => {
const component = mount(<Application />)
expect(component.find('.site-name').text()).toBe('Blogging')
expect(component.find('.title').text()).toBe('React on Rails')
expect(component.find('.body').text()).toBe('I can use React with Rails.')
})
})
mount() will instantiate your component, call all the React lifecycle methods (we’ll cover this later), render it and make the component available for you to write assertions against.
A note about rendering components with Enzyme.
Enzyme has three different methods for rendering a React component:
shallow(<C />) renders C but not its child components.
mount(<C />) renders C and, recursively, all of its children.
render(<C />)renders just the component’s HTML.
It took me a long time to realize that these three methods do totally different things and have a totally different API. This confused the hell out of me for weeks. Once I realized that, I decided to just stick with mount() unless I have a very good reason to just do a shallow rendering which, so far, is never.
A Minor rant
Way back when the first JUnit extensions appeared that let you write tests in pseudo-English so that you could have your customer read them or maybe even write them, Ron Jeffries said that this was a dead end. Pseudo-English would always have an uncanny valley feel about it; the tests would be hard to read and harder to write. If programmers write tests, better that they use normal programming conventions that programmers can understand.
I didn’t listen.
After struggling mightily with Fit, Fitnesse, Cucumber, RSpec and God knows what else, I decided that maybe Ron had a point and went back to plain JUnit-style assertions.
In 20-something years, I’ve never had a customer who was interested in reading — nevermind writing — these tests. Just write your best ruby/javascript/whatever and give the tests good names. It will be OK.
Running the tests
We have to tell Jest where to find the tests. Create jest.config.js in the root of your rails app.
If you sign in to CircleCI via your GitHub account, you can have CircleCI run your tests for you.
Click Set Up Project then follow the instructions to add the CircleCI config file to your project. I won’t repeat them here. If you are lucky, it will work first time.
If you are unlucky like me, you’ll have to copy a working config.xml from somewhere else. You can copy mine if you like. I had to add minitest-ci to my Gemfile to get the test results to show up. I got there in the end.
If you want to be really fancy, you can add the little badge that CircleCI generates to your README.md file.