使用 Preact 测试库进行测试

¥Testing with Preact Testing Library

Preact 测试库preact/test-utils 的轻量级封装。它提供了一组查询方法来访问渲染的 DOM,其方式类似于用户在页面上查找元素的方式。这种方法允许你编写不依赖于实现细节的测试。因此,当被测试的组件被重构时,这使得测试更容易维护并且更具弹性。

¥The Preact Testing Library is a lightweight wrapper around preact/test-utils. It provides a set of query methods for accessing the rendered DOM in a way similar to how a user finds elements on a page. This approach allows you to write tests that do not rely on implementation details. Consequently, this makes tests easier to maintain and more resilient when the component being tested is refactored.

Enzyme 不同,Preact 测试库必须在 DOM 环境中调用。

¥Unlike Enzyme, Preact Testing Library must be called inside a DOM environment.



安装

¥Installation

通过以下命令安装测试库 Preact 适配器:

¥Install the testing-library Preact adapter via the following command:

npm install --save-dev @testing-library/preact

注意:该库依赖于存在的 DOM 环境。如果你使用的是 Jest,它已默认包含并启用。如果你使用其他测试运行程序(例如 MochaJasmine),你可以通过安装 jsdom 向节点添加 DOM 环境。

¥Note: This library relies on a DOM environment being present. If you're using Jest it's already included and enabled by default. If you're using another test runner like Mocha or Jasmine you can add a DOM environment to node by installing jsdom.

用法

¥Usage

假设我们有一个 Counter 组件,它显示初始值,并有一个按钮可以更新它:

¥Suppose we have a Counter component which displays an initial value, with a button to update it:

import { h } from 'preact';
import { useState } from 'preact/hooks';

export function Counter({ initialCount }) {
  const [count, setCount] = useState(initialCount);
  const increment = () => setCount(count + 1);

  return (
    <div>
      Current value: {count}
      <button onClick={increment}>Increment</button>
    </div>
  );
}

我们想要验证我们的计数器是否显示初始计数,并且单击按钮会增加它。使用你选择的测试运行器(例如 JestMocha),我们可以写下这两个场景:

¥We want to verify that our Counter displays the initial count and that clicking the button will increment it. Using the test runner of your choice, like Jest or Mocha, we can write these two scenarios down:

import { expect } from 'expect';
import { h } from 'preact';
import { render, fireEvent, screen, waitFor } from '@testing-library/preact';

import Counter from '../src/Counter';

describe('Counter', () => {
  test('should display initial count', () => {
    const { container } = render(<Counter initialCount={5}/>);
    expect(container.textContent).toMatch('Current value: 5');
  });

  test('should increment after "Increment" button is clicked', async () => {
    render(<Counter initialCount={5}/>);

    fireEvent.click(screen.getByText('Increment'));
    await waitFor(() => {
      // .toBeInTheDocument() is an assertion that comes from jest-dom.
      // Otherwise you could use .toBeDefined().
      expect(screen.getByText("Current value: 6")).toBeInTheDocument();
    });
  });
});

你可能已经注意到那里的 waitFor() 调用。我们需要这个来确保 Preact 有足够的时间渲染到 DOM 并刷新所有待处理的效果。

¥You may have noticed the waitFor() call there. We need this to ensure that Preact had enough time to render to the DOM and flush all pending effects.

test('should increment counter", async () => {
  render(<Counter initialCount={5}/>);

  fireEvent.click(screen.getByText('Increment'));
  // WRONG: Preact likely won't have finished rendering here
  expect(screen.getByText("Current value: 6")).toBeInTheDocument();
});

在幕后,waitFor 重复调用传递的回调函数,直到不再抛出错误或超时(默认值:1000 毫秒)。在上面的示例中,当计数器增加并将新值渲染到 DOM 中时,我们知道更新已完成。

¥Under the hood, waitFor repeatedly calls the passed callback function until it doesn't throw an error anymore or a timeout runs out (default: 1000ms). In the above example we know that the update is completed, when the counter is incremented and the new value is rendered into the DOM.

