境界値に対してのテスト実装状況を検証する静的解析ツールを作った

に公開

はじめに

CyberAgentで開催されたGo Collegeというインターンの成果物として静的解析ツールを作ったので、作り方の手順、ツールの説明をしていこうと思います。参加レポートについては以下の記事をご参照ください。
https://qiita.com/Mtsubasa/items/305dd522cbf8816997c9
また、今回の記事でanalyzerの内部構造のセクションがありますが、Claudeと壁打ちしながら作りました。この記事執筆時点で持ち合わせた知識ではないです。

作ったもの

コード内の境界に対してテストが十分な数実施されているか境界値分析、同値分割の観点から静的解析を行うツールです。

if x > 1 {
    return x
}

上のようなケースだと1に対して1より大きい値, 1, 1より小さい値のテストケースが用意されているか検証します。
成果物は以下のリポジトリにあります。
https://github.com/Mtsubasa/go-boundary-checker

何を検出するツールなのか

今回作ったツールはテストが通るかどうかではなく、テストを行う前にテストが設計に対して十分に用意されているかを検証します。実行することで対応漏れのあるテストケースを以下3つの観点で検出します。

  • 境界より小さい値でテストされているか
  • 境界と同じ値でテストされているか
  • 境界より大きい値でテストされているか

作った理由

先日qiitaにてCIにテストカバレッジを導入する記事を書きました。その時、境界値に対して十分なテストケースが用意されていないのにも関わらず、カバレッジが100%となっていました。そこで、既存のtestingパッケージのcoverオプションは厳密な分析は行っていない、テストの網羅性は保証しないのではないかと考え、静的解析ツールを作ることにしました。
https://qiita.com/Mtsubasa/items/d4e37671a867cfdb2212

境界値チェックの必要性

コードを実装する際に、作成者が意図せず<<=の記述を間違えるなど、比較演算子周りはバグが紛れ込みやすいです。厳密性が求められるサービスでは仕様とのズレが致命的になりうるので事前に検証しておくことが重要です。

既存ツールとの比較

設計通りに実装できているか検証するツールとしてミューテーションテストなどのツールがあります。ミューテーションテストとはソースコードに意図的にバグを注入しそのバグが生き残ったかどうかレポートを行うものです。今回僕が作ったツールはミューテーションテストと比較して以下の利点があります。

  • 静的に検証するため動作が速い
  • 対応漏れのあるテストケースについてレポートされる

ツールの仕様

どんなコードを対象にしているか

テストが用意されているコードを対象にしています。既存のtestingパッケージで行えるテストケースが用意されているかどうかの検証はここでは行いません。

どういうときに警告を出すか

例えば以下のコードに対して

func GetRarity(rate float64) Rarity {
	if rate < 0.05 {
		return RaritySSR
	}
	if rate < 0.17 {
		return RaritySR
	}
	return RarityR
}

テーブルテストで書かれたケースが以下のように存在する場合

{name: "over SSR boundary", rate: 0.04, expected: example.RaritySSR},
{name: "under SR boundary", rate: 0.06, expected: example.RaritySR},
{name: "SR", rate: 0.16, expected: example.RaritySR},
{name: "under R boundary", rate: 0.17, expected: example.RarityR},

足りていない検証値としては0.050.18です。
これを今回のツールで検証すると、

boundary ./...
example.go:12:12: GetRarity: boundary value 0.05 is not tested
example.go:15:12: GetRarity: no test value greater than 0.17

このように、境界値の0.05がテストされていません0.17より大きいテスト値がありませんと警告を出します。

境界値分析+同値分割をどう扱ったか

