Testing screens

Draw a screen in a test as your app does, tap it, and check what it shows.

server/Order.test.tsx
import assert from "node:assert/strict";
import { it } from "node:test";
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Order } from "./Order.js";
import { drawScreen } from "./test/drawScreen.js";
it("counts each item on its own", async () => {
render(await drawScreen(<Order items={["Flat white", "Latte"]} />));
await userEvent.click(screen.getByText("Flat white: 0"));
assert.ok(screen.getByText("Flat white: 1"));
assert.ok(screen.getByText("Latte: 0"));
});

A test draws a screen the way your app does, then taps it and checks what it shows. A new React Native project has one, server/Home.test.tsx, and runs it with npm test.

What a test runs

drawScreen builds the screen as your server does, and runs it as your app does, with React Native's web build standing in for React Native:

server/test/drawScreen.ts
import { createRequire } from "node:module";
import { bundler } from "@backtickjs/bundler";
import type { Spliceable } from "@backtickjs/core";
import { evaluate } from "@backtickjs/react-native-client";
import * as React from "react";
import * as JSXRuntime from "react/jsx-runtime";
import * as ReactNativeWeb from "react-native-web";
// What the app provides, as `server/index.tsx` and `App.tsx` say, with React
// Native's web build standing in for React Native. Add a package here when
// your app provides one.
const require = createRequire(import.meta.url);
const packageVersions = {
react: require("react/package.json").version,
"react-native": require("react-native/package.json").version,
};
const modules = {
react: React,
"react/jsx-runtime": JSXRuntime,
"react-native": ReactNativeWeb,
};
// A screen, bundled as your server bundles it and run as your app runs it,
// for Testing Library to draw.
export async function drawScreen(input: Spliceable): Promise<React.ReactNode> {
const bundle = await bundler.build({ input, packageVersions });
const { code } = bundle.generate({ format: "cjs" });
return evaluate(code, modules) as React.ReactNode;
}

Testing Library draws it into jsdom, a document that runs in Node.

Here's the screen the test above draws. Order is a server component, and each Stepper keeps its own count on the phone:

server/Order.tsx
import { cs } from "@backtickjs/core";
import { useState } from "@backtickjs/react";
import { Pressable, Text, View } from "@backtickjs/react-native";
// A client component: its count lives on the phone.
const Stepper = cs`({ name }: { name: string }) => {
const [count, setCount] = $useState(0);
return (
<$Pressable onPress={() => setCount(count + 1)}>
<$Text>
{name}: {count}
</$Text>
</$Pressable>
);
}`;
// A server component: what to order comes from your server.
export async function Order({ items }: { items: string[] }) {
return cs`(
<$View>
{$items.map((name) => (
<$Stepper key={name} name={name} />
))}
</$View>
)`;
}

Tapping

userEvent.click taps what it's given, as a finger would. A Pressable answers it as it answers a tap, and the client component's state changes as it would on the phone. Each check reads what the screen shows, so the test passes or fails on what a user would see.

Data from your server

A server component runs in the test as it runs on your server, so it reads its data the same way. Give it test data through its props, as the test gives Order its items, or point what it reads at a test database.

Packages your app provides

When your app provides another package, add it to both lists in drawScreen.ts: its version to packageVersions, as server/index.tsx does, and its module to modules, as App.tsx does.

A package with native code, like expo-haptics, has nothing to run in Node. Give modules a stand-in with the exports your screens use:

const modules = {
react: React,
"react/jsx-runtime": JSXRuntime,
"react-native": ReactNativeWeb,
"expo-haptics": { impactAsync: async () => {} },
};

What a test doesn't cover

React Native's web build isn't React Native. A test checks what your screen shows and how it answers taps, but not:

  • Native modules, which a test replaces with stand-ins.
  • Layout and platform behaviour, like safe areas and keyboard handling.
  • Native animation, which the web build runs in JavaScript.

Try those on a phone.

Running

npm test runs every server/**/*.test.tsx with Node's test runner:

node --import ./server/test/setup.mjs --import @backtickjs/node-plugin --test "server/**/*.test.tsx"

The test runner compiles scripts without type-checking them, so a test can pass on a screen with a type error. Run npm run typecheck too, as CI should. npm run reset-project rewrites server/Home.test.tsx to match its blank screen.