SODA Engineering Blog
🕌

部分的にGoのテスト速度向上

2024/12/14に公開

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に渡すことができます。

scripts/testselector/main.go
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関数があります。

scripts/loadpackage/main.go
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: パッケージ名とパッケージパスを取得します。NamePkgPathを設定します。
  • NeedImports: 各パッケージがインポートしているパッケージの概要リストを取得します。Importsを設定します。

また、依存関係グラフにテストも含めたいので、Teststrueに設定します。

> 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"のケースのために、インポートされたパッケージパスと同じパッケージ名はフィルタリングする必要があります。
scripts/loadpackage/main.go
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)を使ってグラフを探索できます!

まずは、パッケージの読み込みコードをテスト選択のコードに整理しましょう。

scripts/testselector/main.go
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は変更されたファイルが存在する場所なので、これをグラフのスタートノードとして使用する必要があります。また、訪問したノードを追跡する必要があります。

scripts/testselector/main.go
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を実行するスクリプトで使用できるようになります。

scripts/testselector/main.go
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 を修正すれば良いです。

scripts/testselector/main.go
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 Engineering Blog
SODA Engineering Blog

Discussion