境界値分析とはコードに書かれている値の境界をテストケースとして選出する方法です。
境界値分析の観点でテストツールを作った場合、コード内の境界値に対して3つテスト検証を行います。例えば、3ならば2, 3, 4、0.3ならば0.2, 0.3, 0.4といった感じです。しかし、このままだと浮動小数点数の場合、0.2999...9, 0.3, 0.3000...1 のように型の限界まで厳密にできてしまいます。また、テストケースの数も境界値ごとに3ケース用意しなければなりません。そこで同値分割も同時に用いてテスト検証を行うようにしました。これは、テスト対象の境界値が2つ以上のケースで有効です。

同値分割とは、入力データを仕様書から 有効な値のグループ(有効同値クラス)無効な値のグループ(無効同値クラス) に分割し、グループの中で代表的な値を1つテストケースとして選出する方法です。
例えば、先ほど例にも挙げたコードを参考にすると

  • 有効同値クラス(0.04以下):代表値 0.03
  • 有効同値クラス(0.05~0.17):代表値 0.13
  • 無効同値クラス(0.18以上):代表値 0.20

本来必要だった0.050.17の間の検証すべき値が0.06~0.16のうち1つでよくなりました。
以上から今回のツールで実装を促すテストケースとしては以下のようになります

入力値 期待結果 分析観点
0.03 SSR 同値
0.05 SR 境界値
0.13 SR 同値
0.17 R 境界値
0.20 R 同値

今回の判定ルール

  • 境界値は必須
  • 境界の内側・外側は代表値が1つあればOK

処理の流れ

  1. テストコードからテスト値を集める
  2. テスト対象コードから境界値を集める
  3. 1と2を照合して足りないテストケースをレポート

静的解析ツールの作り方

ツールを作るにあたって重要なパッケージとしてはgolang.org/x/tools/go/analysisgo/astです。
実装するにあたって以下の記事を参考にさせていただきました。
https://zenn.dev/hsaki/books/golang-static-analysis
また、ここからは内部実装寄りの話になるので、実装のみ知りたい方は、実装手順の章まで飛ばしていただいて大丈夫です。

golang.org/x/tools/go/analysis とは

Goの静的解析ツールを作るためのフレームワークです。このパッケージを使うことでgo vetgolangci-lintに組み込める形でツールを作れます。今回はsinglecheckerを使って単独のCLIツールとして公開しています。
このフレームワークを使ってツールを作るには、analysis.Analyzerという構造体にツールの定義を記述します。

Analayzer の構造

var Analyzer = &analysis.Analyzer{
    Name:     "boundary",
    Doc:      doc,
    Run:      run,
    Requires: []*analysis.Analyzer{inspect.Analyzer},
}

各フィールドの役割として

  • Name:ツールの名前
  • Doc:ツールの説明
  • Run:実際の解析ロジックを持つ関数で、func(pass *analysis.Pass) (any, error)というシグネチャを持ちます。
  • Requires:このアナライザーが依存する他のアナライザーを指定します。ここでinspect.Analyzerを指定することで、run関数の中でinspector.Inspectorが使えるようになります。

Requireに指定したアナライザーの実行結果は、run関数内でpass.ResultOfから取得できます。これについては後述します。

Pass とは何か

Passはrun関数に渡されるコンテキストで、解析に必要な情報がすべて入っています。

// passの中身(一部抜粋)
type Pass struct {
        // アナライザー本体のアドレス
	Analyzer *Analyzer 

        // 行番号を管理
	Fset         *token.FileSet
        // 解析対象パッケージのGoファイルをASTに変換したものの一覧
	Files        []*ast.File    

        // 警告を出力する
	Reportf func(Diagnostic)
        // アナライザーの実行結果を管理
	ResultOf map[*Analyzer]any
}

inspector.Inspector とは

pass.ResultOfを使ってinspect.Analyzerの結果を取り出すと以下のようになります。

inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)

pass.ResultOf[inspect.Analyzer]inspect.Analyzerの実行結果(any型)を取得して、*inspector.Inspectorにキャストしています。

ここでのinspect.Analyzeranalysis.Analyzer型で定義されたアナライザーです。

