跳到内容

c

测试React 应用

有许多不同的方法可以测试React应用程序。让我们来看看它们。

本课程以前使用了Facebook开发的Jest库来测试React组件。我们现在使用来自Vite开发人员的新一代测试工具,称为Vitest。除了配置之外,这两个库提供了相同的编程接口,因此在测试代码中几乎没有任何区别。

让我们首先安装Vitest和模拟Web浏览器的jsdom库:

npm install --save-vitest vitest jsdom

除了Vitest之外,我们还需要另一个测试库,用于帮助我们渲染组件进行测试。目前最好的选择是react-testing-library,它在最近的时间内迅速增长了人气。还值得使用jest-dom库扩展测试的表达能力。

让我们使用以下命令安装这些库:

npm install --save-dev @testing-library/react @testing-library/jest-dom

在我们进行第一个测试之前,我们需要进行一些配置。

我们在package.json文件中添加一个脚本来运行测试:

{
  "scripts": {
    // ...
    "test": "vitest run"
  }
  // ...
}

让我们在项目根目录中创建一个名为testSetup.js的文件,内容如下:

import { afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
import '@testing-library/jest-dom/vitest'

afterEach(() => {
  cleanup()
})

现在,在每个测试之后,将执行reset函数,该函数重置了模拟浏览器的jsdom。

vite.config.js文件扩展如下:

export default defineConfig({
  // ...
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './testSetup.js', 
  }
})

通过设置 globals: true ,我们无需在测试中导入关键字,如 describetestexpect

让我们首先为负责渲染注释的组件编写测试:

const Note = ({ note, toggleImportance }) => {
  const label = note.important
    ? 'make not important'
    : 'make important'

  return (
    <li className='note'>      {note.content}
      <button onClick={toggleImportance}>{label}</button>
    </li>
  )
}

请注意,li元素的CSS属性className的值为note,可以用于在我们的测试中访问该组件。

Rendering the component for tests

我们将在与组件本身位于同一目录的 src/components/Note.test.js 文件中编写测试。

第一个测试验证组件是否呈现了注释的内容:

import { render, screen } from '@testing-library/react'
import Note from './Note'

test('renders content', () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  render(<Note note={note} />)

  const element = screen.getByText('Component testing is done with react-testing-library')
  expect(element).toBeDefined()
})

在初始配置之后,测试使用了由react-testing-library提供的 render 函数来渲染组件:

render(<Note note={note} />)

通常,React组件会渲染到 DOM 中。我们使用的 render 方法以适合测试的格式渲染组件,而无需将其渲染到DOM中。

我们可以使用 screen 对象来访问渲染的组件。我们使用screen的 getByText 方法来搜索具有注释内容的元素,并确保它存在:

  const element = screen.getByText('Component testing is done with react-testing-library')
  expect(element).toBeDefined()

使用Vitest的 expect 命令来检查元素的存在性。expect从其参数生成断言,可以使用各种条件函数来测试其有效性。现在,我们使用了 toBeDefined ,它测试expect的 element 参数是否存在。

使用命令npm test运行测试:

$ npm test

> notes-frontend@0.0.0 test
> vitest


 DEV  v1.3.1 /Users/mluukkai/opetus/2024-fs/part3/notes-frontend

 ✓ src/components/Note.test.jsx (1)
   ✓ renders content

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  17:05:37
   Duration  812ms (transform 31ms, setup 220ms, collect 11ms, tests 14ms, environment 395ms, prepare 70ms)


 PASS  Waiting for file changes...

Eslint在测试中抱怨关键字testexpect。可以通过安装eslint-plugin-vitest-globals来解决这个问题:

npm install --save-dev eslint-plugin-vitest-globals

然后通过编辑.eslint.cjs文件来启用插件,如下所示:

