部分的にGoのテスト速度向上
Goにはとても堅牢なファーストパーティのテストシステムがあり、他の言語に比べてテストが高速に実行できるのが特徴です。でも、大規模なコードベースになると、テストが遅くなったり非効率になったりすることがあります。特にCI環境では、テストが遅いとコストがどんどん増えてしまうことも。この記事では、Goの重要なコードを効率よく、選択的にテストする方法について見ていきます。
部分的にテストとは
選択的テストとは、「重要な」コードだけをテストする方法です。「重要なテスト」とは、以下の条件のいずれかを満たすテストとして定義できます:
- テスト対象のコードが変更された。
- テスト対象のコードが変更されたコードをインポートしている。
これを前提にしても問題ない理由は、変更されていないパッケージや、変更されたファイルを依存していないパッケージのテストは、影響を受けずに成功する可能性が高いためです。したがって、これらはテストから除外しても問題ありません。
目的
この記事の目的は、Goテストのうち「重要なテスト」だけを選択的に実行できるシンプルなGoコードを作成することです。各ステップの直感的な理解を深めながら進めていきます。
bashスクリプトで実行する
まずは、設定するためにどれくらいシンプルにできるかを確認するために、shellスクリプトを作成してみましょう。
変更されたファイル発見する
テストが対象とする変更されたコードがどれかを知るためには、まずどのファイルが変更されたかを確認する必要があります。
変更されたファイルを確認するのは比較的簡単です。git diff --name-only
を使うことで、2つのブランチ間で内容が異なるファイルのリストを取得できます。
> git diff --name-only main ft/new-coupon
pkg/model/coupon.go
pkg/model/coupon_test.go
pkg/repository/coupon.go
pkg/usecase/coupon/input.go
pkg/usecase/coupon/usecase.go
pkg/usecase/coupon/usecase_mock.go
pkg/usecase/coupon/usecase_test.go
migration/050_add_coupon_type_to_coupons.down.sql
migration/050_add_coupon_type_to_coupons.up.sql
これにはGoファイル以外も含まれます。 grepを使って"*.go"拡張子のファイルだけをフィルタリングすることができます。
> git diff --name-only main ft/new-coupon | grep \.go$
pkg/model/coupon.go
pkg/model/coupon_test.go
pkg/repository/coupon.go
pkg/usecase/coupon/input.go
pkg/usecase/coupon/usecase.go
pkg/usecase/coupon/usecase_mock.go
pkg/usecase/coupon/usecase_test.go
変更されたファイルの検出とフィルタリングを設定可能な形でまとめて行うCIアクションもあります。
パッケージまとめる
変更されたファイルのリストができたので、次はそれをテストが必要なパッケージのリストに変換します。
ターゲットコードが同じパッケージ内にテストがあると仮定して問題ないため、各パスの最後のセグメントを切り取るだけで済みます。これを実現するために、出力をxargs -n1 dirname
にパイプして処理します。ここでのdirname
は、パスの最後のセグメントを削除してディレクトリ部分を取得する標準的なコマンドです。
> git diff --name-only main ft/new-coupon | grep \.go$ | xargs -n1 dirname
pkg/model
pkg/model
pkg/repository
pkg/usecase/coupon
pkg/usecase/coupon
pkg/usecase/coupon
pkg/usecase/coupon
この方法の問題点は、パッケージ内で複数のファイルが変更されることがあるため、パッケージ名が重複する可能性があることです。これを解決するためには、パッケージ名をセットに入れて、そのセットをリストとして出力すればよいです。これはsort -u
を使うことで簡単に実現できます。
> git diff --name-only main ft/new-coupon | grep \.go$ | xargs -n1 dirname | sort -u
pkg/model
pkg/repository
pkg/usecase/coupon
さらに、sed 's|^|\./|'
を追加することで、各パスの先頭に./
を付け加えることができます。|^|
は検索する文字列、|\./|
はそれと置き換える文字列です。
> git diff --name-only main ft/new-coupon | grep \.go$ | xargs -n1 dirname | sort -u | sed 's|^|\./|'
pkg/model
pkg/repository
pkg/usecase/coupon
テスト実行する
準備が整ったパッケージのリストができたので、次にそれをgo test
の引数として処理する必要があります。
スクリプトを書きましょう:
#!/bin/sh
packages=$(git diff --name-only main ft/new-coupon | grep \.go$ | xargs -n1 dirname | sort -u | sed 's|^|\./|')
# Do not double quote the argument.
go test $(echo "$packages" | tr '\n' ' ')
これで、変更されたファイルに応じて、どのパッケージをテストするかを自動的に選択できるようになりました。CI環境では、git diff
のためにターゲットブランチと現在のブランチをパラメータとして渡すことができます。
Goコードで実行する
より複雑な機能を実現するためには、より柔軟に同じ処理を行うGoスクリプトを作成する必要があります。また、Goコードはshellスクリプトに比べて可読性が高いべきです。
発見された変更済みファイルをGoに入力
shellスクリプトのセクションと同じ手順を使います。変更されたファイルのリストを取得した後、それをGoのstdinに渡すことができます。
package main
import (
"bufio"
"io"
"os"
"strings"
)
func getInput(r io.Reader) []string {
scanner := bufio.NewScanner(r)
files := make([]string, 0)
for scanner.Scan() {
files = append(files, strings.Split(scanner.Text(), " ")...)
}
return files
}
func main() {
files := getInput(os.Stdin)
fmt.Println(files)
}
> git diff --name-only main ft/new-coupon | go run ./scripts/testselector
[pkg/model/coupon.go pkg/model/coupon_test.go pkg/repository/coupon.go pkg/usecase/coupon/input.go pkg/usecase/coupon/usecase.go pkg/usecase/coupon/usecase_mock.go pkg/usecase/coupon/usecase_test.go migration/050_add_coupon_type_to_coupons.down.sql migration/050_add_coupon_type_to_coupons.up.sql]
パッケージまとめる
ファイルのリストを文字列の配列として取得した後、それを潜在的なパッケージ名に処理できます。まず、".go"ファイルをフィルタリングする必要があります。なぜなら、それらだけがパッケージとして扱えるパスだからです。パッケージの重複を排除するために、map[string]struct{}
を文字列のセットとして使用しましょう。
package main
import (
// ..
"path/filepath"
"strings"
"maps"
"slices"
)
// ..
// For readability.
func addToSet[T comparable](set map[T]struct{}, s T) {
set[s] = struct{}{}
}
func summarizePkgs(files []string) map[string]struct{} {
pkgSet := make(map[string]struct{})
for _, file := range files {
if !strings.HasSuffix(file, ".go") {
continue
}
addToSet(pkgSet, "./"+filepath.Dir(file))
}
return pkgSet
}
func main() {
files := getInput(os.Stdin)
pkgSet := summarizePkgs(files)
pkgs := slices.Sorted(maps.Keys(pkgSet))
fmt.Println(strings.Join(pkgs, " "))
}
> git diff --name-only main ft/new-coupon | go run ./scripts/testselector
./pkg/model ./pkg/repository ./pkg/usecase/coupon
ここまでで、go test
に渡す準備が整いましたが、さらに一歩進めて、変更されたパッケージに依存するすべてのパッケージを取得したいと考えています。
変更あるパッケージに依存しているパッケージを発見する
パッケージの依存関係を検出するためには、パッケージ依存関係グラフを構築する必要があります。これを実現する方法はいくつかあります。
この記事では、"golang.org/x/tools/go/packages"ライブラリを使用します。このライブラリには、Goモジュールの依存関係グラフを決定するために必要な情報を持つパッケージのリストを返す Load
関数があります。
import (
"fmt"
"golang.org/x/tools/go/packages"
)
func main() {
pkgs, err := packages.Load(&packages.Config{
Dir: ".",
Mode: packages.NeedName | packages.NeedDeps | packages.NeedImports,
Tests: true,
}, "./...")
if err != nil {
panic(err)
}
for _, pkg := range pkgs {
fmt.Println(pkg.PkgPath)
}
}
パッケージを読み込む方法を設定するために、さまざまなMode
を使用することができます。
-
NeedName
: パッケージ名とパッケージパスを取得します。Name
とPkgPath
を設定します。 -
NeedImports
: 各パッケージがインポートしているパッケージの概要リストを取得します。Imports
を設定します。
また、依存関係グラフにテストも含めたいので、Tests
をtrue
に設定します。
> go run ./scripts/loadpackage
github.com/johndoe/hoge/pkg/http/handler/coupon
github.com/johndoe/hoge/pkg/http/handler/coupon.test
github.com/johndoe/hoge/pkg/http/handler/coupon_test
github.com/johndoe/hoge/pkg/model
github.com/johndoe/hoge/pkg/model.test
github.com/johndoe/hoge/pkg/model_test
github.com/johndoe/hoge/pkg/repository
github.com/johndoe/hoge/pkg/repository.test
github.com/johndoe/hoge/pkg/repository_test
github.com/johndoe/hoge/pkg/usecase/coupon
github.com/johndoe/hoge/pkg/usecase/coupon.test
github.com/johndoe/hoge/pkg/usecase/coupon_test
github.com/johndoe/hoge/pkg/utils
...
出力には".test"と"_test"パッケージがあります。
- ".test"パッケージはテスト用のバイナリパッケージです。これらはテストを実行するために使用される隠れたパッケージです。
- "_test"パッケージは"_test"パッケージ名の下に配置されたテストファイルです。ターゲットが"hoge"の場合、テストファイルのパッケージ名は"hoge_test"です。
パッケージ名を処理する際には、以下の点を考慮する必要があります:
- ".test"サフィックスが付いたパッケージはテスト用バイナリパッケージなので無視します。
- "_test"を取り除き、それを元のパッケージの一部として扱います。
- "github.com/johndoe/hoge/pkg/repository_test"は "github.com/johndoe/hoge/pkg/repository"の一部になります。
- 完全修飾パッケージパスを相対パッケージパスに変換します。
- "github.com/johndoe/hoge/pkg/repository"は"./pkg/repository" になります。
- パッケージ名をインポーター/依存パッケージのマップとして処理します。
M[X] = <Xに依存するパッケージ>
-
Imports
フィールドはmap[string]*packages.Package
で、インポートされたパッケージのリストが含まれています。基本的に、このデータ構造を逆転させる必要があります。 - また、"_test"のケースのために、インポートされたパッケージパスと同じパッケージ名はフィルタリングする必要があります。
import (
"fmt"
"strings"
"golang.org/x/tools/go/packages"
)
const modulePath = "github.com/johndoe/hoge"
func getRelativePkgPath(pkgPath string) string {
if !strings.HasPrefix(pkgPath, modulePath) {
return pkgPath
}
return "." + pkgPath[len(modulePath):]
}
func main() {
pkgs, err := packages.Load(&packages.Config{
Dir: ".",
Mode: packages.NeedName | packages.NeedDeps | packages.NeedImports,
Tests: true,
}, "./...")
if err != nil {
panic(err)
}
// M[X] = <Packages that depend on X>
graph := make(map[string]map[string]struct{})
for _, pkg := range pkgs {
// For safety.
if !strings.HasPrefix(pkg.PkgPath, modulePath) {
continue
}
if strings.HasSuffix(pkg.PkgPath, ".test") {
continue
}
pkgPath := strings.TrimSuffix(pkg.PkgPath, "_test")
pkgPath = getRelativePkgPath(pkgPath)
for importedPkgPath := range pkg.Imports {
// Do not consider external packages.
if !strings.HasPrefix(importedPkgPath, modulePath) {
continue
}
importedPkgPath = getRelativePkgPath(importedPkgPath)
// Do not consider itself as importer (because of "_test" packages).
if importedPkgPath == pkgPath {
continue
}
importers, ok := graph[importedPkgPath]
if !ok {
importers = make(map[string]struct{})
graph[importedPkgPath] = importers
}
// Add to M[X] as an importer.
addToSet(importers, pkgPath)
}
}
for pkgPath, importerPkgPaths := range graph {
fmt.Println(pkgPath, importerPkgPaths)
}
}
> go run ./scripts/loadpackage
./pkg/http/handler/coupon map[./pkg/usecase/coupon:{}]
./pkg/model map[./pkg/repository:{} ./pkg/usecase/coupon:{} ./pkg/http/handler/coupon:{}]
./pkg/repository map[./pkg/usecase/coupon:{}]
./pkg/usecase/coupon map[./pkg/http/handler/coupon:{}]
./pkg/utils map[./pkg/model:{} ./pkg/repository:{} ./pkg/usecase/coupon:{} ./pkg/http/handler/coupon:{}]
...
パッケージ依存関係グラフでテストを決める
依存関係グラフを表現したら:
作成した依存関係グラフを使って、どの依存するテストを実行するべきかを決定できます。視覚的なグラフを見ると、基本的に上から下に向かって探索していく形になります。
例えば、もし"./pkg/utils"に変更があれば、"./pkg/utils"に依存するすべてのパッケージとその子パッケージもテストする必要があります。もし"./pkg/usecase/coupon"に変更があれば、"./pkg/usecase/coupon"とその子である"./pkg/http/handler/coupon"のみがテストされます。なぜなら、"./pkg/usecase/coupon"の唯一の子パッケージは "./pkg/http/handler/coupon"だからです。
これを実現するためには、深さ優先探索(Depth First Search)を使ってグラフを探索できます!
まずは、パッケージの読み込みコードをテスト選択のコードに整理しましょう。
package main
import (
// ..
)
const modulePath = "github.com/johndoe/hoge"
// ..
func getRelativePkgPath(pkgPath string) string {
if !strings.HasPrefix(pkgPath, modulePath) {
return pkgPath
}
return "." + pkgPath[len(modulePath):]
}
func loadPackages() map[string]map[string]struct{} {
pkgs, err := packages.Load(&packages.Config{
Dir: ".",
Mode: packages.NeedName | packages.NeedDeps | packages.NeedImports,
Tests: true,
}, "./...")
if err != nil {
panic(err)
}
// M[X] = <Packages that depend on X>
graph := make(map[string]map[string]struct{})
for _, pkg := range pkgs {
// For safety.
if !strings.HasPrefix(pkg.PkgPath, modulePath) {
continue
}
if strings.HasSuffix(pkg.PkgPath, ".test") {
continue
}
pkgPath := strings.TrimSuffix(pkg.PkgPath, "_test")
pkgPath = getRelativePkgPath(pkgPath)
for importedPkgPath := range pkg.Imports {
// Do not consider external packages.
if !strings.HasPrefix(importedPkgPath, modulePath) {
continue
}
importedPkgPath = getRelativePkgPath(importedPkgPath)
// Do not consider itself as importer (because of "_test" packages).
if importedPkgPath == pkgPath {
continue
}
importers, ok := graph[importedPkgPath]
if !ok {
importers = make(map[string]struct{})
graph[importedPkgPath] = importers
}
// Add to M[X] as an importer.
addToSet(importers, pkgPath)
}
}
return graph
}
func decideTestedPkgs(pkgSet map[string]struct{}, graph map[string]map[string]struct{}) map[string]struct{} {
// To do...
}
func main() {
files := getInput(os.Stdin)
pkgSet := summarizePkgs(files)
graph := loadPackages()
testedPkgSet := decideTestedPkgs(pkgSet, graph)
fmt.Println(testedPkgSet)
}
コンピュータ科学を学んだことがあれば、DFS(深さ優先探索)はこの問題ではかなり簡単に認識できるはずです。もし分からない場合は、調べてみてください。DFSの視覚化を見てみましょう。パッケージのグラフをDFSで探索できるグラフとして想像してみてください。
この場合、再帰的に関数を使って探索を行います。pkgSet
は変更されたファイルが存在する場所なので、これをグラフのスタートノードとして使用する必要があります。また、訪問したノードを追跡する必要があります。
package main
// ..
func decideTestedPkgs(pkgSet map[string]struct{}, graph map[string]map[string]struct{}) map[string]struct{} {
visited := make(map[string]struct{})
// Starting nodes.
for pkgPath := range pkgSet {
traverseDependents(pkgPath, graph, visited)
}
return visited
}
func traverseDependents(pkgPath string, graph map[string]map[string]struct{}, visited map[string]struct{}) {
// Do not retraverse visited nodes.
if _, ok := visited[pkgPath]; ok {
return
}
addToSet(visited, pkgPath)
// Traverse childs/importer of pkgPath.
for importerPkgPath := range graph[pkgPath] {
traverseDependents(importerPkgPath, graph, visited)
}
}
// ..
visited
の中身が実際に私たちが必要としている結果です!これを処理して標準出力(stdout)に出力することで、go test
を実行するスクリプトで使用できるようになります。
package main
import (
// ..
"slices"
"maps"
)
// ..
func main() {
files := getInput(os.Stdin)
pkgSet := summarizePkgs(files)
graph := loadPackages()
testedPkgSet := decideTestedPkgs(pkgSet, graph)
// Convert to slice and sort.
testedPkgs := slices.Sorted(maps.Keys(testedPkgSet))
fmt.Println(strings.Join(testedPkgs, " "))
}
> git diff --name-only main ft/new-coupon | go run ./scripts/testselector
./pkg/model ./pkg/repository ./pkg/usecase/coupon ./pkg/http/handler/coupon
最初はテストされるパッケージが3つだけでしたが、現在では"./pkg/http/handler/coupon"も含まれています。なぜなら、"./pkg/http/handler/coupon"は変更があった"./pkg/usecase/coupon"をインポートしているからです。
Goスクリプトを用意する
これで必要な部分はすべて揃いました。Goスクリプトの出力をgo test
に使用できます。 "scripts/testselector/main.go"をあなたのGoモジュール内に配置すれば、ファイルのリストを受け入れる準備が整います。
Goファイル以外対応
.html
ファイルや .sql
ファイルがGoコードに関連している場合があるかもしれません。この関連を簡単に追加するには、依存関係が計算される前に pkgSet
を修正すれば良いです。
package main
import (
// ..
"strings"
)
// ..
func hasSqlFile(files []string) bool {
for _, file := range files {
if strings.HasSuffix(file, ".sql") {
return true
}
}
return false
}
// ..
func main() {
files := getInput(os.Stdin)
pkgSet := summarizePkgs(files)
graph := loadPackages()
// SQL files are related to repository tests.
// Test repository if there are any SQL changes.
if hasSqlFile(files) {
addToSet(pkgSet, "./pkg/repository")
}
testedPkgSet := decideTestedPkgs(pkgSet, graph)
// Convert to slice and sort.
testedPkgs := slices.Sorted(maps.Keys(testedPkgSet))
fmt.Println(strings.Join(testedPkgs, " "))
}
もっと最適化する
さらに最適化したい場合は、すべてのドメインが適切に分離された形でパッケージを設計する必要があります。例えば、"./pkg/repository"や"./pkg/model"のように、すべてのドメインのモデルやリポジトリを1つのパッケージにまとめるのは理想的ではありません。これらのパッケージ内で1つの変更があると、テストセレクターがすべてをテストすることになってしまいます!
CIを設定する
すべての設定が完了したら、部分的なスクリプトに対応するシンプルなCIを設定できます。
もしプルリクエストに対してこれを実行したい場合、かつ環境がGitHubであれば、スクリプトは以下のようになります:
name: test
on:
- pull_request
jobs:
go-test:
name: Test Go
runs-on: ubuntu
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Determine changed files
uses: technote-space/get-diff-action@v6
id: changed_files
with:
PATTERNS: |
*.go
go.+(mod|sum)
.env.test
pkg/email/template/**/*.+(txt|html)
pkg/render/template/**/*.html
migration/*.sql
- name: Setup go
if: steps.changed_files.outputs.diff
uses: actions/setup-go@v5
- name: Determine tested files
if: steps.changed_files.outputs.diff
id: tested_files
shell: bash
run: |
TESTED_FILES=$(echo "${{ steps.changed_files.outputs.diff }}" | go run ./scripts/testselector)
echo "targets=$TESTED_FILES" >> $GITHUB_OUTPUT
- name: Run tests
if: steps.tested_files.outputs.targets
run: go test ${{ steps.tested_files.outputs.targets }}
git diff
を手動で実行する代わりに、"technote-space/get-diff-action" のようなアクションを使って、どのファイルが変更されたかを自動的に判定することもできます。
まとめ
大規模なコードベースでは、すべてのテストを実行すると遅くなる可能性があります。これに対処するためにGoでテスト選択スクリプトを作りました。スクリプトは、Go独自のライブラリを使用して依存関係グラフのトラバーサルを通じてテストが必要なパッケージを判別できるようになりました。その生成した結果はgo test
で使用できます。
Gist: https://gist.github.com/ezraisw/2e112f49517739efd52807352a57a072
株式会社SODAの開発組織がお届けするZenn Publicationです。 是非Entrance Bookもご覧ください! → recruit.soda-inc.jp/engineer
Discussion