---
title: "Testing your integration"
description: "What to assert about a component you did not write. The stable contracts, the accessibility assertions worth copying, and the tests that break on any refactor."
url: "https://opsinjs.pensievelabs.org/handbook/testing"
source: "https://opsinjs.pensievelabs.org/handbook/testing.md"
section: "Handbook"
kind: "handbook"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["testing components", "what to assert", "integration tests", "asserting accessibility", "testing library"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="handbook" />

## The short version [#the-short-version]

<NotBuiltYet />

**opsinjs has no test suite of its own.** There is none, automated or manual,
for the design system itself; [Accessibility for
testing](../accessibility/for-testing.mdx) records the same gap. What runs on
every change is static: `check:generated`, `check:ia`, `check:a11y` over
component source, `check:llms`, eslint and `tsc`. None of those mounts a
component. The nightly adds a browser half against the built `/view` routes,
checking hit area and survival at 1.3x and at 200%. That is a real measurement,
but it arrives the morning after a merge rather than before it.

So assume nothing underneath your composition is covered, and assert what
matters to you. The list below is what you would want to assert about **your
composition** regardless of who owns the layer beneath it, and until opsinjs has
a suite it is also the only thing standing between a regression in a component
and a reader.

Assert against contracts that are covered by semver:

* **Roles and accessible names**, queried with
  `getByRole("button", { name: "Save reading" })`
* **Data attributes**, matched with `[data-status="urgent"]` and `[data-open]`
* **Visible text** is what the reader actually reads

Do not assert against class names, DOM structure, or the internal nesting of a
compound component. Those change without a major version and your test suite
becomes a change-detector rather than a safety net.

## How it works [#how-it-works]

### What is stable [#what-is-stable]

| Stable, safe to assert                                        | Not stable, do not assert |
| ------------------------------------------------------------- | ------------------------- |
| ARIA roles and accessible names                               | Generated class names     |
| `data-*` attributes ([the vocabulary](./data-attributes.mdx)) | Element nesting depth     |
| Visible text content                                          | Internal element order    |
| Keyboard behaviour                                            | Inline style values       |
| Focus position after an interaction                           | Portal container identity |

### The accessibility assertions worth copying [#the-accessibility-assertions-worth-copying]

These are the ones that catch real regressions in a health product, and they are
cheap:

* **Every interactive element has an accessible name.** A whole-page sweep
  catches the icon button someone added last week.
* **Focus goes where you said it goes.** After opening a sheet, after a failed
  submit, after closing a dialog. Focus bugs are invisible until somebody
  without a mouse finds them.
* **The error is associated with its control.** The control's
  `aria-describedby` resolves to the message element, not merely a message
  somewhere on the page.
* **`aria-invalid` clears when the error clears.**
* **A status change is announced**, i.e. it lands in a live region, rather than
  only appearing.
* **The status word is present as text**, not only as a colour. This is
  [colour independence](../accessibility/colour-independence.mdx) as a unit
  test.
* **An automated accessibility scan on the rendered output.** It finds a
  meaningful minority of issues; it does not find focus order, and it never
  finds wording.

## Do this [#do-this]

* **Query by role and name first**, by text second, by test id last. The order
  reflects how close the query is to what a reader experiences.
* **Test the flow, not the frame.** For a pattern, assert the sequence: submit
  fails, summary appears, focus is on the summary, activating an entry focuses
  the field.
* **Assert on the honest states.** A stale value must not carry a status; an
  empty state must not render a zero. Those are the assertions that protect the
  reader rather than the code.
* **Use fake timers deliberately** for debounced validation, and assert on the
  final state rather than on intermediate ones.
* **Test with `prefers-reduced-motion` set.** If a test fails only when motion
  is off, motion was carrying meaning.
* **Keep one end-to-end test per critical flow**, such as logging a reading or
  disclosing a result. Let unit tests cover the rest.

## Not this [#not-this]

* **Do not snapshot markup.** A DOM snapshot of a component you do not own turns
  every upstream improvement into a failing test and trains the team to update
  snapshots without reading them.
* **Do not assert on class names.** They are not a contract; `data-*` attributes
  are.
* **Do not test that a library works.** That a `Dialog` traps focus is Base UI's
  test to run, not yours.
* **Do not use `container.querySelector` to reach into a component's
  internals.** If you need it, the component is missing an accessible affordance
  and that is the actual bug.
* **Do not mock the component under test.** Mocking a `ResultCard` to test a
  page tests nothing about the page's real behaviour.
* **Do not rely on an automated scan alone.** It will pass a page whose reading
  order is nonsense.

## Gotchas [#gotchas]

* **Portals render outside the container** returned by your render helper.
  Queries scoped to it will not find a dialog's contents; query the document.
* **`jsdom` does not do layout.** Anything about size, position, overflow,
  visibility-by-clipping or target size cannot be tested there. Those need a
  real browser.
* **`jsdom` does not implement `matchMedia` by default**, so a component reading
  `prefers-reduced-motion` needs it stubbed or it silently takes one branch.
* **Exit transitions keep an element mounted.** A "closed" assertion immediately
  after a close will fail until the transition finishes; wait for removal.
* **Fake timers and `user-event` interact badly** unless the timer configuration
  is passed through; the symptom is a test that hangs rather than fails.
* **An accessible name computed from `aria-labelledby` needs the target to
  exist.** A test that renders a fragment without it will report a missing name
  that is present in the real page.

## Related [#related]

* [Data attributes](./data-attributes.mdx) has the contracts that are safe to
  assert on.
* [For testing](../accessibility/for-testing.mdx) is the accessibility test
  plan by role.
* [Keyboard and focus](../accessibility/keyboard-and-focus.mdx) has the global
  contract your focus assertions are checking.
* [Component checklist](./contributing/component-checklist.mdx) says what
  opsinjs itself has to pass.
