3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Go】テーブル駆動テストもsqlmockも初見で難しかった

3
Posted at

みなさん、エンジニア楽しんでいますか?

普段はLaravelを使っていて、そっちではテストを書いているのですが、学習中のGoでテストの書き方なんかを勉強してみるとわからないことがかなり多かったです。

一般的にGoではテーブル駆動テストと呼ばれるテスト手法が用いられているようで、シンプルに覚えることや書き方が難しかったです。
合わせて、sqlmockというものも学習に取り入れたのですが、頭の中がごちゃごちゃになりました。
なので、この記事では2つを分けて1つずつ説明し、最後に組み合わせます。

また、テストはAIが一気に書いてくれるみたいなのがありますが、基礎がないと過剰テストの見分けなどもつかないので、愚直な学習をしています。

この記事で話すこと

  • テーブル駆動テストの読み方とメリット
  • sqlmockの基本とハマったところ
  • 2つを組み合わせたテストの書き方
  • テストが落ちたときのエラーメッセージの読み方

前提

  • Go 1.22以上
  • go-sqlmock v1.5.2
  • DBはPostgreSQLを想定

記事中のエラーメッセージは、実際にテストを実行して出たものです。
(長いものは一部省略)

テーブル駆動テスト

まずはふつうに書いてみる

題材は、合計金額から送料を返す関数です。

shipping.go
package shipping

// Fee は合計金額から送料を返す。5000円以上なら無料、それ未満は500円。
func Fee(total int) int {
	if total >= 5000 {
		return 0
	}
	return 500
}

テーブル駆動を使わずにテストを書くと、以下のようになるかと思います。

shipping_test.go
package shipping

import "testing"

func TestFee_Under5000(t *testing.T) {
	got := Fee(4999)
	if got != 500 {
		t.Errorf("Fee(4999) = %d, want 500", got)
	}
}

func TestFee_Just5000(t *testing.T) {
	got := Fee(5000)
	if got != 0 {
		t.Errorf("Fee(5000) = %d, want 0", got)
	}
}

// 10000円のケースも同じ形で続く

Goのテストは、標準ライブラリの testing パッケージだけで書けます。
ファイル名を _test.go で終わらせ、Test で始まる関数に *testing.T を受け取らせると、go test がテストとして実行してくれます。

PHPUnitの assertSame のようなアサーションは標準では無いので、if で比べて t.Errorf で自分で失敗させます。
got(実際の値)と want(期待する値)という変数名は、Goのテストの慣習のようです。

見てのとおり、中身は「引数と期待値が違うだけ」です。
ここを1つにまとめたのがテーブル駆動テストです。

テーブル駆動で書くとこうなる

shipping_test.go
package shipping

import "testing"

func TestFee(t *testing.T) {
	tests := []struct {
		name  string
		total int
		want  int
	}{
		{name: "5000円未満は送料500円", total: 4999, want: 500},
		{name: "5000円ちょうどは無料", total: 5000, want: 0},
		{name: "5000円を超えても無料", total: 10000, want: 0},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := Fee(tt.total)
			if got != tt.want {
				t.Errorf("Fee(%d) = %d, want %d", tt.total, got, tt.want)
			}
		})
	}
}

初めてこれを見たとき、私は1行目から読めませんでした。
ただ、分解するとやっていることは3つだけです。

① tests := []struct{...}{...} で表を作る

読めなかった一番の理由は、型の定義と値が1つの式に入っていることでした。
前半の []struct { name string; total int; want int } が「構造体のスライス」という型で、後半の { {...}, {...} } がその中身です。
構造体に名前を付けず、その場で定義しているので「無名構造体」と呼ばれます。

PHPなら連想配列で済むところですが、Goでは先に型として宣言します。
そのぶん、want を wnat と打ち間違えるとコンパイルの時点で止まってくれます。
name 以外のフィールド名は、テストする関数に合わせて自分で決めます。

② for _, tt := range tests で1行ずつ取り出す

_ は使わないインデックスを捨てる記号で、tt はテストケースを表す慣習的な名前です。

③ t.Run(tt.name, func(t *testing.T) {...}) でサブテストとして実行する

t.Run は、第1引数の名前でサブテストを作り、第2引数の関数を実行します。
中の t は外側の t とは別物で、サブテスト専用の t です。

