Wails in Practice #8 A Testing Strategy — Verifying the Service and Repository
Back in #1, splitting the layers, we promised “you can test the service logic in pure Go without Wails.” This post keeps that promise. A practice track that skips testing does not live up to the name “practice,” so we write tests that actually verify the service and repository we’ve built so far. The good news: thanks to the layer split in #1, this is easy.
This series is ten posts in two parts.
- Part 1: #1 · #2 · #3 · #4 · #5 · #6
- Part 2: #7 The note editor for real · #8 A testing strategy ← this post · #9 Implementing auto-update · #10 Finishing touches
Why testing got easy: the payoff of layer separation #
The reason #1 split binding, service, and repository with dependencies pointing one way shows up here. Had all the logic hung on the App struct and called the Wails runtime inside it, testing would mean spinning up the whole Wails app. Because the service and repository are pure Go with no dependency on Wails, they verify with go test alone. That is the practical payoff of splitting the layers.
Testing goes two ways: the service verifies domain rules, the repository verifies actual DB behavior.
Unit tests: the service’s rules #
Start with what verifies without a DB. The rules of Create built in #2 (reject an empty title, trim surrounding whitespace) are the classic case. Sweep several inputs at once with a table-driven test — the most widely used test form in Go.
func TestCreateValidation(t *testing.T) {
cases := []struct {
name string
title string
wantErr bool
}{
{"valid title", "Meeting notes", false},
{"empty title", "", true},
{"whitespace-only title", " ", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
svc := NewNoteService(newTestRepo(t)) // repository backed by a temp DB
_, err := svc.Create(c.title, "body")
if (err != nil) != c.wantErr {
t.Errorf("Create(%q): err=%v, wantErr=%v", c.title, err, c.wantErr)
}
})
}
}Naming each case with t.Run shows immediately which input broke on failure. Adding a new rule means adding one case line, so the rules and tests grow together.
Integration tests: the repository with a temporary SQLite #
The repository runs actual SQL, so it needs a DB. Here #2’s pure-Go driver pays off again. With no CGO, it just runs in the test environment without a C toolchain, and you create a fresh temporary file DB per test so they do not interfere. t.TempDir() gives a directory that is auto-removed when the test finishes.
func newTestRepo(t *testing.T) *NoteRepository {
t.Helper()
dir := t.TempDir() // auto-removed when the test ends
db, err := sql.Open("sqlite", filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
repo := &NoteRepository{db: db}
if err := repo.migrate(); err != nil { // #2's migration as-is
t.Fatal(err)
}
return repo
}
func TestInsertAndList(t *testing.T) {
repo := newTestRepo(t)
if _, err := repo.Insert(Note{Title: "First note", Body: "body", CreatedAt: time.Now(), UpdatedAt: time.Now()}); err != nil {
t.Fatal(err)
}
notes, err := repo.List()
if err != nil {
t.Fatal(err)
}
if len(notes) != 1 {
t.Errorf("note count = %d, want 1", len(notes))
}
}The temp DB runs migrate as-is, so the schema and FTS5 triggers built in #2 and #3 apply exactly as in production. That is, this integration test verifies the migration too.
What is worth testing #
Try to test everything and you burn out with nothing to show. Set priorities.
- Rules and edges: points where conditions branch, like rejecting an empty title and handling whitespace. These regress often.
- Search correctness: whether #3’s FTS5 search does prefix matching (
word*) properly and whether an empty query falls through to the full list. These break quietly as data grows. - Migration: whether an older-version DB steps up to the next version correctly. User data is at stake, so failure is costly.
Conversely, screen rendering or plain getters give little return for the testing cost. Leave the frontend to smoke tests of the core flow, and put the weight of verification on the Go layer where data is at stake.
Wiring into CI: automatic before a release #
The release workflow built in #6 builds and signs the moment you push a tag. Put testing ahead of it so a release proceeds only if tests pass. Also keep a separate workflow running go test on every push and PR.
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.23" }
- run: go test ./... -race # also check for race conditionsThe -race flag catches data races between goroutines. Because #4 ran the tray and shortcut on separate goroutines, this check is meaningful. Thanks to the pure-Go driver, this test job also just runs on ubuntu-latest with no CGO setup.
Summary #
- The service and repository are pure Go with no dependency on Wails, so they verify with
go test. That is the practical payoff of #1’s layer split. - Verify service rules with table-driven unit tests, sweeping several inputs named with
t.Runat once. - Integration-test the repository isolated with
t.TempDir’s temporary SQLite. Runmigrateas-is to verify the schema and FTS5 too. - Test priorities are rules/edges, search correctness, and migration. Defer screens and getters.
- Run
go test ./... -racein CI on every push and PR, and stand it ahead of the release so it must pass to ship. - Next post implements auto-update to deliver a new version safely to the shipped app.