ReactLynx Testing Library

ReactLynx Testing Library implements a set of testing solutions for ReactLynx components based on Lynx Test Environment (@lynx-js/test-environment, JS implementation of Lynx).

ReactLynx Testing Library is inspired by React Testing Library. The underlying Lynx Test Environment is responsible for providing the JS implementation of the Lynx Test Environment, similar to the DOM environment provided by tools such as jsdom. We use jsdom in Lynx Test Environment to implement Element PAPI, so you can directly use tools such as @testing-library/dom and @testing-library/jest-dom to assist testing.

Applicable scenarios

ReactLynx Testing Library is suitable for unit testing of ReactLynx components. It is based on Lynx Test Environment and @testing-library/dom (querying and event triggering of page elements), shielding the implementation details of Lynx dual threads, and abstracting the rendering process of ReactLynx into render, fireEvent, screen and other APIs similar to React Testing Library. Users can also assert on the elements in the page, for example, use data-testid to quickly find the element, and use toBeInTheDocument in @testing-library/jest-dom to determine whether the element is in the page.

Setup

ReactLynx Testing Library is integrated in the testing-library subdirectory of the @lynx-js/react package and can be used directly.

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

Then you can start writing tests and running them!

If you need to use methods such as toBeInTheDocument in @testing-library/jest-dom, you need to install @testing-library/jest-dom:

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

Then you can start writing tests and running them. Here is a complete example:

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

Examples

Quick Start

This is a minimal example that shows how to test with 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');
});

Basic rendering

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,
  });
  // Since Lynx Test Environment uses jsdom to implement Element PAPI at the bottom layer
  // you can use the method in `@testing-library/jest-dom` to assert whether the page element exists
  expect(getByTestId('wrapper')).toBeInTheDocument();
  expect(container.firstChild).toMatchInlineSnapshot(`
    <view
      data-testid="wrapper"
    >
      <view
        data-testid="inner"
        style="background-color: yellow;"
      />
    </view>
  `);
});

Firing events

When firing an event, you need to explicitly specify the type of event. For example, new Event('catchEvent:tap') (eventType:eventName) means triggering a tap event of type catch. Please refer to Event Handler Properties. The possible values of eventType are:

  • bindEvent: trigger an event of type bind. For example, an event bound to bindtap should be triggered using new Event('bindEvent:tap').
  • catchEvent: trigger an event of type catch. For example, an event bound to catchtap should be triggered using new Event('catchEvent:tap').
  • capture-bind: trigger an event of type capture-bind. For example, an event bound to capture-bindtap should be triggered using new Event('capture-bind:tap').
  • capture-catch: trigger an event of the capture-catch type. For example, an event bound to capture-catchtap should be triggered using new Event('capture-catch:tap').

You can directly construct an Event object yourself, or you can directly pass in the event type and initialization parameters to let the Testing Library automatically construct an Event object.

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 will mount the event handler to the `eventMap` property of Element.
  // If you need to assert whether the event handler is mounted, you can use the `eventMap` property.
  expect(button.eventMap).toMatchInlineSnapshot(`
    {
      "catchEvent:tap": [Function],
    }
  `);

  expect(handler).toHaveBeenCalledTimes(0);

  // Method 1: Construct the Event object yourself
  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",
  }
  `);

  // Method 2: Pass in event type and initialization parameters
  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 sets the `has-react-ref` attribute for elements with ref
  // So you can use snapshot testing to determine whether ref is set correctly
  expect(container).toMatchInlineSnapshot(`
    <page>
      <view
        has-react-ref="true"
      />
    </page>
  `);
  // ref.current is a NodesRef object
  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 refers to the Child component instance
  expect(ref2.current).toHaveProperty('x', 'x');
});

Using @testing-library/dom

@testing-library/dom is reexported in ReactLynx Testing Library, so you can use its exports directly in @testing-library/dom to query page elements.

import '@testing-library/jest-dom';
import { Component } from '@lynx-js/react';
import { expect } from 'vitest';
// waitForElementToBeRemoved is a method in @testing-library/dom that waits for an element to be removed
import {
  render,
  screen,
  waitForElementToBeRemoved,
} from '@lynx-js/react/testing-library';

const fetchAMessage = () =>
  new Promise((resolve) => {
    // we are using random timeout here to simulate a real-time example
    // of an async operation calling a callback at a non-deterministic time
    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 in Lynx Test Environment is used to maintain the page element tree
  expect(elementTree.root).toMatchInlineSnapshot(`
    <page>
      <text>
        Loading...
      </text>
    </page>
  `);
  const loading = () => {
    return screen.getByText('Loading...');
  };
  await waitForElementToBeRemoved(loading);
  // Since Lynx Test Environment uses jsdom to implement Element PAPI at the bottom layer
  // you can directly access document.body to get page elements
  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 your component', 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');

  {
    // Unlike React Testing Library, container needs to be retrieved after rerender
    // Because ReactLynx creates a new page element each time it loads
    const { container } = rerender(<Greeting message="hey" />);
    expect(container.firstChild).toHaveTextContent('hey');

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

list

For list usage documentation, please refer to: 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;
  // Enter the list-item element at the given index 0, it will load the list-item element
  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>
  `);
  // Leave the list-item element at the given index 0, it will mark the list-item element as unused and can be recycled
  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>
  `);
  // Trigger componentAtIndex method of list, load the 1th item (it will reuse the recycled 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>
  `);
});

Main thread script

For the usage document of the main thread script, please refer to: 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 />, {
    // You can try to enable both the main thread and the background thread at the same time
    // and the results will be the same
    // 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",
        },
      ],
    ]
  `);
});

Choose the thread used for rendering

The second parameter of the render method supports enableMainThread and enableBackgroundThread are two options for enabling the main thread and background threads. By default, enableMainThread is false and enableBackgroundThread is true, so the main thread does not render the first screen, only the background thread runs the full Preact runtime and sends UI updates to the main thread for rendering. The reason why we don't enable both the main thread and the background thread is because unit tests do not compile files into two bundles like using Rspeedy does, which will result in the top-level code being executed only once, and by default in the background thread. Suppose you wrote a component like this:

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

The top-level isBackground of this module will only be executed once, and by default it is true (Lynx Test Environment defaults to the background thread after initialization). In order to avoid rendering the wrong first screen result when enableMainThread: true, we set enableMainThread to false by default, and get an empty first screen, which can avoid this problem.

If you confirm that there is no difference between the top-level code of your component and the dual thread, it is recommended to turn on enableMainThread, so that you can get the correct first screen result.

More usage

For more usage, please refer to the test cases maintained in the ReactLynx Testing Library source code.

API Reference

See details in API Reference.

Except as otherwise noted, this work is licensed under a Creative Commons Attribution 4.0 International License, and code samples are licensed under the Apache License 2.0.