👁

Goで自動テストをしよう

この章では、Goの標準 testing パッケージを使った自動テストについて学び、ハンズオン形式で学習します。これにより、テーブル駆動テスト・サブテスト・カバレッジ計測・httptest など、自動テストの基本が身につきます。

1. 本章の概要

1.1 本章の目的

Goは標準の testing パッケージだけで、単体テストからHTTPハンドラのテストまでカバーできる設計になっています。外部フレームワークを選ぶ前に、標準機能でどこまで書けるか・書きやすいかを押さえておくと、テスト設計の判断がしやすくなります。本章では、testing パッケージによる単体テストの基本、テーブル駆動テストとサブテストに加えて、カバレッジ計測、httptest を使ったHTTPハンドラのテスト、テストヘルパの書き方も扱います。

1.2 ハンズオンの流れ

小さな関数の単体テストを書くところから始めて、テーブル駆動テストとサブテストで整理します。仕上げに net/http で書いた小さなハンドラに対して httptest でテストを追加し、Goでのテスト設計に慣れます。

1.3 事前準備

前提となる講座

この章では、以下の知識を前提としています。自信がない場合は先に関連講座を実施してみましょう。

講座名 必要な知識
テストの考え方 単体テスト・カバレッジなど、テストの基本概念

必要なツール

この章では、以下のツールを使用します。まだインストールしていない場合は、リンク先の手順に沿って準備をお願いします。

ツール名 関連箇所 理由
Visual Studio Code Visual Studio Codeのインストール テスト対象コードとテストコードを記述するエディタとして使用する
Go Goのインストール go test コマンドの実行環境として使用する

2. Goのテスト設計の考え方

Goは標準の testing パッケージで自動テストが完結する言語です。外部のテストフレームワークをインストールしなくても、_test.go という命名のファイルにテスト関数を書き、go test コマンドを実行するだけで動きます。実際の現場でもこの標準機能をベースに使うのが基本で、モックが必要な場面で testify を組み合わせるのが一般的な構成です。

flowchart LR
    Code[プロダクトコード<br>calculator.go] --> Test[テストコード<br>calculator_test.go]
    Test --> Run[go test]
    Run --> Result[✅ PASS / ❌ FAIL]
    Run --> Cover[カバレッジ計測]

3. プロジェクトの準備

テストを書くためのGoモジュールを用意します。任意の場所に go-test-hands-on フォルダを作成し、Visual Studio Codeの「ファイル」→「フォルダーを開く」から作成した go-test-hands-on フォルダを開きます。以降の操作は、Visual Studio Codeのターミナルから行います。

go-test-hands-on/  ← このフォルダを作成

以下のコマンドで、Go がインストールされていることを確認します。

go version

以下のように Go のバージョンが表示されれば、インストールは確認できています。

go version go1.x.x darwin/arm64

バージョンが表示されない場合は、Goのインストール を先に実施してください。

インストールが確認できたら、モジュールを初期化します。

go mod init testdemo

以下のような実行結果が表示されます。

go: creating new go.mod: module testdemo

go.mod が生成されていれば準備完了です。

4. 単体テストの書き方

単体テストの基本を、少しずつ書き方を洗練させながら扱います。まずは testing パッケージのもっとも基本の形で1つ書いてみて、そのあとテーブル駆動テストt.Run によるサブテスト分割エラーを返す関数のテストへと段階的に発展させます。

4.1 基本の testing パッケージ

testing パッケージのもっとも基本の形として、テスト対象の関数を用意し、それに対する1つのテストを書いて go test コマンドで実行するまでを体験します。以降の応用(テーブル駆動テスト・サブテスト・カバレッジ・httptest)の土台になります。

対象コードの用意

Visual Studio Code のエクスプローラーで go-test-hands-on フォルダを右クリックし、「新しいファイル」を選択して calculator.go を作成します。

go-test-hands-on/
├── go.mod
└── calculator.go  ← このファイルを作成

作成したファイルに以下の内容を記述して保存します。テスト対象となる2つの関数(AddDivide)と、0除算用のエラー変数を定義しています。

package testdemo

import "errors"

var ErrDivideByZero = errors.New("0で割ることはできません")

func Add(a, b int) int {
    return a + b
}

func Divide(a, b int) (int, error) {
    if b == 0 {
        return 0, ErrDivideByZero
    }
    return a / b, nil
}

コードを解説します。

var ErrDivideByZero = errors.New("0で割ることはできません")

パッケージレベルで定義した公開エラー変数です。呼び出し側は errors.Is(err, ErrDivideByZero) でこのエラーかどうかを判定できます。

func Divide(a, b int) (int, error) {
    if b == 0 {
        return 0, ErrDivideByZero
    }
    return a / b, nil
}

