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

sxkk20082年前知识分享115

前言

本文承接上文 如何测试驱动开发 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 系列的下文。

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

相关文章

经过一晚上的配置和折腾,花钱不说,虽然可以实现在 iPad 上编程的需求,但体验远却比不上 PC,今天我就来推荐一种新的方式,让你的 iPad 变成真正的生产力工具。

Cloud Studio 简介

Cloud Studio 是基于浏览器的集成式开发环境(IDE),为开发者提供了一个永不间断的云端工作站。用户在使用 Cloud Studio 时无需安装,随时随地打开浏览器就能使用。

Cloud Studio iPad 编程 以上是我用 iPad 浏览器,在 1 分钟内初始化了一个 next 初始化模板,可以说速度比本地开发还快,当我在左侧修改代码时,右侧预览界面便会同步热更新。

Cloud Studio 安装 react snippts

Cloud Studio 可以说是提供了一台云服务器,并且把 VSCode 搬到了线上,我们可以同本地开发一样,在上面安装插件,比如可以在左侧扩展中搜索 react,安装这个 react snippts 扩展,便可以帮助我们提供常用代码片段,快速创建组件。

iPad 编程生产力

前言iPad 有个口号,就是“买前生产力,买后爱奇艺”,使用 iPad,配合 Procreate 来作画体验还可以, 如果你想让你的 iPad 可以编程,你却不得不为之花费时间和精力,我搜了网上的教程...

预防儿童小眼镜近视怎样关口前移?—聚焦儿童青少年眼健康

预防儿童小眼镜近视怎样关口前移?—聚焦儿童青少年眼健康

  眼睛,被人们喻为“心灵之窗”。2023年6月6日是第28个全国爱眼日。近年来,我国儿童青少年近视呈高发、低龄化趋势。预防“小眼镜”怎样关口前移?近视纠正服务市场如何标准?...

应用试客【必做平台】

钱咖ios,一个不错的苹果手赚平台,钱咖注册完善资料就有2元,任务单价都在1元以上,还有双倍任务,任务量也比其他平台多,满10元就能...

AI检测:开启新媒体时代的智能呼唤

AI检测:开启新媒体时代的智能呼唤

  AI(人工智能)技术的不断发展和普及,已经渗透到各行各业中。其中,AI检测作为新兴应用领域之一,正在改变我们的生活方式和工作方式。借助AI检测技术,我们能够更加高效准确地...

百度AI开放平台通用文字识别:高效便捷的智能识别技术助力未来

百度AI开放平台通用文字识别:高效便捷的智能识别技术助力未来

  随着科技的不断发展,人工智能技术已经逐渐应用于各个领域,并在文字识别方面取得了巨大进展。作为百度AI开放平台的一项核心技术,通用文字识别正以其高效便捷的特点,为各行各业提...

油库智能化-AI技术助力数字化转型

油库智能化-AI技术助力数字化转型

  随着数字化转型的推进,越来越多企业开始尝试引入AI技术,优化工作流程、提高效率。油库这一行业同样不例外。当前,油库智能化正处于蓬勃发展的阶段,AI技术也逐渐成为推动油库数...

发表评论    

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