サーバーサイドGo入門 #4 ~test~
前回はこちら prelude.hatenablog.jp
今回作るもの
gin
とgorm
を使ったTODOアプリが完成しました。今回はこれらのテストを行います。
基本的には公式のtesting
パッケージを使いますが、DBのモック用にgo-sqlmock
を使用します。
また、net/http
のテスト用のUtilであるnet/test/httptest
も使用します。
testing
標準パッケージであるtesting
は非常に便利ですが、アサーションやutilityがあまり充実していません。これは未開発なのではなく、意図的に開発していない機能になります。
理由には、アサーションやUtility、フレームワークなどに頼ると、正しいエラーハンドリングや正しいエラー報告について考慮せずにテストを書いてしまうということです。さらに、テストフレームワークは独自の発展によりミニ言語のようなものが生まれてしまう傾向にある、という厳しい言及もあります。
以上のことは下記のリンクにて説明されています。
- Frequently Asked Questions (FAQ) - The Go Programming Language
- Frequently Asked Questions (FAQ) - The Go Programming Language
アサーションがないことによって記述が冗長になると懸念されるかもしれません。しかし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.ResponseWriter
interfaceを満たしています。テストで使う際にはこのResponseRecorder
を通じて結果を書き込んでもらい、それを検証すれば良いだろうということがわかります。
改めてnet/http/httptest
の全体観としては、Request
とResponse
とServer
、というすごくシンプルなものであるとわかります。
httptest - The Go Programming Language
DBのテスト
DATA-DOG/go-sqlmock
これはsql-driverのモックを提供してくれます。
モックの初期化の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
によるテストは公式で言及されているように考えることがとても多いパッケージです。使用する際は公式のドキュメントやテストコードを見て肌感を掴むと良い気がしています。