Testing
GTKX provides @gtkx/testing, a Testing Library-inspired package for testing GTK components. It offers familiar APIs like screen, userEvent, and query functions.
Installation
npm install -D @gtkx/testing
Setup
@gtkx/testing works with any test runner (Jest, Vitest, Node's built-in test runner, etc.).
Display Requirements
Tests require xvfb-run because GTK needs a display. On Wayland systems, set GDK_BACKEND=x11 to ensure windows render offscreen:
GDK_BACKEND=x11 xvfb-run -a <your-test-command>
Writing Tests
Basic Test Structure
import { cleanup, render, screen } from "@gtkx/testing";
import { App } from "../src/app.js";
// Clean up after each test
afterEach(async () => {
await cleanup();
});
test("renders the title", async () => {
await render(<App />);
const title = await screen.findByText("Welcome");
expect(title).toBeDefined();
});
GTK is automatically initialized on the first render() call—no manual setup required.
Query Functions
GTKX testing provides async query functions to find elements:
| Variant | Returns | Throws if not found? |
|---|---|---|
findBy* | Single element | Yes |
findAllBy* | Array of elements | Yes (if empty) |
All queries are async and will wait for elements to appear (with a default timeout of 1000ms).
By Text
// Find by exact text
const label = await screen.findByText("Hello, World!");
// Find by partial text (regex)
const greeting = await screen.findByText(/hello/i);
// Find all matching elements
const allLabels = await screen.findAllByText(/item/i);
By Role
GTK widgets have accessibility roles. Use findByRole to query by role:
import { AccessibleRole } from "@gtkx/ffi/gtk";
// Find a button by role and name
const button = await screen.findByRole(AccessibleRole.BUTTON, {
name: "Submit",
});
// Find any button
const anyButton = await screen.findByRole(AccessibleRole.BUTTON);
// Find a checked checkbox
const checked = await screen.findByRole(AccessibleRole.CHECKBOX, { checked: true });
// Find an expanded expander
const expanded = await screen.findByRole(AccessibleRole.BUTTON, { expanded: true });
Common roles:
AccessibleRole.BUTTON— ButtonsAccessibleRole.LABEL— LabelsAccessibleRole.TEXT_BOX— Text inputsAccessibleRole.CHECKBOX— CheckboxesAccessibleRole.RADIO— Radio buttonsAccessibleRole.TOGGLE_BUTTON— Toggle buttonsAccessibleRole.SWITCH— SwitchesAccessibleRole.SEARCH_BOX— Search inputsAccessibleRole.SPIN_BUTTON— Spin buttons
By Label Text
Find form controls by their associated label:
const input = await screen.findByLabelText("Email Address");
By Test ID
Find elements by their widget name (test ID). Set the name prop on a widget to use this query:
// In your component
<Button name="submit-btn">Submit</Button>
// In your test
const button = await screen.findByTestId("submit-btn");
User Interactions
Use userEvent to simulate user actions:
Clicking
import { userEvent } from "@gtkx/testing";
const button = await screen.findByRole(AccessibleRole.BUTTON, {
name: "Increment",
});
await userEvent.click(button);
// Double-click
await userEvent.dblClick(button);
Typing
const input = await screen.findByRole(AccessibleRole.TEXT_BOX);
await userEvent.type(input, "Hello, World!");
// Clear input field
await userEvent.clear(input);
Custom Configuration
Use userEvent.setup() to create an instance with custom options:
const user = userEvent.setup({ delay: 100 });
await user.click(button);
await user.type(input, "text");
Low-Level Events
For more control, use fireEvent to emit GTK signals directly:
import { fireEvent } from "@gtkx/testing";
// Fire any signal by name
fireEvent(button, "clicked");
// Convenience methods
fireEvent.click(button);
fireEvent.activate(entry);
fireEvent.toggled(checkbox);
fireEvent.changed(entry);
Waiting for Changes
waitFor
Wait for a condition to be true:
import { waitFor } from "@gtkx/testing";
await userEvent.click(submitButton);
await waitFor(async () => {
const message = await screen.findByText("Success!");
expect(message).toBeDefined();
});
// With custom options
await waitFor(
async () => {
const done = await screen.findByText("Done");
expect(done).toBeDefined();
},
{ timeout: 2000, interval: 100 }
);
waitForElementToBeRemoved
Wait for an element to be removed from the widget tree:
import { waitForElementToBeRemoved } from "@gtkx/testing";
const loader = await screen.findByText("Loading...");
await waitForElementToBeRemoved(loader);
findBy* Queries
findBy* queries automatically wait for elements:
// Waits up to 1000ms for the element to appear
const message = await screen.findByText("Loading complete");
Complete Example
Here's a full test for a counter component:
import { AccessibleRole } from "@gtkx/ffi/gtk";
import { cleanup, render, screen, userEvent } from "@gtkx/testing";
import { Counter } from "../src/counter.js";
afterEach(async () => {
await cleanup();
});
test("renders initial count of zero", async () => {
await render(<Counter />);
const label = await screen.findByText("Count: 0");
expect(label).toBeDefined();
});
test("increments count when clicking increment button", async () => {
await render(<Counter />);
const button = await screen.findByRole(AccessibleRole.BUTTON, {
name: "Increment",
});
await userEvent.click(button);
await screen.findByText("Count: 1");
});
test("decrements count when clicking decrement button", async () => {
await render(<Counter />);
const button = await screen.findByRole(AccessibleRole.BUTTON, {
name: "Decrement",
});
await userEvent.click(button);
await screen.findByText("Count: -1");
});
test("resets count when clicking reset button", async () => {
await render(<Counter />);
// Increment a few times
const increment = await screen.findByRole(AccessibleRole.BUTTON, {
name: "Increment",
});
await userEvent.click(increment);
await userEvent.click(increment);
await userEvent.click(increment);
await screen.findByText("Count: 3");
// Reset
const reset = await screen.findByRole(AccessibleRole.BUTTON, {
name: "Reset",
});
await userEvent.click(reset);
await screen.findByText("Count: 0");
});
Render Options
The render function is async and accepts an options object.
Default ApplicationWindow Wrapper
By default, render wraps your component in an ApplicationWindow. This means you don't need to manually wrap your test content:
import { render } from "@gtkx/testing";
// This works out of the box - no ApplicationWindow needed
await render(<Button label="Click me" />);
// Equivalent to:
await render(
<ApplicationWindow>
<Button label="Click me" />
</ApplicationWindow>
);
Custom Wrapper
You can provide a custom wrapper component, which replaces the default ApplicationWindow wrapper:
import { render } from "@gtkx/testing";
// With a wrapper component (useful for providers)
const Wrapper = ({ children }) => (
<ApplicationWindow>
<ThemeProvider theme="dark">{children}</ThemeProvider>
</ApplicationWindow>
);
const { container, rerender, unmount, debug } = await render(<MyComponent />, {
wrapper: Wrapper,
});
// Rerender with new props
await rerender(<MyComponent newProp="value" />);
// Debug the widget tree
debug();
// Unmount the component
await unmount();
Disabling the Default Wrapper
For advanced cases like testing multiple windows, disable the default wrapper by providing a pass-through fragment wrapper:
import { render } from "@gtkx/testing";
// Render multiple windows without the default wrapper
await render(
<>
<ApplicationWindow>
<Button label="Window 1" />
</ApplicationWindow>
<ApplicationWindow>
<Button label="Window 2" />
</ApplicationWindow>
</>,
{ wrapper: ({ children }) => <>{children}</> }
);
API Reference
Lifecycle Functions
| Function | Description |
|---|---|
render(element, options?) | Render a React element for testing. Wraps in ApplicationWindow by default. Returns Promise<RenderResult>. |
cleanup() | Unmount rendered components. Returns Promise<void>. Call after each test. |
teardown() | Clean up GTK entirely. Returns Promise<void>. Used in global teardown. |
RenderResult
The object returned by render():
| Property/Method | Description |
|---|---|
container | The GTK Application instance |
rerender(element) | Re-render with a new element. Returns Promise<void>. |
unmount() | Unmount the rendered component. Returns Promise<void>. |
debug() | Print the widget tree to console |
findBy*, findAllBy* | Query methods bound to the container |
Screen Queries
All queries are available on the screen object and on RenderResult:
| Query Type | Variants | Description |
|---|---|---|
*ByRole | find, findAll | Find by accessible role |
*ByText | find, findAll | Find by text content |
*ByLabelText | find, findAll | Find by label text |
*ByTestId | find, findAll | Find by widget name |
Query Options
TextMatchOptions
await screen.findByText("hello", {
exact: false, // Enable substring matching (default: true)
normalizer: (text) => text.toLowerCase(), // Custom text normalizer
});
ByRoleOptions
await screen.findByRole(AccessibleRole.BUTTON, {
name: "Submit", // Match by accessible name
checked: true, // For checkboxes/radios
expanded: true, // For expanders
pressed: true, // For toggle buttons
selected: true, // For selectable items
level: 2, // For headings
});
User Events
| Function | Description |
|---|---|
userEvent.click(element) | Click an element |
userEvent.dblClick(element) | Double-click an element |
userEvent.type(element, text) | Type text into an input |
userEvent.clear(element) | Clear an input field |
userEvent.setup(options?) | Create instance with custom options |
Fire Event
| Function | Description |
|---|---|
fireEvent(element, signalName) | Fire any GTK signal |
fireEvent.click(element) | Fire "clicked" signal |
fireEvent.activate(element) | Fire "activate" signal |
fireEvent.toggled(element) | Fire "toggled" signal |
fireEvent.changed(element) | Fire "changed" signal |
Async Utilities
| Function | Description |
|---|---|
waitFor(callback, options?) | Wait for a condition to be true |
waitForElementToBeRemoved(element, options?) | Wait for element removal |
WaitForOptions
await waitFor(callback, {
timeout: 1000, // Max wait time in ms (default: 1000)
interval: 50, // Poll interval in ms (default: 50)
onTimeout: (error) => new Error("Custom message"), // Custom timeout error
});
Tips
- Always call
await cleanup()inafterEachto prevent test pollution - Use
await render()— render is async - Use
findBy*queries — all queries are async and will wait for elements - Use roles over text when possible for more robust tests
- Test behavior, not implementation — focus on what users see and do
- Use
debug()to inspect the widget tree when tests fail