11
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Go言語で習作: 自動テスト

Posted at

Go言語における自動テスト

自動テストは現代のソフトウェア開発においては行って当然であり,
やるかやらないかではなく, いかにしてやるかだけが議論の的となるプラクティスといってよいでしょう.
多くの言語ではJavaのJUnitの子孫たちが移植され使われており, 自動テストのデファクトとなっている思われます.
Go言語では自動テストについても持ち前の自前主義を発揮し, サードパーティのライブラリではなく
ファーストパーティライブラリ, あるいは言語レベル(コンパイラレベル?)で機能を提供しています.
今回は, Go言語のユニットテスト機能について簡単に見ていきたいと思います.

なお, Go言語のテスト機能についての正式な名称が見当たらなかったので, 以下で必要な時はGoTestという名称を使います.
もっと適切な名称がありましたら, コメントで指摘お願いします.

基本的なルール

実行方法

テストの実行するには, 実行したいテストが含まれたディレクトリに移動し, 以下のようなコマンドを実行します.

> go test

このコマンドで, 現在のディレクトリに含まれているすべてのテスト関数が実行されます.

> go test
PASS
ok      my.local/go-chat/client/src/mymath      0.003s

-vオプションを追加することで, 実行された各テストの詳細情報を表示することもできます.

> go test -v
=== RUN   TestIsEven
--- PASS: TestIsEven (0.00s)
=== RUN   TestIsForenoon
--- PASS: TestIsForenoon (0.00s)
PASS
ok      my.local/go-chat/client/src/mymath      0.003s

コマンドへの引数でテスト対象のディレクトリを指定することも可能ですが, その際にはGOPATHからの相対パスを指定する必要があるようです.

> pwd
/home/vagrant/go/src/my.local/go-chat/client/src/mymath
> echo $GOPATH
/home/vagrant/go
> go test -v my.local/go-chat/client/src/mymath
=== RUN   TestIsEven
--- PASS: TestIsEven (0.00s)
=== RUN   TestIsForenoon
--- PASS: TestIsForenoon (0.00s)
PASS
ok      my.local/go-chat/client/src/mymath      0.004s
>

テストファイルとテスト関数の作り方

JUnitがそうであったように, GoTestでもテスト対象となるファイルと関数は名前で判断します.
具体的には, GoTestは*_test.goという名前のファイルに含まれたTestXxxという名前の関数を
テスト関数とみなし実行します.

例えば, 以下のようなlib.goファイルを作った場合

/mymath/lib.go
package mymath;

func IsEven(i int) bool {
    return i % 2 == 0;
} 

これに対応するテストファイルとして, lib_test.goファイルを作る形になります.

/mymath/lib_test.go
package mymath

import "testing"

func TestIsEven(t *testing.Test) {
    // ....
} 

テスト関数の引数として受けとっている*testing.Test型の変数はテストの失敗を記録するため構造体です.
このように作られたテストファイルは, 通常のビルド時(go build)にはビルドの対象となりません.

アサーション

JUnitにおいては, テスト結果の比較用に各種アサーションメソッドが用意されていました.
しかしGoTestには, そのようなアサーション関数は用意されていません.
前節で少し書きましたが, テスト関数が受け取る*testing.Test型の引数はあくまでテスト失敗時に結果を残す機能のみしか用意されておらず,
予想値と実測値の比較処理はすべて自分で記述する必要があります.

/mymath/lib_test.go
package mymath

import "testing"

func TestIsEven(t *testing.Test) {
     if !IsEven(2) {
         t.Error("2 was expected to be even number but it was odd number");
     }
} 

なぜJUnit等には用意されているアサート関数がGoTestには用意されていないのか?
それは早すぎる最適化を避けるというGo言語の思想によるものであり, アサート関数のような特定の状況でのみ有用な
ヘルパ関数は本当に必要になったときに初めて作るべきだからです. (と偉そうに書きましたがすべて伝聞です)
この思想の是非はさておき, ある程度テストが増え共通のパターンが見えてきたら, それに合わせてアサート関数を自作することは良いでしょう.

/mymath/lib_test.go
package mymath

import "testing"

func TestIsEven(t *testing.Test) {
    var tests = []int { 4, 16, 36, 64, 81 };

    for _, test := range tests {
        assertEven(test, t);
    }
} 