var Analyzer = &analysis.Analyzer{
	Name:             "inspect",
	Doc:              "optimize AST traversal for later passes",
	URL:              "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/inspect",
	Run:              run,
	RunDespiteErrors: true,
	ResultType:       reflect.TypeFor[*inspector.Inspector](),
}

run関数の中身はシンプルで、pass.Filesからinspector.Inspectorを生成して返すだけです。

func run(pass *analysis.Pass) (any, error) {
	return inspector.New(pass.Files), nil
}

これがboundaryRequiresに指定されているので、フレームワークがinspect.Analyzerを先に実行してInspectorを生成し、pass.ResultOf経由で渡してくれます。

Inspectorの内部構造

Inspectoreventsというスライスだけを持ちます

type Inspector struct {
    events []event
}

eventはASTノードのpush/popを表す構造体です。

type event struct {
	node   ast.Node
	typ    uint64 // ノードの型情報
	index  int32  // push/popイベントのインデックス
	parent int32  // 親ノードのインデックス
}

さらに詳しく話すとrun関数の内のNew()*Inspectorを返す関数です。

func New(files []*ast.File) *Inspector {
	return &Inspector{traverse(files)}
}

さらにさらに詳しく話すとtraverse()はイベント列を生成する関数です。

func traverse(files []*ast.File) []event {
	var extent int
        //イベントスライスの容量を事前に計算
	for _, f := range files {
		extent += int(f.End() - f.Pos())
	}
	capacity := min(extent*33/100, 1e6)

	v := &visitor{
		events: make([]event, 0, capacity),
		stack:  []item{{index: -1}}, 
	}
	for _, file := range files {
                //ここでASTを走査してイベント列を生成
		walk(v, edge.Invalid, -1, file)
	}
	return v.events
}

Inspectorが生成されるとき、ASTを1回だけ全走査してすべてのノードをpush/popのイベント列として記録します。
木構造のASTをイベント列に変換する感じです。

GetRarity関数のASTを例にすると
FuncDecl(GetRarity)
    ➡ IfStmt (if rate < 0.05)
        ➡ BinaryExpr(rate < 0.05)
        
↓ イベント列に変換

[push:FuncDecl,   index = 5]
[push:IfStmt,     index = 4]
[push:BinaryExpr, index = 3]
[pop:BinaryExpr]
[pop:IfStmt]
[pop:FuncDecl]

event.indexはpushのとき対応するpopの位置を指しています。これによって不要な走査を行うことなく必要な要素にアクセスすることができます。

まとめると、pass.ResultOfmap[*Analyzer]any型のマップでinspect.Analyzerをキーとして持ち、*inspector.Inspectorをany型で取得する。*inspector.Inspectorにはtraverse()で生成されたイベント列を持つデータが含まれている。

inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)

go/astとは

AST(Abstract Syntax Tree) はプログラムのソースコードを木構造を表現したものです。
go/astgoで書かれたコードをASTで操作するためのライブラリです。
多くのインデックスが用意されていますが今回は実際に使用したもののみ紹介します。

*ast.FuncDecl

関数の宣言を表すノードです。

FuncDecl struct {
	Doc  *CommentGroup // 関連ドキュメント or nil
	Recv *FieldList    // メソッド
	Name *Ident        // 関数名
	Type *FuncType     // 引数・戻り値の型
	Body *BlockStmt    // 関数の中身
}

今回はName.Nameで関数名を取得して、Bodyの中を操作してIfStmtを探したり、Testと書かれた関数名を抽出取りだすことに使っています。

*ast.IfStmt

if文を表すノードです

IfStmt struct {
	If   token.Pos  // if分の位置
	Init Stmt       // 初期化文 or nil
	Cond Expr       // 条件式
	Body *BlockStmt // ifの中身
	Else Stmt       // elseの中身 or nil
}

今回はCondから境界値を収集するために使っています

*ast.BinaryExpr

a < bのような二項演算を表すノードです。