PHPUnitでいうと、DataProvider がほぼ同じ発想です。

ShippingTest.php(抜粋)
public static function feeProvider(): array
{
    return [
        '5000円未満は送料500円' => [4999, 500],
        '5000円ちょうどは無料' => [5000, 0],
        '5000円を超えても無料' => [10000, 0],
    ];
}

#[DataProvider('feeProvider')]
public function test_fee(int $total, int $want): void
{
    $this->assertSame($want, shippingFee($total));
}

データと検証ロジックを分ける、という考え方は同じです。
Goでは専用の仕組みを使わず、スライスとforループで同じことをしているだけです。

メリット

試しに、Fee の >= を > に書き間違えてテストを実行してみます。

$ go test -v ./...
=== RUN   TestFee
=== RUN   TestFee/5000円未満は送料500円
=== RUN   TestFee/5000円ちょうどは無料
    shipping_test.go:20: Fee(5000) = 500, want 0
=== RUN   TestFee/5000円を超えても無料
--- FAIL: TestFee (0.00s)
    --- PASS: TestFee/5000円未満は送料500円 (0.00s)
    --- FAIL: TestFee/5000円ちょうどは無料 (0.00s)
    --- PASS: TestFee/5000円を超えても無料 (0.00s)
FAIL

どのケースが落ちたのかが、名前で出ます。
「5000円ちょうどの境界でだけ壊れている」ことが、出力を見るだけで分かります。

go test -run 'TestFee/5000円ちょうどは無料' のように、ケース単位で実行することもできます。
ケース名の半角スペースは、出力や -run の指定では _ に置き換わるので注意してください。

ケースを増やすときは、表に1行足すだけです。
境界値が縦に並ぶので、「境界の反対側のケースが無い」といった抜けにも気づきやすくなります。

sqlmockってなんや

LaravelのDBテストとの違い

Laravelでは、RefreshDatabase を使ってテスト用のDBを別建てし、そっちにSQLを流すことが多いと思います。
sqlmockは逆で、SQLを実行しません。

sqlmockが返す *sql.DB は本物ですが、その裏で動いているドライバが偽物です。
「このSQLが来るはず」「来たらこの結果を返す」という台本を仕込んでおき、repositoryから飛んできたSQLを台本と照合して、一致すれば仕込んだ結果を返します。

DBが無くても動き、「接続が切れた」「0件だった」といった状況も1行で作れます。
逆に、SQLそのものが正しいかは確かめられないので、そこは実DBを使うテストで別に確かめます。

テスト対象のrepository

product.go
package repository

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
)

type Product struct {
	ID      int64
	Name    string
	Price   int
	Version int64
}

var ErrNotFound = errors.New("not found")

type ProductRepository struct {
	db *sql.DB
}

func NewProductRepository(db *sql.DB) *ProductRepository {
	return &ProductRepository{db: db}
}

func (r *ProductRepository) GetByID(ctx context.Context, id int64) (*Product, error) {
	const query = `SELECT id, name, price, version FROM products WHERE id = $1`

	var p Product
	err := r.db.QueryRowContext(ctx, query, id).Scan(&p.ID, &p.Name, &p.Price, &p.Version)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrNotFound
		}
		return nil, fmt.Errorf("select product: %w", err)
	}
	return &p, nil
}

func (r *ProductRepository) Create(ctx context.Context, p *Product) error {
	const query = `INSERT INTO products (name, price) VALUES ($1, $2) RETURNING id, version`

	err := r.db.QueryRowContext(ctx, query, p.Name, p.Price).Scan(&p.ID, &p.Version)
	if err != nil {
		return fmt.Errorf("insert product: %w", err)
	}
	return nil
}

QueryRowContext は1行だけ受け取るSQL用の、標準ライブラリ database/sql のメソッドで、Scan が結果の列を左から順番に変数へ詰めます。
Create はINSERTですが、RETURNING の値を受け取るので QueryRowContext を使っています。
0件のときは sql.ErrNoRows を自作の ErrNotFound に変換し、それ以外のエラーは %w で包んで(wrapして)返しています。

基本は3ステップ

まずはテーブル駆動を使わずに、GetByID の成功パターンだけをテストします。

product_test.go
package repository

