之前记录过如何通过 Vitest 作为框架搭建单元测试的环境,但是 Vitest 仅作为运行环境提供了断言等测试API,如果要进行 DOM 测试,则还需要jsdom或者happy-dom等工具来进行dom解析。

Testing Library 就是基于jsdom的一款 DOM 测试工具,它提供了几乎所有主流前端框架的DOM测试工具,像React、Vue等。

环境配置

在vitest+React环境基础上安装DOM测试依赖,vitest的配置就不多说了

$ pnpm install -D @testing-library/react @testing-library/jest-dom jsdom

在根目录创建vitest-setup.js文件

import '@testing-library/jest-dom/vitest'

这个文件是为了引入Testing Library提供的对vitest断言的拓展,如果TS的项目类型报错需要额外配置下类型,新建一个vitest.d.ts(记得在tsconfig.json中添加include)。

这个能力Testing Library应该提供,但是没生效,好像是声明 declare module '@vitest/expect'匹配不上,需要自己拓展一下

import type { TestingLibraryMatchers } from '@testing-library/jest-dom'

declare module 'vitest' {
  interface Assertion<T = any> extends TestingLibraryMatchers<T> { }
  interface AsymmetricMatchersContaining extends TestingLibraryMatchers { }
}

然后继续配置vite.config.ts,添加测试相关的配置

/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@/': new URL('./src/', import.meta.url).pathname,
    }
  },
  test: {
    environment: 'jsdom',
    setupFiles: ['./vitest-setup.js'],
  },
})

下面来进行下测试

function App() {

  return (
    <>
      <h1>Vite + React</h1>
    </>
  )
}

import { it, describe, expect } from "vitest";
import { render, screen } from "@testing-library/react";

describe('index', () => {
  it('test', () => {
    expect(1 + 1).toBe(2)
  })

  it('dom', () => {
    render(<App />)
    expect(screen.getAllByText('Vite + React')[0].tagName).toBe('H1')
  })
})

可以正常通过测试

image-20240524225917402

API

在开始进行DOM单元测试学习之前,了解一下一些一些常用的API是很有必要的。Testing Libaray中最核心的API包括元素查询和事件控制,这也是前端交互中的主要内容。

查询

查询有三种类型,其作用都是查找指定的元素,区别如下:

  • get:查询不到元素时会抛出异常;
  • find:查询结果是一个Promise,如果查找不到会重试(默认超时时间1000ms),适用于一些异步渲染的场景;
  • query:查找不到元素不会抛出异常,而是返回null。

这三种元素还有查询多个元素的方法getAllfindAllqueryAll,返回结果时元素组成的数组。

从查询的纬度来看,有以下几种类型(都可以和上面的6种前缀组合):

  • ByLableText(label, options):通过label标签查询元素,可以是for绑定的元素也可以是嵌套的元素;
  • ByPlaceholderText(placeholder, options):通过占位提示文本查询表单元素;
  • ByText(text, options):通过文本节点的内容查询;可以通过options.ignore设置忽略的标签,默认是’script, style’。
  • ByDisplayValue(value, options):通过显示的值来选择表单元素,通常是input, textareaselect
  • ByAltText(alt, options):通过alt属性查询元素,通常是img标签;
  • ByTitle(title, options):通过title属性来查询元素,svg中的title也可以查到;
  • ByTestId(testId, options):通过testid来查询元素,例如<div data-testid="custom-element" />这样的元素;
  • ByRole:这是最复杂也是功能最灵活的一种查询方式,通过WAI-ARIA 规范中定义的 roles 来进行查询。直接看文档吧,内容太多,这里只言片语也讲不清楚。

除了ByRole之外的每种查询都可以通过options.exact设置绝对匹配,默认为true。

事件

在选择玩元素之后,可以通过原生的 Events API 或者 @testing-library/user-event(推荐)提供的API来触发事件。

通过fireEvent(dom, event)可以模拟用户触发操作,通过手动创建事件对象在指定的dom元素上触发。

例如我们有一个计数器组件,每次点击时count都会加一,此时我们可以通过fireEvent来进行测试验证

describe("event", () => {
  beforeAll(() => {
    render(<App />, { wrapper: BrowserRouter })
  })

  it("fireEvent click", () => {
    expect(screen.getByTitle('displayCount').innerHTML).toBe('count: 0')

    const button = screen.getByTestId('test-button')
    fireEvent(button, new MouseEvent('click', { bubbles: true, cancelable: true }))
    expect(screen.getByTitle('displayCount').innerHTML).toBe('count: 1')
  });
})

fireEvent可以通过fireEvent[eventName](dom, eventProperties)的形式来快速触发事件,我们可以在刚才的基础上补充一个这样的用例

