Wails 実践講座 #8 テスト戦略 — サービスとリポジトリを検証する
#1 で層を分けたとき、「サービスロジックを Wails なしの純粋な Go でテストできる」と約束しました。この記事はその約束を守ります。実践トラックがテストを飛ばせば「実践」の名に値しないので、これまで積み上げたサービスとリポジトリを実際に検証するテストを書きます。良い知らせは、#1 で層を分けておいたおかげでこれが簡単だということです。
このシリーズは全 10 編、2 部構成です。
なぜテストが簡単になったか: 層分離の見返り #
#1 でバインディング・サービス・リポジトリを分け、依存を一方向に置いた理由がここで表れます。もしすべてのロジックが App 構造体に付いていて、その中で Wails ランタイムを直接呼んでいたら、テストするには Wails アプリ全体を立ち上げる必要がありました。サービスとリポジトリは Wails に依存しない純粋な Go のコードなので、go test だけで検証されます。これが層を分けた実質的な見返りです。
テストは二つに分かれます。サービスはドメインのルールを、リポジトリは実際の DB の動作を検証します。
単体テスト: サービスのルール #
サービスロジックのうち DB なしで検証できるものから見ます。#2 で作った Create のルール(タイトルが空なら拒否、前後の空白を除去)が代表的です。テーブル駆動テストで複数の入力を一度になめます。Go で最も広く使われるテストの形です。
func TestCreateValidation(t *testing.T) {
cases := []struct {
name string
title string
wantErr bool
}{
{"正常なタイトル", "議事録", false},
{"空のタイトル", "", true},
{"空白だけのタイトル", " ", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
svc := NewNoteService(newTestRepo(t)) // 一時 DB を基にしたリポジトリ
_, err := svc.Create(c.title, "本文")
if (err != nil) != c.wantErr {
t.Errorf("Create(%q): err=%v, wantErr=%v", c.title, err, c.wantErr)
}
})
}
}各ケースに t.Run で名前を付けると、失敗したときにどの入力が壊れたかがすぐ分かります。新しいルールを追加するときはケースを 1 行足すだけなので、ルールとテストが一緒に育ちます。
統合テスト: 一時的な SQLite でリポジトリ #
リポジトリは実際の SQL を走らせるので DB が必要です。ここで #2 の純粋な Go ドライバーがまた得をくれます。CGO がないのでテスト環境で C ツールチェーンなしにそのまま動き、テストごとに一時ファイルの DB を新しく作って互いに干渉しないようにします。t.TempDir() がテスト終了時に自動で消えるディレクトリをくれます。
func newTestRepo(t *testing.T) *NoteRepository {
t.Helper()
dir := t.TempDir() // テスト終了時に自動削除
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 のマイグレーションをそのまま
t.Fatal(err)
}
return repo
}
func TestInsertAndList(t *testing.T) {
repo := newTestRepo(t)
if _, err := repo.Insert(Note{Title: "最初のノート", 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("ノート数 = %d, 期待 1", len(notes))
}
}一時 DB には migrate をそのまま通すので、#2・#3 で作ったスキーマと FTS5 のトリガーが本番とまったく同じに適用されます。つまりこの統合テストはマイグレーションまで一緒に検証します。
何をテストする価値があるか #
すべてをテストしようとすると疲れて何も残りません。優先順位を決めます。
- ルールと境界: 空のタイトルの拒否、空白の処理のように条件が分かれる箇所。回帰が多いところです。
- 検索の正確性: #3 の FTS5 検索が前方一致(
単語*)をきちんとするか、空の検索語が全体一覧に行くか。データが増えると静かに壊れやすいところです。 - マイグレーション: 既存バージョンの DB が次のバージョンに正しく上がるか。ユーザーデータがかかっているので失敗のコストが大きいです。
逆に画面のレンダリングや単純な getter は、テストのコストの割に見返りが小さいです。フロントエンドは核心の流れのスモークテスト程度にとどめ、検証の重みはデータがかかった Go の層に載せます。
CI に入れる: リリース前に自動で #
#6 で作ったリリースのワークフローは、タグをプッシュするとすぐにビルド・署名します。その前にテストを立て、テストが通ってこそリリースが進むようにします。別のワークフローで push・PR ごとに go test を走らせるのも一緒に置きます。
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 # 競合状態まで検査-race フラグはゴルーチン間のデータ競合を捕まえてくれます。#4 でトレイ・ショートカットを別のゴルーチンで走らせたので、この検査に意味があります。純粋な Go ドライバーのおかげで、このテストジョブも CGO の設定なしに ubuntu-latest でそのまま動きます。
まとめ #
- サービス・リポジトリは Wails に依存しない純粋な Go なので
go testで検証されます。#1 の層分離がくれた実質的な見返りです。 - サービスのルールはテーブル駆動の単体テストで、複数の入力を
t.Runで名前を付けて一度になめます。 - リポジトリは
t.TempDirの一時的な SQLite で隔離して統合テストします。migrateをそのまま通してスキーマ・FTS5 まで検証します。 - テストの優先順位はルール・境界、検索の正確性、マイグレーションです。画面・getter は後回しにします。
go test ./... -raceを push・PR ごとに CI で走らせ、リリースの前に立てて通ってこそ配信されるようにします。- 次の記事では、配信したアプリに新しいバージョンを安全に届ける自動更新を実装します。