> For the complete documentation index, see [llms.txt](https://jimmylv.gitbook.io/tdd-frontend/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://jimmylv.gitbook.io/tdd-frontend/coding/00-project-bookshelf/04-testing-redux-data-flow.md).

# 任务 3：Redux 数据流测试

Hey，你好，我是 JimmyLv 吕靖，这是你开始 前端 TDD 练习的第 17 天。今天你将会开始进入 Redux Store，对 Redux 的数据流进行测试，驱动出更好的状态管理，把代码放在该放的位置。

在上一个任务当中你已经用上了 React Router 的几个官方 Hook，其实，你会发现，你写 React 的思路就变成了：先驱动出组件树拆分，组件就是函数，那这个函数可能时不时需要来点儿副作用，比如说跳转一下路由，或者获取一下数据，那就用钩子（Hook）把外部代码"钩"进来。

也许你之前已经用上了 `useState` 来做一些简单的状态管理，但是你现在需要一个全局的状态管理器来进行状态管理，此时就不能直接去 `set` 某个 state 的值啦。于是乎，`useDispatch` 就被拿来触发（dispatch）一个 action，然后再去 Redux store 改变 state 的值：

```javascript
import React from 'react'
import { useDispatch } from 'react-redux'

export const CounterComponent = ({ value }) => {
  const dispatch = useDispatch()

  return (
    <div>
      <span>{value}</span>
      <button onClick={() => dispatch({ type: 'counter/increment' })}>Increment counter</button>
    </div>
  )
}
```

然后当你需要某个状态的时候，便可以通过 `useSelector` 再从 Redux store 取回来，非常方便的同时，selector 还能帮你做一次获取数据的缓存，如果是同样的输入参数，因为是纯函数就必然会计算返回同样的结果，所以干脆就直接返回上一次的结果得了。

```javascript
import React from 'react'
import { useSelector } from 'react-redux'

export const TodoListItem = (props) => {
  const todo = useSelector((state) => state.todos[props.id])
  return <div>{todo.text}</div>
}
```

## 练习目标

* 使用 `redux-saga` 处理数据请求等异步 Action
* 先写 `redux-saga-test-plan` 测试，再写 Redux model 数据逻辑
* 学习 Jest Mock API 及异步测试方法 (`mock()` `fn()` `spyOn()`)
* 测试 selectors 与 `useSelector`，最简单的纯 JavaScript 逻辑单测

### 使用 `redux-saga-test-plan` 集成测试 Redux store

```javascript
import { expectSaga } from 'redux-saga-test-plan'
import * as service from '../services/book'
import reducer, { sagas, types } from './book'

jest.mock('../services/book')

test('should fetch book list', async () => {
  const payload = { category: '文学' }
  service.getAllBooks.mockResolvedValue([{ name: '你不知道的JavaScript' }])

  const result = await expectSaga(sagas)
    .withReducer(reducer) // reduce state update
    .dispatch({ type: types.FETCH, payload }) // dispatch action
    .run()

  expect(service.getAllBooks).toBeCalledWith(payload)
  expect(result.storeState).toEqual({
    list: [{ name: '你不知道的JavaScript' }],
    total: 1,
    error: null,
  })
})
```

### 如何测试 connect 到 Redux 的 React 组件

```javascript
import userEvent from '@testing-library/user-event'
import { createMemoryHistory } from 'history'
import { renderWithReduxAndRouter } from '../utils/testHelpers'
import { categoryTabs } from './CategoryTabs.stories'

const mockDispatch = jest.fn()
jest.mock('react-redux', () => ({
  useDispatch: () => mockDispatch,
}))

test('should show category list and fetch books by category', () => {
  const history = createMemoryHistory()
  const { queryByText } = renderWithReduxAndRouter(categoryTabs(), { history })

  userEvent.click(queryByText('文学'))

  expect(mockDispatch).toBeCalledWith({
    type: 'book/fetch',
    payload: {
      category: '文学',
    },
  })
  expect(history.location.search).toBe('?category=文学')
})

test('should show "文学" category book when select "文学" tab', () => {})
```

## 本次任务的练习要求

* `redux-saga` over `redux-thunk`（利于测试）
* 组件化拆分，不能有重复逻辑（通过 React Hook 提取）
* 实现 Redux 数据流，并保持 Outside-In TDD 的节奏
* 别忘了 Cypress E2E 测试还在运行着呢！

## 也请你思考一下

### 什么时机，回到 Cypress 检查页面元素是否正确显示？

```javascript
describe('Home Page', () => {
  beforeEach(() => {
    cy.visit('/home')
  })
  it('should show book list by category and tag', () => {
    // todo: should update to testId from plain text?
    cy.contains('书架').click()
    cy.contains('Bookshelf')

    cy.contains('编程').click()
    cy.contains('Java编程思想')

    cy.contains('JavaScript 1本').click()
    cy.contains('Java编程思想')
    cy.contains('你不知道的JavaScript')

    cy.contains('文学').click()
    cy.contains('平凡的世界')
  })
})
```

如果有必要的话，你还可以检查一下是否调用后端 API，但我建议在 E2E 层面只关心真实用户会怎么用你的网页，普通用户可能并不关心浏览器是否发送 HTTP request。

```javascript
describe('Home page to fetch books API', () => {
  beforeEach(() => {
    cy.visit('/home')
  })
  const initialBooks = [] // [...]

  const getItems = () => cy.request('/api/books').its('body')

  it('should fetch all books', () => {
    // before the request goes out we need to set up spying
    // see https://on.cypress.io/network-requests
    cy.server()
    cy.route('GET', '/api/books').as('book')
    cy.wait('@book').should('have.property', 'status', 200)
    cy.get('@book').its('response.body').should('deep.equal', {
      title: 'example post',
      body: 'this is a post sent to the server',
      userId: 1,
    })
  })
})
```
