[TDD] 如何测试 React 异步组件?

sxkk20082年前知识分享152

前言

本文承接上文 如何测试驱动开发 React 组件?,这次我将继续使用 @testing-library/react 来测试我们的 React 应用,并简要简要说明如何测试异步组件。

异步组件的测试内容

我们知道异步请求主要用于从服务器上获取数据,这个异步请求可能是主动触发的,也可能是(鼠标)事件响应,本文主要包含 2 方面内容:

  1. 如何测试在 componentDidMount 生命周期中发出的异步请求 ?

  2. 如何测试(鼠标)事件发出的异步请求 ?


对于异步组件,有两件步骤需要进行测试:

第一:测试异步方法本身有没有被调用,并且传了正确的参数。

第二:在调用之后,应用程序应该做出响应。

一起来看看代码中该如何实现?

假设你有一个用 React 编写的小型博客应用程序。有一个登录页面,还有有一个文章列表页面,内容就跟我的博客一样。

登录测试

先来实现登录页,先脑补一个效果图吧

image.png

我们先来写下测试用例

  1. 界面包含账号和密码输入框
  2. 接口请求包含 username 和 password
  3. 防止登录重复点击
  4. 登录成功跳转页面
  5. 登录失败显示错误信息

测试渲染

代码未动,测试先行,先确保我们的组件可以渲染。

import React from 'react'
import { render } from '@testing-library/react'
import Login from './index'

describe('Login component', () => {
  it('should render', () => {
    const { getByText } = render(<Login onSubmit={() => {}} />)
    expect(getByText(/账号/)).toBeInTheDocument()
    expect(getByText(/密码/)).toBeInTheDocument()
  })
})

登录组件实现

为了保证是一个纯组件,将提交方法onSubmit作为一个 props 传入,接下来我们实现下组件代码

import React from 'react'

function Login({ onSubmit }) {
  function handleSubmit(event) {
    event.preventDefault()
    const { username, password } = event.target.elements

    onSubmit({
      username: username.value,
      password: password.value,
    })
  }
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="username-field">账号:label>
        <input id="username-field" name="username" type="text" />
      div>
      <div>
        <label htmlFor="password-field">密码:label>
        <input id="password-field" name="password" type="password" />
      div>
      <div>
        <button type="submit">登录button>
      div>
    form>
  )
}

export default Login

为了方便理解我们这边没有使用其他三方库,若在生产环境中,我推荐使用 react-hook-form

测试提交

接下来测试下 onSubmit 方法必须包含 usernamepassword,

我们需要模拟用户输入,这个时候我们需要安装 @test-library/user-event

import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Login from './index'

test('onSubmit calls  with username and password', () => {
  let submittedData
  const handleSubmit = (data) => (submittedData = data)
  render(<Login onSubmit={handleSubmit} />)
  const username = 'user'
  const password = 'password'

  userEvent.type(screen.getByPlaceholderText(/username/i), username)
  userEvent.type(screen.getByPlaceholderText(/password/i), password)
  userEvent.click(screen.getByRole('button', { name: /登录/ }))

  expect(submittedData).toEqual({
    username,
    password,
  })
})

我们可以选用 @testing-libraryget*By* 函数获取 dom 中的元素, 这里使用 getByPlaceholderText

image.png

以上测试用例只测试了登录函数,但是我们并未写登录成功或者失败的逻辑,接下来来我们通过 jest 的 mock 函数功能来模拟登录。

测试登录成功

由于测试登录成功的例子已经包含了"测试提交"和"测试渲染"的功能,所以,可以将前面 2 个单元测试删除。登录后,按钮改成 loading 状态 disabled。

const fakeData = {
  username: 'username',
  password: 'username',
}

