обновление 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 приемочных теста, чтобы охватить наиболее ценный путь (список, детали и поиск)