🐘

【Golang】UTの有無をチェックするCIを回す

2023/12/01に公開

はじめに

単体テストを書いたかどうかのチェック、大変じゃないですか?
私は大変です。
レビューで目視チェックをしているんですが、漏れが怖い。というか漏れてる。
なので、CI で自動的に確認できるようにしてみました 🙋🏻‍♀️ イェイ

要件

実装するにあたって決めた要件は以下です。

  • 除外ディレクトリを指定できる
  • 関数、メソッドどちらにも対応可能
  • 直前の行に// no-test-check-functionを記述することで、スキップ可能にする
  • 全てのチェックを終えた後に、UT 未実装関数の一覧が表示される
  • GitHubActions で CI を回せる

実装

とりあえず要件を満たすようにメイン関数を書きます。
カレントディレクトリ以下を再起的に処理する形にしています。

func main() {
	// カレントディレクトリ以下のファイルを再帰的に処理
	err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}

		// テストファイルを除くgoファイルのみを対象とする
		if strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go"){
            // ここでファイルごとにUTの有無を確認
		}

		return nil
	})

	if err != nil {
		fmt.Printf("ファイルの再起的な処理に失敗しました: %v\n", err)
	}
}

除外ディレクトリを指定できるようにする

今回はエントリーポイントをまとめているcmdディレクトリと、generate.goを入れているtoolsディレクトリを除外します。

func main() {
	excludeDirs := map[string]bool{
        // ここに除外ディレクトリを追加していく
        "cmd":          true,
        "tools":        true,
	}

	// カレントディレクトリ以下のファイルを再帰的に処理
	err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}

        // 除外ディレクトリの場合はスキップ
		if info.IsDir() {
			_, excluded := excludeDirs[info.Name()]
			if excluded {
				return filepath.SkipDir
			}
			return nil
		}

		// テストファイルを除くgoファイルのみを対象とする
		if strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go"){

    ...省略...
}

ファイル内に含まれる関数・メソッドをチェックする

該当箇所の検出

対象ファイルを 1 行ずつ確認し、関数定義が書かれている部分を検出します。

// 指定されたファイルに含まれる未テストの関数またはメソッドをチェック
func checkFileForUntestedFunctions(filePath string, unTestedFuncs *[]unTestedFunc) {
	file, err := os.Open(filePath)
	if err != nil {
		fmt.Printf("ファイルを開けませんでした %s: %v\n", filePath, err)
		return
	}
	defer file.Close()

	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		currentLine := scanner.Text()
		// 関数定義の検出
		if strings.HasPrefix(currentLine, "func ") {
				// 関数名・メソッド名を抽出し、UTが存在するか確認する。
		}
	}

	if err := scanner.Err(); err != nil {
		fmt.Printf("ファイルをスキャンできませんでした %s: %v\n", filePath, err)
	}
}

正規表現で関数名・メソッド名を抽出する

関数名・メソッド名の抽出は別関数にします。
go では、以下のような関数型のパターンとメソッド型があります。

// 関数型
func hoge(){}

// メソッド型
func (t *fugaType) fuga(){}

どちらのパターンも抽出しようとすると、正規表現は以下のようになります。
func (\((\w+ )?\*?(\w+)\) )?(\w+)

funcRegex := regexp.MustCompile(`func (\((\w+ )?\*?(\w+)\) )?(\w+)`)
matches := funcRegex.FindStringSubmatch(line)

こんな感じで抽出すると、関数型の場合、

matches[0]: func hoge
matches[1]:
matches[2]:
matches[3]:
matches[4]: hoge

メソッド型の場合、

matches[0]: func (t *fugaType) fuga
matches[1]: (t *fugaType)
matches[2]: t
matches[3]: fugaType
matches[4]: fuga

matchesに入ってくることになるので、条件分岐しつつ、matches[4]を関数名として取得します。

メソッド場合、型名とメソッドかどうかのフラグも保持しておきたかったので、struct を定義します。

type funcMethod struct {
	typeName string
	funcName string
	isMethod bool
}

func extractFunctionName(line string) *funcMethod {
	// 関数宣言の正規表現パターン
	funcRegex := regexp.MustCompile(`func (\((\w+ )?\*?(\w+)\) )?(\w+)`)
	matches := funcRegex.FindStringSubmatch(line)

	if len(matches) < 5 {
		return nil
	}

	// メソッドの場合
	if matches[1] != "" {
		return &funcMethod{
			typeName: matches[3],
			funcName: matches[4],
			isMethod: true,
		}
	}

	// 通常の関数の場合
	return &funcMethod{
		funcName: matches[4],
	}
}

func checkFileForUntestedFunctions(filePath string) {
    ...省略...

	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		currentLine := scanner.Text()
		// 関数定義の検出
		if strings.HasPrefix(currentLine, "func ") {
				// 関数名・メソッド名を抽出
                funcMethod := extractFunctionName(currentLine)

                //UTが存在するか確認する
		}
	}

    ...省略...
}

UT が存在するか確認する

先程抽出した関数のテストが存在するか確認する関数を作ります。
関数が書かれているファイルパスを受け取り、テスト用のファイルを開きます。
テストの関数名は色々なパターンがあるかもしれないですが、あまりケースを増やしたくないので以下で統一するルールを設けることにします。

つまり、先ほどの例だと以下になります。

// 関数型
func Test_hoge(){}

// メソッド型
func Test_fugaType_fuga(){}

上記の関数が存在するか1行ずつチェックします。

