このCodelabは、準備編・リファクタ編・テスト実装編の3つのChapterに分かれて、Testabilityの低いGoのAPI ServerからTestabilityの高い設計へリファクタを行います。 その過程でTestabilityに関する知識や、設計に関する知識、Test Doubleなどのテクニックについて学びます。
それぞれのChapterの最初に必要な知識を学び、その後実際に手を動かして実装する、という構成になっています。
まず、本編に入る前に本Codelabのテーマであるテスタビリティについて簡単に考えてみたいと思います。
ソフトウェアテスト293の法則(日経BP社)ではテスタビリティ(=テスト容易性)について以下のように記述しています。
つまり、システムの「状態」を操作・シュミレートできるかがテスタビリティの1つの要素ということができます。
今回のサンプルアプリケーションでもある、ユーザを登録するAPIを例にして具体的に考えてみましょう。 もしこのAPIにemail addressの重複は許さないという仕様があると、この仕様に関するテストを行うためにはemail addressが重複するような状態を作り出さなければいけません。 テスト時にこの状態をどれだけ「簡単」に「安定して」作り出せるかがテスタビリティの要素ということになります。
リファクタリング 既存のコードを安全に改善する(Martin Fawler)によると、次のように定義されています。
実践する上では、外部の振る舞いを担保するような仕組み、つまり自動テストが必須になってきます。 今回のCodelabでは、このためのテストとして簡単なE2Eテストを実装して行こうと思います。 なお、今回実装するような、リファクタのための外部の振る舞いが変わっていないことを一気通貫でチェックするテストのことを ピンニングテスト と呼びます。
テスタビリティとリファクタリングという概念について改めて整理しました。 次のStepから実際にサンプルアプリケーションを動かし、リファクタの前の前準備としての ピンニングテスト を実装していきたいと思います。
実行環境として、下記が必要です。ここでは環境構築について割愛しますので、各自構築をお願いします。
$ git clone git@github.com:DeNA/codelabs.git
$ cd codelabs/sources/testable-architecture-with-go
$ make
$ make test/e2e
test用のDBが立ち上がり、testが実行されます。 以下のようにTestがpassすればOKです go test -count=1 ./e2e/...
ok github.dena.jp/swet/go-sampleapi/e2e 0.148s
おつかれさまです! サンプルアプリケーションのダウンロード・テストはこれにて完了です。次の章ではアプリケーションのコードをレビューし、仕様と構造を把握しましょう。
ここではアプリケーションコードをレビューし、全体の構造を把握しましょう。
まず、ディレクトリ構造から確認します。Step2でbuildが終了していると、下記のようなディレクトリ構造になっているかと思います。
.
├── Dockerfile
├── Makefile
├── README.md
├── answer
├── bin
├── docker
├── docker-compose.yaml
├── e2e
├── go.mod
├── go.sum
├── main.go
├── internal
│ ├── apierr
│ ├── config
│ ├── handler
│ ├── logging
│ ├── model
│ └── validator
├── tools
├── spec.md
└── vendor
プロジェクト直下には main.go
があり、アプリケーションのentrypointとなります。 spec.md
には、アプリケーションのAPIの詳しい仕様が記述されています。一度目を通して、仕様を把握しましょう。
internal
以下にhandler
やconfig
などの主要なパッケージが配置してあります。 internal/model
にはDB Accessの際に用いる構造体を格納しており、便宜的にmodel
というpackage名にしています。 internal/handler
の中に、メインのロジックが実装されているuser.go
を設置しています。 internal/handler/user.go
をエディタで開いて、中をレビューしてみましょう。
なお、answer
には今回のCodelabでの最終形が配置されています。 もしどうしてもわからないようでしたら参照してください。
user.go
ではhttp request/responseのjsonをbindingする構造体、DBのデータをbindingする構造体の他に、 PostUser
というhttp.HandlerFunc
を返す関数が定義されていると思います。 PostUser
では、db
, logger
などの依存のモジュールを受け取り、http.HandlerFunc
を作成しています。
PostUser
の中をレビューして、処理の流れを把握してみましょう。以下のような流れで実装してあります。
特に、3のデータが正当化かどうかのチェックの部分が一見複雑でわかりにくいかもしれません。 ピックアップしてチェックしてみましょう。
// 3. データが正当かどうかのチェックを行う
// emailによりuserの存在チェックを行う
var id int
err = db.GetContext(ctx, &id, "SELECT id from users where email = ?", user.Email)
if err != nil && err != sql.ErrNoRows { // sql.ErrNoRows以外のerrorが発生しているケース
logger.Warnf("select failed: %+v", err)
writeError(w, http.StatusInternalServerError, ErrInternalServerError)
return
} else if err == nil { // errが発生していないケース、つまりuserが存在しているケース
writeError(w, http.StatusBadRequest, ErrEmailAlreadyExists)
return
}
重複したEmailでの登録を許さないという仕様なので、DBを叩いてEmailがすでに存在しているかどうかをチェックしています。 SELECT
をDBに発行してチェックを行っていますが、重要なのはレコードが存在しない場合、つまり、sql.ErrNoRows
のエラーが返ってきている場合がOKという、ロジックが反転した作りになっています。
このように、直感的ではないロジックの部分は特にテストを書いて正当性をチェックしたいところですが、handler
の中にすべての処理が書かれているため、この部分だけをピックアップしてテストすることが難しい作りになっています。
今回のCodelabではこのような処理を切り出し、簡単にテストできるようにするための方法を学びます。
このアプリケーションでは、便宜的に以下のライブラリを利用しています。 標準ではありませんが、広く使われているライブラリです。 もし興味がある方は調べてみてください。
お疲れさまでした! これでこのアプリケーションのコアな部分は把握できたと思います。
それではこのアプリケーションをTestableな設計にリファクタしていきましょう!
改めてリファクタの定義を確認しますが、 リファクタリングは、「ソフトウェアの外部の振る舞いを保ったままで、内部の構造を改善していく作業」とされています。
ここでは、「ソフトウェアの外部の振る舞いを保ったまま」の部分を保証する仕組みとして、このアプリケーションに対するE2Eテスト(ピンニングテスト)を実装してみましょう。
e2e
ディレクトリ以下にある、user_handler_test.go
を開いて見ましょう。 Test_E2E_PostUser
というTestが実装されていると思います。これが、Step2で実施したTestの中身となっています。 Test_E2E_PostUser
では、正常系、つまり、ユーザが正常に作成されているかをチェックしています。
ソースを下にスクロールしていくと、Test_E2E_PostUser_DuplicateEmail
という空の関数を見つけられるかと思います。 このTestを実装して行きましょう。このTestでは、関数名の通り、重複したEmailアドレスによるユーザ登録の挙動をチェックします。
func Test_E2E_PostUser_DuplicateEmail(t *testing.T) {
// TODO: testを記述していく
}
まず、対象のtestが正常に 実行されているか を確認してみましょう。 testを書いたつもりでも、test runnerの設定等のミスで、実はそのテストが実行されていなかったというケースは意外とよくあるものです。 その防止のために、Test_E2E_PostUser_DuplicateEmail
を以下のように修正してください。
func Test_E2E_PostUser_DuplicateEmail(t *testing.T) {
t.Errorf("not implemented")
}
このTestでは、 Failしていること を確認します。このTestがFailすれば、正常に実行されていることがわかると思います。 そして、再度testを実行してみましょう。
$ make test/e2e
以下のようにfailしていればOKです。
go test -count=1 ./e2e/...
--- FAIL: Test_E2E_PostUser_DuplicateEmail (0.00s)
user_handler_test.go:76: not implemented
FAIL
FAIL github.dena.jp/swet/go-sampleapi/e2e 1.419s
FAIL
make: *** [test/e2e] Error 1
それでは、記述したt.Errorf("not implemented")
を削除し、Testの実装に入りましょう。
Testに使うDataのセットアップ、および、Test終了時にDBをcleanupする仕組みを実装しましょう。 Test_E2E_PostUser_DuplicateEmail
を下のように書き換えてください。
func Test_E2E_PostUser_DuplicateEmail(t *testing.T) {
db := sqlx.MustConnect("mysql", config.Config().DBSrc())
defer func() {
db.MustExec("set foreign_key_checks = 0")
db.MustExec("truncate table users")
db.MustExec("set foreign_key_checks = 1")
db.Close()
}()
email := "test@example.com"
if _, err := db.Exec("insert into users(first_name, last_name, email, password_hash) values (?, ?, ?, ?)", "dummy_first_name", "dummy_last_name", email, "dummy_password"); err != nil {
t.Fatal(err)
}
}
dbのconnectionを初期化し、defer
の中で、DBの初期化と、Close()
を行っています。 ここでは、 test@example.com
というメールアドレスのユーザを予めDBにセットしておき、同じメールアドレスのユーザの登録を試みます。
それでは、Test用のServerに対しリクエストを行うコードを記述します。 先程書いたコードに続けて、以下のように実装してください。
var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(&handler.ReqPostUserJSON{
FirstName: "テスト姓",
LastName: "テスト名",
Email: email,
Password: "passw0rd1234",
}); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/", &body)
rec := httptest.NewRecorder()
handler.PostUser(db, logging.Logger()).ServeHTTP(rec, req)
Request Body用の構造体を作り、その後 標準のhttptestを使ってrequest/responseをシュミレートしています。 Goではhttptestを使うことにより、 Serverをlistenさせることなくhttp request/responseのtestを行うことができます(httptest.NewServer
を使うことにより、listenするようなserverを立ち上げることも可能です)。
それでは、Responseを検証してみましょう。rec(ResponseRecorder
)には、ResponseのStatusCodeやBodyが記録されています。 この中身をチェックすることにより、正しく仕様が満たせているかどうかをCheckします。
続いて、以下のようにコードを記述してください。
if rec.Code != http.StatusBadRequest {
t.Errorf("status code must be 400 but: %d", rec.Code)
}
var result handler.ResError
if err := json.NewDecoder(rec.Body).Decode(&result); err != nil {
t.Fatal(err)
}
// responseのMessageをチェックする
if result.Message != string(handler.ErrEmailAlreadyExists) {
t.Errorf("error Message must be %s but %s", handler.ErrEmailAlreadyExists, result.Message)
}
それではtestを実行し、結果を確認しましょう。
$ make test/e2e
....
go test -count=1 ./e2e/...
ok github.dena.jp/swet/go-sampleapi/e2e 0.220s
PassしていればOKです。 もしFailしていた場合、コードをチェックしてみましょう。今回書いたTestコードは以下のようになっているはずです。
func Test_E2E_PostUser_DuplicateEmail(t *testing.T) {
db := sqlx.MustConnect("mysql", config.Config().DBSrc())
defer func() {
db.MustExec("set foreign_key_checks = 0")
db.MustExec("truncate table users")
db.MustExec("set foreign_key_checks = 1")
db.Close()
}()
email := "test@example.com"
if _, err := db.Exec("insert into users(first_name, last_name, email, password_hash) values (?, ?, ?, ?)", "dummy_first_name", "dummy_last_name", email, "dummy_password"); err != nil {
t.Fatal(err)
}
var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(&handler.ReqPostUserJSON{
FirstName: "テスト姓",
LastName: "テスト名",
Email: email,
Password: "passw0rd1234",
}); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/", &body)
rec := httptest.NewRecorder()
handler.PostUser(db, logging.Logger()).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status code must be 400 but: %d", rec.Code)
}
var result handler.ResError
if err := json.NewDecoder(rec.Body).Decode(&result); err != nil {
t.Fatal(err)
}
// responseのMessageをチェックする
if result.Message != string(handler.ErrEmailAlreadyExists) {
t.Errorf("error Message must be %s but %s", handler.ErrEmailAlreadyExists, result.Message)
}
}
t.Error
とt.Fatal
の使い分けTestが失敗した際 t.Error
とt.Fatal
の種類を使って表現していますが、Goにはtestを失敗させるために使う関数がいくつかあります。 以下にそれぞれの挙動の違いを載せます。
Testは失敗するか | Testの実行がStopするか | 失敗時にメッセージを出力できるか | |
t.Fail | yes | no | no |
t.FailNow | yes | yes | no |
t.Error | yes | no | yes |
t.Fatal | yes | yes | yes |
t.Error
, t.Fatal
はそれぞれt.Fail
, t.FailNow
のメッセージが出力可能なバージョンです。 基本的にはメッセージが出力されたほうがTest失敗時の調査には有利なので、t.Error
, t.Fatal
を使うほうがおすすめです。 その上で、Testの実行をStopさせるかどうかでt.Error
とt.Fatal
を使い分けましょう。 例えば、JSONをGoのstructにmappingし、mappingされたstructの中身をチェックする、というシナリオを考えたときに、 JSONからGoのstructへのmappingがそもそも失敗したらその後のチェックを続けても意味がありません。 Test失敗のlogが大量に出ると、調査の妨げになるので、この場合は最初にmappingが完了した時点でtest自体を終了させるため、t.Fatal
を活用したほうが良いケースかと考えられます。 このように、チェックに失敗したら後続の処理・チェックの意味がないものに関してはt.Fatal
を使い、それ以外の一般的なチェックに関してはt.Error
を使いましょう。
お疲れさまでした!これでEmailが重複していたときのケースのテストの実装が完了しました! DBを操作しデータを挿入しEmailが重複している状態を作り出しました。 今回はテーブルが1つなので簡単ですが、より現実的には外部キー制約などで依存している多くのリレーションを再現する必要があり、DBを直接操作して状態を再現するのはコストが高くメンテナンスが難しい方法です。 続くChapterでより低コストにロジックをテストする方法について学びましょう。
このアプリケーションには他にも仕様があり、チェックすべき項目があります。spec.md
を元に、他のケースのテストを実装してみましょう。
例えば、
などが考えられます。
answer/e2e
の下に様々なケースを実装したコードを置いておきました。 参考にしながら、時間が許す限り実装してみてください。
このChapterはこれで完了です!おつかれさまでした!
引き続きChapter2でテスタビリティの高いコードにしていく設計や方針を紹介していこうと思います。