describe("event", () => {
  // ......
  
  it('quickly event', () => {
    expect(screen.getByTitle('displayCount').innerHTML).toBe('count: 1')

    const button = screen.getByTestId('test-button')
    fireEvent.click(button)
    expect(screen.getByTitle('displayCount').innerHTML).toBe('count: 2')
  })
})

这种方式比第一种更加灵活,不需要构建事件对象,只需要传入关键的事件属性即可。

另外,可以通过createEvent[eventName](dom, eventProperties)方法快速创建事件作为fireEvent(dom, event) 中的event参数。当需要访问event事件上的属性,但是想通过事件属性对象快速创建时,这种方式就很有用了。

上面就是fireEvent的用法,虽然并不算复杂,但是官方更推荐使用user-event

常见场景测试

React测试主要是对以下场景进行验证,例如:

  1. 方法测试:主要是对一些通用工具方法的测试,一般不涉及DOM,是纯JS逻辑的验证;
  2. Hooks 测试:Hooks 是React通用能力的封装,Hooks的正确与否影响了众多引用者的正确运行;
  3. 路由测试:前端路由是现代前端应用的核心能力了,正确的路由跳转是非常关键的;
  4. 组件测试:组件测试是最复杂的,需要对DOM进行全方位测试,有以下场景
    • 测试页面加载:确保页面是否成功加载,并且所有必要的 DOM 元素是否存在。
    • 属性操作:验证针对特定属性(如 class、style、innerText 等)进行读写操作时是否正确。
    • 事件绑定和触发:检查事件绑定功能是否正常工作,以及触发事件后相关行为是否符合预期。数据更新和展示:确认数据或状态变化时页面上相应 DOM 的更新情况。
    • 表单交互:测试表单元素(如 input、textarea、select)输入与提交的情况,包括内容校验和提交数据验证等方面。
    • 异步加载内容处理: 当涉及到异步请求获取内容渲染,则需包含同样异步回调函数

如果在测试组件遇到了TypeError: window.matchMedia is not a function错误,可以在vitest的setup文件中添加以下内容,这个时jest-dom官方的建议。

Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation(query => ({
   matches: false,
   media: query,
   onchange: null,
   addListener: vi.fn(), // Deprecated
   removeListener: vi.fn(), // Deprecated
   addEventListener: vi.fn(),
   removeEventListener: vi.fn(),
   dispatchEvent: vi.fn(),
  })),
});

下面就来对一些常见的场景进行代码示例

Router

使用React Router作为路由工具时,我们可以很方便的对路由层面进行测试。我们以最基础的项目进行测试,现在的App内容如下

export default function App() {
  return (
    <Routes>
      <Route path="/" element={<Layout />}>
        <Route index element={<Home />} />
        <Route path="about" element={<About />} />
        <Route path="*" element={<NotFoundPage />} />
      </Route>
    </Routes>
  );
}

其中对应的三个页面组件都只是简单的文本渲染,就不贴了,Layout中定义了声明式的跳转按钮,渲染效果如下

image-20240526220216049

针对这个页面我们可以进行以下内容的测试

import App from "@/App";
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it } from "vitest";
import { BrowserRouter } from "react-router-dom";
import userEvent from '@testing-library/user-event';
import { createBrowserHistory } from "history";

describe('routes test', () => {
  beforeAll(() => {
    render(<App />, { wrapper: BrowserRouter })
  })

  it('home', () => {
    expect(location.pathname).toBe('/')
    expect(document).toContain(screen.queryByText(/home page/i))
  })

  it('route jump', async () => {
    const user = userEvent.setup()

    await user.click(screen.getByText(/about/i, { selector: 'a' }))
    expect(location.pathname).toBe('/about')
    expect(document).toContain(screen.queryByText(/about page/i))
  })

  it('not found', () => {
    const history = createBrowserHistory()
    // 命令式路由跳转
    history.push('/abc')
    expect(location.pathname).toBe('/abc')
    // 重新渲染
    render(<App />, { wrapper: BrowserRouter })
    expect(document).toContain(screen.queryByText(/404/i, { selector: 'h1' }))
  })
})

第一个用例中我们对首页内容进行了验证,第二个用例我们验证了路由跳转的交互正常,第三个用例我们用命令式的路由跳转验证了NotFound时的渲染是否正确。

🌟render(ui[, options])

对Core API的render进行了React层面的适配,用于渲染第一个参数ui(React组件)的内容到document.body中,第二个参数是一个options,配置了渲染函数的一些参数:

  • container:默认情况下会创建一个div渲染到document.body中,在某些时候例如测试<tr><td>等标签时,可以将container声明为一个<table>元素。
  • baseElement:声明渲染的根元素,默认是document.body。
  • hydrate:在服务端渲染时设为true,可以使用ReactDOM.hydrate进行渲染。
  • wrapper:传递一个React组件作为包装器选项,一般是用于Provider的包裹。
  • queries:用于自定义一些查询函数或者覆盖默认的查询函数,如果不设置则使用默认,在某些场景下我们可能会通过以下自定义dom属性来进行查询,这时使用自定义queries将会非常有用。

