system-testing
v1.0.78
Published
System testing with Selenium and browsers.
Maintainers
Readme
System testing
Rails inspired system testing for Expo apps.
Install
npm install --save-dev system-testingUsage
import retry from "awaitery/build/retry.js"
import SystemTest from "system-testing/src/system-test.js"
import wait from "awaitery/build/wait.js"
import waitFor from "awaitery/build/wait-for.js"
import createUser from "@/src/testing/create-user.js"
import initialize from "@/src/initialize"
import Option from "@/src/models/option"
describe("Sign in page", () => {
test("it navigates to the sign in page and signs in", async () => {
await initialize()
await SystemTest.run(async (systemTest) => {
await createUser(userAttributes)
await systemTest.visit("/")
await systemTest.findByTestID("frontpageScreen", {useBaseSelector: false})
await wait(250)
await retry(async () => {
await systemTest.click("[data-testid='signInButton']")
await systemTest.findByTestID("app/sign-in")
})
await systemTest.interact("[data-testid='signInEmailInput']", "sendKeys", "[email protected]")
await systemTest.interact("[data-testid='signInPasswordInput']", "sendKeys", "password")
const emailInputValue = await systemTest.interact("[data-testid='signInEmailInput']", "getAttribute", "value")
const passwordInputValue = await systemTest.interact("[data-testid='signInPasswordInput']", "getAttribute", "value")
expect(emailInputValue).toEqual("[email protected]")
expect(passwordInputValue).toEqual("password")
await systemTest.click("[data-testid='signInSubmitButton']")
await systemTest.expectNotificationMessage("You were signed in.")
await waitFor(async () => {
const optionUserID = await Option.findBy({key: "userID"})
if (!optionUserID) {
throw new Error("Option for user ID didn't exist")
}
expect(optionUserID.value()).toEqual("805")
})
})
})
})Driver selection
SystemTest uses Selenium by default. To use Appium instead, pass a driver config when creating the instance:
await SystemTest.run({
driver: {
type: "appium",
options: {
serverArgs: {
useDrivers: ["uiautomator2"],
port: 4723
},
capabilities: {
platformName: "Android",
"appium:automationName": "UiAutomator2",
"appium:deviceName": "Android Emulator",
"appium:app": "/path/to/app.apk"
}
}
}
}, async (systemTest) => {
await systemTest.findByTestID("loginScreen")
})If you already run an Appium server, provide serverUrl instead of serverArgs. By default, findByTestID uses the Appium accessibility id strategy. To use CSS instead (for web contexts), set options.testIdStrategy to "css" and optionally options.testIdAttribute (defaults to "data-testid").
Using useSystemTest in your Expo app
useSystemTest wires your Expo app to the system-testing runner: it listens for WebSocket commands, initializes the browser helper, and lets tests navigate or reset state. Add it near the root layout of your Expo Router app (for example in _layout.tsx or a top-level provider component).
To enable system tests in native builds, set EXPO_PUBLIC_SYSTEM_TEST=true at build time (and optionally EXPO_PUBLIC_SYSTEM_TEST_HOST to reach the test runner from a device/emulator). For native Appium runs, set SYSTEM_TEST_HOST=native in the test environment and point Appium at your APK.
Minimal example:
import {Stack} from "expo-router"
import useSystemTest from "system-testing/build/use-system-test.js"
export default function RootLayout() {
const {enabled, systemTestBrowserHelper} = useSystemTest({
onFirstInitialize: () => {
// One-time setup the first time the helper initializes
},
onInitialize: () => {
// Reset any app state before each test run
}
})
// Optionally register classes for remote eval once scoundrel is ready
// useEffect(() => {
// if (systemTestBrowserHelper) {
// systemTestBrowserHelper.getScoundrel().registerClass("MyModel", MyModel)
// }
// }, [systemTestBrowserHelper])
return (
<Stack screenOptions={{headerShown: false}}>
<Stack.Screen name="(tabs)" />
</Stack>
)
}Notes:
- The hook auto-connects when the page is opened with
?systemTest=true(as the runner does). onFirstInitializeruns only on the firstinitializecommand; use it for one-time setup.onInitializeis registered once when the helper is ready, but it runs on everyinitializecommand (eachSystemTest.run); use it to reset globals/session.- If you need scoundrel remote evaluation, wait for
systemTestBrowserHelperand register your classes there, as shown in the commented snippet above. - Add a root wrapper with
testID="systemTestingComponent"(and optionallydata-focussed="true") around your app so the runner has a stable element to detect and scope selectors against. - From your tests, use
await systemTest.getScoundrelClient()to obtain the browser Scoundrel client for remote evaluation. useSystemTestcallsuseRouter()fromexpo-router. If you are not using Expo Router, installexpo-routeror provide your own guard to avoid navigation errors.
Root path and blankText
SystemTest.run() visits SystemTest.rootPath (defaults to /blank?systemTest=true) and waits for an element with testID="blankText" inside the focused systemTestingComponent. If your app does not have a /blank route, set a custom root path and ensure the element exists on that screen.
Example setup:
import SystemTest from "system-testing/build/system-test.js"
SystemTest.rootPath = "/?platform=web&systemTest=true"<View testID="systemTestingComponent" dataSet={{focussed: "true"}}>
<Text testID="blankText">Blank</Text>
{children}
</View>Base selector and focused container
System tests scope selectors to the active screen by default. The app marks the active layout container with data-focussed="true" on the element with data-testid="systemTestingComponent". In the dummy app, the root layout wraps the navigator and sets data-focussed="true" once so the base selector stays stable across screens.
SystemTest.find and SystemTest.findByTestID use a base selector that targets the focused container:
[data-testid='systemTestingComponent'][data-focussed='true']This prevents tests from matching elements on inactive or background screens.
When to bypass base selector:
Some UI (modals, overlays, portals) can render outside the focused container. For those cases, use useBaseSelector: false so the selector is not scoped:
await systemTest.findByTestID("scannerModeExitPinInput", {useBaseSelector: false})Use useBaseSelector: false only for modal or overlay content. Keep the default scoping for regular screens to avoid false matches.
Finder options
Most selector helpers accept the same options:
timeout(number): override how long the lookup should wait.visible(boolean|null): require elements to be visible (true) or hidden (false), or disable visibility filtering withnull.useBaseSelector(boolean): scope the selector to the focused container.
These options are supported by find, findByTestID, and all. click also accepts the same options when a selector string is used:
await systemTest.click("[data-testid='signInButton']", {useBaseSelector: false, visible: true})interact supports a selector object so you can pass finder options inline:
await systemTest.interact({selector: "[data-testid='scanFooterMenuButton']", useBaseSelector: false}, "click")Reinitialize a system test
Some test failures can leave the app in a broken state (for example a crashed React tree or a stuck WebSocket session). In those cases, fully restart the SystemTest instance to restore a clean browser/app state before continuing.
await systemTest.reinitialize()This tears down the browser, servers, and sockets, then starts them again so subsequent steps run against a fresh app instance.
Dummy Expo app
A ready-to-run Expo Router dummy app that uses system-testing lives in spec/dummy. Build the web bundle with npm run export:web and execute the sample system test with npm run test:system from that folder.