我们还可以使用 "findBy" 版本的查询而不是 "getBy" 以异步优先的方式编写测试。异步查询在后台使用 waitFor 重试,并返回 Promises,因此你需要等待它们。

¥We can also write tests in an async-first way by using the "findBy" version of the queries instead of "getBy". Async queries retry using waitFor under the hood, and return Promises, so you need to await them.

test('should increment counter", async () => {
  render(<Counter initialCount={5}/>);

  fireEvent.click(screen.getByText('Increment'));

  await screen.findByText('Current value: 6'); // waits for changed element

  expect(screen.getByText("Current value: 6")).toBeInTheDocument(); // passes
});

寻找元素

¥Finding Elements

有了完整的 DOM 环境,我们就可以直接验证 DOM 节点。通常测试检查属性是否存在,例如输入值或元素是否出现/消失。为此,我们需要能够在 DOM 中定位元素。

¥With a full DOM environment in place, we can verify our DOM nodes directly. Commonly tests check for attributes being present like an input value or that an element appeared/disappeared. To do this, we need to be able to locate elements in the DOM.

使用内容

¥Using Content

测试库的理念是 "你的测试越接近软件的使用方式,就越能给你带来信心"。

¥The Testing Library philosophy is that "the more your tests resemble the way your software is used, the more confidence they can give you".

与页面交互的推荐方法是按照用户的方式通过文本内容查找元素。

¥The recommended way to interact with a page is by finding elements the way a user does, through the text content.

你可以在测试库文档的 '我应该使用哪个查询' 页面上找到选择正确查询的指南。最简单的查询是 getByText,它查看元素的 textContent。还有针对标签文本、占位符、标题属性等的查询。getByRole 查询是最强大的,因为它对 DOM 进行抽象,并允许你在可访问性树中查找元素,这就是屏幕读取页面的方式 读者。结合 roleaccessible name 在单个查询中涵盖了许多常见的 DOM 遍历。

¥You can find a guide to picking the right query on the 'Which query should I use' page of the Testing Library docs. The simplest query is getByText, which looks at elements' textContent. There are also queries for label text, placeholder, title attributes, etc. The getByRole query is the most powerful in that it abstracts over the DOM and allows you to find elements in the accessibility tree, which is how your page is read by a screen reader. Combining role and accessible name covers many common DOM traversals in a single query.

import { render, fireEvent, screen } from '@testing-library/preact';

test('should be able to sign in', async () => {
  render(<MyLoginForm />);

  // Locate the input using textbox role and the accessible name,
  // which is stable no matter if you use a label element, aria-label, or
  // aria-labelledby relationship
  const field = await screen.findByRole('textbox', { name: 'Sign In' });

  // type in the field
  fireEvent.change(field, { value: 'user123' });
})

有时,当内容变化很大时,或者如果你使用将文本翻译成不同语言的国际化框架,直接使用文本内容会产生摩擦。你可以通过将文本视为快照数据来解决此问题,这样可以轻松更新,但将事实来源保留在测试之外。

¥Sometimes using text content directly creates friction when the content changes a lot, or if you use an internationalization framework that translates text into different languages. You can work around this by treating text as data that you snapshot, making it easy to update but keeping the source of truth outside the test.

test('should be able to sign in', async () => {
  render(<MyLoginForm />);

  // What if we render the app in another language, or change the text? Test fails.
  const field = await screen.findByRole('textbox', { name: 'Sign In' });
  fireEvent.change(field, { value: 'user123' });
})

即使你不使用翻译框架,你也可以将字符串保存在单独的文件中,并使用与以下示例相同的策略:

¥Even if you don't use a translation framework, you can keep your strings in a separate file and use the same strategy as in the example below:

test('should be able to sign in', async () => {
  render(<MyLoginForm />);

  // We can use our translation function directly in the test
  const label = translate('signinpage.label', 'en-US');
  // Snapshot the result so we know what's going on
  expect(label).toMatchInlineSnapshot(`Sign In`);

  const field = await screen.findByRole('textbox', { name: label });
  fireEvent.change(field, { value: 'user123' });
})

使用测试 ID