module.exports = {
  root: true,
  env: {
    browser: true,
    es2020: true,
    "vitest-globals/env": true  },
  extends: [
    'eslint:recommended',
    'plugin:react/recommended',
    'plugin:react/jsx-runtime',
    'plugin:react-hooks/recommended',
    'plugin:vitest-globals/recommended',  ],
  // ...
}

Test file location

在 React 中,测试文件的位置至少有 两种不同的约定。我们按照当前标准创建了我们的测试文件,将它们放在与被测组件相同的目录中。

另一个约定是将测试文件“正常”存储在单独的 test 目录中。无论我们选择哪种约定,几乎可以肯定会有人认为是错误的。

我不喜欢将测试和应用程序代码存储在同一目录中的这种方式。我们选择遵循此约定的原因是它在由 Vite 或 create-react-app 创建的应用程序中默认配置。

Searching for content in a component

react-testing-library 包提供了多种不同的方法来调查被测组件的内容。实际上,我们的测试中的 expect 根本不需要:

import { render, screen } from '@testing-library/react'
import Note from './Note'

test('renders content', () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  render(<Note note={note} />)

  const element = screen.getByText('Component testing is done with react-testing-library')

  expect(element).toBeDefined()})

如果 getByText 没有找到它正在查找的元素,测试将失败。

们还可以使用 CSS 选择器 通过使用 querySelector 方法来查找呈现的元素对象 container,这是 render 返回的字段之一:

import { render, screen } from '@testing-library/react'
import Note from './Note'

test('renders content', () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  const { container } = render(<Note note={note} />)
  const div = container.querySelector('.note')  expect(div).toHaveTextContent(    'Component testing is done with react-testing-library'  )})

注意:选择元素的更一致方法是使用 [数据属性](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Global_attributes/data-*),该属性专门为测试目的而定义。使用 react-testing-library,我们可以利用 getByTestId 方法来选择具有指定 data-testid 属性的元素。

Debugging tests

在编写测试时,我们通常会遇到许多不同类型的问题。

对象 screen 具有方法 debug,可用于将组件的 HTML 打印到终端。如果我们按如下方式更改测试:

import { render, screen } from '@testing-library/react'
import Note from './Note'

test('renders content', () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  render(<Note note={note} />)

  screen.debug()
  // ...

})

HTML被打印到控制台:

console.log
  <body>
    <div>
      <li
        class="note"
      >
        Component testing is done with react-testing-library
        <button>
          make not important
        </button>
      </li>
    </div>
  </body>

也可以使用相同的方法将所需元素打印到控制台:

import { render, screen } from '@testing-library/react'
import Note from './Note'

test('renders content', () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }

  render(<Note note={note} />)

  const element = screen.getByText('Component testing is done with react-testing-library')

  screen.debug(element)
  expect(element).toBeDefined()
})

现在打印出所需元素的HTML:

  <li
    class="note"
  >
    Component testing is done with react-testing-library
    <button>
      make not important
    </button>
  </li>

Clicking buttons in tests

除了显示内容之外,Note 组件还确保在按下与注释关联的按钮时调用 toggleImportance 事件处理程序函数。

让我们安装一个库 user-event,它使模拟用户输入变得更容易:

npm install --save-dev @testing-library/user-event

可以像这样测试此功能:

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'import Note from './Note'

// ...

test('clicking the button calls event handler once', async () => {
  const note = {
    content: 'Component testing is done with react-testing-library',
    important: true
  }
  
  const mockHandler = vi.fn()
  render(
    <Note note={note} toggleImportance={mockHandler} />  )

  const user = userEvent.setup()  const button = screen.getByText('make not important')  await user.click(button)
  expect(mockHandler.mock.calls).toHaveLength(1)})

此测试有一些与之相关的有趣之处。事件处理程序是一个使用 Vitest 定义的 mock 函数:

const mockHandler = vi.fn()

session 启动以与呈现的组件进行交互:

const user = userEvent.setup()

测试基于呈现组件的文本找到按钮并单击元素:

const button = screen.getByText('make not important')
await user.click(button)

单击使用 userEvent 库的 click 方法进行。

测试的期望使用 toHaveLength 来验证mock 函数已被调用一次:

expect(mockHandler.mock.calls).toHaveLength(1)

Mock 对象和函数 是测试中常用的 stub 组件,用于替换被测组件的依赖项。Mocks 可以返回硬编码的响应,并验证 mock 函数被调用的次数以及使用什么参数。

在我们的示例中,mock 函数是一个完美的选择,因为它可以很容易地用于验证该方法被调用一次。

让我们为Togglable组件编写一些测试。让我们将togglableContent CSS 类名添加到返回子组件的 div。

Tests for the Togglable component

让我们为Togglable组件编写一些测试。让我们将togglableContent CSS 类名添加到返回子组件的 div。

const Togglable = forwardRef((props, ref) => {
  // ...

  return (
    <div>
      <div style={hideWhenVisible}>
        <button onClick={toggleVisibility}>
          {props.buttonLabel}
        </button>
      </div>
      <div style={showWhenVisible} className="togglableContent">        {props.children}
        <button onClick={toggleVisibility}>cancel</button>
      </div>
    </div>
  )
})

测试如下所示:

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Togglable from './Togglable'

describe('<Togglable />', () => {
  let container

  beforeEach(() => {
    container = render(
      <Togglable buttonLabel="show...">
        <div className="testDiv" >
          togglable content
        </div>
      </Togglable>
    ).container
  })

  test('renders its children', async () => {
    await screen.findAllByText('togglable content')
  })

  test('at start the children are not displayed', () => {
    const div = container.querySelector('.togglableContent')
    expect(div).toHaveStyle('display: none')
  })

  test('after clicking the button, children are displayed', async () => {
    const user = userEvent.setup()
    const button = screen.getByText('show...')
    await user.click(button)

    const div = container.querySelector('.togglableContent')
    expect(div).not.toHaveStyle('display: none')
  })
})

beforeEach 函数在每个测试之前调用,然后渲染Togglable组件并保存返回值的 container 字段。

第一个测试验证Togglable组件是否渲染其子组件

<div className="testDiv">
  togglable content
</div>

其余的测试使用 toHaveStyle 方法来验证Togglable组件的子组件最初不可见,方法是检查div元素的样式是否包含 { display: 'none' }。另一个测试验证当按下按钮时组件可见,这意味着隐藏它的样式不再分配给组件。

我们还可以添加一个测试,该测试可用于验证可以通过单击组件的第二个按钮来隐藏可见的内容:

describe('<Togglable />', () => {

  // ...

  test('toggled content can be closed', async () => {
    const user = userEvent.setup()
    const button = screen.getByText('show...')
    await user.click(button)

    const closeButton = screen.getByText('cancel')
    await user.click(closeButton)

    const div = container.querySelector('.togglableContent')
    expect(div).toHaveStyle('display: none')
  })
})

Testing the forms

我们在之前的测试中已经使用了 user-eventclick 函数来单击按钮。

const user = userEvent.setup()
const button = screen.getByText('show...')
await user.click(button)

我们还可以使用userEvent模拟文本输入。

让我们为NoteForm组件做一个测试。组件的代码如下。

import { useState } from 'react'

const NoteForm = ({ createNote }) => {
  const [newNote, setNewNote] = useState('')

  const handleChange = (event) => {
    setNewNote(event.target.value)
  }

  const addNote = (event) => {
    event.preventDefault()
    createNote({
      content: newNote,
      important: true,
    })

    setNewNote('')
  }

  return (
    <div className="formDiv">
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <input
          value={newNote}
          onChange={handleChange}
        />
        <button type="submit">save</button>
      </form>
    </div>
  )
}

export default NoteForm

该表单通过调用作为道具 createNote 接收到的函数来工作,其中包含新便笺的详细信息。

测试如下:

import { render, screen } from '@testing-library/react'
import NoteForm from './NoteForm'
import userEvent from '@testing-library/user-event'

test('<NoteForm /> updates parent state and calls onSubmit', async () => {
  const createNote = vi.fn()
  const user = userEvent.setup()

  render(<NoteForm createNote={createNote} />)

  const input = screen.getByRole('textbox')
  const sendButton = screen.getByText('save')

  await user.type(input, 'testing a form...')
  await user.click(sendButton)

  expect(createNote.mock.calls).toHaveLength(1)
  expect(createNote.mock.calls[0][0].content).toBe('testing a form...')
})

测试使用函数 getByRole 访问输入字段。

userEvent 的 type 方法用于向输入字段写入文本。

第一个测试期望确保提交表单会调用 createNote 方法。 第二个期望检查事件处理程序是否使用正确的参数调用——即在填写表单时创建具有正确内容的便笺。

值得注意的是,好的旧 console.log 在测试中照常工作。例如,如果您想查看 mock 对象存储的调用是什么样的,可以执行以下操作

test('<NoteForm /> updates parent state and calls onSubmit', async() => {
  const user = userEvent.setup()
  const createNote = vi.fn()

  render(<NoteForm createNote={createNote} />)

  const input = screen.getByRole('textbox')
  const sendButton = screen.getByText('save')

  await user.type(input, 'testing a form...')
  await user.click(sendButton)

  console.log(createNote.mock.calls)})

In the middle of running the tests, the following is printed 在运行测试的过程中,打印以下内容

[ [ { content: 'testing a form...', important: true } ] ]

About finding the elements

让我们假设表单有两个输入字段

const NoteForm = ({ createNote }) => {
  // ...

  return (
    <div className="formDiv">
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <input
          value={newNote}
          onChange={handleChange}
        />
        <input          value={...}          onChange={...}        />        <button type="submit">save</button>
      </form>
    </div>
  )
}

现在我们的测试用于查找输入字段的方法

const input = screen.getByRole('textbox')

会导致错误:

node error that shows two elements with textbox since we use getByRole

错误消息建议使用getAllByRole。测试可以修复如下:

const inputs = screen.getAllByRole('textbox')

await user.type(inputs[0], 'testing a form...')

方法getAllByRole现在返回一个数组,正确的输入字段是数组的第一个元素。然而,这种方法有点可疑,因为它依赖于输入字段的顺序。

通常输入字段有一个占位符文本,提示用户期望哪种类型的输入。让我们在表单中添加一个占位符:

const NoteForm = ({ createNote }) => {
  // ...

  return (
    <div className="formDiv">
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <input
          value={newNote}
          onChange={handleChange}
          placeholder='write note content here'        />
        <input
          value={...}
          onChange={...}
        />    
        <button type="submit">save</button>
      </form>
    </div>
  )
}

现在使用 getByPlaceholderText 方法可以轻松找到正确的输入字段:

test('<NoteForm /> updates parent state and calls onSubmit', () => {
  const createNote = vi.fn()

  render(<NoteForm createNote={createNote} />) 

  const input = screen.getByPlaceholderText('write note content here')  const sendButton = screen.getByText('save')

  userEvent.type(input, 'testing a form...')
  userEvent.click(sendButton)

  expect(createNote.mock.calls).toHaveLength(1)
  expect(createNote.mock.calls[0][0].content).toBe('testing a form...')
})

在测试中查找元素的最灵活的方法是querySelector方法,它是 container 对象的方法,由 render 返回,如 本部分前面 所述。任何 CSS 选择器都可以与此方法一起用于搜索测试中的元素。

例如,考虑我们为输入字段定义一个唯一的 id

const NoteForm = ({ createNote }) => {
  // ...

  return (
    <div className="formDiv">
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <input
          value={newNote}
          onChange={handleChange}
          id='note-input'        />
        <input
          value={...}
          onChange={...}
        />    
        <button type="submit">save</button>
      </form>
    </div>
  )
}