test('onSubmit success', async () => {
  // mock 登录函数
  const login = jest.fn().mockResolvedValueOnce({ data: { success: true } })
  render(<Login onSubmit={login} />)

  userEvent.type(screen.getByPlaceholderText(/username/i), fakeData.username)
  userEvent.type(screen.getByPlaceholderText(/password/i), fakeData.password)
  userEvent.click(screen.getByRole('button', { name: /登录/ }))
  //登录后,按钮改成 loading 状态 disabled
  const button = screen.getByRole('button', { name: /loading/ })

  expect(button).toBeInTheDocument()
  expect(button).toBeDisabled()

  await waitFor(() => expect(login).toHaveBeenCalledWith(fakeData))

  expect(login).toHaveBeenCalledTimes(1)

  // 文档中没有 loading 按钮
  expect(screen.queryByRole('button', { name: 'loading' })).not.toBeInTheDocument()
})

接下来我们修改组件, 顺便把登录失败的逻辑也写了,登录失败在登录框下显示服务端返回信息。

import React, { useState } from 'react'

function Login({ onSubmit }) {
  const [loading, setLoading] = useState(false)
  const [message, setMessage] = useState('')
  function handleSubmit(event) {
    event.preventDefault()
    const { username, password } = event.target.elements
    setLoading(true)

    onSubmit({
      username: username.value,
      password: password.value,
    })
      .then((res) => {
        setLoading(false)
      })
      .catch((res) => {
        setLoading(false)
        setMessage(res.data.message)
      })
  }
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="username-field">账号:label>
        <input id="username-field" name="username" placeholder="username" type="text" />
      div>
      <div>
        <label htmlFor="password-field">密码:label>
        <input id="password-field" name="password" placeholder="password" type="password" />
      div>
      <div>
        <button disabled={loading} type="submit">
          {loading ? 'loading' : '登录'}
        button>
      div>
      <div>{message}div>
    form>
  )
}

export default Login

测试登录失败

我们直接复制成功的测试用例,修改失败的逻辑。测试用例:

  • 失败后文档中显示服务端的消息
  • 失败后按钮又显示登录并且可以点击
test('onSubmit failures', async () => {
  const message = '账号或密码错误!'
  // mock 登录函数失败
  const login = jest.fn().mockRejectedValueOnce({
    data: { message },
  })
  render(<Login onSubmit={login} />)

  userEvent.type(screen.getByPlaceholderText(/username/i), fakeData.username)
  userEvent.type(screen.getByPlaceholderText(/password/i), fakeData.password)
  userEvent.click(screen.getByRole('button', { name: /登录/ }))

  const button = screen.getByRole('button', { name: /loading/ })

  expect(button).toBeInTheDocument()
  expect(button).toBeDisabled()

  await waitFor(() => expect(login).toHaveBeenCalledWith(fakeData))

  expect(login).toHaveBeenCalledTimes(1)

  // 确保文档中有服务端返回的消息
  expect(screen.getByText(message)).toBeInTheDocument()

  // 文档中没有 loading 按钮
  expect(screen.queryByRole('button', { name: 'loading' })).not.toBeInTheDocument()

  expect(screen.getByRole('button', { name: /登录/ })).not.toBeDisabled()
})

博客列表测试

相信经过登录的测试,我们在来写博客列表的测试已经不难了,我们先来写下测试用例:

  • 接口请求中页面显示 loading
  • 请求成功显示博客列表
  • 列表为空显示暂无数据
  • 请求失败显示服务端错误

博客列表代码

下面的代码中, 使用了 react-use,首先我们先要安装这个包

import React from 'react'
import { fetchPosts } from './api/posts'
import { useAsync } from 'react-use'

export default function Posts() {
  const posts = useAsync(fetchPosts, [])

  if (posts.loading) return 'Loading...'
  if (posts.error) return '服务端错误'
  if (posts.value && posts.value.length === 0) return '暂无数据'

  return (
    <>
      <h1>My Postsh1>
      <ul>
        {posts.value.map((post) => (
          <li key={post.id}>
            <a href={post.url}>{post.title}a>
          li>
        ))}
      ul>
    >
  )
}

当然我们也可以写 class 的实现方式,代码如下:

import React from 'react'
import { fetchPosts } from './api/posts'

export default class Posts extends React.Component {
  state = { loading: true, error: null, posts: null }

