任务 3:Redux 数据流测试

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

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

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

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 还能帮你做一次获取数据的缓存,如果是同样的输入参数,因为是纯函数就必然会计算返回同样的结果,所以干脆就直接返回上一次的结果得了。

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

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 组件

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 检查页面元素是否正确显示?

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。

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,
    })
  })
})

最后更新于