Wails 実践講座 #8 テスト戦略 — サービスとリポジトリを検証する

読了 5分

#1 で層を分けたとき、「サービスロジックを Wails なしの純粋な Go でテストできる」と約束しました。この記事はその約束を守ります。実践トラックがテストを飛ばせば「実践」の名に値しないので、これまで積み上げたサービスとリポジトリを実際に検証するテストを書きます。良い知らせは、#1 で層を分けておいたおかげでこれが簡単だということです。

このシリーズは全 10 編、2 部構成です。

なぜテストが簡単になったか: 層分離の見返り #

#1 でバインディング・サービス・リポジトリを分け、依存を一方向に置いた理由がここで表れます。もしすべてのロジックが App 構造体に付いていて、その中で Wails ランタイムを直接呼んでいたら、テストするには Wails アプリ全体を立ち上げる必要がありました。サービスとリポジトリは Wails に依存しない純粋な Go のコードなので、go test だけで検証されます。これが層を分けた実質的な見返りです。

テストは二つに分かれます。サービスはドメインのルールを、リポジトリは実際の DB の動作を検証します。

単体テスト: サービスのルール #

サービスロジックのうち DB なしで検証できるものから見ます。#2 で作った Create のルール(タイトルが空なら拒否、前後の空白を除去)が代表的です。テーブル駆動テストで複数の入力を一度になめます。Go で最も広く使われるテストの形です。

service_test.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() がテスト終了時に自動で消えるディレクトリをくれます。

repository_test.go — 一時 DB で隔離
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 を走らせるのも一緒に置きます。

.github/workflows/test.yml — push・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 # 競合状態まで検査

-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 で走らせ、リリースの前に立てて通ってこそ配信されるようにします。
  • 次の記事では、配信したアプリに新しいバージョンを安全に届ける自動更新を実装します。
X