ReactLynx Testing Library

ReactLynx Testing Library 基于 Lynx Test Environment(@lynx-js/test-environment,Lynx 的 JS 实现) 实现了一套 ReactLynx 组件的测试方案。

ReactLynx Testing Library 借鉴了 React Testing Library 的设计。底层的 Lynx Test Environment 负责提供 Lynx 环境的 JS 实现,类似于 jsdom 等工具提供的 DOM 环境。我们在 Lynx Test Environment 中使用了 jsdom 来实现 Element PAPI,因此你可以直接使用 @testing-library/dom@testing-library/jest-dom 等工具来辅助测试。

适用场景

ReactLynx Testing Library 适用于对于 ReactLynx 组件的单元测试。它基于 Lynx Test Environment 和 @testing-library/dom(页面元素的查询和事件触发),屏蔽了 Lynx 双线程的实现细节,将 ReactLynx 的渲染流程抽象成 renderfireEventscreen 等和 React Testing Library 类似的 API。用户也可以对页面中的 Element 进行断言,例如使用 data-testid 来快速寻找元素,使用 @testing-library/jest-dom 中的 toBeInTheDocument 判断元素在页面中。

配置

ReactLynx Testing Library 集成在 @lynx-js/react 包的 testing-library 子目录中,可以直接使用。

配置 Vitest:

vitest.config.js
import { defineConfig, mergeConfig } from 'vitest/config';
import { createVitestConfig } from '@lynx-js/react/testing-library/vitest-config';

const defaultConfig = await createVitestConfig();
const config = defineConfig({
  test: {
    // ...
  },
});

export default mergeConfig(defaultConfig, config);

如果需要使用 @testing-library/jest-dom 中的 toBeInTheDocument 等方法,你需要安装 @testing-library/jest-dom

npm
yarn
pnpm
bun
npm add -D @testing-library/jest-dom

然后你就可以开始编写测试并运行了,下面是一个完整的示例:

src/__tests__/index.test.tsx
import '@testing-library/jest-dom';
import { expect, test, vi } from 'vitest';
import { render, getQueriesForElement } from '@lynx-js/react/testing-library';
import { Component } from '@lynx-js/react';

export interface IProps {
  onMounted?: () => void;
}

export class App extends Component<IProps> {
  override componentDidMount() {
    this.props?.onMounted?.();
  }

  override render() {
    return (
      <view>
        <text id="app-text">Hello World!</text>
      </view>
    );
  }
}

test('App', async () => {
  const cb = vi.fn();

  render(
    <App
      onMounted={() => {
        cb(`__MAIN_THREAD__: ${__MAIN_THREAD__}`);
      }}
    />,
  );
  expect(cb).toBeCalledTimes(1);
  expect(cb.mock.calls).toMatchInlineSnapshot(`
    [
      [
        "__MAIN_THREAD__: false",
      ],
    ]
  `);
  expect(elementTree.root).toMatchInlineSnapshot(`
    <page>
      <view>
        <text
          id="app-text"
        >
          Hello World!
        </text>
      </view>
    </page>
  `);
  const { findByText } = getQueriesForElement(elementTree.root!);
  const element = await findByText('Hello World!');
  expect(element).toBeInTheDocument();
  expect(element).toMatchInlineSnapshot(`
    <text
      id="app-text"
    >
      Hello World!
    </text>
  `);
});

示例

快速开始

这是一个最小化的示例,展示了如何使用 ReactLynx Testing Library 进行测试。

import '@testing-library/jest-dom';
import { expect, it, vi } from 'vitest';
import { render, fireEvent, screen } from '@lynx-js/react/testing-library';

it('basic', async function () {
  const Button = ({ children, onClick }) => {
    return <view bindtap={onClick}>{children}</view>;
  };
  const onClick = vi.fn(() => {});

  // ARRANGE
  const { container } = render(
    <Button onClick={onClick}>
      <text data-testid="text">Click me</text>
    </Button>,
  );

  expect(onClick).not.toHaveBeenCalled();

  // ACT
  fireEvent.tap(container.firstChild);

  // ASSERT
  expect(onClick).toBeCalledTimes(1);
  expect(screen.getByTestId('text')).toHaveTextContent('Click me');
});

基础渲染

import '@testing-library/jest-dom';
import { expect, it } from 'vitest';
import { render } from '@lynx-js/react/testing-library';