Goではエラーを戻り値で返すのが基本です。第2戻り値に error 型を返し、正常時は nil を、異常時は具体的なエラー値を返します。

はじめてのテスト

calculator_test.go を作成します。テストファイルは対象ファイルと同じディレクトリ・同じパッケージ名で、末尾を _test.go にするのが原則です。

go-test-hands-on/
├── go.mod
├── calculator.go
└── calculator_test.go  ← このファイルを作成

テスト関数は Test で始まる名前で、引数に *testing.T を取ります。書き方は以下のとおりです。

func Test対象関数名(t *testing.T) {
    got := 対象関数呼び出し
    want := 期待値
    if got != want {
        t.Errorf("失敗時のメッセージ", got, want)
    }
}

以下を保存します。

package testdemo

import "testing"

func TestAdd(t *testing.T) {
    got := Add(3, 5)
    want := 8
    if got != want {
        t.Errorf("Add(3, 5) = %d, want %d", got, want)
    }
}

コードを解説します。

func TestAdd(t *testing.T) {

Test で始まる関数がテストとして認識されます。引数は *testing.T 型です。

t.Errorf("Add(3, 5) = %d, want %d", got, want)

テストが失敗したら t.Errorf でメッセージを残します。Go のテストは Assert 系関数がなく、if got != want で自分で判定して t.Errorf を呼ぶスタイルが基本です。

実行

ターミナルで go test を実行します。

go test

以下のような実行結果が表示されます。

PASS
ok  	testdemo	0.001s

PASS と表示されれば、テストが通っています。詳しい出力を見たいときは -v オプションを付けます。

go test -v

以下のような実行結果が表示されます。

=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok  	testdemo	0.001s

=== RUN--- PASSの行が加わり、どのテスト関数が実行されたかが確認できます。

4.2 テーブル駆動テスト

テーブル駆動テストの利点

同じロジックを複数の入力・期待値パターンで検証したい場合、それぞれ別々に if を書いていくと冗長になり、追加も面倒になります。Go では入力と期待値のテーブルを作ってループで回すテーブル駆動テストが慣用パターンです。実際のGoコードで最もよく見るテストの書き方です。

書き方

入力と期待値をスライスに並べ、range で1件ずつ回して同じ検証を繰り返します。書き方は以下のとおりです。

cases := []struct {
    name string       // ケース識別用
    入力 型            // 入力
    want 型            // 期待値
}{
    {"ケース名1", 入力1, 期待値1},
    {"ケース名2", 入力2, 期待値2},
}

for _, c := range cases {
    got := 対象関数(c.入力)
    if got != c.want {
        t.Errorf("失敗時のメッセージ", ...)
    }
}

calculator_test.go に以下を追記します。テストケースをスライスで並べ、ループで回して同じ検証を繰り返す形です。

func TestAddTableDriven(t *testing.T) {
    cases := []struct {
        name string
        a, b int
        want int
    }{
        {"正+正", 3, 5, 8},
        {"負+正", -2, 5, 3},
        {"ゼロ+ゼロ", 0, 0, 0},
        {"負+負", -3, -4, -7},
    }

    for _, c := range cases {
        got := Add(c.a, c.b)
        if got != c.want {
            t.Errorf("Add(%d, %d) = %d, want %d", c.a, c.b, got, c.want)
        }
    }
}

コードを解説します。

cases := []struct {
    name string
    a, b int
    want int
}{
    {"正+正", 3, 5, 8},
    ...
}

cases はテストケースのスライスで、匿名 struct として書きます。各ケースには識別用の name を付けておくと、失敗時にどのケースが失敗したかが分かりやすくなります。

for _, c := range cases {
    got := Add(c.a, c.b)
    if got != c.want {
        t.Errorf("Add(%d, %d) = %d, want %d", c.a, c.b, got, c.want)
    }
}

rangeですべてのケースを順に取り出し、それぞれで Add を呼び出して期待値と比較します。ケースを増やしたければテーブルに1行足すだけで済みます。

実行結果

続いて、追記したテストを含めて実行します。

go test -v

以下のような実行結果が表示されます。

=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
=== RUN   TestAddTableDriven
--- PASS: TestAddTableDriven (0.00s)
PASS

TestAddTestAddTableDrivenの両方が実行され、PASS になっていることが分かります。

4.3 t.Run によるサブテストへの分割

サブテストの利点

先ほどの書き方だと、複数ケースが全て「1つのテスト関数の実行結果」として扱われるため、どのケースが失敗したのかがログを追わないと分かりません。t.Run を使うと、各ケースをサブテストとして個別に実行でき、実行結果もケースごとに PASS / FAIL が表示されるようになります。

書き方

各ケースを t.Run の中で実行することで、独立したサブテストとして扱えます。書き方は以下のとおりです。

for _, c := range cases {
    t.Run(c.name, func(t *testing.T) {
        // このサブテストの検証(外側と別の *testing.T が渡される)
    })
}

calculator_test.go にサブテスト形式のテストを追記します。t.Run でケースごとにサブテストとして実行できます。

func TestAddSub(t *testing.T) {
    cases := []struct {
        name string
        a, b int
        want int
    }{
        {"正+正", 3, 5, 8},
        {"負+正", -2, 5, 3},
        {"ゼロ+ゼロ", 0, 0, 0},
    }

    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            got := Add(c.a, c.b)
            if got != c.want {
                t.Errorf("Add(%d, %d) = %d, want %d", c.a, c.b, got, c.want)
            }
        })
    }
}

