Testing
Drift includes a widget testing framework in pkg/testing that lets you mount widgets, drive the build/layout/paint pipeline, simulate gestures, and compare render tree snapshots without a real window or GPU.
Since the package name collides with the standard library, import it with an alias:
import drifttest "github.com/go-drift/drift/pkg/testing"
Setting Up a Test
Create a WidgetTester, configure it if needed, and mount your widget with PumpWidget:
func TestGreeting(t *testing.T) {
tester := drifttest.NewWidgetTesterWithT(t)
tester.PumpWidget(MyGreeting{Name: "World"})
result := tester.Find(drifttest.ByText("Hello, World!"))
if !result.Exists() {
t.Error("expected greeting text")
}
}
NewWidgetTesterWithT registers a cleanup function via t.Cleanup() so global state is restored automatically.
By default the tester uses an 800x600 surface at 1x scale with a Material light theme. You can override these before calling PumpWidget:
tester.SetSize(graphics.Size{Width: 375, Height: 812})
tester.SetScale(2.0)
tester.SetTheme(myCustomTheme)
Pumping Frames
PumpWidget mounts the widget and runs one full frame (build, layout, paint). After making state changes you need to pump additional frames:
tester.Tap(drifttest.ByText("+"))
tester.Pump() // process the rebuild triggered by setState
Use PumpAndSettle to keep pumping frames until the framework is completely idle (no dirty elements, active tickers, or queued dispatches):
tester.PumpAndSettle(5 * time.Second)
It returns drifttest.ErrSettleTimeout if the framework doesn't settle within the timeout.
Finding Widgets
Finders locate elements in the widget tree. Pass them to tester.Find() to get a FinderResult:
// By widget type
tester.Find(drifttest.ByType[widgets.Text]())
// By exact text content
tester.Find(drifttest.ByText("Submit"))
// By text substring
tester.Find(drifttest.ByTextContaining("Sub"))
// By widget key
tester.Find(drifttest.ByKey("submit-btn"))
// By custom predicate
tester.Find(drifttest.ByPredicate(func(e core.Element) bool { ... }))
// Scoped: descendants of a match
tester.Find(drifttest.Descendant(parentFinder, childFinder))
The returned FinderResult provides accessors for inspecting matches:
result := tester.Find(drifttest.ByType[widgets.Text]())
result.Exists() // bool -- at least one match
result.Count() // int
result.Widget() // first match's widget
result.RenderObject() // first match's render object
result.All() // []core.Element
Inspecting Widgets
Cast the result from Widget() to access widget-specific fields:
tester.PumpWidget(widgets.Text{Content: "count: 42"})
result := tester.Find(drifttest.ByType[widgets.Text]())
txt := result.Widget().(widgets.Text)
if txt.Content != "count: 42" {
t.Errorf("expected %q, got %q", "count: 42", txt.Content)
}
Simulating Gestures
The tester routes synthetic pointer events through the render tree's hit testing, matching the production dispatch path.
// Tap the center of the first match
tester.Tap(drifttest.ByText("Click"))
tester.Pump()
// Tap at a specific position
tester.TapAt(graphics.Offset{X: 100, Y: 200})
// Drag a widget
tester.Drag(finder, graphics.Offset{X: 0, Y: -300})
Here's a full example testing a button tap:
func TestButton_Tap(t *testing.T) {
tester := drifttest.NewWidgetTesterWithT(t)
tapped := false
tester.PumpWidget(widgets.Button{
Label: "Click",
OnTap: func() { tapped = true },
Color: graphics.RGB(33, 150, 243),
TextColor: graphics.ColorWhite,
FontSize: 16,
Haptic: true,
})
if err := tester.Tap(drifttest.ByText("Click")); err != nil {
t.Fatalf("Tap failed: %v", err)
}
tester.Pump()
if !tapped {
t.Error("expected button tap callback to fire")
}
}
Controlling Time
The tester injects a FakeClock that replaces time.Now() for all tickers, giving tests deterministic control over animations:
func TestFadeIn(t *testing.T) {
tester := drifttest.NewWidgetTesterWithT(t)
tester.PumpWidget(FadeIn{Duration: 300 * time.Millisecond})
tester.Clock().Advance(150 * time.Millisecond)
tester.Pump()
// assert intermediate state...
tester.Clock().Advance(150 * time.Millisecond)
tester.PumpAndSettle(time.Second)
// assert final state...
}
Snapshot Testing
Snapshots serialize the render tree and display list operations to JSON. They catch unintended layout or paint regressions without pixel comparison.
A snapshot contains two sections:
- Render tree -- every render object with its type, size, offset, and properties.
- Display operations -- the paint commands (
drawRect,drawRRect,translate,clipRect, etc.) recorded through the canvas.
Here's what a snapshot file looks like:
{
"renderTree": {
"id": "RenderText#0",
"type": "RenderText",
"size": [800, 600],
"offset": [0, 0],
"props": {
"maxLines": 0,
"text": "hello"
}
}
}
Writing a Snapshot Test
Capture a snapshot and compare it against a golden file:
func TestLoginForm_Layout(t *testing.T) {
tester := drifttest.NewWidgetTesterWithT(t)
tester.SetSize(graphics.Size{Width: 375, Height: 667})
tester.PumpWidget(LoginForm{})
snap := tester.CaptureSnapshot()
snap.MatchesFile(t, "testdata/login_form.snapshot.json")
}
Creating and Updating Snapshots
On first run, the test fails because the golden file doesn't exist. Create or update snapshot files by setting the DRIFT_UPDATE_SNAPSHOTS environment variable:
DRIFT_UPDATE_SNAPSHOTS=1 go test ./...
Subsequent runs compare against the saved file and report a diff on mismatch:
snapshot mismatch: testdata/login_form.snapshot.json
--- expected
+++ actual
- "size": [375.00, 48.00],
+ "size": [375.00, 56.00],
To update: DRIFT_UPDATE_SNAPSHOTS=1 go test -run TestLoginForm_Layout
Snapshot File Convention
There is no enforced directory, but the convention is to place snapshots in testdata/ alongside the test file:
mypackage/
widget.go
widget_test.go
testdata/
widget.snapshot.json
Asserting on Snapshot Data
You can also inspect snapshot data programmatically. For example, checking that a container paints a red rectangle:
snap := tester.CaptureSnapshot()
rects := findOps(snap.DisplayOps, "drawRect")
for _, op := range rects {
if c, ok := op.Params["color"].(string); ok && c == "0xFFFF0000" {
// found it
}
}
Or comparing two snapshots directly without golden files:
before := tester.CaptureSnapshot()
// ... modify state ...
tester.Pump()
after := tester.CaptureSnapshot()
if diff := before.Diff(after); diff != "" {
t.Errorf("unexpected change:\n%s", diff)
}
Next Steps
- Widget Catalog - Detailed usage for every Drift widget
- Gestures - Gesture handling in detail
- Animation - Animation system