it('basic render', () => {
  const WrapperComponent = ({ children }) => (
    <view data-testid="wrapper">{children}</view>
  );
  const Comp = () => {
    return <view data-testid="inner" style="background-color: yellow;" />;
  };
  const { container, getByTestId } = render(<Comp />, {
    wrapper: WrapperComponent,
  });
  // 由于 Lynx Test Environment 底层使用的是 jsdom 来实现 Element PAPI
  // 因此可以使用 `@testing-library/jest-dom` 中的方法来断言页面元素是否存在
  expect(getByTestId('wrapper')).toBeInTheDocument();
  expect(container.firstChild).toMatchInlineSnapshot(`
    <view
      data-testid="wrapper"
    >
      <view
        data-testid="inner"
        style="background-color: yellow;"
      />
    </view>
  `);
});

事件触发

在触发事件时,需要显式指定事件的类型。例如 new Event('catchEvent:tap')eventType:eventName) 表示触发 catch 类型的 tap 事件,请参考事件处理器属性eventType 的可能值有:

  • bindEvent:触发 bind 类型的事件,例如 bindtap 绑定的事件应该使用 new Event('bindEvent:tap') 触发。
  • catchEvent:触发 catch 类型的事件,例如 catchtap 绑定的事件应该使用 new Event('catchEvent:tap') 触发。
  • capture-bind:触发 capture-bind 类型的事件,例如 capture-bindtap 绑定的事件应该使用 new Event('capture-bind:tap') 触发。
  • capture-catch:触发 capture-catch 类型的事件,例如 capture-catchtap 绑定的事件应该使用 new Event('capture-catch:tap') 触发。

可以直接自己构造 Event 对象,也可以使用直接传入事件类型和初始化参数让 Testing Library 自动构造 Event 对象。

import { render, fireEvent } from '@lynx-js/react/testing-library';
import { vi, expect } from 'vitest';

it('fireEvent', async () => {
  const handler = vi.fn();

  const Comp = () => {
    return <text catchtap={handler} />;
  };

  const {
    container: { firstChild: button },
  } = render(<Comp />);

  expect(button).toMatchInlineSnapshot(`<text />`);

  // Lynx Test Environment 会将事件处理器挂载到 Element 的 `eventMap` 属性上。
  // 如果需要断言事件处理器是否被挂载,可以使用 `eventMap` 属性。
  expect(button.eventMap).toMatchInlineSnapshot(`
    {
      "catchEvent:tap": [Function],
    }
  `);

  expect(handler).toHaveBeenCalledTimes(0);

  // 方式一:自己构造 Event 对象
  const event = new Event('catchEvent:tap');
  Object.assign(event, {
    eventType: 'catchEvent',
    eventName: 'tap',
    key: 'value',
  });
  expect(fireEvent(button, event)).toBe(true);

  expect(handler).toHaveBeenCalledTimes(1);
  expect(handler).toHaveBeenCalledWith(event);
  expect(handler.mock.calls[0][0].type).toMatchInlineSnapshot(
    `"catchEvent:tap"`,
  );
  expect(handler.mock.calls[0][0]).toMatchInlineSnapshot(`
  Event {
    "eventName": "tap",
    "eventType": "catchEvent",
    "isTrusted": false,
    "key": "value",
  }
  `);

  // 方式二:传入事件类型和初始化参数
  fireEvent.tap(button, {
    eventType: 'catchEvent',
    key: 'value',
  });
  expect(handler).toHaveBeenCalledTimes(2);
  expect(handler.mock.calls[1][0]).toMatchInlineSnapshot(`
  Event {
    "eventName": "tap",
    "eventType": "catchEvent",
    "isTrusted": false,
    "key": "value",
  }
  `);
});

Ref 获取

import { test, expect } from 'vitest';
import { render } from '@lynx-js/react/testing-library';
import { Component, createRef } from '@lynx-js/react';

it('element ref', async () => {
  const ref = createRef();
  const Comp = () => {
    return <view ref={ref} />;
  };
  const { container } = render(<Comp />);
  // ReactLynx 对于有 ref 的元素会设置 `has-react-ref` 属性
  // 因此可以通过快照测试来判断 ref 是否被正确设置
  expect(container).toMatchInlineSnapshot(`
    <page>
      <view
        has-react-ref="true"
      />
    </page>
  `);
  // ref.current 是一个 NodesRef 对象
  expect(ref.current).toMatchInlineSnapshot(`
    NodesRef {
      "_nodeSelectToken": {
        "identifier": "1",
        "type": 2,
      },
      "_selectorQuery": {},
    }
  `);
});

it('component ref', async () => {
  const ref1 = vi.fn();
  const ref2 = createRef();

  class Child extends Component {
    x = 'x';
    render() {
      return <view />;
    }
  }

  class Comp extends Component {
    render() {
      return (
        this.props.show && (
          <view>
            <Child ref={ref1} />
            <Child ref={ref2} />
          </view>
        )
      );
    }
  }

  const { container } = render(<Comp show />);
  expect(container).toMatchInlineSnapshot(`
      <page>
        <view>
          <view />
          <view />
        </view>
      </page>
    `);
  expect(ref1).toBeCalledWith(
    expect.objectContaining({
      x: 'x',
    }),
  );
  // ref2 指向的是 Child 组件实例
  expect(ref2.current).toHaveProperty('x', 'x');
});

