Acceptance Test Driven Development with React/Redux — Part 5
update 1: This article is part of a series, check out the full series: Part1, Part 2, Part 3, part 4 and part 5.
update 2: I have published a book named Build React Application with Acceptance Test driven development to cover more topic and practices about ATDD with React, please check it out!
Searching
Our third feature is that a user can search books by its name. This is very useful when the book list become very long (it’s hard for a user to find what he is looking for when content is more than one screen or one page)
Acceptance test
Similarly, we start by writing an acceptance test
:
test('Show books which name contains keyword', async () => {
await page.goto(`${appUrlBase}/`) const input = await page.waitForSelector('input.search')
page.type('input.search', 'design') // await page.screenshot({path: 'search-for-design.png'});
await page.waitForSelector('.book .title')
const books = await page.evaluate(() => {
return [...document.querySelectorAll('.book .title')].map(el => el.innerText)
}) expect(books.length).toEqual(1)
expect(books[0]).toEqual('Domain-driven design')
})
We try to type keyword design
into .search
input box, and expect that only Domain-driven design
shows up in the book list.
The simplest way to implement is just modifying the BookListContainer
and add an input
to it:
render() {
return (
<div>
<input type="text" className="search" placeholder="Type to search" />
<BookList {...this.state}/>
</div>
)
}
And then define a handling method for the change
event for the input
component:
filterBook(e) {
this.setState({
term: e.target.value
}) axios.get(`http://localhost:8080/books?q=${e.target.value}`).then(res => {
this.setState({
books: res.data,
loading: false
})
}).catch(err => {
this.setState({
loading: false,
error: err
})
})
}
and bind it on input
component:
<input type="text" className="search" placeholder="Type to search" onChange={this.filterBook}
value={this.state.term}/>
Note that we are using books?q=${e.target.value}
as the URL to fetching data, that's a full-text searching API provided by json-server
, you just need to send books?q=domain
to the backend and it will return all the content that contains domain
.
You can try it on the command line like this:
curl http://localhost:8080/books?q=domain
Now our tests green again. Let’s jump to next step of the Red-Green-Refactoring
.
Refactoring
Obviously, the filterBook
is almost same to the code in componentDidMount
, we can extract a function fetchBooks
to remove the duplication:
componentDidMount() {
this.fetchBooks()
} fetchBooks() {
const {term} = this.state
axios.get(`http://localhost:8080/books?q=${term}`).then(res => {
this.setState({
books: res.data,
loading: false
})
}).catch(err => {
this.setState({
loading: false,
error: err
})
})
} filterBook(e) {
this.setState({
term: e.target.value
}, this.fetchBooks)
}
Emm, better than before. And since fetchBooks
are coupling network request and state
changing together, we can split them into define 2 functions:
updateBooks(res) {
this.setState({
books: res.data,
loading: false
})
} updateError(err) {
this.setState({
loading: false,
error: err
})
} fetchBooks() {
const {term} = this.state
axios.get(`http://localhost:8080/books?q=${term}`).then(this.updateBooks).catch(this.updateError)
} filterBook(e) {
this.setState({
term: e.target.value
}, this.fetchBooks)
}
Now the code turns much clean and easy to read.
One step further
Let’s say, someone else may want to use the search box we just finished on his own page, how can we reuse it? Actually, it’s very hard because currently the search box is highly tightly with the rest code in BookListContainer
, we need to extract into another component SearchBox
:
import React from 'react'function SearchBox({term, onChange}) {
return (<input type="text" className="search" placeholder="Type to search" onChange={onChange}
value={term}/>)
}export default SearchBox
After that extraction, the render
method of BookListContainer
turns:
render() {
return (
<div>
<SearchBox term={this.state.term} onChange={this.filterBook} />
<BookList {...this.state}/>
</div>
)
}
And for unit tests we can simply test it this way:
import React from 'react'
import {shallow} from 'enzyme'
import SearchBox from './SearchBox'describe('SearchBox', () => {
it('Handle searching', () => {
const onChange = jest.fn()
const props = {
term: '',
onChange
} const wrapper = shallow(<SearchBox {...props}/>)
expect(wrapper.find('input').length).toEqual(1) wrapper.simulate('change', 'domain')
expect(onChange).toHaveBeenCalled()
expect(onChange).toHaveBeenCalledWith('domain')
})
})
Note that we are using jest.fn()
to create a spy
object that can record the trace of invocations. And we use simulate
API provided by enzyme
to simulate a change
event with domain
as it's payload. We can then expect that onChange
method has been called with data domain
.
Now we noticed that SearchBox
is just a presentational component, we can move it to components
folder:
src
├── App.css
├── App.js
├── components
│ ├── BookDetail
│ │ ├── index.js
│ │ └── index.test.js
│ ├── BookList
│ │ ├── index.css
│ │ ├── index.js
│ │ └── index.test.js
│ └── SearchBox
│ ├── index.js
│ └── index.test.js
├── containers
│ ├── BookDetailContainer.js
│ └── BookListContainer.js
├── e2e.test.js
├── index.css
├── index.js
└── setupTests.js
Some style updates
.search {
box-sizing: border-box;
width: 100%;
padding: 2px 4px;
height: 32px;
}
Now our user interface looks quite like a real application:
Furthermore, let’s restructure the container
folder to make it consistency with the component
folder:
src
├── App.css
├── App.js
├── components
│ ├── BookDetail
│ │ ├── index.js
│ │ └── index.test.js
│ ├── BookList
│ │ ├── index.css
│ │ ├── index.js
│ │ └── index.test.js
│ └── SearchBox
│ ├── index.css
│ ├── index.js
│ └── index.test.js
├── containers
│ ├── BookDetailContainer
│ │ └── index.js
│ └── BookListContainer
│ └── index.js
├── e2e.test.js
├── index.css
├── index.js
└── setupTests.js
We defined an index.js
in each folder, then you can simply import it by the folder name just like
import BookListContainer from "./containers/BookListContainer/"
without that, you may see some duplication in the path like this:
import BookListContainer from "./containers/BookListContainer/BookListContainer"
Great, we have finished all the 3 features! Let’s take a look at what we’ve got here:
- 3 presentational components (BookDetail, BookList, SearchBox) and their unit tests
- 2 container components (BookDetailContainer, BookListContainer)
- 3 acceptance tests to cover the most valuable path (list, detail, and searching)