この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から実際にサンプルアプリケーションを動かし、リファクタの前の前準備としての ピンニングテスト を実装していきたいと思います。

推奨環境

実行環境として、下記が必要です。ここでは環境構築について割愛しますので、各自構築をお願いします。

サンプルアプリケーションのダウンロード

  1. レポジトリからcloneしてくる
    $ git clone git@github.com:DeNA/codelabs.git
    
  2. サンプルコードのrootへいく
    $ cd codelabs/sources/testable-architecture-with-go
    
  3. プロジェクトのrootでmakeを実行する
    $ make
    
  4. テストを実行する
    $ make test/e2e
    
    test用のDBが立ち上がり、testが実行されます。 以下のようにTestがpassすればOKです
    go test -count=1 ./e2e/...
    ok  	github.dena.jp/swet/go-sampleapi/e2e	0.148s
    
  1. 完了

おつかれさまです! サンプルアプリケーションのダウンロード・テストはこれにて完了です。次の章ではアプリケーションのコードをレビューし、仕様と構造を把握しましょう。

ここではアプリケーションコードをレビューし、全体の構造を把握しましょう。

全体構造について

まず、ディレクトリ構造から確認します。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以下にhandlerconfigなどの主要なパッケージが配置してあります。 internal/model にはDB Accessの際に用いる構造体を格納しており、便宜的にmodelというpackage名にしています。 internal/handlerの中に、メインのロジックが実装されているuser.goを設置しています。 internal/handler/user.goをエディタで開いて、中をレビューしてみましょう。

なお、answerには今回のCodelabでの最終形が配置されています。 もしどうしてもわからないようでしたら参照してください。

handlerの実装について

user.goではhttp request/responseのjsonをbindingする構造体、DBのデータをbindingする構造体の他に、 PostUserというhttp.HandlerFuncを返す関数が定義されていると思います。 PostUserでは、db, logger などの依存のモジュールを受け取り、http.HandlerFuncを作成しています。

PostUserの中をレビューして、処理の流れを把握してみましょう。以下のような流れで実装してあります。

  1. jsonからGoのstructへのbind
  2. validation (libraryとしてgo-playground/validatorを使っています)
  3. データが正当かどうかのチェック
  4. DBへのinsert
  5. http responseの作成とwrite

特に、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の実装に入りましょう。

DBのSetup

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にセットしておき、同じメールアドレスのユーザの登録を試みます。

Requestの実行

それでは、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のAssertion

それでは、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実行

それでは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.Errort.Fatalの使い分け

Testが失敗した際 t.Errort.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.Errort.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でテスタビリティの高いコードにしていく設計や方針を紹介していこうと思います。

つづきはこちら