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 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.