Skip to main content

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:

VariantReturnsThrows if not found?
findBy*Single elementYes
findAllBy*Array of elementsYes (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 — Buttons
  • AccessibleRole.LABEL — Labels
  • AccessibleRole.TEXT_BOX — Text inputs
  • AccessibleRole.CHECKBOX — Checkboxes
  • AccessibleRole.RADIO — Radio buttons
  • AccessibleRole.TOGGLE_BUTTON — Toggle buttons
  • AccessibleRole.SWITCH — Switches
  • AccessibleRole.SEARCH_BOX — Search inputs
  • AccessibleRole.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

FunctionDescription
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/MethodDescription
containerThe 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 TypeVariantsDescription
*ByRolefind, findAllFind by accessible role
*ByTextfind, findAllFind by text content
*ByLabelTextfind, findAllFind by label text
*ByTestIdfind, findAllFind 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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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

  1. Always call await cleanup() in afterEach to prevent test pollution
  2. Use await render() — render is async
  3. Use findBy* queries — all queries are async and will wait for elements
  4. Use roles over text when possible for more robust tests
  5. Test behavior, not implementation — focus on what users see and do
  6. Use debug() to inspect the widget tree when tests fail