func isTestPresent(funcMethod *funcMethod, filePath string) bool {
	if funcMethod == nil {
		return false
	}

	// テストファイルのパスを生成
	testFilePath := strings.TrimSuffix(filePath, ".go") + "_test.go"

	// テストファイルを開く
	file, err := os.Open(testFilePath)
	if err != nil {
		return false
	}
	defer file.Close()

	// テスト関数名を生成
	var testFuncName string
	if funcMethod.isMethod {
		testFuncName = fmt.Sprintf("Test_%s_%s", funcMethod.typeName, funcMethod.funcName)
	} else {
		testFuncName = fmt.Sprintf("Test_%s", funcMethod.funcName)
	}

	// ファイルを行ごとに読み込み、テスト関数が存在するかをチェック
	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		if strings.Contains(scanner.Text(), testFuncName) {
			return true
		}
	}

	return false
}

func checkFileForUntestedFunctions(filePath string) {
    ...省略...

		if strings.HasPrefix(currentLine, "func ") {
				// 関数名・メソッド名を抽出
                funcMethod := extractFunctionName(currentLine)

                //UTが存在するか確認する
                if !isTestPresent(funcMethod, filePath) {
                    fmt.Println("UTが存在しません")
                    os.Exit(1)
                }
		}
	}

    ...省略...
}

全てのチェックを終えた後に、UT 未実装関数の一覧が表示される

今のコードだと、UT 未実装の関数を発見したら即座に終了してしまいます。
CI で回す際には全てチェックして、一覧を表示させたいので、UT 未実装の関数の情報を保持する slice を定義します。
main 関数の中で、slice が空じゃなければ関数情報を出力します。

type unTestedFunc struct {
	filePath   string
	funcMethod *funcMethod
}

func main() {
    // UT未実装の関数情報を保持するslice
	unTestedFuncs := []unTestedFunc{}

    ...省略...

    // テストファイルを除くgoファイルのみを対象とする
	if strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") {
		checkFileForUntestedFunctions(path, &unTestedFuncs)
	}

    ...省略...

    if len(unTestedFuncs) > 0 {
		fmt.Println("未テストの関数またはメソッドがあります:")
		for _, unTestedFunc := range unTestedFuncs {
			if unTestedFunc.funcMethod != nil {
				if unTestedFunc.funcMethod.isMethod {
					fmt.Printf("%s: %s.%s\n", unTestedFunc.filePath, unTestedFunc.funcMethod.typeName, unTestedFunc.funcMethod.funcName)
				} else {
					fmt.Printf("%s: %s\n", unTestedFunc.filePath, unTestedFunc.funcMethod.funcName)
				}
			}
		}
		os.Exit(1)
	} else {
		fmt.Println("未テストの関数またはメソッドはありません")
	}
}

func checkFileForUntestedFunctions(filePath string, unTestedFuncs *[]unTestedFunc) {
    ...省略...

	funcMethod := extractFunctionName(currentLine)
	if funcMethod != nil {
		// テストが存在しない場合は未テストの関数として追加
		if !isTestPresent(funcMethod, filePath) {
			unTestedFunc := unTestedFunc{
							filePath:   filePath,
							funcMethod: funcMethod,
						}
			*unTestedFuncs = append(*unTestedFuncs, unTestedFunc)
		}
	}

    ...省略...
}

直前の行に// no-test-check-functionを記述することで、スキップ可能にする

現時点では全ての関数をチェックしています。
が、テストするほどでもない関数はスキップしたいです。
なので、直前の行に// no-test-check-functionが書かれているかを確認し、書かれていた場合はチェックをスキップするようにします。

func checkFileForUntestedFunctions(filePath string, unTestedFuncs *[]unTestedFunc) {
...省略...

	scanner := bufio.NewScanner(file)
    // 直前の行の情報を保持
	var prevLine string
	for scanner.Scan() {
		currentLine := scanner.Text()
		// 関数定義の検出
		if strings.HasPrefix(currentLine, "func ") {
			// no-test-check-functionが直前の行に無い場合のみチェック対象とする
			if !strings.Contains(prevLine, "// no-test-check-function") {
				// 関数名・メソッド名の抽出
				funcMethod := extractFunctionName(currentLine)
				if funcMethod != nil {
					// テストが存在しない場合は未テストの関数として追加
					if !isTestPresent(funcMethod, filePath) {
						unTestedFunc := unTestedFunc{
							filePath:   filePath,
							funcMethod: funcMethod,
						}
						*unTestedFuncs = append(*unTestedFuncs, unTestedFunc)
					}
				}
			}
		}
		// 現在の行を次のループのために保存
		prevLine = currentLine
	}

...省略...
}

これで実装は完了です。
実行してみると以下のように出力されます。

go run cmd/check/isExistUnitTest.go
未テストの関数またはメソッドがあります:
hoge/hogehoge.go: hoge
fuga/fugafuga/fugafugafuga.go: fugaType.fuga
exit status 1

GitHubActions で PR ごとにチェックする

yml ファイルを作成します。

name: Unit Test Check

on:
  pull_request:

jobs:
  test-check:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Repository
        uses: actions/checkout@v2

      - name: Set up Go
        uses: actions/setup-go@v2
        with:
          go-version: "1.21"

      - name: Check for Unit Tests
        run: go run cmd/check/isExistUnitTest.go

これで push の度に PR で自動的にチェックされます。

おわりに

今回は「とりあえずサクッと実装したい」という気持ちが強かったので、結構力技の実装です!
たぶん本来は抽象構文木を使うんだろうな〜〜〜〜〜とポヤポヤ考えてます。
どこかのタイミングで AST を使うようにして別途ライブラリとして公開できると良いな。

Discussion