  async componentDidMount() {
    try {
      this.setState({ loading: true })
      const posts = await fetchPosts()
      this.setState({ loading: false, posts })
    } catch (error) {
      this.setState({ loading: false, error })
    }
  }

  render() {
    if (this.state.loading) return 'Loading...'
    if (this.state.error) return '服务端错误'
    if (this.state.posts && this.state.posts.length === 0) return '暂无数据'

    return (
      <>
        <h1>My Postsh1>
        <ul>
          {this.state.posts.map((post) => (
            <li key={post.id}>
              <a href={post.url}>{post.title}a>
            li>
          ))}
        ul>
      >
    )
  }
}

Mock 接口

jest.mock('./api/posts')

我们可以在官方文档中阅读关于 jest.mock 的更多信息。

它所做的就是告诉 Jest 替换/api/posts 模块。

现在我们已经有了 mock,让我们来渲染组件,并且界面显示 loading:

import React from 'react'
import { render, screen } from '@testing-library/react'
import Post from './index'

jest.mock('./api/posts')

test('should show loading', () => {
  render(<Posts />)
  expect(screen.getByText('Loading...')).toBeInTheDocument()
})

这是第一步,现在我们需要确保我们的 fetchPosts 方法被正确调用:

import React from 'react'
import { render, screen } from '@testing-library/react'
import Posts from './index'
import { fetchPosts } from './api/posts'

jest.mock('./api/posts')

test('should show a list of posts', () => {
  render(<Posts />)
  expect(screen.getByText('Loading...')).toBeInTheDocument()

  expect(fetchPosts).toHaveBeenCalledTimes(1)
  expect(fetchPosts).toHaveBeenCalledWith()
})

通过 toHaveBeenCalledTimes 测试调用次数,通过 toHaveBeenCalledWith 测试调用方法的参数,虽然这边是空数据,但是我们也可以写,确保调用参数是空。

此时我们的测试代码会报错,因为我们没有 Mock fetchPosts 方法

import React from 'react'
import { render, screen, wait } from '@testing-library/react'
import Posts from './index'
import { fetchPosts } from './api/posts'

jest.mock('./api/posts')

test('should show a list of posts', async () => {
  // mock 接口
  const posts = [{ id: 1, title: 'My post', url: '/1' }]
  fetchPosts.mockResolvedValueOnce(posts)

  render(<Posts />)
  expect(screen.getByText('Loading...')).toBeInTheDocument()
  expect(fetchPosts).toHaveBeenCalledWith()
  expect(fetchPosts).toHaveBeenCalledTimes(1)

  //等待标题渲染
  await waitFor(() => screen.findByText('My Posts'))
  posts.forEach((post) => expect(screen.getByText(post.title)).toBeInTheDocument())
})

我们使用 mockResolvedValueOnce 返回一些假数据。然后,我们等待异步方法解析并等待 Posts 组件重新渲染。为此,我们使用 waitFor 方法,同时检查标题是否呈现,之后遍历检查,确保每一个标题在页面上。

测试接口错误

接下来我们要测试错误是否被正确呈现,那么只需要修改 mock:

test('should show an error message on failures', async () => {
  fetchPosts.mockRejectedValueOnce('Error!')

  render(<Posts />)
  expect(screen.getByText('Loading...')).toBeInTheDocument()

  await waitFor(() => {
    expect(screen.getByText('服务端错误')).toBeInTheDocument()
  })
  expect(fetchPosts).toHaveBeenCalledWith()
  expect(fetchPosts).toHaveBeenCalledTimes(1)
})

小结

以下是测试异步组件的步骤:

  1. 通过 mock  使组件可以获取静态假数据;
  2. 测试加载状态;
  3. 测试异步方法是否被正确调用,并且带上了正确的参数;
  4. 测试组件是否正确地渲染了数据
  5. 测试异步方法错误时,组件是是否渲染了正确的状态

文中关于登录成功后页面跳转并未测试,那么如何测试 react 路由 ?请关注我,我会尽快出 React test 系列的下文。

希望这篇文章对大家有所帮助,也可以参考我往期的文章或者在评论区交流你的想法和心得,欢迎一起探索前端。

