Jest 测试框架提升笔记

本文在 Jest 测试框架入门笔记 - 掘金 (juejin.cn) 的基础之上,深入 Jest 测试的各个方面,涉及元素渲染、选择;数据,模块 Mock 等内容。读完本文基本上就可以将 Jest 单元测试应用到实际工作中了。

1. 常用的第三方测试库

常见的测试库的作用:

2. 先睹为快

渲染、查找、比较

模拟用户事件

处理异步或者等待网络数据

3. 测试目标

带有特定后缀的文件或者特殊名称的文件夹是我们的测试目标。

4. 查找元素的方式

常见的选择函数有:

  1. getBy: getByRole getByText
  2. getAllBy: getAllByText getAllByRole
  3. queryBy: queryByDisplayName queryByTitle
  4. queryAllBy: queryAllByTitle queryAllByText
  5. findBy: findByRole findByText
  6. findAllBy: findAllByText findAllByDisplayValue

选择单个和选择多个元素的函数及其返回值:

Looking for a Single Element?

Name 0 matches 1 match >1 match Notes
getBy* Throw Element Throw Looks for an element over the span of 1 second
queryBy* null Element Throw
findBy* Throw (after timeout) Element Throw (after timeout) Looks for an element over the span of 1 second

Looking for Multiple Elements?

Name 0 matches 1 match >1 match Notes
getAllBy* Throw [Element] [Element] Looks for elements over the span of 1 second
queryAllBy* Throw [Element] [Element]
findAllBy* Throw (after timeout) [Element] [Element] Looks for elements over the span of 1 second, returns a Promise

When to use each

Goal of test Prove an element exists Prove an element does not exist Make sure an element eventually exists Use
getBy , getAllBy X Use when you need to assert that an element or elements exist immediately.
queryBy , queryAllBy X Use when you want to check for the absence of an element without throwing an error.
findBy , findAllBy X Use when you need to wait for an element or elements to appear in the DOM over time.

Notes:

  • getBy* and getAllBy* will throw an error if the element(s) do not exist, making them suitable for proving existence.
  • queryBy* and queryAllBy* will not throw an error if the element(s) do not exist, returning null or an empty array [], respectively, which is useful for proving non-existence.
  • findBy* and findAllBy* return a Promise and will retry until the element(s) are found or a timeout is reached, making them ideal for checking eventual existence.

4.1. getBy, queryBy, findBy 异常处理

我们以选择元素的时候的报错为例,说明测试环境下出错之后应该怎么处理。

这是我们用于测试的组件:

js 复制代码
function ColorList () {
  return (
    <ul>
      <li>Red</li>
      <li>Blue</li>
      <li>Green</li>
    </ul>
  )
}

根据第 4 小节的内容,我们知道,如果找不到相关的元素,那么 getBy findBy 都会直接报错,而 queryBy 则返回 null. 但是与 getBy不同,findBy 是异步报错的,因此,我们的测试代码可以写成:

js 复制代码
test('getBy, queryBy, findBy finding 0 elements', async () => {
  render(<ColorList />);

  expect(
    () => screen.getByRole('textbox')
  ).toThrow();

  expect(screen.queryByRole('textbox')).toEqual(null);

  let errorThrown = false;
  try {
    await screen.findByRole('textbox')
  } catch (err) {
    errorThrown = true;
  }

  expect(errorThrown).toBe(true);
})

4.2 getBy, queryBy, findBy 正确判断

js 复制代码
function ColorList () {
  return (
    <ul>
      <li aria-label='Red'>Red</li>
      <li aria-label='Blue'>Blue</li>
      <li aria-label='Green'>Green</li>
    </ul>
  )
}

test('getBy, queryBy, findBy finding 1 element', async () => {
  render(<ColorList />);

  expect(
    screen.getByRole('listitem', {name: /Red/i})
  ).toBeInTheDocument();

  expect(screen.queryByRole('listitem', {name: /Blue/i})).toBeInTheDocument();
  
  expect(await screen.findByRole('listitem', {name: /Green/i})).toBeInTheDocument();
})

4.3 getAllBy, queryAllBy, findAllBy 正确判断

使用 All 相关的查询函数找到元素之后,我们最先要检查的是,获取的符合条件的元素的数目是否符合预期。因此我们一般使用 toHaveLength 来进行判断。

js 复制代码
function ColorList () {
  return (
    <ul>
      <li aria-label='Red'>Red</li>
      <li aria-label='Blue'>Blue</li>
      <li aria-label='Green'>Green</li>
    </ul>
  )
}


test('getByAll, queryByAll, findByAll finding elements', async () => {
  render(<ColorList />);

  expect(
    screen.getAllByRole('listitem')
  ).toHaveLength(3);

  expect(screen.queryAllByRole('listitem')).toHaveLength(3);
  
  expect(await screen.findAllByRole('list')).toHaveLength(1);
})

4.4 断言元素存在或者不存在的标准写法

  • 断言元素存在,不要省略的写法 -- 用 getByRole
js 复制代码
const element = screen.getByRole('textbox'); // 不要依赖这句话的报错与否来判断元素是否存在
expect(element).toBeInTheDocument();
  • 断言元素不存在 -- 用 queryByRole
js 复制代码
const element = screen.queryByRole('textbox');
expect(element).not.toBeInTheDocument();

4.5 其余查询函数

除了上面说到的最常见的六种查找方法之外。这里还有一些其他方法,这些方法中可以使用正则表达式,在某些场景下也非常好用。

js 复制代码
function TestOtherMethods () {
  return (
    <div data-testid="image wrapper">
      <button role="button">Click me</button>
      <input type="email" aria-label="Email" />
      <input type="text" placeholder="Red" />
      <div>Enter Data</div>
      <input type="email" value="asdf@asdf.com" />
      <img src="image.jpg" alt="data" />
      <span title="Click when ready to submit">Submit</span>
    </div>
  )
}

test('selecting different elements', () => {
  render(<TestOtherMethods />);

  const elements = [
    // 使用 getByRole 查询角色为 'button' 的元素
    screen.getByRole('button'),
    // 使用 getByLabelText 查询带有 'Email' 标签的输入框
    screen.getByLabelText('Email'),
    // 使用 getByPlaceholderText 查询带有 'Red' 占位符的输入框
    screen.getByPlaceholderText('Red'),
    // 使用 getByText 查询包含文本 'Enter Data' 的元素
    screen.getByText(/Enter Data/i),
    // 使用 getByDisplayValue 查询显示值为 'asdf@asdf.com' 的输入框
    screen.getByDisplayValue('asdf@asdf.com'),
    // 使用 getByAltText 查询带有 'data' 替代文本的图片
    screen.getByAltText('data'),
    // 使用 getByTitle 查询带有 'Click when ready to submit' 标题的元素
    screen.getByTitle('Click when ready to submit'),
    // 使用 getByTestId 查询数据测试标识符为 'image wrapper' 的元素
    screen.getByTestId('image wrapper')
  ];
  for (let element of elements) {
    expect(element).toBeInTheDocument();
  }
});

5. 自带 role 含义的元素

通过给 input 增加 label 来精细化选择

将原来的 html 结构

