обновление 1: Эта статья является частью серии, ознакомьтесь с полной серией: Часть 1, Часть 2 , Часть 3 , Часть 4 и Часть 5 .
обновление 2: я опубликовал книгу под названием Создание приложения React с разработкой, управляемой приемочными испытаниями, в которой описаны дополнительные темы и практические рекомендации по ATDD с помощью React. , "Пожалуйста, проверьте это"!
Searching
Наша третья особенность заключается в том, что пользователь может искать книги по названию. Это очень полезно, когда список книг становится очень длинным (пользователю трудно найти то, что он ищет, когда контент занимает более одного экрана или одной страницы)
Вступительный тест
Точно так же мы начинаем с написания 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') })
Мы пытаемся ввести ключевое слово design
в поле ввода .search
и ожидаем, что в списке книг появится только Domain-driven design
.
Самый простой способ реализовать - просто изменить BookListContainer
и добавить к нему input
:
render() {
return (
<div>
<input type="text" className="search" placeholder="Type to search" />
<BookList {...this.state}/>
</div>
)
}
Затем определите метод обработки события change
для компонента input
:
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 }) }) }
и привяжите его к input
компоненту:
<input type="text" className="search" placeholder="Type to search" onChange={this.filterBook}
value={this.state.term}/>
Обратите внимание, что мы используем books?q=${e.target.value}
в качестве URL-адреса для получения данных, это API полнотекстового поиска, предоставляемый json-server
, вам просто нужно отправить books?q=domain
на бэкэнд, и он вернет весь контент, содержащий domain
.
Вы можете попробовать это в командной строке следующим образом:
curl http://localhost:8080/books?q=domain
Теперь наши тесты снова зеленые. Перейдем к следующему шагу Red-Green-Refactoring
.
Рефакторинг
Очевидно, что filterBook
почти такой же, как и код в componentDidMount
, мы можем извлечь функцию fetchBooks
, чтобы удалить дублирование:
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) }
Эмм, лучше, чем раньше. А поскольку fetchBooks
связывают сетевой запрос и state
изменение вместе, мы можем разделить их на две функции:
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) }
Теперь код становится более чистым и легко читаемым.
На шаг впереди
Допустим, кто-то другой может захотеть использовать только что созданное окно поиска на его собственной странице. Как мы можем использовать его повторно? На самом деле, это очень сложно, потому что в настоящее время окно поиска очень тесно связано с остальным кодом в BookListContainer
, нам нужно извлечь его в другой компонент 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
После этого извлечения render
метод BookListContainer
превращается:
render() {
return (
<div>
<SearchBox term={this.state.term} onChange={this.filterBook} />
<BookList {...this.state}/>
</div>
)
}
А для модульных тестов мы можем просто протестировать это следующим образом:
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') }) })
Обратите внимание, что мы используем jest.fn()
для создания объекта spy
, который может записывать трассировку вызовов. И мы используем simulate
API, предоставленный enzyme
, для моделирования события change
с domain
в качестве полезной нагрузки. Затем мы можем ожидать, что onChange
метод был вызван с данными domain
.
Теперь мы заметили, что SearchBox
- это просто презентационный компонент, мы можем переместить его в папку components
:
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
Некоторые обновления стиля
.search {
box-sizing: border-box;
width: 100%;
padding: 2px 4px;
height: 32px;
}
Теперь наш пользовательский интерфейс выглядит как настоящее приложение:
Кроме того, давайте реструктурируем папку container
, чтобы она соответствовала папке component
:
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
Мы определили index.js
в каждой папке, затем вы можете просто импортировать его по имени папки, как и
import BookListContainer from "./containers/BookListContainer/"
без этого вы можете увидеть некоторое дублирование в пути, например:
import BookListContainer from "./containers/BookListContainer/BookListContainer"
Отлично, мы завершили все 3 функции! Давайте посмотрим, что у нас получилось:
- 3 презентационных компонента (BookDetail, BookList, SearchBox) и их модульные тесты
- 2 компонента-контейнера (BookDetailContainer, BookListContainer)
- 3 приемочных теста, чтобы охватить наиболее ценный путь (список, детали и поиск)