コードを解説します。

t.Run(c.name, func(t *testing.T) {
    ...
})

t.Run は第1引数にサブテスト名、第2引数にサブテストとして実行する関数を渡します。関数の中では、外側と別の *testing.T が渡されるため、ケースごとの成否が独立して記録されます。

実行結果

続いて、追加したテストを絞って実行します。-run で対象のテストを絞れます。

go test -v -run TestAddSub

以下のような実行結果が表示されます。

=== RUN   TestAddSub
=== RUN   TestAddSub/正+正
=== RUN   TestAddSub/負+正
=== RUN   TestAddSub/ゼロ+ゼロ
--- PASS: TestAddSub (0.00s)
    --- PASS: TestAddSub/正+正 (0.00s)
    --- PASS: TestAddSub/負+正 (0.00s)
    --- PASS: TestAddSub/ゼロ+ゼロ (0.00s)
PASS

サブテストごとに個別の PASS が表示されます。失敗した場合も、どのケースで失敗したかがログから即座に分かります。

失敗したケースだけを再実行することもできます。

go test -v -run 'TestAddSub/負\+正'

-runテスト関数名/サブテスト名 のパターンを渡すと、対象のサブテストだけを実行できます。

4.4 エラーを返す関数のテスト

期待するエラーの検証

Divide は 0 で割ろうとするとエラーを返します。エラーが期待通りかも検証します。書き方は以下のとおりです。エラー比較は errors.Is、致命的な不一致は t.Fatalf で以降をスキップするのが一般的です。

if !errors.Is(err, 期待するエラー) {
    t.Fatalf("エラー不一致: got=%v, want=%v", err, 期待するエラー)
}
// t.Fatalf は t.Errorf と違い、そのケースの以降の処理をスキップする

calculator_test.goimport を修正し、テスト関数を追記します。

import (
    "errors"
    "testing"
)

func TestDivide(t *testing.T) {
    cases := []struct {
        name    string
        a, b    int
        want    int
        wantErr error
    }{
        {"通常", 10, 2, 5, nil},
        {"割り切れない", 10, 3, 3, nil},
        {"ゼロ除算", 10, 0, 0, ErrDivideByZero},
    }

    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            got, err := Divide(c.a, c.b)

            if !errors.Is(err, c.wantErr) {
                t.Fatalf("エラー不一致: got=%v, want=%v", err, c.wantErr)
            }
            if err == nil && got != c.want {
                t.Errorf("Divide(%d, %d) = %d, want %d", c.a, c.b, got, c.want)
            }
        })
    }
}

コードを解説します。

{"ゼロ除算", 10, 0, 0, ErrDivideByZero},

wantErr に期待するエラー値を渡します。正常系は nil、異常系はパッケージレベルで定義したエラー変数(ErrDivideByZero)を指定します。

if !errors.Is(err, c.wantErr) {
    t.Fatalf("エラー不一致: got=%v, want=%v", err, c.wantErr)
}

errors.IsGoエラーハンドリング で学んだ通り、nil 同士や、ラップされたエラー同士も正しく比較できる関数です。t.Fatalft.Errorf と違い、そのケースの以降の処理をスキップします。「エラーが違ったので、値の検証には進めない」場面で使います。

if err == nil && got != c.want {
    t.Errorf("Divide(%d, %d) = %d, want %d", c.a, c.b, got, c.want)
}

errnil(正常終了)だったときだけ、戻り値が期待値と一致するかを検証します。エラーが返っている場合の戻り値は保証されないため、比較の対象から外します。

5. カバレッジの計測

5.1 カバレッジ取得の意義

「どこまでテストが到達しているか」を数値で見えるようにする指標がカバレッジです。テストが薄い領域を特定するのに役立ちます。

5.2 実行

-coverオプション付きでgo testを実行します。

go test -cover

以下のような実行結果が表示されます(数値はテストの充実度に依存します)。

PASS
coverage: 100.0% of statements
ok  	testdemo	0.002s

coverage: の行にステートメントベースのカバレッジ率が表示されます。ここでは 100% になっています。