BinaryExpr struct {
	X     Expr        // 左辺
	OpPos token.Pos   // 比較演算子の位置
	Op    token.Token // 比較演算子
	Y     Expr        // 右辺
}

今回はOpで演算子の種類を確認して、Yから境界値(0.05など)を取得するために使っています。

*ast.BasicLit

数値・文字列などのリテラル値を表すノード

BasicLit struct {
	ValuePos token.Pos   // リテラルの位置
	Kind     token.Token // INT, STRING, FLOATなどの型情報
	Value    string      // 0.05など文字列として格納
}

今回はValueから境界値の数値を取得するために使っています。strconv.ParseFloatfloat64に変換しています。

*ast.KeyValueExpr

rate: 0.05のようなキー:値の構造を表すノードです。

KeyValueExpr struct {
		Key   Expr      // フィールド名(rateなど)
		Colon token.Pos // ":"の位置
		Value Expr      // 値(0.05など)
}

今回はテーブルテストのコードからテスト値を収集するために使っています。

実装手順

ツールを実装する際に、以下のツールにお世話になりました。goのコードをastに変換するツールです。
https://yuroyoro.github.io/goast-viewer/

検証対象ファイルの作成

静的解析ツールを作るにあたって、まずはこのコードでなら確実に動かすというサンプルコードを作っておくと作りやすいです。今回は以下のコードを対象としました。

example.go

package example

type Rarity string

const (
	RaritySSR Rarity = "SSR"
	RaritySR  Rarity = "SR"
	RarityR   Rarity = "R"
)

func GetRarity(rate float64) Rarity {
	if rate < 0.05 {
		return RaritySSR
	}
	if rate < 0.17 {
		return RaritySR
	}
	return RarityR
}

example_test.go

package example_test

import (
	"testing"

	"github.com/Mtsubasa/go-boundary-checker/example"
)

func TestGetRarity(t *testing.T) {
	tests := []struct {
		name     string
		rate     float64
		expected example.Rarity
		wantErr  bool
	}{
		{name: "over SSR boundary", rate: 0.04, expected: example.RaritySSR},
		{name: "under SR boundary", rate: 0.06, expected: example.RaritySR},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := example.GetRarity(tt.rate)
			if got != tt.expected {
				t.Errorf("GetRarity(%f) = %v, expected %v", tt.rate, got, tt.expected)
			}
		})
	}
}

AST Viewerで構造を確認する

検証対象のファイルをGoAst Viewerでパースします。
example.go

