Testing
Assertions
expect checks what the screen shows. An assertion on a locator retries until it holds or its timeout passes,
so you never wait by hand. This page lists the matchers, how retrying works, soft assertions, polling, and
accessibility and screenshot snapshots.
Assertions on a locator
Pass a locator to expect and await the matcher.
await expect(screen.getByRole('heading', { name: 'Welcome back' })).toBeVisible()
await expect(screen.getByTestId('count')).toHaveText('Count: 1')
await expect(screen.getByRole('listitem')).toHaveCount(3)
await expect(screen.getByRole('switch', { name: 'Remember me' })).not.toBeChecked()| Matcher | Holds when the element |
|---|---|
toBeVisible() | is on screen. { visible: false } asks for the opposite |
toBeHidden() | does not exist, or is not visible |
toBeAttached() | exists in the tree. { attached: false } asks for the opposite |
toBeEnabled(), toBeDisabled() | is enabled, or disabled |
toBeChecked() | is checked. { checked: false } and { indeterminate: true } ask for the other states |
toBeFocused() | has focus |
toHaveText(expected) | has exactly this text, counting its descendants' text |
toContainText(expected) | has text that contains this |
toHaveValue(expected) | has this value, as a text input or slider reports it |
toHaveAccessibleName(expected) | has this accessible name |
toHaveRole(role) | has this role |
toHaveCount(n) | The locator matches n elements |
toMatchAriaSnapshot(template) | has an accessibility subtree that matches the template |
toHaveScreenshot(name?, options?) | looks like the stored picture |
Every matcher takes { timeout } as its last argument, and .not negates every one but toHaveScreenshot.
expected is a string or a regular expression. toHaveText, toContainText and toHaveAccessibleName also
take { ignoreCase: true }.
toHaveText and toContainText compare case-sensitively, after collapsing whitespace. Given a list, they
check one element per entry, and the locator must match exactly that many:
await expect(screen.getByRole('listitem')).toHaveText(['Oat milk', 'Bread', /Eggs/])How retrying works
A locator assertion asks the device, checks, and asks again after 100, 250, 500 and then every 1,000 milliseconds until it holds. The timeout is 10 seconds unless you change it:
await expect(screen.getByText('Synced')).toBeVisible({ timeout: 30_000 }) // this assertion
const slow = expect.configure({ timeout: 30_000 }) // every assertion made with `slow`
await slow(screen.getByText('Synced')).toBeVisible()The config's expect.timeout sets it for a project or the whole run.
Locator assertions are strict. When the locator matches more than one element the assertion fails at once
with LOCATOR_STRICT_VIOLATION; only toHaveCount and the list forms accept several. A failed assertion
prints the locator, what it expected and received, and an excerpt of the accessibility tree, as shown in
when a test fails.
Assertions on a value
Anything that is not a locator or the screen is a plain value, checked once with Jest's matchers: toBe,
toEqual, toStrictEqual, toBeTruthy, toBeFalsy, toBeNull, toBeUndefined, toBeDefined, toBeNaN,
toBeGreaterThan, toBeGreaterThanOrEqual, toBeLessThan, toBeLessThanOrEqual, toBeCloseTo,
toBeInstanceOf, toContain, toContainEqual, toHaveLength, toHaveProperty, toMatch, toMatchObject
and toThrow. .resolves and .rejects unwrap a promise first. The asymmetric matchers are expect.any,
expect.anything, expect.objectContaining, expect.arrayContaining, expect.stringContaining,
expect.stringMatching and expect.closeTo.
expect(await screen.getByRole('listitem').count()).toBeGreaterThan(2)
expect(await device.apps()).toContainEqual(expect.objectContaining({ bundleId: 'co.ex.shop' }))Retrying anything
expect.poll retries a function and a value matcher together. toPass retries a block of assertions until
none throws.
await expect.poll(() => screen.ariaSnapshot()).not.toBe('')
await expect(async () => {
await screen.getByRole('button', { name: 'Refresh' }).tap()
await expect(screen.getByText('Up to date')).toBeVisible({ timeout: 2_000 })
}).toPass({ timeout: 30_000 })Both take timeout and intervals, and stop at the assertion timeout when you give none.
Soft assertions and messages
expect.soft records a failure and lets the test go on. The test still fails at the end, with every soft
failure listed. A second argument to expect is a message printed above the failure.
await expect.soft(screen.getByTestId('total')).toHaveText('12.00')
await expect.soft(screen.getByTestId('tax')).toHaveText('1.20')
await expect(screen.getByTestId('pay'), 'the pay button unlocks after the form').toBeEnabled()Accessibility snapshots
toMatchAriaSnapshot compares an element's accessibility subtree, or the whole screen, with a template.
Each line is - role "name", with optional attributes in brackets and a value after a colon. Children are
indented under a line that ends in a colon.
await expect(screen).toMatchAriaSnapshot(`
- toolbar "Cart":
- heading "Cart" [level=1]
- textbox "Email": "a@b.co"
- switch "Remember me" [checked]
- button /Sign (in|up)/
- text: "Total: 12.00"
`)The match is partial. A template line matches an element with that role whose name, value and attributes all
match; elements the template does not mention may sit between its lines, and its children must appear in order.
The attributes are [checked], [checked=mixed], [disabled], [expanded], [selected], [focused],
[pressed], [level=N] and [testId="…"]. screen.ariaSnapshot() prints the current tree in the same form
to start from.
Screenshots
toHaveScreenshot compares the screen, or one element, with a PNG stored beside the spec file:
await expect(screen.getByTestId('count')).toHaveScreenshot('count-1.png')
await expect(screen).toHaveScreenshot('home.png', { maxDiffPixelRatio: 0.01 })The snapshot for e2e/cart.e2e.ts in the project ios is e2e/cart.e2e.ts-snapshots/count-1-ios.png.
Without a name it is the test's title and a counter.
- The first run has nothing to compare with. It writes the snapshot from the first screenshot that is stable and fails the test once, so that you look at the picture before you commit it.
- Later runs retry until a screenshot matches or the timeout passes. A mismatch attaches the expected, actual and difference images to the test.
- To accept changes, run
bunx troupe test --update-snapshots(or-u). It writes the snapshots that are missing or differ, and those tests pass.
| Option | Meaning | Default |
|---|---|---|
threshold | How different one pixel's colour may be, from 0 to 1 | 0.2 |
maxDiffPixels | How many pixels may differ | 0 |
maxDiffPixelRatio | The share of pixels that may differ, from 0 to 1 | 0 |
timeout | How long to retry | the assertion timeout |
The variable TROUPE_UPDATE_SNAPSHOTS chooses the mode directly: missing (the default), changed (what
-u sets), all (rewrite every snapshot) or none (write nothing, and fail on a missing snapshot).
Your own matchers
expect.extend returns a new expect with your matchers added. A matcher receives the value and returns
{ pass, message }:
import { expect as base, Locator } from '@alivelabs/troupe'
export const expect = base.extend({
async toHaveBadge(received: unknown, count: number) {
if (!(received instanceof Locator)) throw new TypeError('toHaveBadge needs a locator')
const name = await received.accessibleName()
return {
pass: name.endsWith(`, ${count} new`),
message: () => `expected the name "${name}" to end with ", ${count} new"`,
}
},
})Use it as await expect(screen.getByRole('tab', { name: /Inbox/ })).toHaveBadge(3). A matcher of your own runs
once; expect.poll retries it like any value matcher.