Using Extend To Make Tests DRY
With our first test up and running, it's time to start thinking about a way to wrap up our wiring function calls so that we're not having to repeat our querying and interactions in each individual test. To better illustrate what we mean, let's create two tests, each clicking a different todo.
You wouldn't usually structure a test like this with multiple assertions in a single test, but we're doing it this way to demonstrate where the repetition comes in.
const fixture = (
<TodoList
todos={[
{
name: 'Todo One',
},
{
name: 'Todo Two',
},
]}
/>
)
describe('TodoList', () => {
test('should toggle the first todo', async () => {
const {findTodoList} = render(fixture)
const {findTodo} = await findTodoList()
const {findCheckbox} = await findTodo({index: 0})
const {click} = await findCheckbox()
click()
expect(todoList).toMatchSnapshot('after clicking first todo')
})
test('should toggle the second todo', async () => {
const {findTodoList} = render(fixture)
const {findTodo} = await findTodoList()
const {findCheckbox} = await findTodo({index: 1})
const {click} = await findCheckbox()
click()
expect(todoList).toMatchSnapshot('after clicking second todo')
})
})
The problem here is that all we care about is clicking a particular todo, but our tests are filled with repeated code. This is one of the biggest sticking points we hit with vanilla `react-testing-library.
For a simple component like this, you can see there's a fair amount to clean up, but for a real component, the amount repeated code can become very cumbersome.
There are three repeated steps we'd like move from the tests into our wiring.
- Getting
clickfromfindCheckboxand calling it - Getting
findCheckBoxfromfindTodo - Getting
findTodofromfindTodoList
Extending Todo with toggle
testRender.js from previous step
{
children: {
todoList: {
findValue: 'todo-list',
serialize: (val, {todoStrings}) => {
todoStrings.map(todoString => `${todoString}\n`)
},
children: {
todo: {
isMultiple: true,
findValue: 'todo',
serialize: (val, {checkBoxString}) => {
return `${checkBoxString}${val ? val.textContent : ''}$`
},
children: {
checkbox: {
findValue: 'checkbox',
serialize: val => (val.checked ? '☑️' : '◻️'),
},
},
},
},
},
},
}
In addition to serialize and
findValue there's a third core piece of
functionality that you'll add to most of your wiring nodes, and that's
extend.
The basic idea of is extend is that you're adding custom helpers to a given
node. To see what that means in action, let's add a toggle function to Todo
that eliminates the first two items in repeated steps list.
...
todo: {
isMultiple: true,
findValue: 'todo',
extend: (val, {findCheckbox}) => {
return {
toggle: async () => {
const {click} = await findCheckbox()
click()
},
}
},
...
}
Also, let's update the tests
//const {findCheckbox} = await findTodo({index: 0})
//const {click} = await findCheckbox()
//click();
const {toggle} = await findTodo({index: 1})
await toggle()
Let's unpack what happened there a little bit.
Like serialize the object in the second argument of extend contains all of
the helpers for interacting with that element. What's unique about extend
however, is that this object also contains all of the find{childNode} helpers
for the current node's children.
In this case, we know that we always want to find the checkbox and click on it,
so we can use the findCheckbox helper created for the checkbox node to
create toggle to eliminate the intermediate finding step.
As a rule, every interaction that happens in a test should be specifically defined in an extend function and given a descriptive name. This has two benefits
- If the specific implementation of the component changes, your tests can stay the same.
- Your tests become a very easy to read sequence of queries, descriptive interaction functions, and assertions.
If we want our tests to be as DRY as possible, let's try to have a single
function called toggleTodo that is the only thing we call in our test.
{
extend: (val, {findTodoList}) => {
return {
toggleTodo: async index => {
const {findTodo, todoList} = await findTodoList()
const {toggle} = await findTodo({index})
await toggle()
return {
todoList
}
},
}
},
children: {
todoList: {
...
},
},
}
And then our final tests look like this.
describe('TodoList', () => {
test('should toggle the first todo', async () => {
const {toggleTodo} = render(fixture)
const {todoList} = await toggleTodo(0)
expect(todoList).toMatchSnapshot('after clicking first todo')
})
test('should toggle the second todo', async () => {
const {toggleTodo} = render(fixture)
const {todoList} = await toggleTodo(1)
expect(todoList).toMatchSnapshot('after clicking second todo')
})
})
Compared with where we started, I think you'll agree this is much more concise and expressive. To finish things up, let's break apart a couple of key details above.
The first question is, if extend adds functionality to wiring nodes, which
node is being extended to add toggleTodo? In this case, it's being added to
the root node. The root node is unique for a
few different reasons, but the one we care
about is that it allows us to extend the functions that get returned from
render, which is what we want to do here. Once we understand how we're extending
the root node, everything else that happens is pretty similar to what we've done
before, with the only wrinkle being that we need to remember to pass long
todoList so we still have access to in our tests.
