LoginSignup
10
3

More than 1 year has passed since last update.

Go でやる mutation testing ~テストの品質を評価しよう~

Last updated at Posted at 2022-12-03

Mutation testing とは

  • テストの品質を評価する手法の一つ
  • テスト対象のプログラムの一部を機械的に書き換えたときに、テストが失敗させられるかを確認する手法

mutation-testing.png

大まかな流れは以下の通りです。

  1. プログラムを一部改変
    • 改変されたプログラムのことを "mutant" と呼びます
  2. 1.で改変した状態でテストを実施
  3. テストが失敗するかどうかをチェック
    • いずれかのテストが失敗するなら、 テストが十分であるとし、OKとします
    • 全てのテストが成功するなら、テストが不十分であるとし、NGとします

このプロセスをたくさん実行し、いろいろなプログラムの改変("mutant")を試してその結果のOKの割合が高いほうが質の高いテストであると判断します。1

テストが失敗する場合に、mutation testingとしてはOKとなります。ややこしいので、この記事では以下のように表現することとします。

  • テストが通ること/通らないことを成功/失敗と表現
  • mutation testing において、"mutant"を検知できること/できないことをOK/NGと表現

早速実行してみよう

ツールのインストール

確認した限りで最も GitHub Star が多かったgo-mutesting といツールを使います。

go install github.com/zimmski/go-mutesting/cmd/go-mutesting@v1.2 

サンプルコード

fizzbuzz をやるだけのコードをサンプルコードとして試します。
コードの中身で気になる点はあると思いますが、あとから理由はわかるので、深く気にせず次に進んでください。

main.go
package main

import (
	"fmt"
)

func main() {
	fmt.Println(fizzbuzz(20))
}

func fizzbuzz(n int) string {
	if n%3 == 0 && n%5 == 0 {
		return "FizzBuzz"
	}
	if n%3 == 0 {
		return "Fizz"
	}
	if n%5 == 0 {
		return "Buzz"
	}
	return ""
}

func deadcode(n int) string {
	if n%3 == 0 && n%5 == 0 {
		return "FizzBuzz2"
	}
	if n%3 == 0 {
		return "Fizz2"
	}
	if n%5 == 0 {
		return "Buzz2"
	}
	return ""
}

今回はこちらが主役です。テーブルテストの形式で実行できるテストのコードです。

main_test.go
package main

import "testing"

func Test_fizzbuzz(t *testing.T) {
	type args struct {
		n int
	}
	tests := []struct {
		name string
		args args
		want string
	}{
		{
			name: "3",
			args: args{n: 3},
			want: "Fizz",
		},
		{
			name: "2000",
			args: args{n: 2000},
			want: "Buzz",
		},
		{
			name: "1111",
			args: args{n: 1111},
			want: "",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := fizzbuzz(tt.args.n); got != tt.want {
				t.Errorf("fizzbuzz() = %v, want %v", got, tt.want)
			}
		})
	}
}

mutation testing

早速 mutation testing を実践してみましょう。以下のコマンドで Go の mutation testing が実施できます。

go-mutesting .

以下のような結果が出ます。ちょっと長いですが、後で一つずつ確認していきますので、ここではざっと見ていただければ大丈夫です。