import (
	"context"
	"regexp"
	"testing"

	"github.com/DATA-DOG/go-sqlmock"
)

func TestProductRepository_GetByID(t *testing.T) {
	// ステップ1:偽のDBを作る
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New: %v", err)
	}
	defer db.Close()

	// ステップ2:台本を仕込む
	mock.ExpectQuery(regexp.QuoteMeta(
		"SELECT id, name, price, version FROM products WHERE id = $1",
	)).
		WithArgs(1).
		WillReturnRows(
			sqlmock.NewRows([]string{"id", "name", "price", "version"}).
				AddRow(1, "テスト商品", 1000, 3),
		)

	repo := NewProductRepository(db)
	got, err := repo.GetByID(context.Background(), 1)
	if err != nil {
		t.Fatalf("GetByID: %v", err)
	}

	want := Product{ID: 1, Name: "テスト商品", Price: 1000, Version: 3}
	if *got != want {
		t.Errorf("got %+v, want %+v", *got, want)
	}

	// ステップ3:台本が全部使われたか確かめる
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Errorf("unmet expectations: %v", err)
	}
}

ステップ1:偽のDBを作る

sqlmock.New() は、repositoryに渡す db と、台本を仕込む mock を返します。
repositoryは *sql.DB を受け取るだけなので、repository側のコードは1行も変えずに偽物へ差し替えられます。
t.Fatalf は t.Errorf と違い、失敗した時点でテストを打ち切ります。

ステップ2:台本を仕込む

メソッド 役割
ExpectQuery 「このSQLが Query 系のメソッドで来るはず」と予告する
WithArgs $1, $2 に渡されるはずの値を、順番どおりに書く
WillReturnRows 返す結果の行を仕込む
WillReturnError 結果の代わりにエラーを返させる
NewRows / AddRow 結果の列名を宣言する / 結果に1行足す

regexp.QuoteMeta で囲んでいる理由は、後のハマりどころで説明します。

結果は *got != want で構造体ごと比べ、%+v(フィールド名付きの出力)でずれた箇所を読みやすくしています。

ステップ3:台本が全部使われたか確かめる

ExpectationsWereMet は、仕込んだ台本が全部使われたかを確かめるメソッドです。
地味ですが、書かないと怖いことが起きます(後述します)。

実際にハマったところ

AddRow と AddRows を間違えた

最初にハマったのは、テストが落ちる以前のコンパイルエラーでした。

// ❌
sqlmock.NewRows(cols).AddRows(1, "テスト商品", 1000, 3)
// ⭕️
sqlmock.NewRows(cols).AddRow(1, "テスト商品", 1000, 3)
cannot use 1 (untyped int constant) as []driver.Value value in argument to sqlmock.NewRows([]string{…}).AddRows
(残りの値も同じエラー)

AddRow(values ...driver.Value) は1行分の値を並べて渡すメソッドで、AddRows(values ...[]driver.Value) は1行を1つのスライスにして複数行を渡すメソッドです。
エラー文の as []driver.Value が、「欲しかったのはスライス」と教えてくれていました。

SQLが一致しない(SELECTから列が抜けていた)

次にハマったのが、このエラーです。

err = select product: Query: could not match actual sql: "SELECT id, price, version FROM products WHERE id = $1" with expected regexp "SELECT id, name, price, version FROM products WHERE id = \$1", want <nil>

長くて読む気が失せますが、actual sql がrepositoryが実際に送ったSQLで、expected regexp がテストに書いた台本です。
縦に並べると、違いがすぐ分かります。

actual   : SELECT id,       price, version FROM products WHERE id = $1
expected : SELECT id, name, price, version FROM products WHERE id = \$1
                      ^^^^^ actual に無い

repository側のSELECTから name が抜けていました。
\$1 は regexp.QuoteMeta がエスケープした結果です。

ここで regexp.QuoteMeta の話をしておきます。
sqlmockは、台本のSQLを正規表現として扱います。
正規表現では $ が「行末」を意味するので、WHERE id = $1 をそのまま書くと絶対にマッチしません。

GetByID: select product: Query: could not match actual sql: "SELECT id, name, price, version FROM products WHERE id = $1" with expected regexp "SELECT id, name, price, version FROM products WHERE id = $1"