html 复制代码
    <form onSubmit={handleSubmit}>
      <div>
        <label>Name</label>
        <input value={name} onChange={(e) => setName(e.target.value)} />
      </div>
      <div>
        <label>Email</label>
        <input value={email} onChange={(e) => setEmail(e.target.value)} />
      </div>
      <button>Add User</button>
    </form>

改成:

html 复制代码
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor='name'>Name</label>
        <input id='name' value={name} onChange={(e) => setName(e.target.value)} />
      </div>
      <div>
        <label htmlFor='email'>Email</label>
        <input id='email' value={email} onChange={(e) => setEmail(e.target.value)} />
      </div>
      <button>Add User</button>
    </form>

这样我们不会再通过下面的方式:

js 复制代码
const [ nameInput, emailInput ] = screen.getAllByRole('textbox');

而是通过下面的精确方式:

js 复制代码
  const nameInput = screen.getByRole('textbox', {
    name: /name/i,
  })
  const emailInput = screen.getByRole('textbox', {
    name: /email/i,
  })

6. expect 上的匹配方法

7. 在测试中渲染一个组件

8. 模拟用户交互的第一步

模拟鼠标和键盘事件

9. Mock.fn() 的原理

10. 非常棒的调试工具

ts 复制代码
screen.logTestingPlaygroundURL();

在使用这个工具获取查询代码的时候,如果有的元素被藏在后面点不着,你可以适当的调节 padding 然后增加背景色的方式使得你的目标元素容易被选取。

11. 牛刀小试 1

js 复制代码
// UserForm.js
import { useState } from 'react';

function UserForm({ onUserAdd }) {
  const [email, setEmail] = useState('');
  const [name, setName] = useState('');

  const handleSubmit = (event) => {
    event.preventDefault();

    onUserAdd({ name, email });
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor='name'>Name</label>
        <input id='name' value={name} onChange={(e) => setName(e.target.value)} />
      </div>
      <div>
        <label htmlFor='email'>Email</label>
        <input id='email' value={email} onChange={(e) => setEmail(e.target.value)} />
      </div>
      <button>Add User</button>
    </form>
  );
}
export default UserForm;

及其测试文件:

js 复制代码
// UserForm.test.js
/* eslint-disable testing-library/no-debugging-utils */
import { render, screen } from '@testing-library/react';
import user from '@testing-library/user-event';
import UserForm from './UserForm';

test('it shows two inputs and a button', () => {
  // render the component
  render(<UserForm />);

  // Manipulate the component or find an element in it
  const inputs = screen.getAllByRole('textbox');
  const button = screen.getByRole('button');

  // Assertion - make sure the component is doing
  // what we expect it to do
  expect(inputs).toHaveLength(2);
  expect(button).toBeInTheDocument();
});

test('mock user event', () => {
  const callback = jest.fn();
  // render the component
  render(<UserForm onUserAdd={callback} />);

  // Manipulate the component or find an element in it
  const nameInput = screen.getByRole('textbox', {
    name: /name/i,
  })
  const emailInput = screen.getByRole('textbox', {
    name: /email/i,
  })
  const button = screen.getByRole('button');

  user.click(nameInput);
  user.keyboard('jane');

  user.click(emailInput);
  user.keyboard('jane@jane.com');

  user.click(button);

  expect(callback).toHaveBeenCalled();
  expect(callback).toHaveBeenCalledWith({
    "email": "jane@jane.com",
    "name": "jane",
  });
});

12. 缩小搜寻的范围

当我们想要去寻找某个元素的时候,如果使用 screen 这个全局变量,那么找到的也是全部的符合筛选条件的元素。这样,在很多情况下可能会超出我们的预期,为此我们需要缩小查找的范围。

有两种方式能够缩小查询的返回,一个是使用 testId, 一个是使用 container.

12.1 testId

首先说结论:如果仅仅是处于测试的目的往已有的元素上面添加额外的属性,这并不是最好的实践方式。

我们需要在目标元素上绑定 data-testId, 然后再测试文件中获取之:

html 复制代码
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Email</th>
        </tr>
      </thead>
      <tbody data-testId="users">
        {users.map((user, index) => (
          <tr key={index}>
            <td>{user.name}</td>
            <td>{user.email}</td>
          </tr>
        ))}
      </tbody>
    </table>
js 复制代码
const rows = within(screen.getByTestId('users')).getAllByRole('row');
expect(rows.length).toBe(2);

12.2 container

如果我们些许了解 jest 测试的原理,那么我们就能更好的缩小需要查询的范围:

在我们使用 render 函数模拟组件渲染的时候,我们是将此渲染的组件包裹在一个最外层的 div 中的。这个 div 会作为 render 函数的返回值之一。我们能够通过这个 handler 结合 within 语法实现类似于浏览器中的 querySelector.

js 复制代码
const { container } = render(<UserList users={users} />);
const rows = container.querySelectorAll('tbody tr');
expect(rows.length).toBe(2);

13. 牛刀小试 2

js 复制代码
// UserList.js
import React from 'react';