🌟screen

当前“屏幕”渲染的内容,可以通过内置的一些查询函数来获取渲染的内容。例如getByText(container, text),在screen下调用可以自动设置container为screen从而忽略container参数,screen.getByText(text)。如果自定义函数也想通过screen这种形式调用,可以通过within函数进行包装,就像bind那样。

事件调用

有些组件需要传递函数来作为某些时机的绑定函数,比如Button组件,我们需要验证这些函数的执行状态,例如我们有这样一个简单的Button组件:

import { PropsWithChildren } from "react";
import styles from './index.module.less'
import classnames from 'classnames'

type ButtonType = 'primary' | 'default' | 'danger';

interface IProps {
  onClick: () => void;
  type?: ButtonType;
  size?: 'small' | 'middle' | 'large';
}

export default function Button(props: PropsWithChildren<IProps & Record<string, unknown>>) {
  const { onClick, type = 'default', size = 'middle', children, ...rest } = props;

  return (
    <button className={
      classnames(
        styles.button,
        styles['button-' + type],
        styles['button-' + size])
    }
      onClick={onClick}
      {...rest}
    >
      {children}
    </button>
  )
}

在进行单元测试时,我们只需要关注逻辑部分,也就是onClick的执行状态(样式问题我们应该交给E2E测试来完成),我们可以通过Vitest Mock一个函数,将这个函数传递给组件,函数内部会记录调用状态,如下

describe("Button", () => {
  it("should render", async () => {
    const handler = vi.fn() // mock 函数
    const user = userEvent.setup()

    render(<Button onClick={handler} type="primary" size="large">click</Button>)

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

    expect(button).toBeInTheDocument() // 是否渲染

    await user.click(button) // 点击
    expect(handler).toBeCalled() // 被调用

    await user.click(button) // 再次点击
    expect(handler).toBeCalledTimes(2) // 调用两次
  });
})

Hooks

Hooks 的测试依赖于Testing Library的renderHook方法,通过返回的对象来获取实时的值。例如我们有一个倒计时的hook如下

import { useState, useEffect } from 'react';

const Countdown = (initialSeconds: number) => {
  const [seconds, setSeconds] = useState(initialSeconds);

  useEffect(() => {
    if (seconds > 0) {
      const timer = setTimeout(() => setSeconds(seconds - 1), 1000);
      return () => clearTimeout(timer);
    }
  }, [seconds]);

  return { seconds }
};

export default Countdown;

renderHook(render[, options]):{rerender, result, unmount}

用于模拟Hooks的渲染,接收两个参数,第一个是执行hook的函数,第二个是一个配置对象,返回一个处理后的对象,因为不会像React真实的执行过程中刷新变量,所以通过对象属性的形式返回结果,就像Ref一样。返回值有三个属性,分别对应重新渲染、执行结果和解绑(解绑可用于验证useEffect的解绑逻辑)。

options有两个属性,initialProps和wrapper,其中wrapper和redner函数中是一样的,指定hook执行的组件容器,initialProps用于指定Hook首次执行时传入的参数,如果rerender时不穿入参数则会按照没有穿入参数更新返回值。

针对于这个Hook,我们要验证Hook在每个时间段的seconds是否正确。

import { renderHook } from '@testing-library/react';
import useTimeout from '@/hooks/useTimeout';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

describe('useTimeout Hook', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })

  afterEach(() => {
    vi.restoreAllMocks()
  })

  it('should display the correct countdown text', async () => {
    const { result } = renderHook(() => useTimeout(5));
    expect(result.current.seconds).toBe(5);

    await vi.advanceTimersByTimeAsync(2000); // 模拟时间过去2秒
    expect(result.current.seconds).toBe(3);

    await vi.advanceTimersByTimeAsync(3000); // 模拟时间过去3秒
    expect(result.current.seconds).toBe(0);
  });

});

Table

Table 不论在B端还是C端,都是一种常见的数据展示形式,表格是一种很直观的数据展示形式,可以把数据明确地告知用户。

但是这些Table页面往往夹杂着网络请求,且数据不确定,使用真实请求的话非常不好确定单测用例。但是别担心,vitest提供了可以拦截请求的方法。

例如我们有这样一个简单的Table页面

