サーバーサイドGo入門 #4 ~test~

前回はこちら prelude.hatenablog.jp

今回作るもの

gingormを使ったTODOアプリが完成しました。今回はこれらのテストを行います。

基本的には公式のtestingパッケージを使いますが、DBのモック用にgo-sqlmockを使用します。
また、net/httpのテスト用のUtilであるnet/test/httptestも使用します。

testing

標準パッケージであるtestingは非常に便利ですが、アサーションやutilityがあまり充実していません。これは未開発なのではなく、意図的に開発していない機能になります。

理由には、アサーションやUtility、フレームワークなどに頼ると、正しいエラーハンドリングや正しいエラー報告について考慮せずにテストを書いてしまうということです。さらに、テストフレームワークは独自の発展によりミニ言語のようなものが生まれてしまう傾向にある、という厳しい言及もあります。

以上のことは下記のリンクにて説明されています。

アサーションがないことによって記述が冗長になると懸念されるかもしれません。しかしGoではテスト対象の入力値と期待値をデータ構造として定義し、そのリストを作成してループで回してテストを行うテーブルドリブンテストというものが紹介されています。

TableDrivenTests · golang/go Wiki · GitHub

HTTPサーバーのテスト

net/http/httptest

net/http/httptestには下記のような定義があります。

func NewRequest(method, target string, body io.Reader) *http.Request
type ResponseRecorder struct
type Server struct { ... }

1つ目のNewRequestと3つ目のServerはなんとなくわかります。では、ResponseRecorderとは何でしょうか?

定義を見てみると下記のようになっています。

func NewRecorder() *ResponseRecorder
func (rw *ResponseRecorder) Flush()
func (rw *ResponseRecorder) Header() http.Header
func (rw *ResponseRecorder) Result() *http.Response
func (rw *ResponseRecorder) Write(buf []byte) (int, error)
func (rw *ResponseRecorder) WriteHeader(code int)
func (rw *ResponseRecorder) WriteString(str string) (int, error)

これらの関数によりhttp.ResponseWriterinterfaceを満たしています。テストで使う際にはこのResponseRecorderを通じて結果を書き込んでもらい、それを検証すれば良いだろうということがわかります。

改めてnet/http/httptestの全体観としては、RequestResponseServer、というすごくシンプルなものであるとわかります。

httptest - The Go Programming Language

DBのテスト

DATA-DOG/go-sqlmock

これはsql-driverのモックを提供してくれます。

github.com

モックの初期化のI/Fは下記のようになっています。

func New(options ...func(*sqlmock) error) (*sql.DB, Sqlmock, error)
func NewWithDSN(dsn string, options ...func(*sqlmock) error) (*sql.DB, Sqlmock, error)

戻り値のモックを利用して期待するSQLを記述しておき、最後にExpectationsWereMetで検証します。
また、下記のサンプルの通り、トランザクションの開始・終了も記述可能です。

// Open new mock database
db, mock, err := New()
if err != nil {
    fmt.Println("error creating mock database")
    return
}

// columns to be used for result
columns := []string{"id", "status"}

// expect transaction begin
mock.ExpectBegin()

// expect query to fetch order, match it with regexp
mock.ExpectQuery("SELECT (.+) FROM orders (.+) FOR UPDATE").
    WithArgs(1).
    WillReturnRows(NewRows(columns).AddRow(1, 1))

// expect transaction rollback, since order status is "cancelled"
mock.ExpectRollback()

// run the cancel order function
someOrderID := 1

// call a function which executes expected database operations
err = cancelOrder(db, someOrderID)
if err != nil {
    fmt.Printf("unexpected error: %s", err)
    return
}

// ensure all expectations have been met
if err = mock.ExpectationsWereMet(); err != nil {
    fmt.Printf("unmet expectation error: %s", err)
}

テスト

では前述のパッケージを用いて、さっそく書いてみましょう。ちなみに今回は異常系を省略してます。

// main_test.go

package main

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    "time"

    "gorm.io/driver/mysql"
    "gorm.io/gorm"

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

func setupDB() (*gorm.DB, sqlmock.Sqlmock, error) {
    sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
    if err != nil {
        return nil, nil, err
    }

    gormdb, err := gorm.Open(mysql.Dialector{
        Config: &mysql.Config{
            DriverName:                "mysql",
            Conn:                      sqlDB,
            SkipInitializeWithVersion: true,
        },
    }, &gorm.Config{})
    if err != nil {
        return nil, nil, err
    }

    return gormdb, mock, nil
}