actualとexpectedが完全に同じに見えるのに落ちます。
このエラーが出たら、まず regexp.QuoteMeta を忘れていないか疑ってください。

WithArgs の値が違う

Create: insert product: Query 'INSERT INTO products (name, price) VALUES ($1, $2) RETURNING id, version', arguments do not match: argument 0 expected [string - 新しい商品] does not match actual [string - テスト商品]

argument 0 は0から数えているので、SQLの $1 のことです。
台本には「新しい商品」と書いたのに、実際は「テスト商品」が渡ってきた、という意味です。
私の場合は、テストケースをコピペしたときに名前を直し忘れていました。

列名を打ち間違えても落ちない

これはエラーが出ないほうのハマりどころです。

// version を verison と打ち間違えている
sqlmock.NewRows([]string{"id", "name", "price", "verison"}).
	AddRow(1, "テスト商品", 1000, 3)

このテストは普通に緑になります。
Scan は列名ではなく、左からの位置で値を受け取っているからです。
つまり NewRows の列名はラベルでしかなく、大事なのは AddRow の値の順番をSELECTの列順と揃えることです。

ついでに、数値の列には 1, 1000, 3 のようにそれぞれ違う値を入れておくのがおすすめです。
全部 1 にすると、Scan の順番を取り違えても気づけません。

知っておきたい作法

ここからは、実際にハマったわけではないものの、知らないとハマりそうな作法です。

INSERTでも RETURNING なら ExpectQuery

ExpectQuery と ExpectExec のどちらを使うかは、SQLの種類ではなく、repositoryがどのメソッドでSQLを投げたかで決まります。

repositoryで使ったメソッド 台本に書くもの
QueryContext / QueryRowContext ExpectQuery と WillReturnRows
ExecContext ExpectExec と WillReturnResult

Create はINSERTですが QueryRowContext を使っているので、「INSERTだから ExpectExec」と書くとこうなります。

Create: insert product: call to Query 'INSERT INTO products (name, price) VALUES ($1, $2) RETURNING id, version' with args [...], was not expected, next expectation is: ExpectedExec => ...

「Query で呼ばれたけど、次の台本は Exec だった」という意味です。

ExpectationsWereMet を書かないと偽の緑になる

たとえば、デバッグ中にSQL実行部分をコメントアウトしたまま戻し忘れ、SQLを投げずに nil を返しているとします。
明らかに壊れていますが、ExpectationsWereMet が無いテストは緑になります。
台本は「来たらこう返す」という予告でしかなく、来なかったことは誰もチェックしていないからです。

ExpectationsWereMet を書いておけば、ちゃんと落ちます。

unmet expectations: there is a remaining expectation which was not matched: ...

sqlmockを使うテストでは、最後に毎回書く定型として覚えてしまうのが安全です。

組み合わせるとこうなる

最後に、GetByID の成功・0件・DBエラーの3パターンを、テーブル駆動にまとめます。

product_test.go
func TestProductRepository_GetByID(t *testing.T) {
	query := regexp.QuoteMeta("SELECT id, name, price, version FROM products WHERE id = $1")
	cols := []string{"id", "name", "price", "version"}
	errDB := errors.New("connection refused")

	tests := []struct {
		name    string
		setup   func(mock sqlmock.Sqlmock)
		want    *Product
		wantErr error
	}{
		{
			name: "成功:1行返ってきたら Product に詰める",
			setup: func(mock sqlmock.Sqlmock) {
				mock.ExpectQuery(query).
					WithArgs(1).
					WillReturnRows(sqlmock.NewRows(cols).AddRow(1, "テスト商品", 1000, 3))
			},
			want: &Product{ID: 1, Name: "テスト商品", Price: 1000, Version: 3},
		},
		{
			name: "0行:ErrNotFound に変換する",
			setup: func(mock sqlmock.Sqlmock) {
				mock.ExpectQuery(query).
					WithArgs(1).
					WillReturnRows(sqlmock.NewRows(cols))
			},
			wantErr: ErrNotFound,
		},
		{
			name: "DBエラー:wrap して返す",
			setup: func(mock sqlmock.Sqlmock) {
				mock.ExpectQuery(query).
					WithArgs(1).
					WillReturnError(errDB)
			},
			wantErr: errDB,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			db, mock, err := sqlmock.New()
			if err != nil {
				t.Fatalf("sqlmock.New: %v", err)
			}
			defer db.Close()

			tt.setup(mock)

			repo := NewProductRepository(db)
			got, err := repo.GetByID(context.Background(), 1)

			if !errors.Is(err, tt.wantErr) {
				t.Fatalf("err = %v, want %v", err, tt.wantErr)
			}
			if tt.want != nil && *got != *tt.want {
				t.Errorf("got %+v, want %+v", *got, *tt.want)
			}

			if err := mock.ExpectationsWereMet(); err != nil {
				t.Errorf("unmet expectations: %v", err)
			}
		})
	}
}