export default function User() {
  const [data, setData] = useState([])

  function load(pageNumber = 1, pageSize = 10) {
    fetch(`https://example.com/api/?page=${pageNumber}&results=${pageSize}`)
      .then(res => res.json())
      .then(res => {
        setData(res.data)
      })
  }

  useEffect(() => {
    load(1, 10)
  }, [])

  return (
    <Table columns={[
      {
        title: '姓名',
        dataIndex: 'name',
        key: 'name',
      },
      {
        title: '年龄',
        dataIndex: 'age',
        key: 'age',
      },
      {
        title: '住址',
        dataIndex: 'address',
        key: 'address',
      },
    ]}
      dataSource={data}
      pagination={{
        pageSize: 10,
      }}
      onChange={({ pageSize, current }) => load(current, pageSize)}
    />
  )
}

表格中的数据源自fetch接口响应,我们可以通过vi.spyOn来拦截fetch请求

describe('User Page', () => {
  const fetchSpy = vi.spyOn(globalThis, 'fetch');

  beforeAll(() => {
    const res = new Response(JSON.stringify({
      ok: true,
      data: [
        { name: 'John', age: 13, address: 'China/Beijing' },
        { name: 'Amy', age: 20, address: 'Japan/Tokyo' },
      ],
    }), {
      headers: {
        "Content-Type": "application/json"
      }
    })
    fetchSpy.mockReturnValue(Promise.resolve(res));
  });

  afterAll(() => {
    fetchSpy.mockRestore();
  });

  it('request data', async () => {
    render(<User />)
    await waitFor(() => {

      const table = screen.getByRole('table');

      // 检查表头内容
      const headerRow = table.querySelector('thead tr');
      expect(headerRow).toHaveTextContent('姓名');
      expect(headerRow).toHaveTextContent('年龄');
      expect(headerRow).toHaveTextContent('住址');

      // 检查第一行数据内容
      const firstDataRow = table.querySelector('tbody tr:first-child');
      expect(firstDataRow?.querySelector('td:nth-child(1)')).toHaveTextContent('John');
      expect(firstDataRow?.querySelector('td:nth-child(2)')).toHaveTextContent('13');
      expect(firstDataRow?.querySelector('td:nth-child(3)')).toHaveTextContent('China/Beijing');
    })
  })
})

我们在测试开始之前手动构造了一个Response实例,并Mock了fetch方法的返回值,当组件渲染时触发了fetch,接收到了我们预设的数据,此时就可以根据我们预设的数据进行验证了。

image-20240530120157014

Form

Form 是数据输入的载体,这里以Antd Form组件为例,要注意的是Antd的Select组件实现并不是基于select元素实现的,所以不能使用传统的select事件来触发交互,要通过模拟鼠标点击来进行测试。

例如我们有以下一个简单的表单组件

export default function CreateUser(props: IProps) {
  const { onFinish } = props;

  return (
    <Form onFinish={onFinish}>
      <Form.Item name="name" label="姓名">
        <Input />
      </Form.Item>
      <Form.Item name="age" label="年龄">
        <Input />
      </Form.Item>
      <Form.Item name="sex" label="性别">
        <Select
          options={[
            { label: '男', value: 'man' },
            { label: '女', value: 'woman' },
          ]}
        />
      </Form.Item>

      <Form.Item>
        <Button type="primary" htmlType="submit">
          创建
        </Button>
      </Form.Item>
    </Form>
  )
}

这里我们用了外部传进来的请求函数,更多的场景下接口一般是和表单绑定的,那样的话可以通过上一小节Table那种拦截请求的方式来进行测试

表单的内容非常简单,有两个Input输入框和一个Select选择框,我们的思路是模拟输入两串文本,然后通过点击行为选择到一个值,最后我们将收集到的值于预期的值进行比较。

describe('form', () => {
  it('form submit', async () => {
    const handler = vi.fn()
    render(<CreateUser onFinish={handler} />)

    const user = userEvent.setup()

    // 输入
    await user.type(screen.getByLabelText('姓名'), 'feng')
    await user.type(screen.getByLabelText('年龄'), '16')

    // 选择
    await user.click(screen.getByRole('combobox', { name: '性别' }))
    await user.click(screen.getByText('女'))

    // 提交
    await user.click(screen.getByRole('button', { name: '创 建' }))

    await waitFor(() => {
      expect(handler).toBeCalledWith({
        name: 'feng',
        age: '16',
        sex: 'woman'
      })
    })
  })
})

通过以上的场景,你应该已经了解到了DOM Test应该怎么做了。Testing Library 是一个强大且灵活的测试工具库,它通过鼓励开发者编写更贴近用户交互的测试用例,提高了测试代码的可读性和稳定性。使用 Testing Library 可以帮助我们构建更健壮、可靠且易于维护的前端应用程序,在保证功能正确性的同时促进代码质量和开发效率。因此,掌握好 Testing Library 将成为每个前端开发者值得投入精力学习和实践的重要技能之一。


前端小白