ReactLynx Testing Library

The @lynx-js/react/testing-library package provides the same set of APIs (e.g. render, fireEvent, screen, etc.) for testing the rendering result of ReactLynx, just like the popular React Testing Library, with the underlying dual-threaded architecture abstracted away by the @lynx-js/testing-environment.

Setup

From create-rspeedy

If you create a new project using create-rspeedy, you can choose whether to use ReactLynx Testing Library (default checked) when creating the project. The created project is already configured with ReactLynx Testing Library.

Adding to an existing project

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

To configure Vitest, you can use @lynx-js/react/testing-library/vitest-config to create a Vitest configuration. You can use the mergeConfig method to merge it with other configurations.

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

Examples

Quick Start

Same as in React Testing Library, it is recommended to divide test cases into three parts: Arrange, Act, and Assert. The Arrange part is used to prepare test data, the Act part is used to perform test operations, and the Assert part is used to assert test results. Here is a simple example:

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

In this example, you may have noticed that we used the toHaveTextContent method from third-party library @testing-library/jest-dom to assert the text content of an element. In React Testing Library, you can use @testing-library/jest-dom because the test framework uses JSDOM to create DOM elements; in ReactLynx Testing Library, we also use JSDOM to implement the behavior of Element PAPI, so it is compatible with DOM API.

Basic rendering

The render method is used to render a ReactLynx component and returns a RenderResult object. The container field is a LynxElement containing the rendering result.

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

Firing events

When firing an event with fireEvent, 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 and usage scenarios are as follows:

Event TypeeventTypeEvent Binding ExampleEvent Triggering Example
bindbindEventbindtapnew Event('bindEvent:tap')
catchcatchEventcatchtapnew Event('catchEvent:tap')
capture-bindcapture-bindcapture-bindtapnew Event('capture-bind:tap')
capture-catchcapture-catchcapture-catchtapnew 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.

In the render process, the event handler will be mounted to the eventMap property of the LynxElement object, so you can use the eventMap property to get the event handler of the element for assertion.

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

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

Testing Refs

In ReactLynx Testing Library, you can use snapshot testing on the rendering result and the corresponding ref object of the element to determine whether it is set correctly.

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

Querying page elements

You can use screen to query page elements, it provides some common methods, such as getByText, getByTestId, etc. There are also methods like waitForElementToBeRemoved to wait for the state of 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, which is re-exported here
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>
  `);
});

In this example, we use the waitForElementToBeRemoved method to wait for the Loading... element to be removed. At this point, the page will render the Loaded this message: Hello World! element. We can then use the screen.getByTestId method to get the element on the page and assert its text content.

Rerendering

The render method returns an object that contains the rerender method, which can be used to re-render the page. The rerender method will render the new component on the page and return a new object. You can use the rerender method to test different states of the component.

WARNING

Unlike React Testing Library, a new container needs to be retrieved after rerender. Because ReactLynx creates a new page element each time it loads.

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

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

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

Testinglist

Since the list element's list-item elements are lazily loaded, they are only loaded when they enter the viewport and marked as recyclable when they leave the viewport. In the testing framework, you can use the elementTree.enterListItemAtIndex and elementTree.leaveListItem methods to simulate loading and recycling of list item elements.

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

In this example, we entered the list item element at index 0, loaded the list item element, then left the list item element at index 0, marked the list item element as recyclable, and finally entered the list item element at index 1, which reused the recycled list-item.

TestingMain thread script

The test for the main thread script does not require additional configuration. It is important to note that you cannot directly call background thread methods in the main thread script, so it is recommended to place the function on globalThis for assertion, as shown below:

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

In this example, we triggered a tap event and called the globalThis.cb function in the event handler. We then asserted that the globalThis.cb function was called once and that the key property in the event object was value.

More usage

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

Advanced

Change the rendering behavior ofdual-threading

In the second parameter RenderOptions of the render method, there are two options enableMainThread and enableBackgroundThread. enableMainThread is used to enable IFR and enableBackgroundThread is used to enable background thread rendering.

Take this example:

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

The table below lists the rendering results under different configurations:

enableMainThreadenableBackgroundThreadIFR resultBackground resultApplicable scenarios
falsetrueNonebackgroundDefault value, suitable for most scenarios
truefalsemain threadNoneScenarios that require ensuring correct IFR rendering result
truetruemain threadbackgroundScenarios that require ensuring the dual-thread rendering works normally

Pitfalls

If you want to write a test case to test the IFR rendering (i.e. enableMainThread: true) result, make sure that the rendering does not depend on side effects from the top level.

In ReactLynx Testing Library, we do not generate two bundles for dual-threading like using Rspeedy, which will cause the top-level code to be executed only once in the background thread. In the following case, the value of the isBackground variable will be set to 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,
    });
    // Test fails
    // Error: expect(element).toHaveTextContent()
    // Expected element to have text content:
    //   main thread
    // Received:
    //   background
    expect(container).toHaveTextContent('main thread');
  });
});

The correct way is to use __BACKGROUND__ inside the component. Here is an example:

import '@testing-library/jest-dom';
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,
    });
    // Test passes
    expect(container).toHaveTextContent('main thread');
  });
});

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.