Testing
GTKX provides testing utilities through @gtkx/testing, offering an API similar to Testing Library for React.
Setup
Install the testing and vitest packages:
npm install -D @gtkx/testing @gtkx/vitest vitest
Create a vitest.config.ts file:
import gtkx from "@gtkx/vitest";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [gtkx()],
test: {
include: ["tests/**/*.test.{ts,tsx}"],
},
});
Configure your test script in package.json:
{
"scripts": {
"test": "vitest run"
}
}
The @gtkx/vitest plugin automatically:
- Starts Xvfb instances for headless display
- Sets required GTK environment variables (
GDK_BACKEND,GSK_RENDERER, etc.) - Ensures proper display isolation between test workers
The render() function from @gtkx/testing handles GTK application lifecycle automatically, so no additional setup is needed.
Basic Test
import * as Gtk from "@gtkx/ffi/gtk";
import { cleanup, render, screen, userEvent } from "@gtkx/testing";
import { afterEach, describe, expect, it } from "vitest";
import { App } from "../src/app.js";
describe("App", () => {
afterEach(async () => {
await cleanup();
});
it("renders the window", async () => {
await render(<App />, { wrapper: false });
const window = await screen.findByRole(Gtk.AccessibleRole.WINDOW, {
name: "My App",
});
expect(window).toBeDefined();
});
});
Testing Hooks
Use renderHook to test custom React hooks in isolation:
import { cleanup, renderHook } from "@gtkx/testing";
import { afterEach, describe, expect, it } from "vitest";
import { useCounter } from "../src/hooks/useCounter.js";
describe("useCounter", () => {
afterEach(async () => {
await cleanup();
});
it("increments the counter", async () => {
const { result, rerender } = await renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
result.current.increment();
await rerender();
expect(result.current.count).toBe(1);
});
it("accepts initial value", async () => {
const { result } = await renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
});
See the renderHook API reference for full details.
Querying by Widget State
Role queries can filter by widget state like pressed, expanded, and selected:
import * as Gtk from "@gtkx/ffi/gtk";
import { cleanup, render, screen } from "@gtkx/testing";
import { afterEach, describe, expect, it } from "vitest";
describe("role state queries", () => {
afterEach(async () => {
await cleanup();
});
it("finds pressed toggle buttons", async () => {
await render(<GtkToggleButton label="Bold" active />, { wrapper: false });
const pressed = await screen.findByRole(Gtk.AccessibleRole.TOGGLE_BUTTON, {
pressed: true,
});
expect(pressed).toBeDefined();
});
it("finds expanded expanders", async () => {
await render(<GtkExpander label="Details" expanded />, { wrapper: false });
const expanded = await screen.findByRole(Gtk.AccessibleRole.BUTTON, {
expanded: true,
});
expect(expanded).toBeDefined();
});
});
Waiting for Async Conditions
Use waitFor to poll until an assertion passes, useful for testing async data loading or delayed UI updates:
import { cleanup, render, screen, waitFor } from "@gtkx/testing";
import { afterEach, describe, expect, it } from "vitest";
describe("async", () => {
afterEach(async () => {
await cleanup();
});
it("waits for data to load", async () => {
await render(<AsyncComponent />, { wrapper: false });
await waitFor(async () => {
const label = await screen.findByText("Loaded");
expect(label).toBeDefined();
});
});
});
Complete Example
import * as Gtk from "@gtkx/ffi/gtk";
import { cleanup, render, screen, userEvent, within } from "@gtkx/testing";
import { afterEach, describe, expect, it } from "vitest";
import { TodoApp } from "../src/app.js";
describe("TodoApp", () => {
afterEach(async () => {
await cleanup();
});
it("adds a new todo", async () => {
await render(<TodoApp />, { wrapper: false });
const input = await screen.findByTestId("todo-input");
const addButton = await screen.findByTestId("add-button");
await userEvent.type(input, "Buy groceries");
await userEvent.click(addButton);
const todoText = await screen.findByText("Buy groceries");
expect(todoText).toBeDefined();
});
it("toggles todo completion", async () => {
await render(<TodoApp />, { wrapper: false });
const input = await screen.findByTestId("todo-input");
await userEvent.type(input, "Test todo");
await userEvent.click(await screen.findByTestId("add-button"));
const checkbox = await screen.findByRole(Gtk.AccessibleRole.CHECKBOX, {
checked: false,
});
await userEvent.click(checkbox);
const checkedBox = await screen.findByRole(Gtk.AccessibleRole.CHECKBOX, {
checked: true,
});
expect(checkedBox).toBeDefined();
});
it("deletes a todo", async () => {
await render(<TodoApp />, { wrapper: false });
const input = await screen.findByTestId("todo-input");
await userEvent.type(input, "Todo to delete");
await userEvent.click(await screen.findByTestId("add-button"));
const deleteButton = await screen.findByTestId(/^delete-/);
await userEvent.click(deleteButton);
const emptyMessage = await screen.findByText("No tasks yet");
expect(emptyMessage).toBeDefined();
});
it("updates the remaining count", async () => {
await render(<TodoApp />, { wrapper: false });
const input = await screen.findByTestId("todo-input");
const addButton = await screen.findByTestId("add-button");
await userEvent.type(input, "Todo 1");
await userEvent.click(addButton);
await userEvent.type(input, "Todo 2");
await userEvent.click(addButton);
let counter = await screen.findByTestId("items-left");
expect((counter as Gtk.Label).getLabel()).toContain("2");
const checkboxes = await screen.findAllByRole(Gtk.AccessibleRole.CHECKBOX);
await userEvent.click(checkboxes[0] as Gtk.Widget);
counter = await screen.findByTestId("items-left");
expect((counter as Gtk.Label).getLabel()).toContain("1");
});
});