使用 @testing-library/dom

ReactLynx Testing Library 中导出了 @testing-library/dom,因此你可以直接使用 @testing-library/dom 中的方法来查询页面元素。

import '@testing-library/jest-dom';
import { Component } from '@lynx-js/react';
import { expect } from 'vitest';
// waitForElementToBeRemoved 是 @testing-library/dom 中的一个方法,用于等待元素被移除
import {
  render,
  screen,
  waitForElementToBeRemoved,
} from '@lynx-js/react/testing-library';

const fetchAMessage = () =>
  new Promise((resolve) => {
    // 我们使用随机超时来模拟一个真实的例子
    const randomTimeout = Math.floor(Math.random() * 100);

    setTimeout(() => {
      resolve({ returnedMessage: 'Hello World' });
    }, randomTimeout);
  });

class ComponentWithLoader extends Component {
  state = { loading: true };

  componentDidMount() {
    fetchAMessage().then((data) => {
      this.setState({ data, loading: false });
    });
  }

  render() {
    if (this.state.loading) {
      return <text>Loading...</text>;
    }

    return (
      <text data-testid="message">
        Loaded this message: {this.state.data.returnedMessage}!
      </text>
    );
  }
}

test('it waits for the data to be loaded', async () => {
  render(<ComponentWithLoader />);
  // Lynx Test Environment 中的 elementTree.root 用于维护页面元素树
  expect(elementTree.root).toMatchInlineSnapshot(`
    <page>
      <text>
        Loading...
      </text>
    </page>
  `);
  const loading = () => {
    return screen.getByText('Loading...');
  };
  await waitForElementToBeRemoved(loading);
  // 由于 Lynx Test Environment 底层使用的是 jsdom 来实现 Element PAPI
  // 因此可以直接访问 document.body 来获取页面元素
  expect(document.body).toMatchInlineSnapshot(`
    <body>
      <page>
        <text
          data-testid="message"
        >
          Loaded this message:
          <wrapper>
            Hello World
          </wrapper>
          !
        </text>
      </page>
    </body>
  `);
  expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/);
  expect(elementTree.root).toMatchInlineSnapshot(`
    <page>
      <text
        data-testid="message"
      >
        Loaded this message:
        <wrapper>
          Hello World
        </wrapper>
        !
      </text>
    </page>
  `);
});

Rerender

import '@testing-library/jest-dom';
import { render } from '@lynx-js/react/testing-library';
import { expect } from 'vitest';

it('rerender will re-render the element', async () => {
  const Greeting = (props) => <text>{props.message}</text>;
  const { container, rerender } = render(<Greeting message="hi" />);
  expect(container).toMatchInlineSnapshot(`
    <page>
      <text>
        hi
      </text>
    </page>
  `);
  expect(container.firstChild).toHaveTextContent('hi');

  {
    // 不同于 React Testing Library,container 需要在 rerender 之后重新获取
    // 因为 ReactLynx 每次加载都会创建一个新的 page 元素
    const { container } = rerender(<Greeting message="hey" />);
    expect(container.firstChild).toHaveTextContent('hey');

    expect(container).toMatchInlineSnapshot(`
      <page>
        <text>
          hey
        </text>
      </page>
    `);
  }
});

list

list 的使用文档请参考:https://lynxjs.org/zh/api/elements/built-in/list.html

import { useState } from '@lynx-js/react';
import { render } from '@lynx-js/react/testing-library';
import { expect } from 'vitest';

