Goのテストツール@アプリケーションのホワイトボックステスト¶
はじめに¶
本サイトにつきまして、以下をご認識のほど宜しくお願いいたします。
01. ホワイトボックステストのツール¶
整形¶
| ツール名 | 解析内容 | 理由 |
|---|---|---|
標準の go fmt コマンド |
静的解析¶
▼ ベストプラクティス¶
| ツール名 | 解析内容 | 理由 |
|---|---|---|
標準の go vet コマンド |
||
| gochecknoinits | init() 関数を使用していないかを検証する。 |
init() 関数を使うと、プログラムの挙動が複雑になり、可読性が低下するため、使用しするべきではない。 |
| gomnd | マジックナンバーを採用していないかを検証する。 | |
| funlen | 関数名が指定した文字数より長くないかを検証する。 | |
| lll | 一行が指定した文字数より長くないかを検証する。 | |
| statickcheck | パフォーマンスの出る実装方法になっているかを検証する。 | |
| stylecheck | Effective Goに則った方法で実装しているかを検証する。 | パッケージ名にアンダースコアを使用していないか。キャメルケースの変数名に略語 (例:HTTP、IDなど) を使う場合、略語は全て大文字にする。 |
| whitespace | 不要な改行がないかを検証する。 | |
| gocognit | コードが複雑 (例:if の入れ子) であるかどうかを検証する。 |
|
| godox | TODOコメントがあるかどうかを検証する。 | |
| goimports | 足りない import や余分な import があるかどうかを検証する。 |
|
| goprintffuncname | printf のような関数の名前が f で終わっているかを検証する。 |
|
| revive | 用意されたコード規約に則っているかを検証する。 |
▼ 脆弱性¶
| ツール名 | 解析内容 | 理由 |
|---|---|---|
| govulncheck | ||
| gosec |
▼ コード規約違反¶
ユーザー定義のコード規約違反を検証する。
ユニットテスト、機能テストツール¶
- 標準の
go fmtコマンド
02. 標準のテストツール¶
標準のテストツールとは¶
go コマンドが提供するホワイトボックス機能のこと。
網羅率¶
網羅率はパッケージを単位として解析される。
03. 設計規約¶
パッケージ名¶
▼ ホワイトボックステスト¶
テストファイルのパッケージ名が、同じディレクトリ配下にある実際の処理ファイルのパッケージ名と同じ場合、それはホワイトボックステストになる。
▼ ブラックボックス風のホワイトテスト¶
テストファイルのパッケージ名が、同じディレクトリ配下にある実際の処理ファイルに『_test』を加えたパッケージ名の場合、それはブラックボックステスト風のホワイトテストになる。
補足として、Goでは1つのディレクトリ内に1つのパッケージ名しか宣言できないが、ブラックボックステストのために『_test』を加えることは許されている。
インターフェースの導入¶
テストできない構造体はモックに差し替えられることなる。
この時、あらかじめ実際の構造体をインターフェースの実装にしておく。
テスト時に、モックもインターフェイスの実装とすれば、モックが実際の構造体と同じデータ型として認識されるようになる。
これにより、モックに差し替えられる。
テストケース構成¶
▼ 手続き的テスト¶
package test
import (
"testing"
)
// 正常系
func TestFoo_ShouldReturnSuccess_WhenSomethingSucceeds(t *testing.T) {
expected := 1
actual := foo()
// 実際値が期待値どおりであることを検証する
if actual != expected {
t.Errorf("should return %d, got %d", expected, actual)
}
}
▼ テーブル駆動テスト¶
package test
import (
"testing"
)
// 正常系
func TestFoo_ShouldReturnSuccess_WhenSomethingSucceeds(t *testing.T) {
// 入力値
type Input struct {
status string
}
// 期待値
type Expected struct {
status string
}
// テストケース
testCases := []struct {
// テストケース名
name string
// 入力値
input Input
// 期待値
expected Expected
}{
{
name: "TestFoo_ShouldReturnSuccess_WhenSomethingSucceeds",
input: Input{
status: "succeed",
},
expected: Expected{
status: "ok",
},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
actual := foo(tt.input.status)
// 実際値が期待値どおりであることを検証する
if actual != tt.expected.status {
t.Errorf("should return %s, got %s", tt.expected.status, actual)
}
})
}
}
アサーション¶
▼ 標準¶
package test
import (
"testing"
)
// 正常系
func TestFoo_ShouldReturnSuccess_WhenSomethingSucceeds(t *testing.T) {
expected := 1
actual := foo()
// 実際値が期待値どおりであることを検証する
if actual != expected {
t.Errorf("should return %d, got %d", expected, actual)
}
}
▼ パッケージ¶
package test
import (
"testing"
"github.com/stretchr/testify/assert"
)
// 正常系
func TestFoo_ShouldReturnSuccess_WhenSomethingSucceeds(t *testing.T) {
expected := 1
actual := foo()
// 実際値が期待値どおりであることを検証する
assert.Equal(t, expected, actual)
}
期待値と入力値¶
▼ 構造体定義の場合¶
package test
import "testing"
// 正常系
func TestFoo_ShouldReturnSuccess_WhenSomethingSucceeds(t *testing.T) {
// 入力値
type Input struct {
status string
}
// 期待値
type Expected struct {
status string
}
// テストケース
testCases := []struct {
// テストケース名
name string
// 入力値
input Input
// 期待値
expected Expected
}{
{
// 正常系テストケース
name: "TestFoo_ShouldReturnSuccess_WhenSomethingSucceeds",
expected: Expected{
status: "ok",
},
input: Input{
status: "succeed",
},
},
}
// テストケースを反復で検証する
for _, tt := range testCases {
// 実際値が期待値どおりであることを検証する
}
}
▼ ファイル定義の場合¶
期待値のファイルは『ゴールデンファイル』ともいう。
package test
import (
"io/ioutil"
"testing"
)
// 正常系
func TestFoo_ShouldReturnSuccess_WhenSomethingSucceeds(t *testing.T) {
// 入力値
input_foo_succeed_status, _ := ioutil.ReadFile("../testdata/data/foo_succeed_status.json")
// 期待値
expected_foo_succeed_status, _ := ioutil.ReadFile("../testdata/expected/foo_succeed_status.json")
// テストケース
cases := []struct {
// テストケース名
name string
// 期待値
expected string
// 入力値
input []byte
}{
{
// 正常系テストケース
name: "TestFoo_ShouldReturnSuccess_WhenSomethingSucceeds",
expected: expected_foo_succeed_status,
input: input_foo_succeed_status,
},
}
// テストケースを反復で検証する
for _, tt := range cases {
// 実際値が期待値どおりであることを検証する
}
}
テストケース¶
▼ テストケース名¶
ユニットテストの命名規則は、Goで一般的な方法(<テストスイート名>_<テストケース名>)にする。
<テストケース名> は、〇〇の場合に~するはずという意味で、 <助動詞><動詞>...When... にする。
つまり、<テストスイート名>_<助動詞><動詞>...When...のようになる。
package test
import "testing"
// 正常系
func TestFoo_ShouldReturnSuccess_WhenSomethingSucceeds(t *testing.T) {
}
package test
import "testing"
// 異常系
func TestFoo_ShouldReturnError_WhenSomethingFailed(t *testing.T) {
}
▼ 正常系¶
package test
import "testing"
// 正常系
func TestFoo_ShouldReturnSuccess_WhenSomethingSucceeds(t *testing.T) {
// 入力値
type Input struct {
status string
}
// 期待値
type Expected struct {
status string
}
// テストケース
testCases := []struct {
// テストケース名
name string
// 期待値
expected Expected
// 入力値
input Input
}{
{
// 正常系テストケース
name: "TestFoo_ShouldReturnSuccess_WhenSomethingSucceeds",
expected: Expected{
status: "ok",
},
input: Input{
status: "succeed",
},
},
}
// テストケースを反復で検証する
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
// foo関数を実行し、実際値を作成する。
actual := foo(tt.input.status)
// 期待値と実際値を比較する。
if actual != tt.expected.status {
t.Errorf("should return %s, got %s", tt.expected.status, actual)
}
})
}
}
▼ 異常系¶
package test
import "testing"
// 異常系
func TestFoo_ShouldReturnError_WhenSomethingFailed(t *testing.T) {
// 入力値
type Input struct {
status string
}
// 期待値
type Expected struct {
status string
}
// テストケース
testCases := []struct {
// テストケース名
name string
// 期待値
expected Expected
// 入力値
input Input
}{
{
// 異常系テストケース
name: "TestFoo_ShouldReturnError_WhenSomethingFailed",
expected: Expected{
status: "error",
},
input: Input{
status: "failed",
},
},
}
// テストケースを反復で検証する
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
// foo関数を実行し、実際値を作成する。
actual := foo(tt.input.status)
// 期待値と実際値を比較する。
if actual != tt.expected.status {
t.Errorf("should return %s, got %s", tt.expected.status, actual)
}
})
}
}
外部依存のテスト¶
▼ Goのモックの特徴¶
Goは、PHP・JavaScript・Python・TypeScriptのようにランタイムでプライベート関数の内部実装を差し替える(モンキーパッチ)ことができない。
そのため、外部依存のテスト時には次のいずれかの方法を使用する
- テスト時は実際の処理を使用し、外部を差し替えしない
- 外部依存の処理をインターフェースにし、テスト時はインターフェースを介してモック構造体に差し替える
Goでは、基本的に『実際の処理を使用し、差し替えしない』方法を使用する。
▼ 外部のHTTPサーバー(httptest.NewServer)¶
外部のHTTPサーバーへのリクエストをテストする場合、httptest.NewServerで実際のHTTPサーバーを起動し、自身に対してリクエストを送信する。
package test
import (
"net/http"
"net/http/httptest"
"testing"
)
// 正常系
func TestClient_ShouldReturnSuccess_WhenRequestSucceeds(t *testing.T) {
// 期待値
type Expected struct {
status string
}
// テストケース
testCases := []struct {
// テストケース名
name string
// 期待値
expected Expected
}{
{
name: "リクエストが成功した場合、ステータスがokであるはず",
expected: Expected{
status: "ok",
},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
// Goではモンキーパッチができないため、HTTPクライアントの内部実装を書き換え、仮の値を返却させることができない
// そのため、実際のHTTPサーバーを起動し、HTTPリクエストを自身に対して送信する
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}))
defer httpServer.Close()
// httpServer.URL を使用してリクエストを送信する
result, err := myClient.Get(httpServer.URL)
// エラーが返却された場合、想定外の挙動なのでテストを失敗させる
if err != nil {
t.Fatalf("should return no error, got %v", err)
}
// 実際値が期待値どおりであることを検証する
if result.Status != tt.expected.status {
t.Errorf("should return %s, got %s", tt.expected.status, result.Status)
}
})
}
}
▼ 外部のファイルシステム(t.TempDir)¶
外部ファイルシステムのファイル操作をテストする場合、t.TempDir()でテスト用の一時ディレクトリを作成し、実際のファイルを操作する。
package test
import (
"os"
"path/filepath"
"testing"
)
// 正常系
func TestWriter_ShouldReturnSuccess_WhenFileWriteSucceeds(t *testing.T) {
// 入力値
type Input struct {
content string
}
// 期待値
type Expected struct {
content string
}
// テストケース
testCases := []struct {
// テストケース名
name string
// 入力値
input Input
// 期待値
expected Expected
}{
{
name: "ファイルに書き込んだ場合、内容が保存されるはず",
input: Input{
content: "hello",
},
expected: Expected{
content: "hello",
},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
// Goではモンキーパッチができないため、ファイルシステムの内部実装をモックに差し替え、仮のファイルを返却させることができない
// そのため、実際のファイルを作成し、ファイルを操作させる
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "test.txt")
// ファイルに書き込む
err := writeFile(filePath, tt.input.content)
// エラーが返却された場合、想定外の挙動なのでテストを失敗させる
if err != nil {
t.Fatalf("should return no error, got %v", err)
}
content, _ := os.ReadFile(filePath)
// 実際値が期待値どおりであることを検証する
if string(content) != tt.expected.content {
t.Errorf("should return %s, got %s", tt.expected.content, string(content))
}
})
}
}