相关文章

为了能完成这个目标,团队内部分享就成了这个目标的关键结果,那么如何在短时间内完成这项任务呢?下面分享下我的技巧。

明确主题

首先我们要明确公司需要什么?我们不能随便搞一个知识点去分享,这样没有人愿意去听,比如公司接下来可能会上前端监控系统,那么我们可以在先做一个技术调研,出一个《前端监控体系搭建要点》,比如公司接下来需要做小程序,那么我们可以出一个《小程序跨端实现方案探索》等,如果没有什么新的功能要开发,那么我们也可以谈一谈《前端性能优化》、《Typescript 快速上手》,总之要明确一个切合实际的目标。

巧用搜索引擎

确定好主题后,我们可以在技术社区搜索相关的技术文章,比如掘金、知乎、思否、微信公众号等, 比如直接在掘金搜索“性能优化” 然后按热度排序,就可以找到不错的文章。

掘金搜索性能优化

接下来我们需要根据这些文章中的内容制作 PPT

使用 markdown 来制作 PPT

程序员做 PPT 可能会浪费不少时间,所以我选择是 markdown 来制作 PPT,这里我分享 2 个工具

Marp for VS Code vscode 插件 Marp for VS Code 只用关注内容,简单分隔一下,就可以制作 PPT,看下 marp 官方文档可以很快学会用法,看看 jeremyxu 写的效果,项目地址:kubernetes 分享 PPT 源文件

Marp kubernetes 分享 PPT

二: Slidev 也可以让我们用 Markdown 写 PPT 的工具库

官网地址:https://sli.dev, 基于 Node.js、Vue.js 开发,而且它可以支持各种好看的主题、代码高亮、公式、流程图、自定义的网页交互组件,还可以方便地导出成 PDF 或者直接部署成一个网页使用。

  • 演讲者头像

当然还有很多酷炫的功能,比如说,我们在讲 PPT 的时候,可能想同时自己也出镜,Slidev 也可以支持。

演讲者头像

  • 演讲录制

Slidev 还支持演讲录制功能,因为它背后集成了 WebRTC 和 RecordRTC 的 API,

演讲录制

如何快速在团队内做一次技术分享?

前言相信很多小伙伴跟我一样,是一位奋斗在一线的业务开发,每天有做不完的任务,还有项目经理在你耳边催你,“这个功能今天能完成吗?”其实作为一名前端工程师,任务就是完成 Leader 的任务, 但公司实行...

AI识图:开启智能时代的图像识别新篇章

AI识图:开启智能时代的图像识别新篇章

  随着人工智能技术的不断发展,AI识图成为了智能时代的重要组成部分。AI识图借助先进的图像识别技术,实现了对物体、场景、文字等各类图像信息的准确识别和分析。这一技术的应用范...

SVG + 动画 实现一个有个性的404页面

前言我们的博客网站有时候 404 页面比较普通,我们可以通过 SVG 加动画来实现一个有个性的页面效果代码片段实现在undraw网站下载喜欢的插画,下载格式为 svg将 svg 转为 HTML 网页观...

AI在线图片识别技术:让百度的识图功能更强大

AI在线图片识别技术:让百度的识图功能更强大

  在现代社会,图片已经成为我们生活中不可或缺的一部分。然而,对于某些特定的图片,我们并不总是能够准确地理解它们所传递的信息。因此,百度推出了在线图片识别服务,通过人工智能技...

深度学习与机器学习的区别

深度学习与机器学习的区别

  随着信息时代的到来,人工智能的技术不断地在更新和发展。现在,每天都有新的人工智能技术被发明,这些技术不仅改善了人们的生活,也促进了社会的发展。在这篇文章中,我们将深入探讨...

AI抠图:让图片处理更高效简单

AI抠图:让图片处理更高效简单

  AI抠图技术的出现,让人们在图片处理方面又迈进了一步,为各个领域优化了效率和成本。相较于传统的图像处理方法,AI抠图技术不需要繁琐的手动处理,仅需在输入的图片上任选一段区...

发表评论    

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。