ReactLynx Testing Library

@lynx-js/react/testing-library 包适用于对 ReactLynx 组件的渲染结果进行测试。它提供了和 React Testing Library 相同的 API,例如 renderfireEventscreen 等,其底层使用了 @lynx-js/testing-environment 包来提供 Lynx 环境的 JS 实现,屏蔽了 Lynx 双线程的实现细节。

配置

从 create-rspeedy 创建新项目

使用 create-rspeedy 创建的项目,在创建时可以主动选择是否使用 ReactLynx Testing Library(默认勾选),勾选后创建出来的项目已经配置好了 ReactLynx Testing Library。

在已有项目中配置

ReactLynx Testing Library 集成在 @lynx-js/react 包的 testing-library 子目录中,无需额外安装其他包。

配置 Vitest 时需要使用 @lynx-js/react/testing-library/vitest-config 中的 createVitestConfig 方法来创建 Vitest 配置。你可以通过 mergeConfig 方法将其和其他配置合并。

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);

示例

快速开始

和 React 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(() => {});

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

  expect(onClick).not.toHaveBeenCalled();

  // 操作
  fireEvent.tap(container.firstChild);

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

在这个示例中,你可能已经注意到了我们用到了第三方包 @testing-library/jest-dom 中的 toHaveTextContent 方法来断言元素的文本内容。在 React Testing Library 中,你可以使用 @testing-library/jest-dom 是因为测试框架会使用 JSDOM 来创建 DOM 元素;在 ReactLynx Testing Library 中,我们同样使用了 JSDOM 来实现 Element PAPI 的行为,因此和 DOM API 完全兼容。

基础渲染

render 方法用于渲染一个 ReactLynx 组件,并返回一个 RenderResult 对象,其中的 container 字段是一个 LynxElement,表示渲染结果的根元素。

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

it('basic render', () => {
  const Comp = () => {
    return <view data-testid="inner" style="background-color: yellow;" />;
  };
  const { container, getByTestId } = render(<Comp />);
  expect(getByTestId('wrapper')).toBeInTheDocument();
  expect(container.firstChild).toMatchInlineSnapshot(`
    <view
      data-testid="wrapper"
    >
      <view
        data-testid="inner"
        style="background-color: yellow;"
      />
    </view>
  `);
});

事件触发

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

事件类型eventType事件绑定举例事件触发举例
bindbindEventbindtapnew Event('bindEvent:tap')
catchcatchEventcatchtapnew Event('catchEvent:tap')
capture-bindcapture-bindcapture-bindtapnew Event('capture-bind:tap')
capture-catchcapture-catchcapture-catchtapnew Event('capture-catch:tap')

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

render 过程中,事件处理器会被挂载到 LynxElementeventMap 属性上,因此可以通过 eventMap 属性来获取元素的事件处理器,用于断言事件处理器是否被正确挂载。

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 />`);

  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

在 ReactLynx Testing Library 中,可以对渲染结果和元素对应的 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');
});

页面元素查询

你可以使用 screen 对象来查询页面元素,它提供了一些常用的方法,例如 getByTextgetByTestId 等。还有像 waitForElementToBeRemoved 这样的方法对页面元素状态进行等待。

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 />);
  // elementTree.root 用于维护页面元素树
  expect(elementTree.root).toMatchInlineSnapshot(`
    <page>
      <text>
        Loading...
      </text>
    </page>
  `);
  const loading = () => {
    return screen.getByText('Loading...');
  };
  await waitForElementToBeRemoved(loading);
  // 由于底层使用的是 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>
  `);
});

在这个例子中,我们使用 waitForElementToBeRemoved 方法来等待 Loading... 元素被移除,此时页面中会渲染出 Loaded this message: Hello World! 元素。这时我们可以用 screen.getByTestId 方法来获取页面中的元素,并断言其文本内容是否正确。

rerender

render 方法返回的对象中包含 rerender 方法,可以用于重新渲染页面。rerender 方法会将新的组件渲染到页面中,并返回一个新的对象。可以使用 rerender 方法来测试组件的不同状态。

WARNING

不同于 React Testing Library,container 需要在 rerender 之后重新获取,因为 ReactLynx 每次加载都会创建一个新的 page 元素。

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');

  {
    const { container } = rerender(<Greeting message="hey" />);
    expect(container.firstChild).toHaveTextContent('hey');

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

测试list

由于 list 下的 list-item 元素是懒加载的,只有元素进入视口时才会被加载,离开视口时会被标记为可回收,在测试框架中可以使用 elementTree.enterListItemAtIndexelementTree.leaveListItem 方法来模拟列表项元素的加载和回收。

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>
  `);

  // 进入给定索引 1 处的列表项元素,此时会复用被回收的 list-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>
  `);
});

在这个例子中,我们进入了索引 0 处的列表项元素,加载了列表项元素。然后我们离开了索引 0 处的列表项元素,将标记列表项元素为可回收。最后我们进入了索引 1 处的列表项元素,此时会复用被回收的 list-item

测试主线程脚本

主线程脚本的测试无需额外配置,需要注意的是,主线程脚本中不能直接调用后台线程的方法,因此需要断言函数被调用时,推荐将函数放在 globalThis 上,例如:

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",
        },
      ],
    ]
  `);
});

在这个例子中,我们触发了一个 tap 事件,并在事件处理器中调用了 globalThis.cb 函数,然后断言 globalThis.cb 函数被调用了一次,并且事件对象中的 key 属性为 value

更多用法

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

进阶功能

控制渲染时的双线程行为

render 方法的第二个参数 RenderOptions 中支持 enableMainThreadenableBackgroundThread 两个选项,enableMainThread 用于开启首帧直出enableBackgroundThread 用于开启后台线程渲染。

以这个组件为例:

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

下表列出了不同配置下的渲染结果:

enableMainThreadenableBackgroundThread首帧直出渲染结果后台线程渲染结果适用场景
falsetruebackground默认值,适用于大部分场景
truefalsemain thread需要确保首帧直出正确的场景
truetruemain threadbackground需要确保双线程一起正常渲染的场景

注意

当你希望编写一个测试用例测试首帧渲染(即 enableMainThread: true)结果时,请确保该渲染没有依赖来自最顶层的副作用。

由于没有和使用 Rspeedy 构建一样将文件编译成双线程对应的两份产物,Lynx 测试环境的初始化环境为后台线程。比如,下面的例子中 isBackground 变量的值会被设置为 true

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

const isBackground = __BACKGROUND__;

describe('IFR Testing', () => {
  it('will render a wrong result if it has top-level side effects', () => {
    const CompWithTopLevelSideEffects = () => {
      return <text>{isBackground ? 'background' : 'main thread'}</text>;
    };

    const { container } = render(<CompWithTopLevelSideEffects />, {
      enableMainThread: true,
      enableBackgroundThread: false,
    });
    // 测试失败
    // Error: expect(element).toHaveTextContent()
    // Expected element to have text content:
    //   main thread
    // Received:
    //   background
    expect(container).toHaveTextContent('main thread');
  });
});

正确的写法应该是,将对环境变量 __BACKGROUND__ 的引用包含在组件内部。这里给出了一个例子:

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

describe('IFR Testing', () => {
  it('will render the correct result if it does not have top-level side effects', () => {
    const Comp = () => {
      return <text>{__BACKGROUND__ ? 'background' : 'main thread'}</text>;
    };

    const { container } = render(<Comp />, {
      enableMainThread: true,
      enableBackgroundThread: false,
    });
    // 测试通过
    expect(container).toHaveTextContent('main thread');
  });
});

API 参考

详见 API 参考

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