5.3 HTMLでの詳細確認

カバレッジ情報をファイルに書き出し、HTMLで可視化できます。まずカバレッジプロファイルをファイルに保存します。

go test -coverprofile=coverage.out

以下のような実行結果が表示されます。

PASS
ok  	testdemo	0.002s

コマンド自体の出力は通常のテスト実行と同じですが、実行と同時にカレントディレクトリに coverage.out が生成されています。次に、そのファイルをHTMLで表示します。

go tool cover -html=coverage.out

ブラウザが自動的に開き、「テストが通ったコード」が緑、「通っていないコード」が赤で表示されます。どの分岐がテストに含まれていないかが視覚的に分かります。

6. httptestによるHTTPハンドラのテスト

6.1 httptestの用途

Web API のハンドラは、実サーバを起動しなくても httptest パッケージを使ってメモリ上でテストできます。テスト対象のハンドラに擬似的なリクエストを送り、レスポンスを検証する形です。

6.2 対象コードの用意

handler.go を作成し、以下を保存します。ヘルスチェック用の単純な HTTP ハンドラです。

package testdemo

import (
    "encoding/json"
    "net/http"
)

func HealthHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

コードを解説します。

func HealthHandler(w http.ResponseWriter, r *http.Request) {

net/http のハンドラ関数の標準シグネチャです。第1引数の w にレスポンスを書き込み、第2引数の r からリクエストを読み取ります。

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})

Content-Type ヘッダをJSONに設定し、ステータス 200 を返した上で、{"status": "ok"} を JSON でエンコードして書き込んでいます。

6.3 テストの記述

httptest を使うと、実サーバを起動せずにハンドラを呼び出してレスポンスを検証できます。書き方は以下のとおりです。

req := httptest.NewRequest(HTTPメソッド, "パス", ボディ)   // 擬似リクエストを作る
rec := httptest.NewRecorder()                             // レスポンスを記録する ResponseWriter
ハンドラ関数(rec, req)                                    // ハンドラを直接呼び出す

// 検証は rec.Code(ステータス)や rec.Body(ボディ)で行う

handler_test.go を作成し、以下を保存します。

package testdemo

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestHealthHandler(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/health", nil)
    rec := httptest.NewRecorder()

    HealthHandler(rec, req)

    if rec.Code != http.StatusOK {
        t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
    }

    var body map[string]string
    if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
        t.Fatalf("JSONデコード失敗: %v", err)
    }
    if body["status"] != "ok" {
        t.Errorf("status field = %q, want %q", body["status"], "ok")
    }
}

コードを解説します。

req := httptest.NewRequest(http.MethodGet, "/health", nil)

httptest.NewRequest は擬似リクエストを作るヘルパです。GET でパス /health、ボディなし、を意味します。

rec := httptest.NewRecorder()
HealthHandler(rec, req)

httptest.NewRecorder はレスポンスを記録する http.ResponseWriter の実装です。ハンドラをそのまま呼び出せば、rec にステータス・ヘッダ・ボディが記録されます。

if rec.Code != http.StatusOK { ... }

rec.Code にステータスコードが入ります。ボディは rec.Body*bytes.Buffer)から読めます。

実行してテストが通ることを確認します。

go test -v -run TestHealthHandler

以下のような実行結果が表示されます。

=== RUN   TestHealthHandler
--- PASS: TestHealthHandler (0.00s)
PASS

7. まとめ

この章では、Goの自動テストの書き方を学びつつ、実際に単体テスト・テーブル駆動テスト・httptest を体験しました。

  • Goは、標準の testing パッケージだけで自動テストが完結する
  • テストファイルは _test.go 命名で、Test で始まる関数がテストとして認識される
  • テーブル駆動テストは、複数入力を効率的に検証するGoの慣用パターンである
  • t.Run でサブテストに分割することで、ケースごとの成否がログで判別できる
  • エラーの比較は errors.Is、致命的な失敗は t.Fatalf で以降をスキップする
  • go test -cover でカバレッジ、go tool cover -html でHTML可視化ができる
  • httptest を使うと、Webハンドラを実サーバなしにテストできる

これでGo講座は一通り終了です。ここまでで、Goの基本文法から並行処理・net/http・GORM・REST API・CORS対応・JWT認証・静的解析・自動テストまで、実際の開発で必要になるGoの基礎から応用までが一通り身につきました。

この教材は役に立ちましたか?

いいねをたくさんいただけると、制作者の励みになり、より多くのセクションが作れるようになります。

感想を一言(任意)

いただいたコメントは次の制作のヒントになります。ぜひお気軽にご投稿ください。

このコメントは他の受講生には公開されません。DevOps Camp運営が、教材改善のために確認します。

0 / 2000