import には、先ほどのものに加えて errors が必要です。
ここで新しく出てきたものが3つあります。

表に「関数」を入れる

送料のテストでは、表の列は値でした。
今回は、成功なら行を、0件なら空の結果を、DBエラーならエラーを返す、という具合に、台本の仕込み方そのものがケースごとに変わります。

そこで、表の列に setup func(mock sqlmock.Sqlmock) という関数を持たせ、ループの中で tt.setup(mock) と呼び出しています。
0件のケースは、NewRows(cols) で列名だけ宣言して AddRow を呼ばないことで、Scan が sql.ErrNoRows を受け取る状況を作っています。

mock はケースごとに作る

sqlmock.New() を for の外ではなく t.Run の中で呼んでいるのには理由があります。
試しに for の外で1回だけ作り、成功ケースの WithArgs(1) をわざと WithArgs(2) に間違えてみます。

=== RUN   TestProductRepository_GetByID/成功:WithArgsをわざと間違えた
    product_test.go:58: err = ... arguments do not match: argument 0 expected [int64 - 2] does not match actual [int64 - 1], want <nil>
=== RUN   TestProductRepository_GetByID/DBエラー:wrap_して返す
    product_test.go:58: err = ... arguments do not match: argument 0 expected [int64 - 2] does not match actual [int64 - 1], want connection refused

何も間違えていないDBエラーのケースまで、同じ理由で落ちています。
成功ケースで使われなかった台本が残り、次のケースのSQLがそれと照合されてしまったからです。
関係ないケースまで落ちると原因が追えなくなるので、mock はケースごとに作り直します。

wantErr は bool ではなく error で持つ

最初は、エラーの期待値を wantErr bool で持ち、if (err != nil) != tt.wantErr と書いていました。
「エラーが返ってきたか」と「エラーを期待していたか」の2つの bool を比べる1行ですが、!= が2つ並んでいて初見だと読みにくいです。
それに bool では、0件のときに ErrNotFound ではない全然違うエラーが返ってきても緑になってしまいます。

そこで wantErr を error 型にして、標準ライブラリの errors.Is で比べる形に変えました。
errors.Is は %w で包まれたエラーも中まで辿るので、DBエラーのケースも errDB と一致します。
成功ケースは wantErr がゼロ値の nil で、errors.Is(nil, nil) は true になるので同じ1行で判定できます。

ここだけ t.Fatalf にしているのは、食い違ったまま進むと、got が nil なのに *got を読んで panic する可能性があるからです。

テストが落ちたときの読み方

sqlmockのテストが落ちたときに、私が見ている場所をまとめておきます。

メッセージに含まれる文言 起きていること 見る場所
could not match actual sql SQLの文面が台本と一致しない actualとexpectedを縦に並べる(同じに見えたら QuoteMeta 忘れ)
arguments do not match WithArgs の値や順番が違う 実装の引数の順番と WithArgs の順番(argument 0 が $1)
was not expected, next expectation is 台本と違う種類・順番で呼ばれた ExpectQuery と ExpectExec の取り違え、呼び出し順
there is a remaining expectation which was not matched 台本があるのにSQLが来なかった 実装がちゃんとSQLを投げているか

エラー文の先頭の select product: は自分で fmt.Errorf に書いた文字列なので、rg "select product" のようにプロジェクト全体を検索すれば、エラーを返した場所に一発で辿り着けます。

そして一番気をつけたいのが、テストを直すのか、実装を直すのかです。
SELECTから name が抜けていたケースも、台本から name を消せば緑になりますが、それはバグを残したままテストを黙らせただけです。
どちらを直すかは、仕様と照らして決めるようにしています。

3
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
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?