Testing
GTKX ships a React Testing Library-inspired testing package: the same API, adapted to GTK4.
Setup
A scaffolded project (answer yes to "Include testing setup (Vitest)?" in npm create gtkx) already ships this config. Otherwise:
npm install -D @gtkx/testing vitestPoint a test script at vitest run and write vitest.config.ts:
import gtkx from "@gtkx/cli/vitest-plugin";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [gtkx()],
test: {
include: ["tests/**/*.test.{ts,tsx}"],
bail: 1,
},
});Each Vitest worker runs in its own headless environment, started before any test code loads and torn down with the worker. Headless runs need the compositor binary, dbus-daemon, and setpriv on the host; plugin options are in the @gtkx/vitest reference.
Importing @gtkx/testing is the entire setup: cleanup, GTK4 loop teardown, and the expect matchers all come with the import. There is no setup file to write.
Rendering and cleanup
render is async and must be awaited:
import { render, screen } from "@gtkx/testing";
const { unmount, rerender, debug } = await render(<MyPanel />);With no options, render presents the element in a harness window. <AdwApplication> is not a widget and cannot live there, so render an app component into rootElement from @gtkx/react:
import { rootElement } from "@gtkx/react";
await render(<App />, { container: rootElement });Queries search every open toplevel, so dialogs and popovers are findable, and animations are disabled unless areAnimationsEnabled: true is passed. wrapper mounts a context provider around the element; the remaining options are in the render reference.
screen proxies to the most recent render and is the idiomatic way to query; within(container) scopes queries to a subtree, and renderHook(callback) tests a hook in isolation. Cleanup is automatic: every test starts from an empty display.
Queries
Every query kind is available as getBy, getAllBy, queryBy, queryAllBy, findBy, and findAllBy:
| Kind | Matches |
|---|---|
ByRole | A Gtk.AccessibleRole, optionally narrowed by name and accessible state |
ByLabelText | A widget labeled by a Gtk.Label mnemonic, an accessibleLabel, or accessibleLabelledBy |
ByText | The label text of LABEL-role widgets |
ByName | The widget's name property (the name prop) |
ByPlaceholderText | The placeholder of an editable widget |
ByDisplayValue | The current text of an editable widget or GtkTextView |
getBy* throws when nothing, or more than one thing, matches; queryBy* returns null when nothing matches; and findBy* polls until a match appears (1000 ms by default), which makes it the right choice after any interaction that triggers a re-render.
Roles are always Gtk.AccessibleRole enum values, never strings: a GtkCheckButton reports CHECKBOX, an AdwActionRow reports LIST_ITEM. ByRole narrows further by name and by accessible state; see ByRoleOptions. Text matchers take a string or number, a RegExp, or a predicate function.
import * as Gtk from "@gtkx/gi/gtk";
const save = await screen.findByRole(Gtk.AccessibleRole.BUTTON, { name: "Save" });
const entry = screen.getByPlaceholderText("Search tasks");Simulating input with userEvent
Every userEvent helper is async and runs inside React's act, so state updates flush before it resolves. Each waits up to 500 ms (actionabilityTimeout) for the widget to become actionable, and throws an error naming the condition that failed when it never does.
await userEvent.click(button);
await userEvent.type(entry, "hello");
await userEvent.keyboard(entry, "{Control>}a{/Control}");userEvent.click on a list row changes the selection, on a Gtk.TreeExpander toggles expansion, and on a sortable column header sorts the view. Off-screen, pointer synthesizes left-button input only and drag refuses a Gtk.Range, so use slide(range, value) to move a slider. The full set of helpers is in the userEvent reference.
fireEvent, act, and waitFor
fireEvent(object, signalName, ...args) emits any GObject signal directly, with no actionability checks, and must be awaited. Reach for it when the test is about a signal handler rather than a user interaction:
await fireEvent(row, "activated");act(callback) is needed only for state mutated outside a userEvent or fireEvent call. waitFor(callback, options?) retries an assertion until it passes, and waitForElementToBeRemoved resolves once a widget leaves the tree; both default to 1000 ms, which configure changes globally.
Matchers
Assertions read at the same level as queries:
expect(label).toHaveTextContent(/world/);
expect(button).toHaveAccessibleName("Save");
expect(check).toBeChecked();The boolean state matchers throw when the widget does not expose that state. Accessible state and properties are asserted through toHaveAccessibleState and toHaveAccessibleProperty, not widget properties:
expect(expander).toHaveAccessibleState(Gtk.AccessibleState.EXPANDED, true);
expect(grid).toHaveAccessibleProperty(Gtk.AccessibleProperty.SORT, Gtk.AccessibleSort.DESCENDING);toAppearBefore and toAppearAfter compare two widgets by tree position, and toContainAnyBy* and toContainOneBy* run a query against a widget's own subtree.
Debugging
screen.debug() prints the widget tree the way the queries see it, with roles, names, and accessibility attributes. screen.logRoles() groups every widget by role, the fastest way to answer which role a widget reports. screenshot(widget) returns the base64 PNG data, and { path } also writes the image to a file; screen.screenshot() takes the same options and captures the active toplevel window instead of one render's subtree. For a live dev session rather than a test, the MCP server exposes the same dumps, queries, and screenshots.
TIP
Tests written this way double as a basic accessibility audit: a widget getByRole cannot find by name is usually one that is missing an accessible label.
A test also fails when the code under test provokes a GLib CRITICAL or a panic inside the GTKX addon. Those cannot be thrown out of the call that caused them, so GTKX raises them as an uncaught exception, which the runner reports against the test that was running. The failure names the contract that was broken, or the Rust file and line that panicked. Levels below CRITICAL stay as stderr lines and fail nothing. Error Handling covers the channel and the one handler that overrides it.
A simple example
A minimal counter component, tested end to end. The test renders it, finds the button by role, clicks it twice, and asserts on the resulting label text:
import * as Gtk from "@gtkx/gi/gtk";
import { GtkBox, GtkButton, GtkLabel } from "@gtkx/jsx/gtk";
import { render, screen, userEvent } from "@gtkx/testing";
import { useState } from "react";
import { describe, expect, it } from "vitest";
function Counter() {
const [count, setCount] = useState(0);
return (
<GtkBox orientation={Gtk.Orientation.VERTICAL}>
<GtkLabel>{`Count: ${count}`}</GtkLabel>
<GtkButton label="Increment" onClicked={() => setCount((c) => c + 1)} />
</GtkBox>
);
}
describe("Counter", () => {
it("increments when the button is clicked", async () => {
await render(<Counter />);
const button = await screen.findByRole(Gtk.AccessibleRole.BUTTON, { name: "Increment" });
await userEvent.click(button);
await userEvent.click(button);
expect(await screen.findByText("Count: 2")).toHaveTextContent("Count: 2");
});
});The same pattern scales from a counter to the full Tasks app in the tutorial.
Next
MCP exposes these same queries and events to an AI agent, so it can drive your running app instead of a test doing it.