func assertEven(i int, t *testing.Test) {
    if !IsEven(i) {
        t.Errorf("%d was expected to be even number but it was odd number", i);
    }
}

応用的な使い方

特定のテスト関数だけ実行したい

プロジェクト初期のテストかまだ少ないころはテスト全体を一括で実行することに時別な問題はありませんが,
プロジェクトが進み, だんだんテストが増え, あるいは外部システム(DBとか)の低速な呼び出しを含むテストなどが作られてゆくと
テスト全体の実行に我慢できないほど時間がかかるようになることもよくあります.
GoTestでは, -runオプションを使用し, 名前に指定した文字列を含むテスト関数のみを実行することができます.

> go test -v
=== RUN   TestIsEven
--- PASS: TestIsEven (0.00s)
=== RUN   TestIsForenoon
--- PASS: TestIsForenoon (0.00s)
PASS
ok      my.local/go-chat/client/src/mymath      0.005s

> go test -v -run=Even
=== RUN   TestIsEven
--- PASS: TestIsEven (0.00s)
PASS
ok      my.local/go-chat/client/src/mymath      0.004s

あとはプロジェクトごとに

  • 遅すぎて一括実行時には除外したいテストなどがある場合は, テスト関数名にShowの文字列を含めるようにする

などといったルールを決めていけば速度低下を軽減していくことができます.

Mock関数を利用する

これまで説明で使ってきた関数はそれ自体で処理が完結した単純なものでした.
しかし, 我々が現実に作らなければならない関数は自分自身だけでは完結せず, 他のパッケージによって提供されている関数に
依存していることが多いでしょう.
そのような依存関係のある関数に対してそのままテストを書くと, テストが失敗した際にその原因が対象の関数にあるのか
はたまたその依存先の関数にあるのか区別することが難しくなってしまいます.

以下のような関数を考えてみましょう.

/mymath/lib.go
package mymath

import "time";

// 現在が午前中かどうかを返す関数
func IsForenoon() {
    var now := time.Now();

    return now.Hour() < 12;
} 

この関数は見ての通り現在時刻が午前か否かを返すだけの単純なものです.
しかし, 現在時刻を得るためにtimeパッケージのNow関数を呼び出しているため, この関数に対するテストを書いても
実行する時間によっては成功したり失敗したとしてしまうことになります.

/mymath/lib_test.go
package mymath

import "testing"

func TestIsForenoon(t *testing.Test) {
     if !IsForenoon() {
         t.Error("now is Afternoon"); // <- 失敗するか否かはタイミング次第...
     }
} 

どうすれば実行タイミングによらず常に一定の結果を得られるテストが書けるようになるのでしょうか?

Javaなどのオブジェクト指向言語では, このような場合は機能を提供するオブジェクトを外部から抽入できるように設計し
テスト時は期待する挙動をするだけのモックオブジェクトに差し替えることで, テストの範囲を狭めています.

Go言語はオブジェクト指向言語ではなく, 関数もパッケージから直接importしているのでMockに差し替えることができないように思えます.
しかし, Go言語において関数はファーストクラスの値であり, 整数を変数に代入し再代入できるように
関数もまた変数に代入し再代入することができます.
当然その変数を関数として実行することも可能です.
なので以下のように, 上記の関数の外部依存部分の関数に__変数として__定義しておき

/mymath/lib.go
package mymath

import "time";

var now = func() : time.Time {
    return time.Now(); 
}

// 現在が午前中かどうかを返す関数
func IsForenoon() {
    var t := now();

    return t.Hour() < 12;
} 

テスト時には, now変数を上書きすることで任意の時間に対してテストを行うことができます.

/mymath/lib_test.go
package mymath

import "time"
import "testing"

func TestIsForenoon(t *testing.Test) {
    // オリジナル関数を一時退避
    var original := now;
    // テスト後にオリジナル関数を復元
    defer func() { now = original; }();

    var test := time.Date(2018, 12, 01, 11, 59, 59, 0, time.Local);
 
    now = func() {
        return test; 
    } 
    
    if !IsForenoon() {
        t.Errorf("%s is Afternoon", test.String()); // <- テスト結果は常に一定
    }
} 