现在可以在测试中找到输入元素,如下所示:

const { container } = render(<NoteForm createNote={createNote} />)

const input = container.querySelector('#note-input')

但是,我们将坚持在测试中使用 getByPlaceholderText 的方法。

在继续之前,让我们看一些细节。让我们假设一个组件会将文本渲染到 HTML 元素,如下所示:

const Note = ({ note, toggleImportance }) => {
  const label = note.important
    ? 'make not important' : 'make important'

  return (
    <li className='note'>
      Your awesome note: {note.content}      <button onClick={toggleImportance}>{label}</button>
    </li>
  )
}

export default Note

测试使用的 getByText 方法找不到元素

test('renders content', () => {
  const note = {
    content: 'Does not work anymore :(',
    important: true
  }

  render(<Note note={note} />)

  const element = screen.getByText('Does not work anymore :(')

  expect(element).toBeDefined()
})

getByText 方法查找具有相同文本的元素作为其参数,仅此而已。如果我们想查找包含文本的元素,我们可以使用额外的选项:

const element = screen.getByText(
  'Does not work anymore :(', { exact: false }
)

或者我们可以使用 findByText 方法:

const element = await screen.findByText('Does not work anymore :(')

重要的是要注意,与其他 ByText 方法不同,findByText 返回一个 Promise!

在某些情况下,queryByText 方法的另一种形式很有用。该方法返回元素,但如果没有找到它,则不会引发异常

例如,我们可以使用该方法来确保没有向组件呈现某些内容:

test('does not render this', () => {
  const note = {
    content: 'This is a reminder',
    important: true
  }

  render(<Note note={note} />)

  const element = screen.queryByText('do not want this thing to be rendered')
  expect(element).toBeNull()
})

Test coverage

我们可以通过使用以下命令运行测试来轻松找出我们测试的覆盖率

npm test -- --coverage

当你第一次运行该命令时,Vitest 会询问你是否要安装必需的库 @vitest/coverage-v8。安装它,然后再次运行该命令:

terminal output of test coverage

HTML 报告将生成到coverage目录。 该报告将告诉我们每个组件中未测试代码的行:

HTML report of the test coverage

你可以在 这个 GitHub 仓库part5-8分支中找到我们当前应用程序的完整代码。

Frontend integration tests

在课程材料的上一部分中,我们为后端编写了集成测试,该测试测试了它的逻辑并通过后端提供的 API 连接数据库。在编写这些测试时,我们有意识地决定不编写单元测试,因为该后端代码相当简单,并且我们应用程序中的错误很可能发生在比单元测试更适合的更复杂的情况下。

到目前为止,我们对前端的所有测试都是单元测试,这些测试验证了各个组件的正确功能。单元测试有时很有用,但即使是全面的单元测试套件也不足以验证应用程序作为一个整体是否正常工作。

我们还可以对前端进行集成测试。集成测试测试了多个组件的协作。这比单元测试困难得多,因为我们必须模拟来自服务器的数据。 我们选择专注于进行端到端测试来测试整个应用程序。我们将在本部分的最后一章中进行端到端测试。

Snapshot testing

Vitest 提供了一种与“传统”测试完全不同的替代方案,称为快照测试。快照测试的有趣之处在于,开发人员自己不需要定义任何测试,采用快照测试很简单。

基本原则是将组件更改后定义的 HTML 代码与更改前存在的 HTML 代码进行比较。

如果快照注意到组件定义的 HTML 中发生了一些变化,那么它要么是新功能,要么是意外造成的“错误”。快照测试会通知开发人员组件的 HTML 代码是否发生变化。开发人员必须告诉 Jest 更改是需要的还是不需要的。如果对 HTML 代码的更改是意外的,那么它强烈暗示存在错误,并且开发人员可以轻松地通过快照测试了解这些潜在问题。