function UserList({ users }) {
  return (
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Email</th>
        </tr>
      </thead>
      <tbody data-testId="users">
        {users.map((user, index) => (
          <tr key={index}>
            <td>{user.name}</td>
            <td>{user.email}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

export default UserList;
js 复制代码
// UserList.test.js
import { render, screen, within } from '@testing-library/react';
import user from '@testing-library/user-event';
import UserList from './UserList';

it('Should have correct number of rows', () => {
  const users = [
    {
      "email": "jane@jane.com",
      "name": "jane",
    },
    {
      "email": "jack@jack.com",
      "name": "jack",
    }
  ];
  const { container } = render(<UserList users={users} />);

  screen.logTestingPlaygroundURL();


  // const rows = screen.getAllByRole('row');
  // const rows = within(screen.getByTestId('users')).getAllByRole('row');
  const rows = container.querySelectorAll('tbody tr');

  expect(rows.length).toBe(2);
})

14. 检查元素是显示在屏幕中 -- toBeInTheDocument

我们可以将一些数据以及待测组件的渲染封装在一个特定的函数中,然后在每一次运行此函数的时候就调用它。

js 复制代码
import { render, screen, within } from '@testing-library/react';
import UserList from './UserList';

function renderComponent() {
  const users = [
    { name: 'jane', email: 'jane@jane.com' },
    { name: 'sam', email: 'sam@sam.com' },
  ];
  render(<UserList users={users} />);

  return {
    users,
  };
}

test('render one row per user', () => {
  // Render the component
  renderComponent();

  // Find all the rows in the table
  const rows = within(screen.getByTestId('users')).getAllByRole('row');

  // Assertion: correct number of rows in the table
  expect(rows).toHaveLength(2);
});

test('render the email and name of each user', () => {
  const { users } = renderComponent();
  for (let user of users) {
    const name = screen.getByRole('cell', { name: user.name });
    const email = screen.getByRole('cell', { name: user.email });

    expect(name).toBeInTheDocument();
    expect(email).toBeInTheDocument();
  }
});

需要注意的是,将上述的 renderComponent 函数放置在 beforeEach 钩子函数中并不是最好的实践,我们要做的是在每一个 test 中手动的调用它。

15. 调试测试

有的时候我们就是不知道到底哪里出了错导致测试无法通过,这可能是笔误或者非常细小的失误引起的,但是我们就是找不到。

每当这种情况发生的时候,我们就需要使用神器 screen.debug() 了。它的作用是能够打印出执行到此句时候页面上的 dom 内容。

通过对比打印出的页面内容,我们可以很清楚的比较出来我们到底哪里想错了。

js 复制代码
import { render, screen, within } from '@testing-library/react';
import UserList from './UserList';

const renderComponent = () => {
  const users = [
    {
      "email": "jane@jane.com",
      "name": "jane",
    },
    {
      "email": "jack@jack.com",
      "name": "jack",
    }
  ];
  render(<UserList users={users} />);
  screen.debug();
  return {
    users,
  }
}

it('Show how to use toBeInTheDocument()', () => {

  const { users } = renderComponent();

  for (let user of users) {

    const name = screen.getByRole('cell', { name: user.name });
    const email = screen.getByRole('cell', { name: user.email });

    expect(name).toBeInTheDocument();
    expect(email).toBeInTheDocument();
  }
})

16. 提交之后清空 input 框

我们通过表单向后端提交数据之后,如果没有发生页面跳转,那么按理来说我们的表单是需要清空的,为了测试这个功能是否生效,我们可以将测试代码写成:

js 复制代码
test('empties the two inputs when form is submitted', async () => {
  render(<UserForm onUserAdd={jest.fn()} />);

  // Find the input elements and the button by their roles and accessible names
  const nameInput = screen.getByRole('textbox', { name: /name/i });
  const emailInput = screen.getByRole('textbox', { name: /email/i });
  const button = screen.getByRole('button', { name: /submit/i }); // Assuming the button is for submission

  // Simulate user filling in the form and submitting it
  userEvent.type(nameInput, 'jane');
  userEvent.type(emailInput, 'jane@jane.com');
  userEvent.click(button);

  // Wait for the form submission to update the DOM
  await waitFor(() => {
    expect(nameInput).toHaveValue(''); // Expect the name input to be empty after submission
    expect(emailInput).toHaveValue(''); // Expect the email input to be empty after submission
  });
});

17. 测试代码阅读 -- 阶段一

js 复制代码
// App.js
import { useState } from 'react';
import UserForm from './UserForm';
import UserList from './UserList';

function App() {
  const [users, setUsers] = useState([]);

  const onUserAdd = (user) => {
    setUsers([...users, user]);
  };

  return (
    <div>
      <UserForm onUserAdd={onUserAdd} />
      <hr />
      <UserList users={users} />
    </div>
  );
}

export default App;

// App.test.js
import { render, screen } from '@testing-library/react';
import user from '@testing-library/user-event';
import App from './App';

test('can receive a new user and show it on a list', () => {
  render(<App />);

  const nameInput = screen.getByRole('textbox', {
    name: /name/i,
  });
  const emailInput = screen.getByRole('textbox', {
    name: /email/i,
  });
  const button = screen.getByRole('button');

  user.click(nameInput);
  user.keyboard('jane');
  user.click(emailInput);
  user.keyboard('jane@jane.com');

  user.click(button);

  const name = screen.getByRole('cell', { name: 'jane' });
  const email = screen.getByRole('cell', { name: 'jane@jane.com' });

  expect(name).toBeInTheDocument();
  expect(email).toBeInTheDocument();
});

// UserForm.js
import { useState } from 'react';

function UserForm({ onUserAdd }) {
  const [email, setEmail] = useState('');
  const [name, setName] = useState('');

  const handleSubmit = (event) => {
    event.preventDefault();

    onUserAdd({ name, email });

    setEmail('');
    setName('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          value={name}
          onChange={(e) => setName(e.target.value)}
        />
      </div>
      <div>
        <label htmlFor="email">Enter Email</label>
        <input
          id="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
      </div>
      <button>Add User</button>
    </form>
  );
}

export default UserForm;

// UserForm.test.js
import { render, screen, waitFor } from '@testing-library/react';
import user from '@testing-library/user-event';
import UserForm from './UserForm';

test('it shows two inputs and a button', () => {
  // render the component
  render(<UserForm />);

  // Manipulate the component or find an element in it
  const inputs = screen.getAllByRole('textbox');
  const button = screen.getByRole('button');

  // Assertion - make sure the component is doing
  // what we expect it to do
  expect(inputs).toHaveLength(2);
  expect(button).toBeInTheDocument();
});

test('it calls onUserAdd when the form is submitted', () => {
  const mock = jest.fn();
  // Try to render my component
  render(<UserForm onUserAdd={mock} />);

  // Find the two inputs
  const nameInput = screen.getByRole('textbox', {
    name: /name/i,
  });
  const emailInput = screen.getByRole('textbox', {
    name: /email/i,
  });

  // Simulate typing in a name
  user.click(nameInput);
  user.keyboard('jane');

  // Simulate typing in an email
  user.click(emailInput);
  user.keyboard('jane@jane.com');

  // Find the button
  const button = screen.getByRole('button');

  // Simulate clicking the button
  user.click(button);

  // Assertion to make sure 'onUserAdd' gets called with email/name
  expect(mock).toHaveBeenCalled();
  expect(mock).toHaveBeenCalledWith({ name: 'jane', email: 'jane@jane.com' });
});

test('empties the two inputs when form is submitted', async () => {
  render(<UserForm onUserAdd={() => { }} />);

  const nameInput = screen.getByRole('textbox', { name: /name/i });
  const emailInput = screen.getByRole('textbox', { name: /email/i });
  const button = screen.getByRole('button');

  user.click(nameInput);
  user.keyboard('jane');
  user.click(emailInput);
  user.keyboard('jane@jane.com');

  user.click(button);
  screen.debug();
  await waitFor(async () => {
    expect(nameInput).toHaveValue('');
  })

  await waitFor(async () => {
    expect(emailInput).toHaveValue('');
  })
  screen.debug();
});

// UserList.js
function UserList({ users }) {
  const renderedUsers = users.map((user) => {
    return (
      <tr key={user.name}>
        <td>{user.name}</td>
        <td>{user.email}</td>
      </tr>
    );
  });

  return (
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Email</th>
        </tr>
      </thead>
      <tbody data-testid="users">{renderedUsers}</tbody>
    </table>
  );
}

export default UserList;

// UserList.test.js
import { render, screen, within } from '@testing-library/react';
import UserList from './UserList';

function renderComponent() {
  const users = [
    { name: 'jane', email: 'jane@jane.com' },
    { name: 'sam', email: 'sam@sam.com' },
  ];
  render(<UserList users={users} />);

  return {
    users,
  };
}

test('render one row per user', () => {
  // Render the component
  renderComponent();

  // Find all the rows in the table
  const rows = within(screen.getByTestId('users')).getAllByRole('row');

  // Assertion: correct number of rows in the table
  expect(rows).toHaveLength(2);
});

test('render the email and name of each user', () => {
  const { users } = renderComponent();
  for (let user of users) {
    const name = screen.getByRole('cell', { name: user.name });
    const email = screen.getByRole('cell', { name: user.email });

    expect(name).toBeInTheDocument();
    expect(email).toBeInTheDocument();
  }
});

18. 快捷的测试学习工具 -- rtl-book

你可以直接使用 npx rtl-book serve roles-nots.js 在 nodeJS 环境下创建一个测试学习环境,非常的方便好用。

19. 元素的自带 role

在Web可访问性中,role 属性用于表示元素的类型或功能:

  1. <a> - 角色通常是 link,但 <a> 元素通常不需要显式设置角色,因为它们默认具有 link 行为。
  2. <button> - 角色是 button
  3. <footer> - 可能的 role 可以是 contentinfo,表示页面或区域的页脚。
  4. <h1> - 通常不需要 role,因为它已经是标题,但可以使用 role="heading" 来明确它是一个标题。
  5. <header> - 可能的 role 可以是 banner,表示页面或区域的头部。
  6. <img> - 角色是 img,并且应该总是与 alt 属性一起使用来提供图像的替代文本。
  7. <input type="checkbox"> - 角色是 checkbox
  8. <input type="number"> - 角色可能是 spinbutton,但通常 type="number"input 元素不需要显式角色。
  9. <input type="radio"> - 角色是 radio
  10. <input type="text"> - 角色可能是 textbox,但通常 type="text"input 元素不需要显式角色。
  11. <li> - 当 <li>olul 的子元素时,其角色是 listitem
  12. <ul> - 角色是 list

请注意,一些元素的 role 属性是隐含的,不需要显式设置,因为它们的语义已经由它们的标签定义。对于其他一些元素,如 <footer><header>,使用 role 可以提供额外的明确性,尤其是在使用非语义化 HTML(如使用 <div> 标签)时。

下面我们就写一个测试来验证这些角色信息:

ts 复制代码
// 测试组件
import { render, screen } from '@testing-library/react';

function RoleExample() {
  return (
    <div>
      <a href="#">Link</a>
      <button>Button</button>
      <footer>Content info</footer>
      <h1>Heading</h1>
      <header>Banner</header>
      <img alt="description" src="" /> 
      <input type="checkbox" /> Checkbox
      <input type="number" /> Spinbutton
      <input type="radio" /> Radio
      <input type="text" /> Textbox
      <ul>
        <li>List item</li> 
      </ul>
    </div>
  );
}
js 复制代码
// 测试文件
test('Should have the correct roles', () => {
  render(<RoleExample />);

  const roles = ['link', 'button', 'contentinfo', 'heading', 'banner', 'img', 'checkbox', 'spinbutton', 'radio', 'textbox', 'listitem'];

  for(let role of roles) {
    const element = screen.getByRole(role);

    expect(element).toBeInTheDocument();
  }
})

20. role 结合 accessible name 更加精确的选择元素

通过 role 确实可以选择出对应的元素,前提是页面上只有一个这种 role 的元素,如果有多个,那么使用 getByRole 就会报错,而如果使用 getAllByRole 又无法精确的得到我们想要的元素,这个时候提供一种更加精细化的选择方案:

20.1 双标签元素

对于双标签的元素,其 accessible name 就是其 innerText,因此我们可以写出如下的测试来:

js 复制代码
function AccessibleName () {
  return (
    <div>
      <button>Submit</button>
      <button>Cancel</button>
    </div>
  )
}

test('Should use accessible name to pick preciesely', () => {
  render(<AccessibleName />);

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

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

  expect(submit).toBeInTheDocument();
  expect(cancel).toBeInTheDocument();
})

上面的代码中,name 的值之所以选择的是 Reg 类型的,是因为这样的化可以增加鲁棒性,防止 innerText 中的一些奇怪字符 (whitespace) 导致元素选择报错。

但是这种做法还是有缺陷的,对于 li 标签(尽管它是双标签)就不能通过这种方式精细化选择。

20.2 单标签元素

对于单个标签,是没有 innerText 的,这个时候,我们就可以使用 label 来对其进行修饰,如下所示:

js 复制代码
function AccessibleName2 () {
  return (
    <div>
      <label for="single">Use Label</label>
      <input id='single' value='单标签元素' type='text' />
    </div>
  )
}

test('Should use accessible name to pick preciesely', () => {
  render(<AccessibleName2 />);

  const input = screen.getByRole('textbox', {
    name: /Use Label/i,
  })

  expect(input).toBeInTheDocument();
})

如果有些时候我们确实不需要显示 label,这个时候给其增加一些 css 样式即可:

js 复制代码
function AccessibleName2 () {
  return (
    <div>
      <label for="single" style={{display: 'block', width: 0, height: 0, overflow: 'hidden'}}>Use Label</label>
      <input id='single' value='单标签元素' type='text' />
    </div>
  )
}

20.3 双标签没有 innerText

如下所示的组件,button 元素没有传统的 accessible name, 其内部是另外一个元素,那么这个时候我们如何精确选择这个元素呢?

js 复制代码
function IconButton () {
  return (
    <div>
      <button>
        <svg />
      </button>
    </div>
  )
}

这种情况下,我们只能退而求其次,使用在目标元素上增加 aria-label 属性的方式方便选择了。

js 复制代码
function IconButton () {
  return (
    <div>
      <button aria-label="sign in">
        <svg />
      </button>
    </div>
  )
}

test('Should use arial label in lack of innerText', () => {
  render(<IconButton />);

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

  expect(button).toBeInTheDocument();
})

这种方式可以推广至所有,无论是单标签,还是有 innerText 的元素选择中。

js 复制代码
function AccessibleName2 () {
  return (
    <div>
      <input id='single' value='单标签元素' type='text' aria-label='Use Label' />
    </div>
  )
}

但只要指定了 aria-label 那么它的值的优先级就是最高的。如下所示,这个时候 accessible name 是 sign in 而不是 sign out:

js 复制代码
function IconButton () {
  return (
    <div>
      <button aria-label="sign in">
      sign out
        <svg />
      </button>
    </div>
  )
}

21. 测试使用后端数据渲染的组件

如果当前组件的渲染依赖于后端所返回的数据,也就是说组件在第一时间内无法完成加载,那么这个时候就只能使用 await + findBy 的查询策略了,如下所示:

js 复制代码
function LoadableColorList () {
  const [colors, setColors] = useState([]);

  function fakeFetchColors() {
    return Promise.resolve(
      ['red','green','blue']
    )
  }

  useEffect(()=>{
    fakeFetchColors().then(c => setColors(c))
  }, []);

  const renderedColors = colors.map(color => {
    return <li key={color}>{color}</li>
  })

  return <ul>{renderedColors}</ul>
}

test('Favor findBy or findAllBy when data fetching', async () => {
  render(<LoadableColorList />);

  const els = await screen.findAllByRole('listitem');

  expect(els).toHaveLength(3);
})

22. 查找函数的链式调用

我们的 within 函数不仅能够和 container 连用,被 within 包裹的查询结果也可以继续使用这些查询函数,这在一定程度上实现了查询函数的链式调用。

js 复制代码
function ColorList () {
  return (
    <ul>
      <li aria-label='Red'>Red</li>
      <li aria-label='Blue'>Blue</li>
      <li aria-label='Green'>Green</li>
    </ul>
  )
}

test('Chain invoking', async () => {
  render(<ColorList />);
  const ul = screen.getByRole('list');
  expect(ul).toBeInTheDocument();
  // 这里就是链式调用
  const lis = within(ul).getAllByRole('listitem');
  expect(lis).toHaveLength(3);
})

23. 自定义 matcher 函数

关于自定义的 matcher 我们采取的策略是:定义、挂载、使用。

  1. 定义
js 复制代码
function toContainRole(container, role, quantity = 1) {
    const elements = within(container).queryAllByRole(role);

    if (elements.length === quantity) {
        return { pass: true };
    }

    return {
        pass: true,
        message: () => `Expected to find ${quantity} ${role} elements.
Found ${elements.length} instead.`
    }
}
  1. 挂载
js 复制代码
expect.extend({toContainRole});
  1. 使用
js 复制代码
test('the form displays two buttons', () => {
    render(<FormData />);

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

    expect(form).toContainRole('button', 2);
})
  1. 组件内容
jsx 复制代码
import React from 'react';

function FormData() {
  return (
    <form> {/* 假设这是一个表单元素 */}
      <button type="submit">Submit</button> {/* 第一个按钮 */}
      <button type="button">Cancel</button> {/* 第二个按钮 */}
    </form>
  );
}

export default FormData;
  1. 全部的测试内容
js 复制代码
function FormData() {
  return (
    <form aria-label='form'> {/* 假设这是一个表单元素 */}
      <button type="submit">Submit</button> {/* 第一个按钮 */}
      <button type="button">Cancel</button> {/* 第二个按钮 */}
    </form>
  );
}

function toContainRole(container, role, quantity = 1) {
    const elements = within(container).queryAllByRole(role);

    if (elements.length === quantity) {
        return { pass: true };
    }

    return {
        pass: false,
        message: () => `Expected to find ${quantity} ${role} elements.
Found ${elements.length} instead.`
    }
}

expect.extend({toContainRole});

test('the form displays two buttons', () => {
    render(<FormData />);
    const form = screen.getByRole('form');
    expect(form).toContainRole('button', 2);
})

如果我们将 expect(form).toContainRole('button', 2); 修改成 expect(form).toContainRole('button', 3); 那么就会得到报错信息:

plainText 复制代码
Expected to find 3 button elements.
Found 2 instead.

24. 如何理解项目中的数据

我们起码有四个层次可以获取并理解项目中的数据:

  1. 使用 console.log
  2. 手动在项目中添加 debugger
  3. 如果是 react 项目的话,使用 React Developer Tools
  4. 使用开发者工具中的 network 面板

25. 小技巧

技巧 1

使用循环 + expect 的方式来批量对页面上的内容进行验证是一个比较好的实践方式。但与此同时,新的问题出现了。

我们知道,如果用字面量的方式定义一个正则表达式,那么就失去了动态性,这种极大的限制了正则表达式的使用范围。为此,我们不使用字面量的方式定义正则,转而使用实例化的方式。

const element = screen.getByText(new RefExp(value));

技巧 2

我们在对某个 react 组件进行测试的时候可以将为待测组件准备的初始化材料和对此组件的 render 封装在一个函数中,然后在每次 test 中都调用一次。注意,这个封装函数的调用最好不要放在 beforeEach 中,因为 React 的测试框架并不推荐这样做。

26. 测试的三大难点

使用 jest 对 react 项目进行测试,一般来说有三大难点:

  1. Module Mocks
  2. Navigation
  3. act

在接下来的篇幅中,我们会逐个解决这些难点,从而从入门阶段跃升至高级阶段。

27. 模拟路由系统

如果你的待测组件中包含了 react router dom 库中的路由组件。那么形象一点来说,你的被测组件可能并不想在测试环境中好好运行,因为在运行的时候它可能想着去够它的依赖环境,在开发环境中它当然能够够得着,但是在测试环境中没有人为其准备相应的环境。于是它就报错了。为此我们需要解决这个问题。

路由的种类

  1. Browser Router
  2. Hash Router
  3. Memory Router 在测试环境中,我们一般使用的是 Memory Router 而不是其它两个。

使用 Memory Router 模拟路由环境

假设我们的待测组件为:

js 复制代码
import React from 'react';
import { Link } from 'react-router-dom';

export default function DemoComponent() {
  return (
    <div>
      <nav>
        <ul>
          <li><Link to="/home">Home</Link></li>
          <li><Link to="/about">About</Link></li>
          <li><Link to="/contact">Contact</Link></li>
        </ul>
      </nav>
    </div>
  );
}

这个时候,不配置路由环境,直接测试就会失败,尽管我们的测试并不针对路由本身。

js 复制代码
import { render, screen } from '@testing-library/react';
import DemoComponent from './DemoComponent';

test('Should find the link element', () => {
  render(
    <DemoComponent />
  );
  expect(screen.getByText(/Home/i)).toBeInTheDocument();
});

报错信息为:

这个时候我们只需要将测试代码改成如下模样,也就是在其外面裹上一层 MemoryRouter 就可以顺利通过测试了!

js 复制代码
import { render, screen } from '@testing-library/react';
import DemoComponent from './DemoComponent';
import { MemoryRouter } from 'react-router-dom';

test('Should find the link element', () => {
  render(
    <MemoryRouter>
      <DemoComponent />
    </MemoryRouter>
  );
  expect(screen.getByText(/Home/i)).toBeInTheDocument();
});

28. act warning

如果你在组件中使用了 useEffect 获取网络数据,那么你将会很容易的看到和 act warning 相关的报错。与此相关的有四个概念:

  1. 在测试环境中期望之外的组件更新是不好的行为。
  2. act 本身是一个函数,这个函数的作用是定义了一个窗口,在这个窗口中,组件可以随意更新。
  3. 在你使用某些测试函数的时候,测试框架本身就在后台调用了 act 提供了这个窗口,也就是说 act 会在你不可见的地方被调用。
  4. 通常使用 findBy 代替其它查询函数可以消除 act warning. 并且你不必参考 warning 本身的提示信息。

一个出现 act 警告的典型场景就是:我们依据网络返回的数据来渲染页面元素,常见的是列表,如果后端返回 10 个 item, 那么组件也就渲染 10 个 li, 如果返回 8 个,那么就渲染 8 个 li. 如果我们没有处理好异步代码和同步代码之间的关系,比如在网络数据返回之前获取所有的 li 并断言其个数,这就会导致 act warning, 因为网络请求是不保证 100% 成功的,所以说 li 不一定能渲染出来,状态是不确定的。

理解 act 函数

  1. act 给了一个更新组件状态的窗口,在此窗口之内可以安全的更新测试组件。
  2. act 函数在退出前会结束所有 useEffect 以及 pending 的状态
  3. 下面这些测试方法会在幕后自动的调用 act 函数
  • screen.findBy 异步
  • screen.findAllBy 异步
  • waitFor 同步 1s
  • user.keyboard ���步
  • user.click 同步
  1. act 函数打开的窗口事件差不多是 1s

手动调用 act 函数打开窗口

如下所示的组件 UserList 中,如果我们点击了按钮,则发送网络请求,根据请求回来的数据渲染列表。下面是我们通过手动调用 act 打开窗口的方式来避免出现 act warning.

js 复制代码
import { render } from 'react-dom';
import { act } from 'react-dom/test-utils';

test('clicking the button loads users', async () => {
  act(()=>{
    render(<UserList />, container);
  })

  const button = document.querySelector('button');
  await act(async () => {
    button.dispatch(new MouseEvent('click'));
  })

  const users = document.querySelectorAll('li');
  expect(users).toHaveLength(3);
})

如果你将上面的代码写在你的测试文件中,那么编辑器就会提示你:Avoid wrapping Testing Library util calls in act eslint testing-library/no-unnecessary-act, 也就是说手动调用 act 函数的方法并不被推荐。

比手动调用更好的方法是直接使用上面列举的 5 种 APIs, 让测试框架在后台自己启用窗口,而不是手动打开。

解决 act warning 的四个层次

首先,不论用哪个解决方案,最不能相信的就是编译器种的报错信息了。

  1. 最好的方式:使用 findBy 或者 findAllBy 自带的窗口。
  2. 差一点的方式:手动调用 act 函数对获取网络数据的代码进行隔离。
  3. 无奈的方式:对于总是会造成警告但并不是测试对象的 Module 进行 Mock.
  4. 兜底的方式:使用 act + pause 组合,人为等待组件状态稳定下来。这个 pasue 函数长这样:
js 复制代码
const pause = (gap) => {
  return new Promise(resolve => {
    setTimeout(resolve, gap)
  })
}

pause 结合 debug 查找渲染时机

很多时候,在容易忽略的地方可能存在着组件的更新,但是它们非常的难找,这个时候我们就可以将 screen.debugpause 函数结合起来使用,以时间为自变量来逐帧分析 component 的渲染状态,找到我们更新的时机,然后采取下一步的动作。

js 复制代码
test('shows a link to the github homepage for the repository', async () => {
  renderComponent();

  screen.debug();

  await pause();

  screen.debug();
});

模拟、跳过出问题的组件

对于实在无法处理的组件,并且它也不是我们的测试对象的时候,我们可以通过 Mock 的方式做一个假的组件来代替真实组件的渲染。

js 复制代码
jest.mock('../TroubleComponent', () => {
  return () => {
    return 'File Icon Component';
  }
})

这样做了之后,不论之前的 <ToubleComponent /> 的内容是什么,在这个测试中 <ToubleComponent /> 的内容就是返回了 'File Icon Component' 这个字符串而已。

更加完整的代码:

js 复制代码
// 引入 Jest 和 React 测试库
import React from 'react';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';

// 引入被测试的组件,这里假设它使用了 TroubleComponent
import MyComponent from './MyComponent';

// 模拟 TroubleComponent 组件
jest.mock('../TroubleComponent', () => {
  return () => {
    return 'File Icon Component';
  }
});

describe('MyComponent', () => {
  it('should render TroubleComponent correctly', () => {
    // 渲染 MyComponent
    const { getByText } = render(<MyComponent />);

    // 检查是否渲染了模拟的 TroubleComponent
    expect(getByText('File Icon Component')).toBeInTheDocument();
  });
});

注意:jest.mock 的路径是相对于测试文件的,测试框架根据这个路径会自动处理其它组件使用被 Mock 组件的路径问题,确保原组件被替换掉。

兜底方案 -- 使用 act + pause 的方案

js 复制代码
test('shows a link to the github homepage for this repository', async () => {
  renderComponent();

  await act(async () => await pause());

  // await screen.findByRole('img', {name: 'Javascript'});
});

const pause = () => new Promise(resolve => setTimeout(resolve, 100));

需要注意的是,这里的代码并没有阻塞测试环境的渲染。

29.新增两个断言

  1. expect(icon).toHaveClass('js-icon');
  2. expect(link).toHaveAttribute('href', "/repositories/"+repository.full_name);

30. 测试环境中的网络数据模拟

在测试环境中,先不论数据的是通过 axios 还是 SWR 或者其它什么框架获取的,核心要点是我们不会真的发起网络请求去获取数据,而这个结论基于两个考虑:

  1. 实际上的网络请求相对比较耗时。
  2. 真正的网络请求不一定能成功,就算成功也不能保证每次结果是一样的,也就是说我们事先无法知道网络请求的结果,这样对于我们的断言不利。

Mock 网络数据的方法

一般来说我们有三种模拟网络数据的方法:

  1. 直接 mock 具有网络交互行为的 Module, 就像上面我们 Mock 的 TroubleComponent 一样,假设 TroubleComponent 中发起的网络请求,那么我们 Mock 的 TroubleComponent 可以是将获得的数据硬编码在其中的状态。当然,也不用这样,有时候我们将网络数据的获取封装成一个 hook,那么我们也可以用 Mock Module 的方式 Mock 这个 hook 即可。
js 复制代码
// 导入 React Testing Library 的工具
import { render, screen } from '@testing-library/react';

// 导入需要测试的组件,这里假设是 HomeRoute
import HomeRoute from "./HomeRoute";

// 模拟 useRepositories 钩子,假设它导出了一个名为 useRepositories 的函数
jest.mock('../hooks/useRepositories', () => {
  return {
    useRepositories: () => {
      return {
        data: [
          { name: 'react' },
          { name: 'bootstrap' },
          { name: 'javascript' }
        ]
      };
    }
  };
});

// 以下是测试代码的示例
describe('HomeRoute', () => {
  it('should display repositories correctly', () => {
    // 渲染 HomeRoute 组件
    render(<HomeRoute />);

    // 假设 HomeRoute 组件中使用了 useRepositories 钩子,并在某个地方显示了仓库名称
    // 这里使用 screen.getByText 来查找文本并断言它是否存在
    expect(screen.getByText('react')).toBeInTheDocument();
    expect(screen.getByText('bootstrap')).toBeInTheDocument();
    expect(screen.getByText('javascript')).toBeInTheDocument();
  });
});

这确实很方便,但不推荐。原因在于:这样做的话,我们内置的一些 hook 逻辑就被忽略了,也就是会导致我们的测试忽略掉一部分组件原来的逻辑,导致其不完整。

  1. 使用专门在测试环境下模拟 axios 的第三方库 -- MSW 和第一种方式比较,这种做法能够更好的还原真实的组件逻辑。MSW 的原理就是检测真实的网络请求并截断,然后使用预置的数据作为返回值。
bash 复制代码
yarn add msw

下面展示如何使用 Mock Service Worker (MSW) 来设置测试环境的一系列步骤:

  1. MSW 设置:配置 MSW 以便在你的测试环境中使用。
  2. 创建测试文件 :在你的项目中创建一个新的测试文件,例如 HomeRoute.test.js
  3. 理解请求细节:确定你的组件将要发出的确切 URL、HTTP 方法(如 GET、POST 等)和预期的返回值。
  4. 创建 MSW 处理器:编写一个 MSW 处理器来拦截请求,并返回一些模拟数据供你的组件使用。
  5. 设置测试钩子 :在你的测试文件中设置 beforeAllafterEachafterAll 钩子,这些钩子通常用于准备测试环境、清理测试状态和关闭测试环境。
  6. 编写测试用例 :在测试用例中,渲染你的组件。使用适当的等待机制,如 waitFor 或检查元素是否可见,以确保组件正确渲染并响应网络请求。

我们待测的组件名为 HomeRoute.js, 其内容如下:

js 复制代码
// 导入 React 和必要的 hooks
import React, { useState } from 'react';

// 假设这是用于获取仓库数据的自定义 hook
import useRepositories from './hooks/useRepositories';

const HomeRoute = () => {
    // 状态用于存储仓库数据
    const { repositories, isLoading, error } = useRepositories();

    return (
        <div>
            <h1>Repositories</h1>
            <ul>
                {repositories.map((repo) => (
                    <li key={repo.id}>{repo.name}</li> // 假设每个仓库对象都有 id 和 name 属性
                ))}
            </ul>
        </div>
    );
};

export default HomeRoute;

其中用到的 hook 内容如下:

js 复制代码
// 导入 React 的 hooks
import { useState, useEffect } from 'react';

// 假设这是获取仓库数据的 API URL
const REPOSITORIES_API_URL = '/api/repositories';

// useRepositories hook 的实现
export default function useRepositories() {
    // 使用 useState 定义一个状态来存储仓库数据
    const [repositories, setRepositories] = useState([]);
    // 使用 useState 定义一个状态来存储加载状态
    const [isLoading, setIsLoading] = useState(false);
    // 使用 useState 定义一个状态来存储错误信息
    const [error, setError] = useState(null);

    useEffect(() => {
        // 设置加载状态为 true
        setIsLoading(true);
        // 重置错误状态
        setError(null);

        // 使用 fetch API 获取数据
        fetch(REPOSITORIES_API_URL)
            .then((response) => {
                if (!response.ok) {
                    throw new Error('Network response was not ok');
                }
                return response.json();
            })
            .then((data) => {
                setRepositories(data);
                setIsLoading(false);
            })
            .catch((error) => {
                console.error('There was a problem with the fetch operation:', error);
                setError(error.message);
                setIsLoading(false);
            });
    }, []); // 空依赖数组意味着这个 effect 只会在组件挂载时运行一次

    // 返回仓库数据和加载状态
    return { repositories, isLoading, error };
}
javascript 复制代码
// 导入必要的库和组件
import { render, screen } from '@testing-library/react';
import { setupServer } from 'msw/node';
import { rest } from 'msw';

// 设置 MSW 服务器
const server = setupServer(
  rest.get('/api/repositories', (req, res, ctx) => {
    return res(ctx.json([
      { id: 1, name: 'react' },
      { id: 2, name: 'bootstrap' },
      { id: 3, name: 'javascript' },
    ]));
  })
);

// 导入要测试的组件
import HomeRoute from './HomeRoute';

// 在 beforeAll 钩子中启动 MSW 服务器
beforeAll(() => server.listen());

// 在 afterEach 钩子中重置 handlers 状态
afterEach(() => server.resetHandlers());

// 在 afterAll 钩子中关闭 MSW 服务器
afterAll(() => server.close());

// 测试用例
describe('HomeRoute', () => {
  it('should render repositories correctly', async () => {
    // 渲染组件
    render(<HomeRoute />);

    // 等待组件渲染完成并检查元素是否可见
    const repositories = await screen.findAllByText(/repository/i);

    // 断言检查
    expect(repositories).toHaveLength(3);
    expect(repositories[0]).toHaveTextContent('react');
    expect(repositories[1]).toHaveTextContent('bootstrap');
    expect(repositories[2]).toHaveTextContent('javascript');
  });
});

31. 使用 createServer

借助工程化的思想,在正式的项目中我们使用 createServer 函数工厂,并向其传入一定配置参数的方式创建所需的 test server。createServer 的位置应该是在每一个 describe 中,在 每个 test 之外的位置。describe 提供了一个 scope, 每一个 scope 都具有独立的生命周期函数,如 beforeAll 等。

一般情况下我们会创建 src/test 这个文件夹,并在此目录中创建 server.js 文件,此文件的内容为:

js 复制代码
import { setupServer } from 'msw/node';
import { rest } from 'msw';

export function createServer(handlerConfig) {
  const handlers = handlerConfig.map((config) => {
    return rest[config.method || 'get'](config.path, (req, res, ctx) => {
      return res(ctx.json(config.res(req, res, ctx)));
    });
  });
  const server = setupServer(...handlers);

  beforeAll(() => {
    server.listen();
  });
  afterEach(() => {
    server.resetHandlers();
  });
  afterAll(() => {
    server.close();
  });

  return server;
}

而这个文件导出的 createServer 方法,我们是这样使用的:

js 复制代码
import { createServer } from '../test/server';

const loc_server = createServer([
  {
    path: '/api/repositories',
    res: (req) => {
      const language = req.url.searchParams.get('q').split('language:')[1];
      return {
        items: [
          { id: 1, full_name: `${language}_one` },
          { id: 2, full_name: `${language}_two` },
        ],
      };
    },
  },
]);

32. 搭建一个简单的测试框架

在这个小节中,我们需要搭建一个工程化的、能够直接上手使用的、包含路由和网路数据请求模拟、数据缓存在内的一个简单的测试框架。这个框架能否搭建成功和依赖之间的版本依赖有着莫大的关系,因此建议直接从我给的仓库下载。

关于这一点,我想说的是,重点在于项目中的 package.jsonpackage-lock.json 两个文件,你可以在仓库中下载下来之后将这两个文件拷走,修改项目名称和版本号,描述或者作者等信息,然后运行 npm install 安装即可。

仓库地址为:https://github.com/fszhangYi/basicTestingFramework.git

创建框架的步骤

  1. 创建项目结构
plaintext 复制代码
.
|-- package-lock.json
|-- package-lock.json.bk      
|-- package.json
`-- src
    |-- hooks
    |   `-- useRepositories.js
    |-- routes
    |   |-- HomeRoute.js      
    |   `-- HomeRoute.test.js 
    `-- test
        `-- server.js
  1. 各个文件中的内容 2.1 最重要的是 package.json 中的内容
json 复制代码
{
  "name": "basictestingframework",
  "version": "0.1.0",
  "private": true,
  "proxy": "http://localhost:8000",
  "dependencies": {
    "@exuanbo/file-icons-js": "3.3.0",
    "@monaco-editor/react": "4.4.6",
    "@playwright/test": "1.28.1",
    "@primer/octicons-react": "17.9.0",
    "@prisma/client": "4.7.0",
    "@tailwindcss/forms": "0.5.3",
    "@testing-library/jest-dom": "5.16.5",
    "@testing-library/react": "13.4.0",
    "@testing-library/user-event": "13.5.0",
    "axios": "0.21.4",
    "classnames": "2.3.2",
    "concurrently": "7.6.0",
    "cookie-session": "2.0.0",
    "dotenv": "16.0.3",
    "express": "4.18.2",
    "msw": "0.49.2",
    "nodemon": "2.0.20",
    "playwright": "1.28.1",
    "prisma": "4.7.0",
    "prop-types": "15.8.1",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "react-router": "6.4.4",
    "react-router-dom": "6.4.4",
    "react-scripts": "5.0.1",
    "react-split": "2.0.14",
    "swr": "2.0.0",
    "validate.js": "0.13.1",
    "web-vitals": "2.1.4"
  },
  "prisma": {
    "schema": "server/prisma/schema.prisma"
  },
  "scripts": {
    "start": "concurrently \"npm:start:server\" \"npm:start:client\"",
    "start:client": "react-scripts start",
    "start:server": "nodemon --watch server server/index.mjs",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "test:debug": "react-scripts --inspect-brk test --runInBand --no-cache",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest"
    ]
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  },
  "devDependencies": {
    "autoprefixer": "10.4.13",
    "postcss": "8.4.19",
    "tailwindcss": "3.2.4"
  }
}

2.2 src 中的内容

这个目录中有三个子目录,它们分别是:hooks routes test, 它们的作用分别是提供获取网络信息的钩子函数、路由组件和测试用的基础设施。

2.2.1 hooks 中的内容

hooks 中只有一个文件 useRepositories.js 其内容如下:

js 复制代码
import axios from 'axios';
import useSWR from 'swr';

async function repositoriesFetcher([url]) {
  const res = await axios.get(url);

  return res.data.items;
}

export default function useRepositories(url) {
  const { data, error, isLoading } = useSWR(
    [url],
    repositoriesFetcher
  );

  return {
    data,
    isLoading,
    error,
  };
}

2.2.2 test 中的内容

test 子目录中只有一个 server.js 文件,其作用是为了提供测试环境的基础设施:

js 复制代码
import { setupServer } from 'msw/node';
import { rest } from 'msw';

export function createServer(handlerConfig) {
    const handlers = handlerConfig.map((config) => {
        return rest[config.method || 'get'](config.path, (req, res, ctx) => {
            return res(ctx.json(config.res(req, res, ctx)));
        });
    });
    const server = setupServer(...handlers);

    beforeAll(() => {
        server.listen();
    });
    afterEach(() => {
        server.resetHandlers();
    });
    afterAll(() => {
        server.close();
    });
}

2.2.3 routes 中的内容

routes 子目录中提供一个名为 HomeRoute.js 的文件,向外提供一个路由组件,其内容为:

js 复制代码
import { Link } from 'react-router-dom';
import useRepositories from '../hooks/useRepositories';

const url = '/api/repositories';

function HomeRoute() {
  const { data: rep } = useRepositories(url);

  return (
    <>
      <ul>{
        rep?.map(_ => {
          return <li key={_.id}>{_.id}</li>
        })}
      </ul>
      <Link to="/fakePath">
        Sign Out
      </Link>
    </>

  );
}

export default HomeRoute;

其对应的测试文件为 HomeRoute.test.js 内容有:

js 复制代码
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import HomeRoute from './HomeRoute';
import { createServer } from '../test/server';
import { SWRConfig } from 'swr';

describe('Test Group 1', () => {
  createServer([
    {
      path: '/api/repositories',
      res: (req) => {
        return {
          items: [
            { id: 1, full_name: `one` },
            { id: 2, full_name: `two` },
          ],
        };
      },
    },
  ]);

  test('renders two links for each language', async () => {
    render(
      <SWRConfig value={{ provider: () => new Map() }}>
        <MemoryRouter>
          <HomeRoute />
        </MemoryRouter>
      </SWRConfig>
    );
    debugger
    const lis = await screen.findAllByRole('listitem');
    expect(lis).toHaveLength(2);
  });
})
  1. 重点内容解析 下面逐个对上述文件内容中的要点进行一一详述。
  • 对于 package.json 需要强调的是其中对依赖版本的锁定,以及对应的 node 的版本号为 18.18.2, npm 的版本号为 9.8.1.
  • useRepositories.js 文件向外暴露出一个 hook 函数,这个函数接受 url 作为其入参,然后将请求回来的数据返回出来。需要注意的是这里我们使用了 useSWR 对网络请求做了缓存。
  • server.js 中我们提供了 server 创建的三个生命周期函数 beforeAll afterEach afterAll 并将简单的入参配置 handlerConfig 加工成 setupServer 所需的格式并创建 server.
  • HomeRoute.js 中我们使用了创建的 hook 来请求数据并渲染。
  1. 测试文件 HomeRoute.test.js 中的技巧
  • 我们使用了 describe + createServer + test 的结构。
  • 我们使用了 MemoryRouter 包裹组件处理待测组件中可能会使用路由相关 API 的可能性。
  • 我们使用了 <SWRConfig value={{ provider: () => new Map() }}> 对 SWR 的强制缓存做了处理,防止测试环境下缓存造成的错误。
  • 我们使用了 debugger 对我们的测试进行了调试,这样有助于及时的发现问题。
  • 我们使用了findBy findAllBy 的技巧,利用测试框架的 act 窗口在网络请求之后成功捕获到页面上的目标元素。

33. 测试也需要 debug -- 使用 only

为了专注于特定的测试案例,我们可以将常规的 testdescribe 语句替换为 test.onlydescribe.only。这样一来,仅当测试文件中存在至少一个使用 only 修饰的测试或描述块时,Jest 才会执行这些特定的 describetest。这种模式有助于我们集中资源和注意力,确保仅运行那些被标记为重点的测试用例。

三种在 debug 过程中缩小查询范围的技术

  1. 使用 test.only 和 describe.only
  2. 创建 debugger
  3. 使用 console.log 打印信息

如何创建 debugger

  1. 在 package.json 中增加一个脚本,如下所示:
json 复制代码
"test:debug": "react-scripts --inspect-brk test --runInBand --no-cache",
  1. 在需要 debugger 的测试文件上先使用 describe.only 和 test.only 缩小范围,然后在感兴趣的测试区域中使用 debugger 语句。
js 复制代码
  test('renders two links for each language', async () => {
    render(
      <MemoryRouter>
        <HomeRoute />
      </MemoryRouter>
    );
    debugger
    const lis = await screen.findAllByRole('listitem');
    debugger
    expect(lis).toHaveLength(2);
  });
  1. 然后在 terminal 中执行 npm run test:debug.
  2. 成功执行之后找一个 chrome 浏览器,访问:chrome://inspect/#devices, 找到下方的 Remote Target, 点击蓝色的 inspect 即可打开调试框。
相关推荐
安冬的码畜日常16 天前
【The Art of Unit Testing 3_自学笔记06】3.4 + 3.5 单元测试核心技能之:函数式注入与模块化注入的解决方案简介
笔记·学习·单元测试·jest
混血哲谈2 个月前
使用@test-library/react的screen中的方法和直接使用getByText,getByTestId等的区别?
前端·javascript·react.js·jest·testing-library
程序员也要学好英语4 个月前
搭建 react + antd 技术栈的测试框架
react.js·jest·ant design
uccs4 个月前
初识 jest
前端·jest
巫瞅瞅5 个月前
明明单独跑某个测试是通过的,怎么全部一起跑就挂了呢
react·jest·unit test
慕仲卿7 个月前
前端测试,你要用 Puppeteer 还是 Jest?
jest
蛞蝓不孤寡7 个月前
jest单元测试——项目实战
单元测试·jest
19组清风8 个月前
前端工程师应该如何正确面对 UI 组件视觉回归测试
前端·javascript·jest
搬砖的乔布梭8 个月前
如何使用React+jest开展单元测试
单元测试·jest·前端工程化