注意が必要なのは, 差し替えられた変数は自動でもとに戻ったりはしないので, 上述のようにdefer構文を使って関数を復元する必要があります.
忘れぬよう.

テストカバレッジの作成

テストがどれだけのコードを実行されたかの統計情報を意味するテストカバレッジは
GoTestツールによって生成されるカバレッジファイルとGo言語が用意しているcoverツールを使って作成します

まずGoTest実行時にcoverprofileオプションで出力ファイルを指定してテストカバレッジファイルを作成します

> go test -v -coverprofile=covarege.o
PASS
coverage: 75.0% of statements
ok      my.local/go-chat/client/src/mymath      0.004s

表示用のHTMLファイルへの変換はcoverツールを使ってを行います.

> go tool cover -html=coverage.o
HTML output written to /tmp/cover518063326/coverage.html

最終的な結果は, 出来上がったHTMLをブラウザで表示することで確認するとこができます

coverage.jpg

何やら禍々しい色使いなのが気になりますが, コードのどの部分がテストされ, どの部分がテストされていないかを
確認することができます.

ベンチマーク

多くの言語ではベンチマークのためのツールとテストのためのツールは別物ですが, Go言語では両方の機能が
GoTestツールに統合されています.

ベンチマーク関数の作り方はテスト関数と似ていて, testing.B型の引数を受け取るBenchmarkから始まる関数は自動的にベンチマーク関数と認識されます.

/mymath/lib_test.go
package mymath

import "time"
import "testing"

func BenchmarkIsForenoon(b *testing.B) {
    for i := 0; i < b.N; i++ {
        IsForenoon();
    }
} 

このように定義されたベンチマーク関数は, GoTestコマンドにbenchオプションを付けて実行することで実行することができます

> go test -bench=.
goos: linux
goarch: amd64
pkg: my.local/go-chat/client/src/mymath
BenchmarkIsForenoon-2             500000              3096 ns/op
PASS
ok      my.local/go-chat/client/src/mymath      1.597s

benchオプションには実行したいベンチマーク関数名のパターンを渡します.
すべてのベンチマーク関数を実行したい場合は「.」を指定します.
ベンチマーク関数で対象関数をN回実行し, その平均実行時間を求めます.
(N回が何回なのかは実行ごとにかわるようです?)
上の例では500000回実行され, 一回当たり3096nsかかったことがわかります.

各種オプションを指定することで, 実行時間以外の項目もベンチマークで調べることができます.
例えば, -benchmenオプションを使うと, 使用メモリとmemory allocate回数を調べることができます.
詳しい情報はヘルプを調べるか, ドキュメントを調べてください

ドキュメント向けのサンプルコードを記述する

最後に, テストではありませんが, Go言語には自動生成されるドキュメント向けのサンプルコードを記述するルールも存在します.
この関数もテストと同じように, ExampleXxxx(Xxxxはサンプルコードを記述したい関数名)という名前で定義することで
ドキュメント作成時に自動的に該当関数に関連づけられてサンプルコードが追記されます.

/mymath/lib_test.go
package mymath

func ExampleIsEven() {
    fmt.PrintLn(IsEven(10));
    fmt.PrintLn(IsEven(11));
    // Output:
    // true
    // false
} 

example.png

また, このExample関数はテスト関数としてもみなされ, 上記のように最後にOutput:と書かれたコメントが書かれていたら
fmt.Println関数で標準出力に出力された結果と, コメントとして書かれた値の結果を比較して一致するかのテストが行われます.

> go test -v
=== RUN   TestIsEven
--- PASS: TestIsEven (0.00s)
=== RUN   TestIsForenoon
--- PASS: TestIsForenoon (0.00s)
=== RUN   ExampleIsEven # <-- テストとして実行されている
--- PASS: ExampleIsEven (0.00s)
PASS
ok      my.local/go-chat/client/src/mymath      0.006s


最後に

急ぎ足となりましたが, Go言語のテスト機能について私が理解している分の解説はこれで終わります.
testingパッケージにはまだまだいろいろな機能があるようなので, より詳しく知りたい方はオンラインドキュメントを調べたり
書籍を読んでみたりなどしてみてください.

参考

testingパッケージオンラインマニュアル

go testコマンドのオプション

みんなのGo言語

プログラミング言語Go

11
9
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
11
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?