#一部抜粋
   135  .  .  2: *ast.FuncDecl {
   198  .  .  .  Body: *ast.BlockStmt {
   201  .  .  .  .  .  0: *ast.IfStmt {
   202  .  .  .  .  .  .  If: foo:12:2
   203  .  .  .  .  .  .  Init: nil
   204  .  .  .  .  .  .  Cond: *ast.BinaryExpr {
   205  .  .  .  .  .  .  .  X: *ast.Ident {
   206  .  .  .  .  .  .  .  .  NamePos: foo:12:5
   207  .  .  .  .  .  .  .  .  Name: "rate"
   208  .  .  .  .  .  .  .  .  Obj: *(obj @ 160)
   209  .  .  .  .  .  .  .  }
   210  .  .  .  .  .  .  .  OpPos: foo:12:10
   211  .  .  .  .  .  .  .  Op: <
   212  .  .  .  .  .  .  .  Y: *ast.BasicLit {
   213  .  .  .  .  .  .  .  .  ValuePos: foo:12:12
   214  .  .  .  .  .  .  .  .  Kind: FLOAT
   215  .  .  .  .  .  .  .  .  Value: "0.05"
   216  .  .  .  .  .  .  .  }
   217  .  .  .  .  .  .  }
   236  .  .  .  .  .  1: *ast.IfStmt {
   237  .  .  .  .  .  .  If: foo:15:2
   238  .  .  .  .  .  .  Init: nil
   239  .  .  .  .  .  .  Cond: *ast.BinaryExpr {
   240  .  .  .  .  .  .  .  X: *ast.Ident {
   241  .  .  .  .  .  .  .  .  NamePos: foo:15:5
   242  .  .  .  .  .  .  .  .  Name: "rate"
   243  .  .  .  .  .  .  .  .  Obj: *(obj @ 160)
   244  .  .  .  .  .  .  .  }
   245  .  .  .  .  .  .  .  OpPos: foo:15:10
   246  .  .  .  .  .  .  .  Op: <
   247  .  .  .  .  .  .  .  Y: *ast.BasicLit {
   248  .  .  .  .  .  .  .  .  ValuePos: foo:15:12
   249  .  .  .  .  .  .  .  .  Kind: FLOAT
   250  .  .  .  .  .  .  .  .  Value: "0.17"
   251  .  .  .  .  .  .  .  }
   252  .  .  .  .  .  .  }

example_test.go

#一部抜粋
    97  .  .  .  Body: *ast.BlockStmt {
   232  .  .  .  .  .  .  .  .  Elts: []ast.Expr (len = 2) {
   233  .  .  .  .  .  .  .  .  .  0: *ast.CompositeLit {
   236  .  .  .  .  .  .  .  .  .  .  Elts: []ast.Expr (len = 3) {
   237  .  .  .  .  .  .  .  .  .  .  .  0: *ast.KeyValueExpr {
   238  .  .  .  .  .  .  .  .  .  .  .  .  Key: *ast.Ident {
   240  .  .  .  .  .  .  .  .  .  .  .  .  .  Name: "name"
   242  .  .  .  .  .  .  .  .  .  .  .  .  }
   244  .  .  .  .  .  .  .  .  .  .  .  .  Value: *ast.BasicLit {
   246  .  .  .  .  .  .  .  .  .  .  .  .  .  Kind: STRING
   247  .  .  .  .  .  .  .  .  .  .  .  .  .  Value: "\"over SSR boundary\""
   248  .  .  .  .  .  .  .  .  .  .  .  .  }
   249  .  .  .  .  .  .  .  .  .  .  .  }
   250  .  .  .  .  .  .  .  .  .  .  .  1: *ast.KeyValueExpr {
   251  .  .  .  .  .  .  .  .  .  .  .  .  Key: *ast.Ident {
   253  .  .  .  .  .  .  .  .  .  .  .  .  .  Name: "rate"
   255  .  .  .  .  .  .  .  .  .  .  .  .  }
   257  .  .  .  .  .  .  .  .  .  .  .  .  Value: *ast.BasicLit {
   259  .  .  .  .  .  .  .  .  .  .  .  .  .  Kind: FLOAT
   260  .  .  .  .  .  .  .  .  .  .  .  .  .  Value: "0.04"
   261  .  .  .  .  .  .  .  .  .  .  .  .  }
   262  .  .  .  .  .  .  .  .  .  .  .  }
   263  .  .  .  .  .  .  .  .  .  .  .  2: *ast.KeyValueExpr {
   264  .  .  .  .  .  .  .  .  .  .  .  .  Key: *ast.Ident {
   266  .  .  .  .  .  .  .  .  .  .  .  .  .  Name: "expected"
   268  .  .  .  .  .  .  .  .  .  .  .  .  }
   270  .  .  .  .  .  .  .  .  .  .  .  .  Value: *ast.SelectorExpr {
   271  .  .  .  .  .  .  .  .  .  .  .  .  .  X: *ast.Ident {
   273  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Name: "example"
   275  .  .  .  .  .  .  .  .  .  .  .  .  .  }
   276  .  .  .  .  .  .  .  .  .  .  .  .  .  Sel: *ast.Ident {
   278  .  .  .  .  .  .  .  .  .  .  .  .  .  .  Name: "RaritySSR"
   280  .  .  .  .  .  .  .  .  .  .  .  .  .  }
   281  .  .  .  .  .  .  .  .  .  .  .  .  }
   282  .  .  .  .  .  .  .  .  .  .  .  }
   283  .  .  .  .  .  .  .  .  .  .  }
   286  .  .  .  .  .  .  .  .  .  }
   287  .  .  .  .  .  .  .  .  .  1: *ast.CompositeLit {
   304  .  .  .  .  .  .  .  .  .  .  .  1: *ast.KeyValueExpr {
   305  .  .  .  .  .  .  .  .  .  .  .  .  Key: *ast.Ident {
   307  .  .  .  .  .  .  .  .  .  .  .  .  .  Name: "rate"
   309  .  .  .  .  .  .  .  .  .  .  .  .  }
   311  .  .  .  .  .  .  .  .  .  .  .  .  Value: *ast.BasicLit {
   313  .  .  .  .  .  .  .  .  .  .  .  .  .  Kind: FLOAT
   314  .  .  .  .  .  .  .  .  .  .  .  .  .  Value: "0.06"
   315  .  .  .  .  .  .  .  .  .  .  .  .  }
   316  .  .  .  .  .  .  .  .  .  .  .  }
   337  .  .  .  .  .  .  .  .  .  .  }

テストコード側の値を集める

テスト値を集めるために、テストファイルの取得→テスト関数の特定→テスト値の収集という順番で実装しています。

1.テストファイルの取得

// 解析対象パッケージにGoファイルが存在するかを判定
if len(pass.Files) > 0 {
        // 解析対象ファイルの絶対パスを返し、それをディレクトリパスとして保存
	dir := filepath.Dir(pass.Fset.File(pass.Files[0].Pos()).Name())
    // 同じディレクトリにある`_test.go`で終わるファイルを全て取得
	testFiles, _ := filepath.Glob(filepath.Join(dir, "*_test.go"))

2.テスト関数の特定

for _, testFile := range testFiles {
        // テストファイルをASTに変換(ファイルが壊れている場合はスキップ)
	f, err := parser.ParseFile(pass.Fset, testFile, nil, 0)
	if err != nil {
		continue
	}
	for _, decl := range f.Decls {
                // 関数宣言かどうか確認(型宣言・変数宣言はスキップ)
		funcDecl, ok := decl.(*ast.FuncDecl)
		if !ok {
			continue
		}
                // 取得したTestXXXという関数をXXXに整形(TestXXXでない関数はスキップ)
		if !strings.HasPrefix(funcDecl.Name.Name, "Test") {
			continue
		}
		funcName := strings.TrimPrefix(funcDecl.Name.Name, "Test")

3.テスト値の収集

ast.Inspect(funcDecl.Body, func(n ast.Node) bool {
    // {rate: 0.05}のようなキーと値のペアを取得
    kv, ok := n.(*ast.KeyValueExpr)
    if !ok {
        return true
    }
    // キーに対応するリテラル値(0.05など)を取得
    lit, ok := kv.Value.(*ast.BasicLit)
    if !ok {
        return true
    }
    // stringをfloat64に変換して格納
    val, err := strconv.ParseFloat(lit.Value, 64)
    if err != nil {
        return true
    }
    // テスト値として格納
    testedValues[funcName] = append(testedValues[funcName], val)
    return true
})

ast.Inspectのコールバックでreturn trueを返すと子ノードの走査を続け、return falseを返すと子ノードの走査を打ち切ります。今回の場合テーブルテストで宣言されているテスト値を継続的に取得するためにreturn trueにしています。

条件式から境界値を取り出す

BoundaryInfoの定義

type BoundaryInfo struct {
    BoundaryValue float64   // 境界値
    funcName      string    // 関数
    pos           token.Pos // 位置情報
}

関数ごとに境界値を取得するために、境界値(0.05など)、関数名、位置情報を格納する構造体を定義しました。

1.nodeFilterとPreorderの設定

boundaryInfos := []BoundaryInfo{}
// 走査対象を関数宣言に絞る
nodeFilter := []ast.Node{
    (*ast.FuncDecl)(nil),
}
// 深さ優先探索でノードを走査
inspect.Preorder(nodeFilter, func(n ast.Node) {
    // 関数宣言かどうか確認
    funcDecl, ok := n.(*ast.FuncDecl)
    if !ok {
        return
    }
    // 関数名を取得
    funcName := funcDecl.Name.Name
余談: nodeFilterの内部動作

nodeFilterを指定するとPreorderの内部でビットマスクとevent.typをAND演算して、
マッチしたノードだけコールバックに渡します。

func (in *Inspector) Preorder(types []ast.Node, f func(ast.Node)) {
	mask := maskOf(types)  // nodeFilterからビットマスクを生成
	for i := int32(0); i < int32(len(in.events)); {
		ev := in.events[i]
		if ev.index > i {
			if ev.typ&mask != 0 {
				f(ev.node) // マッチしたノードだけコールバック
			}
			pop := ev.index
			if in.events[pop].typ&mask == 0 {
				i = pop + 1
				continue
			}
		}
		i++
	}
}

Inspectorの内部構造で説明したイベント列をここで活用しています。

2.IfStmtからBinaryExprの取得

ast.Inspect(funcDecl.Body, func(n ast.Node) bool {
    // if文の情報を取得
    ifStmt, ok := n.(*ast.IfStmt)
    if !ok {
        return true
    }
    // if文情報から条件式を取得
    binaryExpr, ok := ifStmt.Cond.(*ast.BinaryExpr)
    if !ok {
        return true
    }
    // 比較演算子の判定
    if !isBoundaryOp(binaryExpr.Op) {
        return true
    }

ここでのisBoundaryOpは比較演算子かどうかを判定する関数です。

func isBoundaryOp(op token.Token) bool {
	switch op {
	case token.LSS, token.GEQ, token.GTR, token.LEQ:
		return true
	default:
		return false
	}
}

3.境界値の取得と格納

// 条件式の右辺からリテラル値を取得
lit, ok := binaryExpr.Y.(*ast.BasicLit)
    if !ok {
        return true
    }
    // stringをfloat64に変換して格納
    boundaryValue, err := strconv.ParseFloat(lit.Value, 64)
    if err != nil {
        return true
    }
    // 境界値情報を格納
    boundaryInfos = append(boundaryInfos, BoundaryInfo{
        BoundaryValue: boundaryValue,
        funcName:      funcName,
        pos:           lit.Pos(), // 警告を出す際の行番号として使用
    })

ここでのreturn trueもテストコード側と同様に関数内の境界値を継続的に探索するためにtrueを返しています。

突き合わせて不足を検出する

1.関数ごとにグループ化してソート

// 関数ごとに境界値をグループ化
boundaryMap := make(map[string][]BoundaryInfo)
for _, info := range boundaryInfos {
    boundaryMap[info.funcName] = append(boundaryMap[info.funcName], info)
}
// 関数内の境界値を昇順でソート
for funcName := range boundaryMap {
    sort.Slice(boundaryMap[funcName], func(i, j int) bool {
        return boundaryMap[funcName][i].BoundaryValue < boundaryMap[funcName][j].BoundaryValue
    })
}

boundaryInfosは全関数の境界値が格納されたスライスです。関数関係なく格納しているため、関数名をキーにしたマップに変換して関数ごとに境界値グループを作成します。

2.テスト値が存在する関数だけを対象にする

for funcName, boundaries := range boundaryMap {
    // テスト値が存在しない関数はスキップ
    if _, ok := testedValues[funcName]; !ok {
        continue
    }

3.境界値ごとの有効範囲を決定する

for i, boundary := range boundaries {
    // 左側の最小値
    leftMin := math.Inf(-1)
    if i > 0 {
        leftMin = boundaries[i-1].BoundaryValue
    }
    // 右側の最大値
    rightMax := math.Inf(1)
    if i < len(boundaries)-1 {
        rightMax = boundaries[i+1].BoundaryValue
    }

最初の境界値の左側と右側には隣接する境界値が存在しないため、math.Infで無限大を設定しています。これによって境界値ごとの有効範囲を決定しています(同値分割の説明で示した図のような状況になります)。

4.照合して不足を検出する

hasLeft := false
hasRight := false
hasBoundary := false
for _, testedValue := range testedValues[funcName] {
    // 境界値の左側にテスト値があるかどうか
    if testedValue > leftMin && testedValue < boundary.BoundaryValue {
        hasLeft = true
    }
    // 境界値の右側にテスト値があるかどうか
    if testedValue > boundary.BoundaryValue && testedValue < rightMax {
        hasRight = true
    }
    // 境界値がテスト値にあるかどうか
    if testedValue == boundary.BoundaryValue {
        hasBoundary = true
    }
}

// レポート出力
if !hasBoundary {
    pass.Reportf(boundary.pos, "%s: boundary value %g is not tested", funcName, boundary.BoundaryValue)
}
if !hasLeft {
    pass.Reportf(boundary.pos, "%s: no test value less than %g", funcName, boundary.BoundaryValue)
}
if !hasRight {
    pass.Reportf(boundary.pos, "%s: no test value greater than %g", funcName, boundary.BoundaryValue)
}

hasLefthasRighthasBoundaryの3つのフラグで境界値の左側・右側・同値それぞれにテスト値が存在するか判定します。全テスト値を走査して有効範囲内に値があればフラグをtrueにします。走査後にフラグがfalseのものだけpass.Reportfで警告を出力します。

動作確認

動作対象のファイルに対してテストの実装状況をチェックしてみます。
example.go

func GetRarity(rate float64) Rarity {
	if rate < 0.05 {
		return RaritySSR
	}
	if rate < 0.17 {
		return RaritySR
	}
	return RarityR
}

example_test.go

{name: "over SSR boundary", rate: 0.04, expected: example.RaritySSR},
{name: "under SR boundary", rate: 0.06, expected: example.RaritySR},

動作結果

go-boundary-checker\example\example.go:15:12: GetRarity: boundary value 0.05 is not tested
go-boundary-checker\example\example.go:18:12: GetRarity: boundary value 0.17 is not tested
go-boundary-checker\example\example.go:18:12: GetRarity: no test value greater than 0.17

実施できてないケース

  • 0.05 : 境界値自身
  • 0.17 : 境界値自身、右側

テストケースを全て揃えると警告が出なくなります。CIツールとして警告がない状態が正常終了です。

{name: "over SSR boundary", rate: 0.04, expected: example.RaritySSR},
{name: "SR", rate: 0.05, expected: example.RaritySR},
{name: "under SR boundary", rate: 0.06, expected: example.RaritySR},
{name: "under R boundary", rate: 0.17, expected: example.RarityR},
{name: "over R boundary", rate: 0.18, expected: example.RarityR},
{name: "R", rate: 0.99, expected: example.RarityR},

まだ対応できていないケース・機能

  • 複合条件(&&, ||など )
  • 変数同士の比較 (rate < boundary)
  • テストに通ったかどうかのレポート

順次機能アップデート予定です!

まとめ

今回はgo/astanalysisを使って境界値に対してのテスト実装状況を検証する静的解析ツールを作りました。僕は今回静的解析ツールを初めて作ったのですが、GoAst viewerを使いながら適切な取得メソッド(*ast.FuncDecl*ast.IfStmt*ast.BinaryExpr)を使うことによって、条件式に設定している値やテストに使われている値を取得して検証するツールを作ることができました。静的解析ツールはASTの構造さえ理解できれば、あとはどのノードを取得して照合ロジックを作成するだけでできます。GoAst Viewerで実際のASTを確認しながら実装するのがおすすめです。

Discussion