func TestList(t *testing.T) {
    _gormDB, mock, err := setupDB()
    if err != nil {
        t.Fatal(err.Error())
    }
    gormDB = _gormDB

    expect := TODO{
        Author:      "author",
        Name:        "name",
        Description: "description",
        DueDate:     time.Now(),
        Model:       gorm.Model{ID: 1},
    }
    mock.ExpectQuery("SELECT * FROM `todos` WHERE `todos`.`deleted_at` IS NULL").
        WillReturnRows(sqlmock.NewRows([]string{"id", "author", "name", "description", "due_date"}).
            AddRow(expect.ID, expect.Author, expect.Name, expect.Description, expect.DueDate))

    request := httptest.NewRequest(http.MethodGet, "http://localhost:8080/todos", nil)
    recorder := httptest.NewRecorder()
    setupRouter().ServeHTTP(recorder, request)

    if err := mock.ExpectationsWereMet(); err != nil {
        t.Fatal(err.Error())
    }

    response := recorder.Result()
    defer response.Body.Close()

    var responseTODOs []TODO
    if err := json.NewDecoder(response.Body).Decode(&responseTODOs); err != nil {
        t.Fatal(err.Error())
    }

    if len(responseTODOs) != 1 {
        t.Errorf("got response count = %d. want = %d", len(responseTODOs), 1)
    }
    if responseTODOs[0].ID != expect.ID {
        t.Errorf("got ID = %d. want = %d", responseTODOs[0].ID, expect.ID)
    }
    if responseTODOs[0].Author != expect.Author {
        t.Errorf("got Author = %s. want = %s", responseTODOs[0].Author, expect.Author)
    }
    if responseTODOs[0].Name != expect.Name {
        t.Errorf("got Name = %s. want = %s", responseTODOs[0].Name, expect.Name)
    }
    if responseTODOs[0].Description != expect.Description {
        t.Errorf("got Description = %s. want = %s", responseTODOs[0].Description, expect.Description)
    }
    if responseTODOs[0].DueDate.Unix() != expect.DueDate.Unix() {
        t.Errorf("got Description = %d. want = %d", responseTODOs[0].DueDate.Unix(), expect.DueDate.Unix())
    }
}

func TestAdd(t *testing.T) {
    _gormDB, mock, err := setupDB()
    if err != nil {
        t.Fatal(err.Error())
    }
    gormDB = _gormDB

    expect := TODO{
        Author:      "author",
        Name:        "name",
        Description: "description",
        DueDate:     time.Now(),
        Model:       gorm.Model{ID: 1},
    }

    mock.ExpectBegin()
    mock.ExpectExec("INSERT INTO `todos` (`created_at`,`updated_at`,`deleted_at`,`author`,`name`,`description`,`due_date`,`id`) VALUES (?,?,?,?,?,?,?,?)").WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), expect.Author, expect.Name, expect.Description, sqlmock.AnyArg(), sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
    mock.ExpectCommit()

    b, err := json.Marshal(expect)
    if err != nil {
        t.Fatal(err.Error())
    }

    request := httptest.NewRequest(http.MethodPost, "http://localhost:8080/todo", bytes.NewBuffer(b))
    recorder := httptest.NewRecorder()
    setupRouter().ServeHTTP(recorder, request)

    if err := mock.ExpectationsWereMet(); err != nil {
        t.Fatal(err.Error())
    }

    response := recorder.Result()
    defer response.Body.Close()

    var responseTODO TODO
    if err := json.NewDecoder(response.Body).Decode(&responseTODO); err != nil {
        t.Fatal(err.Error())
    }

    if responseTODO.ID != expect.ID {
        t.Errorf("got ID = %d. want = %d", responseTODO.ID, expect.ID)
    }
    if responseTODO.Author != expect.Author {
        t.Errorf("got Author = %s. want = %s", responseTODO.Author, expect.Author)
    }
    if responseTODO.Name != expect.Name {
        t.Errorf("got Name = %s. want = %s", responseTODO.Name, expect.Name)
    }
    if responseTODO.Description != expect.Description {
        t.Errorf("got Description = %s. want = %s", responseTODO.Description, expect.Description)
    }
    if responseTODO.DueDate.Unix() != expect.DueDate.Unix() {
        t.Errorf("got Description = %d. want = %d", responseTODO.DueDate.Unix(), expect.DueDate.Unix())
    }
}

補足

setupDB内のモックの初期化にてsqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)を渡しています。これはSQLアサーションのために利用されます。

type QueryMatcher interface {
    // Match expected SQL query string without whitespace to
    // actual SQL.
    Match(expectedSQL, actualSQL string) error
}

Match(expectedSQL, actualSQL string) errorを満たしていれば自作のMatcherも作ることが可能です。今回はgo-sqlmockで提供されているQueryMatcherEqualを使用しました。

var QueryMatcherEqual QueryMatcher = QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
    expect := stripQuery(expectedSQL)
    actual := stripQuery(actualSQL)
    if actual != expect {
        return fmt.Errorf(`actual sql: "%s" does not equal to expected "%s"`, actual, expect)
    }
    return nil
})

まとめ

今回はロジックが非常に簡単なため、テストコードも非常に簡単なものでした。しかし、testingによるテストは公式で言及されているように考えることがとても多いパッケージです。使用する際は公式のドキュメントやテストコードを見て肌感を掴むと良い気がしています。