result
--- main.go	2022-11-03 23:15:44.000000000 +0900
+++ /var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.0	2022-11-03 23:16:05.000000000 +0900
@@ -10,7 +10,7 @@
 
 func fizzbuzz(n int) string {
 	if n%3 == 0 && n%5 == 0 {
-		return "FizzBuzz"
+
 	}
 	if n%3 == 0 {
 		return "Fizz"

FAIL "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.0" with checksum 8e9b05fb30c0a288bb05582e6e052062
PASS "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.1" with checksum 740f4e6fccecce2f6e52ce3cb84ad34b
PASS "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.2" with checksum 29a2d857491bef083a74875d4f2d7913
--- main.go	2022-11-03 23:15:44.000000000 +0900
+++ /var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.3	2022-11-03 23:16:05.000000000 +0900
@@ -23,7 +23,7 @@
 
 func deadcode(n int) string {
 	if n%3 == 0 && n%5 == 0 {
-		return "FizzBuzz2"
+
 	}
 	if n%3 == 0 {
 		return "Fizz2"

FAIL "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.3" with checksum 5c49bcb3692f4e2d82b00f7cd59d1ab1
--- main.go	2022-11-03 23:15:44.000000000 +0900
+++ /var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.4	2022-11-03 23:16:05.000000000 +0900
@@ -26,7 +26,7 @@
 		return "FizzBuzz2"
 	}
 	if n%3 == 0 {
-		return "Fizz2"
+
 	}
 	if n%5 == 0 {
 		return "Buzz2"

FAIL "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.4" with checksum 9145022df6ed98b1dcaceb7eb76e8f39
--- main.go	2022-11-03 23:15:44.000000000 +0900
+++ /var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.5	2022-11-03 23:16:06.000000000 +0900
@@ -29,7 +29,7 @@
 		return "Fizz2"
 	}
 	if n%5 == 0 {
-		return "Buzz2"
+
 	}
 	return ""
 }

FAIL "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.5" with checksum cdc5ca67372f5ef667001750d753bc86
PASS "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.6" with checksum b3da850552e6b7bf23bf7ba25cd80a09
PASS "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.7" with checksum d190366bd793a838f923ba67a9e92d98
--- main.go	2022-11-03 23:15:44.000000000 +0900
+++ /var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.8	2022-11-03 23:16:06.000000000 +0900
@@ -22,7 +22,7 @@
 }
 
 func deadcode(n int) string {
-	if n%3 == 0 && n%5 == 0 {
+	if true && n%5 == 0 {
 		return "FizzBuzz2"
 	}
 	if n%3 == 0 {

FAIL "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.8" with checksum 7ce7e635a1bd4ca5ce48f9a8b3bd8b08
--- main.go	2022-11-03 23:15:44.000000000 +0900
+++ /var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.9	2022-11-03 23:16:06.000000000 +0900
@@ -22,7 +22,7 @@
 }
 
 func deadcode(n int) string {
-	if n%3 == 0 && n%5 == 0 {
+	if n%3 == 0 && true {
 		return "FizzBuzz2"
 	}
 	if n%3 == 0 {

FAIL "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.9" with checksum c0e17bb17b40ebbbfc3b165b6f8b4901
--- main.go	2022-11-03 23:15:44.000000000 +0900
+++ /var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.10	2022-11-03 23:16:07.000000000 +0900
@@ -5,7 +5,7 @@
 )
 
 func main() {
-	fmt.Println(fizzbuzz(20))
+	_ = fmt.Println
 }
 
 func fizzbuzz(n int) string {

FAIL "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.10" with checksum 0fdf24c351c7a945a2a2597bdb86678d
The mutation score is 0.363636 (4 passed, 7 failed, 0 duplicated, 0 skipped, total is 11)

これで mutation test ができました :tada:
以下で結果の読み解き方と改善方法に関して詳しく見ていきます。

結果の読み解き方と改善方法

全体の結果

まず一番最後の行に注目します。

result 最後の行
The mutation score is 0.363636 (4 passed, 7 failed, 0 duplicated, 0 skipped, total is 11)

ここを見ると以下のことがわかります。

  • 合計 11 個の "mutant" でテストを実施した
  • その結果、4 個はOKで、7 個はNGだった
  • したがってスコアは 4/11 = 0.363636 である

つまり、 11 個の "mutant" を作ったが、"正しく"テストが失敗したものは 4 個しかなく、他の 7 個はプログラムを改変していたにも関わらず、テストが成功してしまったということを意味しています。そのため、スコアは 0.363636 と低いものになっています。

NGの確認と改善方法

次に、NG(failed)の詳細を見ていきます。通常のテストと同様、NG(failed)になったものに関しては詳細がログに出ています。

1つ目: "FizzBuzz" ケースのテストがない

1つ目です。これは、fizzbuzz 関数の return "FizzBuzz" の行を削除して実行したにも関わらず、テストが成功してしまったということを意味しています。これはなぜでしょうか?その答えはテストコードにあります。もう一度、見ていただくと分かる通り、このテストでは、"FizzBuzz" になるケースのテストが存在しません。そのため、このような改変をしても失敗するテストが存在しなかったのです。

テストの網羅性が低い ことが原因です。改善方法としては、"FizzBuzz" のケースのテストの追加が考えられるでしょう。

整理すると以下のようになります。

  • 原因: テストの網羅性不足
  • 改善方法: "FizzBuzz" のケースのテストの追加
result 1個目の失敗ログ
...
--- main.go	2022-11-03 23:15:44.000000000 +0900
+++ /var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.0	2022-11-03 23:16:05.000000000 +0900
@@ -10,7 +10,7 @@
 
 func fizzbuzz(n int) string {
 	if n%3 == 0 && n%5 == 0 {
-		return "FizzBuzz"
+
 	}
 	if n%3 == 0 {
 		return "Fizz"

FAIL "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.0" with checksum 8e9b05fb30c0a288bb05582e6e052062
...

2つ目: dead code がある

次のNGを見ていきましょう。共通の原因のものなので複数のNGをまとめて記載しています。
こちらも同様に、行の削除や条件式の変更などが行われた変更が確認できます。しかし、ここの修正部分にはテストがないので、すべて問題なくテストが成功してしまいます。というよりも、これは main 関数からも使われていない関数で export もされていないので、いわゆる dead code の関数であることに気づくことができます。
このように mutation testing はテストの品質に関することだけでなく、dead code の検知にも有効に働きます。

整理すると以下のようになります。

  • 原因: dead code
  • 改善方法: dead code の削除
result 2-6個目の失敗ログ
...
--- main.go	2022-11-03 23:15:44.000000000 +0900
+++ /var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.3	2022-11-03 23:16:05.000000000 +0900
@@ -23,7 +23,7 @@
 
 func deadcode(n int) string {
 	if n%3 == 0 && n%5 == 0 {
-		return "FizzBuzz2"
+
 	}
 	if n%3 == 0 {
 		return "Fizz2"

FAIL "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.3" with checksum 5c49bcb3692f4e2d82b00f7cd59d1ab1
--- main.go	2022-11-03 23:15:44.000000000 +0900
+++ /var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.4	2022-11-03 23:16:05.000000000 +0900
@@ -26,7 +26,7 @@
 		return "FizzBuzz2"
 	}
 	if n%3 == 0 {
-		return "Fizz2"
+
 	}
 	if n%5 == 0 {
 		return "Buzz2"

FAIL "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.4" with checksum 9145022df6ed98b1dcaceb7eb76e8f39
--- main.go	2022-11-03 23:15:44.000000000 +0900
+++ /var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.5	2022-11-03 23:16:06.000000000 +0900
@@ -29,7 +29,7 @@
 		return "Fizz2"
 	}
 	if n%5 == 0 {
-		return "Buzz2"
+
 	}
 	return ""
 }

FAIL "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.5" with checksum cdc5ca67372f5ef667001750d753bc86
...
--- main.go	2022-11-03 23:15:44.000000000 +0900
+++ /var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.8	2022-11-03 23:16:06.000000000 +0900
@@ -22,7 +22,7 @@
 }
 
 func deadcode(n int) string {
-	if n%3 == 0 && n%5 == 0 {
+	if true && n%5 == 0 {
 		return "FizzBuzz2"
 	}
 	if n%3 == 0 {

FAIL "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.8" with checksum 7ce7e635a1bd4ca5ce48f9a8b3bd8b08
--- main.go	2022-11-03 23:15:44.000000000 +0900
+++ /var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.9	2022-11-03 23:16:06.000000000 +0900
@@ -22,7 +22,7 @@
 }
 
 func deadcode(n int) string {
-	if n%3 == 0 && n%5 == 0 {
+	if n%3 == 0 && true {
 		return "FizzBuzz2"
 	}
 	if n%3 == 0 {

FAIL "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.9" with checksum c0e17bb17b40ebbbfc3b165b6f8b4901
...

3つ目: main 関数のテストがない

最後は、main 関数が書き換えられてもテストが成功してしまう問題です。
これは main 関数自体のテストがないためです。テストを新たに作成することで改善できます。

...
--- main.go	2022-11-03 23:15:44.000000000 +0900
+++ /var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.10	2022-11-03 23:16:07.000000000 +0900
@@ -5,7 +5,7 @@
 )
 
 func main() {
-	fmt.Println(fizzbuzz(20))
+	_ = fmt.Println
 }
 
 func fizzbuzz(n int) string {

FAIL "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-2384613249/main.go.10" with checksum 0fdf24c351c7a945a2a2597bdb86678d
...

こちらも整理すると以下のようになります。

  • 原因: テストの網羅性不足
  • 改善方法: main 関数のテストを追加

改善後

上記の改善方法に従って改善したソースコードが以下のとおりです。
注目するポイントは次の3点です。

  • "FizzBuzz" ケースのテストを追加
  • dead code だった deadcode 関数の削除
  • main 関数のテストの追加
main.go
package main

import (
	"fmt"
)

func main() {
	fmt.Println(fizzbuzz(20))
}

func fizzbuzz(n int) string {
	if n%3 == 0 && n%5 == 0 {
		return "FizzBuzz"
	}
	if n%3 == 0 {
		return "Fizz"
	}
	if n%5 == 0 {
		return "Buzz"
	}
	return ""
}

// func deadcode を削除
main_test.go
package main

import (
	"testing"
)

func Test_fizzbuzz(t *testing.T) {
	type args struct {
		n int
	}
	tests := []struct {
		name string
		args args
		want string
	}{
		{
			name: "3",
			args: args{n: 3},
			want: "Fizz",
		},
		{
			name: "2000",
			args: args{n: 2000},
			want: "Buzz",
		},
		{
			name: "1111",
			args: args{n: 1111},
			want: "",
		},
		{ // テストケースを追加
			name: "30",
			args: args{n: 30},
			want: "FizzBuzz",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := fizzbuzz(tt.args.n); got != tt.want {
				t.Errorf("fizzbuzz() = %v, want %v", got, tt.want)
			}
		})
	}
}

// main のテストを追加(Testable Example)
func ExampleMain() {
	main()
	// Output: Buzz
}

では、改めて mutation testing をやってみます。

go-mutesting .
result
PASS "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-4101300835/main.go.0" with checksum a95ff3525c0ba5f986dde9ead9a17836
PASS "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-4101300835/main.go.1" with checksum 030cfffd02f975436df714b9128562f8
PASS "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-4101300835/main.go.2" with checksum 8f9deabeb296c505fae081dde8a07602
PASS "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-4101300835/main.go.3" with checksum e6eba6b2272b48befb8608a575da19b1
PASS "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-4101300835/main.go.4" with checksum 04990bd1e35c1dc7353345df0bd3a5a3
PASS "/var/folders/6x/v7j2g02s7vvfsmgjnnsg_72r0000gp/T/go-mutesting-4101300835/main.go.5" with checksum b013fdcfa412ee01d5aa1d13045b6698
The mutation score is 1.000000 (6 passed, 0 failed, 0 duplicated, 0 skipped, total is 6)

見事スコア1の満点になりました! :tada:

total の数が 11 -> 6 に減っていますが、元のソースコードが減ったために mutant 自体も減少したためです。

余談ですが、main 関数のテストには Testable Example を利用しています。標準出力を出すだけのような簡単なテストの場合に便利です。

まとめ

  • mutation testing はテストの品質を評価でき、テストの改善につなげることができる
  • 副作用的に dead code の検知にも役立つ

ぜひ mutation testing を取り入れて良い開発者体験(DX)を実現しましょう!

  1. これは、実開発において「誤って修正した」ようなケースでテストのどこかで落ちるかどうかを機械的にたくさん試しているようなものです。

10
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
3