¥Using Test IDs

测试 ID 是添加到 DOM 元素的数据属性,可在选择内容不明确或不可预测的情况下提供帮助,或者与 DOM 结构等实现细节分离。当其他查找元素的方法都没有意义时,可以使用它们。

¥Test IDs are data attributes added to DOM elements to help in cases where selecting content is ambiguous or unpredictable, or to decouple from implementation details like DOM structure. They can be used when none of the other methods of finding elements make sense.

function Foo({ onClick }) {
  return (
    <button onClick={onClick} data-testid="foo">
      click here
    </button>
  );
}

// Only works if the text stays the same
fireEvent.click(screen.getByText('click here'));

// Works if we change the text
fireEvent.click(screen.getByTestId('foo'));

调试测试

¥Debugging Tests

要调试当前 DOM 状态,你可以使用 debug() 函数打印出 DOM 的美化版本。

¥To debug the current DOM state you can use the debug() function to print out a prettified version of the DOM.

const { debug } = render(<App />);

// Prints out a prettified version of the DOM
debug();

提供自定义上下文提供程序

¥Supplying custom Context Providers

通常,你最终会得到一个依赖于共享上下文状态的组件。通用提供程序的范围通常包括路由、状态,有时还包括主题以及其他针对你的特定应用而言全局的提供程序。为每个测试用例重复设置可能会变得很乏味,因此我们建议通过封装 @testing-library/preact 中的函数来创建自定义 render 函数。

¥Quite often you'll end up with a component which depends on shared context state. Common Providers typically range from Routers, State, to sometimes Themes and other ones that are global for your specific app. This can become tedious to set up for each test case repeatedly, so we recommend creating a custom render function by wrapping the one from @testing-library/preact.

// helpers.js
import { render as originalRender } from '@testing-library/preact';
import { createMemoryHistory } from 'history';
import { FooContext } from './foo';

const history = createMemoryHistory();

export function render(vnode) {
  return originalRender(
    <FooContext.Provider value="foo">
      <Router history={history}>
        {vnode}
      </Router>
    </FooContext.Provider>
  );
}

// Usage like usual. Look ma, no providers!
render(<MyComponent />)

测试 Preact Hooks

¥Testing Preact Hooks

通过 @testing-library/preact,我们还可以测试我们的钩子的实现!想象一下,我们想要为多个组件重用计数器功能(我知道我们喜欢计数器!)并将其提取到一个钩子。我们现在想要测试它。

¥With @testing-library/preact we can also test the implementation of our hooks! Imagine that we want to re-use the counter functionality for multiple components (I know we love counters!) and have extracted it to a hook. And we now want to test it.

import { useState, useCallback } from 'preact/hooks';

const useCounter = () => {
  const [count, setCount] = useState(0);
  const increment = useCallback(() => setCount(c => c + 1), []);
  return { count, increment };
}

和以前一样,其背后的方法是相似的:我们想要验证我们是否可以增加计数器。所以我们需要以某种方式调用我们的钩子。这可以通过 renderHook() 函数来完成,该函数会在内部自动创建周围组件。该函数返回 result.current 下的当前钩子返回值,我们可以用它来进行验证:

¥Like before, the approach behind it is similar: We want to verify that we can increment our counter. So we need to somehow call our hook. This can be done with the renderHook()-function, which automatically creates a surrounding component internally. The function returns the current hook return value under result.current, which we can use to do our verifications:

import { renderHook, act } from '@testing-library/preact';
import useCounter from './useCounter';

test('should increment counter', () => {
  const { result } = renderHook(() => useCounter());

  // Initially the counter should be 0
  expect(result.current.count).toBe(0);

  // Let's update the counter by calling a hook callback
  act(() => {
    result.current.increment();
  });

  // Check that the hook return value reflects the new state.
  expect(result.current.count).toBe(1);
});

有关 @testing-library/preact 的更多信息请查看 https://github.com/testing-library/preact-testing-library

¥For more information about @testing-library/preact check out https://github.com/testing-library/preact-testing-library .

Preact 中文网 - 粤ICP备13048890号