it('list', () => {
  const Comp = () => {
    const [list, setList] = useState([0, 1, 2]);
    return (
      <list>
        {list.map((item) => (
          <list-item key={item} item-key={item}>
            <text>{item}</text>
          </list-item>
        ))}
      </list>
    );
  };
  const { container } = render(<Comp />);
  expect(container).toMatchInlineSnapshot(`
    <page>
      <list
        update-list-info="[{"insertAction":[{"position":0,"type":"__Card__:__snapshot_f75b7_test_2","item-key":0},{"position":1,"type":"__Card__:__snapshot_f75b7_test_2","item-key":1},{"position":2,"type":"__Card__:__snapshot_f75b7_test_2","item-key":2}],"removeAction":[],"updateAction":[]}]"
      />
    </page>
  `);
  const list = container.firstChild;
  // 进入给定索引 0 处的列表项元素,加载列表项元素
  const uid0 = elementTree.enterListItemAtIndex(list, 0);
  expect(list).toMatchInlineSnapshot(`
    <list
      update-list-info="[{"insertAction":[{"position":0,"type":"__Card__:__snapshot_f75b7_test_2","item-key":0},{"position":1,"type":"__Card__:__snapshot_f75b7_test_2","item-key":1},{"position":2,"type":"__Card__:__snapshot_f75b7_test_2","item-key":2}],"removeAction":[],"updateAction":[]}]"
    >
      <list-item
        item-key="0"
      >
        <text>
          0
        </text>
      </list-item>
    </list>
  `);
  // 离开给定索引 0 处的列表项元素,将标记列表项元素为可回收
  elementTree.leaveListItem(list, uid0);
  expect(list).toMatchInlineSnapshot(`
    <list
      update-list-info="[{"insertAction":[{"position":0,"type":"__Card__:__snapshot_f75b7_test_2","item-key":0},{"position":1,"type":"__Card__:__snapshot_f75b7_test_2","item-key":1},{"position":2,"type":"__Card__:__snapshot_f75b7_test_2","item-key":2}],"removeAction":[],"updateAction":[]}]"
    >
      <list-item
        item-key="0"
      >
        <text>
          0
        </text>
      </list-item>
    </list>
  `);
  // 触发 list 的 componentAtIndex 方法,加载第 1 个 item,此时会复用被回收的 item
  const uid1 = elementTree.enterListItemAtIndex(list, 1);
  expect(list).toMatchInlineSnapshot(`
    <list
      update-list-info="[{"insertAction":[{"position":0,"type":"__Card__:__snapshot_f75b7_test_2","item-key":0},{"position":1,"type":"__Card__:__snapshot_f75b7_test_2","item-key":1},{"position":2,"type":"__Card__:__snapshot_f75b7_test_2","item-key":2}],"removeAction":[],"updateAction":[]}]"
    >
      <list-item
        item-key="1"
      >
        <text>
          1
        </text>
      </list-item>
    </list>
  `);
});

主线程脚本

主线程脚本的使用文档请参考:https://lynxjs.org/zh/react/main-thread-script.html

import { fireEvent, render } from '@lynx-js/react/testing-library';
import { expect } from 'vitest';

it('main thread script', async () => {
  globalThis.cb = vi.fn();
  const Comp = () => {
    return (
      <view
        main-thread:bindtap={(e) => {
          'main thread';
          globalThis.cb(e);
        }}
      >
        <text>Hello Main Thread Script</text>
      </view>
    );
  };
  const { container } = render(<Comp />, {
    // 你可以尝试开启同时主线程和后台线程,得到的效果都将是一样的
    // enableMainThread: true,
    // enableBackgroundThread: true,
  });
  expect(container).toMatchInlineSnapshot(`
    <page>
      <view>
        <text>
          Hello Main Thread Script
        </text>
      </view>
    </page>
  `);
  fireEvent.tap(container.firstChild, {
    key: 'value',
  });
  expect(cb).toBeCalledTimes(1);
  expect(cb.mock.calls).toMatchInlineSnapshot(`
    [
      [
        {
          "eventName": "tap",
          "eventType": "bindEvent",
          "isTrusted": false,
          "key": "value",
        },
      ],
    ]
  `);
});

控制渲染所用的线程

render 方法的第二个参数中支持 enableMainThreadenableBackgroundThread 两个选项,用于开启主线程和后台线程。默认情况下 enableMainThreadfalseenableBackgroundThreadtrue,这样主线程不会渲染首屏,只有后台线程运行完整的 Preact 运行时,并将 UI 更新发送到主线程进行处理。我们之所以不同时开启主线程和后台线程,是因为单元测试并没有和使用 Rspeedy 构建一样将文件编译成两份产物,这样会导致最顶层的代码只能执行一次,并且默认是在后台线程中。假如你写了一个这样的组件:

const isBackground = __BACKGROUND__;
const Comp = () => {
  return (
    <view>
      <text>{isBackground ? 'background' : 'main thread'}</text>
    </view>
  );
};

这个组件最顶层的 isBackground 只会被执行一次,而且默认情况下是 true(Lynx Test Environment 初始化后默认为后台线程),为了避免 enableMainThread: true 时渲染出错误的首屏结果,我们默认将 enableMainThread 设置为 false,得到空首屏,可以避免此问题。

如果确认自己的组件顶层代码没有双线程的差异,建议开启 enableMainThread,这样可以得到正确的首屏结果。

更多用法

更多用法请参考可以参考 ReactLynx Testing Library 源码中维护的测试用例

API 参考

详见 API 参考

除非另有说明,本项目采用知识共享署名 4.0 国际许可协议进行许可,代码示例采用 Apache License 2.0 许可协议进行许可。