この章で行うこと

  • ヘルパー関数の用意
  • エンドポイントの統合テストをゴールデンテストで行う

プレゼンテーションのテスト方針

プレゼンテーションのテストは行っていませんでした。ここでは、どのようにそれらをテストしていくかの方針について述べていきます。

まずは、単体テスト/結合テストという用語について触れていきます。

単体テスト/統合テスト

テストの種類には大きく分けると、単体テスト・統合テストの2種類があります。

単体テスト

単体テストとは、プログラムの最小単位(関数・メソッド・クラスなど)の動きを個別に確認するテストです。一般的には、外部依存(DB・ネットワークなど)を排除し、モックやスタブを使って独立した動作を確認します。

例えば、ユースケースのテストが単体テストにあたります。これらは、実際、いくつかの別コンポーネントに依存していますが、依存先はインターフェースであり、テスト時は「モック」に差し替えることで「ユースケース単体」の挙動を検証することができていました。

結合テスト

結合テストとは、複数のコンポーネントを組み合わせて、連携が正しく動作するかを確認するテストです。実際のDBや外部APIなどの依存先を使うことが多いです。

8章にて実装したリポジトリーのテストは、「統合テスト」だと言えるでしょう。テストDB環境やsqlcといったORMなど、インフラストラクチャ層のコンポーネントを多く利用して、リポジトリが正しくデータ操作できるかを確かめました。

プレゼンテーションのテストは「エンドポイントの統合テスト」として実施

見出しの通りです。

このように判断したポイントは二つあります。

  • ハンドラーは多くの依存を抱えている
  • モックに差し替えた時のコードの複雑性

ハンドラーは多くの依存を抱えている

プレゼンテーション(ハンドラー・プレゼンター)は、いわばAPIの入口ということもできるでしょう。つまり、プレゼンテーションは、総じて多くの依存先を抱えています。以下は、前章でも用いた図です。 また、プレゼンテーションは、それ単体にビジネスロジックがあるわけではなく、ユースケース処理を呼び出すのが主な責務です。

モックに差し替えた時のコード複雑性

上で、ハンドラーは多くの依存先を抱えていると述べました。ドメインサービス、リポジトリ、ユースケース、さらにDB環境をモック化してテストを行うとなると、コードが複雑になるのは自明です。大量のモック化を行う必要があります...。

これら2点を踏まえると、プレゼンテーション単体の挙動を確かめるのではなく、ユースケースやリポジトリ、ドメインサービス等のコンポーネントとの連携を確かめつつプレゼンテーションの挙動も確認できるよう、「エンドポイントの統合テスト」として実施した方が良いのではないかいう判断ができます。

エンドポイントの統合テストとは

ここでいう「エンドポイントの統合テスト」とは、

リクエストを送信して、期待通りのレスポンスかどうかを検証する

こととします。

エンドツーエンドで挙動を確かめるとも言えそうですね。

テストコードの中でリクエストを投げ、レスポンスを確かめます。レスポンスを確かめるのに、「ゴールデンテスト」という手法を用いていきます。

ゴールデンテストとは

ゴールデンテストとは、期待される出力(ゴールデンファイル)を事前に保存し、テスト時に実際の出力と比較する手法です。

主に構造化された出力(JSON, XML, HTMLなど) のテストに用いられ、差分を検出することでリグレッション(意図しない変更)を防ぐ目的があります。

ゴールデンテストを扱いやすくするためのパッケージとしてsebdah/goldieを使用します。

https://github.com/sebdah/goldie

以下のコマンドを打ってインストールしてください。

make get-app name="github.com/sebdah/goldie/v2"

このパッケージを使うと、実際のレスポンスからゴールデンファイルを自動生成してくれます。いちいち自身でJSONのレスポンスをファイルに記述して...という手間が必要ありません。

ざっくりいうと、

  • -updateフラグをつけてテストを実行するとゴールデンファイルが生成される
  • フラグなしでテストを実行すると、「出力されたレスポンス」と「既に生成されたゴールデンファイルに記録されているレスポンス」を比較する

という使い方をします。

詳しくは、次のセクション以降で見ていきましょう。

https://swet.dena.com/entry/2020/03/16/173000 https://speakerdeck.com/toc0522/go-de-golden-file-test?slide=8 https://developer.so-tech.co.jp/entry/2021/11/01/155158

テストの実装

以下の流れで実装を進めていきます。

  • ヘルパー関数の用意
  • テストフィクスチャの用意
  • テストを記述

ヘルパー関数の用意

テスト用のヘルパー関数を用意していきましょう。用意する関数は以下のとおりです。

  • JSONのフォーマットを整形する(FormatJSON())
  • ランダムに生成されるULIDの値を固定化する(NormalizeUlid())
  • レスポンスとして返るJSONが含むjwt_tokenフィールドの中身を固定化する(NormalizeJWT())
  • 認証状態を操作する
    • ログイン状態にする(LoginForTest())
    • ログアウト状態にする(LogoutForTest())
ヘルパー関数の実装

JSONのフォーマットを整形するヘルパー関数

// app/infrastructure/api_test/testhelper/format_json.go
package testhelper

import (
	"bytes"
	"encoding/json"
	"testing"
)

func FormatJSON(t *testing.T, b []byte) []byte {
	t.Helper()

	var out bytes.Buffer
	// bodyをインデント整形し、バッファoutに書き込む
	err := json.Indent(&out, b, "", "  ")
	if err != nil {
		t.Fatal(err)
	}

	return out.Bytes()
}

ランダムに生成されるULIDの値を固定化するヘルパー関数

// app/infrastructure/api_test/testhelper/normalize_ulid.go
package testhelper

import (
	"regexp"
	"testing"
)

func NormalizeULID(t *testing.T, b []byte) []byte {
	t.Helper()

	regexp := regexp.MustCompile(`"id":\s?"[0-9A-HJKMNP-TV-Z]{26}"`)
	s := regexp.ReplaceAllString(string(b), `"id": "ulid"`)
	return []byte(s)
}

レスポンスとして返るJSONが含むjwt_tokenフィールドの中身を固定化する

// app/infrastructure/api_test/testhelper/normalize_jwt.go
package testhelper

import (
	"regexp"
	"testing"
)

func NormalizeJWT(t *testing.T, b []byte) []byte {
	t.Helper()

	// 正規表現でJWT全体を "jwt" に置き換える
	regexp := regexp.MustCompile(`"jwt_token":\s?"[^"]+"`)
	s := regexp.ReplaceAllString(string(b), `"jwt_token": "jwt"`)
	return []byte(s)
}

認証状態を操作するヘルパー関数

// app/infrastructure/api_test/testhelper/setup_auth.go
package testhelper

import (
	"context"
	"testing"
	"time"

	"github.com/kakkky/app/adapter/repository"
	"github.com/kakkky/app/infrastructure/auth"
	"github.com/kakkky/app/infrastructure/kvs"
)

// ログイン状態をセットアップする
// userID:JTIのペアを、KVSに保存する
// 生成したJWTトークンを返す
func LoginForTest(t *testing.T, userID string) string {
	t.Helper()

	jwtAuthenticator := auth.NewJwtAuthenticator()
	// トークン生成
	jwtToken, err := jwtAuthenticator.GenerateJwtToken(userID, "jti")
	if err != nil {
		t.Fatalf("error occuerd in jwtAuthenticator.GenerateJwtToken() :%v", err)
	}
	jwtAuthenticatorRepository := repository.NewJwtAuthenticatorRepository(kvs.NewRedisCommander())
	// Redisに保存
	jwtAuthenticatorRepository.Save(context.Background(), time.Duration(2*time.Hour), userID, "jti")
	return jwtToken
}

func LogoutForTest(t *testing.T, userID string) {
	t.Helper()

	jwtAuthenticatorRepository := repository.NewJwtAuthenticatorRepository(kvs.NewRedisCommander())
	if err := jwtAuthenticatorRepository.Delete(context.Background(), userID); err != nil {
		t.Fatalf("error occured in jwtAuthenticatorRepository.Delete() :%v", err)
	}
}

FormatJSON()

レスポンスでJSONがゴールデンファイルに書き込まれますが、その際に整形されたJSONであった方が見てわかりやすいです。json.Indent()関数によってインデントを揃えるなどしています。

NormalizeULID()

この関数は、レスポンスに含まれる以下のようなフィールドの値を固定化するためのものです。POST系のハンドラー(postUserHandlerとpostTaskHandler)のテストにて使用します。

タスク・ユーザーの両者とも、識別子としてidフィールドを持っています。

// ユーザーエンティティ
type User struct {
	id             string
	email          Email
	name           string
	hashedPassword HashedPassword
}

// タスクエンティティ 
type Task struct {
	id      string
	userId  string
	content Content
	state   State
}

そして、割り当てるidには、ulidを使用していました。つまり、生成されるidはランダムだということです。そうなると、毎回、POST /userとリクエストを投げた時に返ってくるレスポンスが異なってしまいます。

// 1回目のレスポンス
{
  "status": 201,
  "data": {
    "id": "01JKAEFYKJY010E45XMTYSFQHR",
    "email": "[email protected]",
    "name": "user0"
  }
}

// 2回目のレスポンス
{
  "status": 201,
  "data": {
    "id": "01JKAEFY9W2VPFK31SYX9P15GM",  // idが異なる
    "email": "[email protected]",
    "name": "user0"
  }
}

レスポンス内容が毎回変わってしまうようでは、ゴールデンテストが一向に成功しません。そのために、JSONのidフィールドの値を"ulid"という文字列に固定化するようにしています。

ランダムなulid(例:"01JKAEFY9W2VPFK31SYX9P15GM")→"ulid"文字列への変換には、正規表現を用いています。

NormalizeJWT()

この関数を用意している理由・処理内容は、NormalizeULID()関数と全く同じです。ログインハンドラー(loginHandler)のテストにて使用します。

ログインすると(POST /login)、レスポンスとしてトークンが以下のように返されます。

{
    "status": 200,
    "data": {
        "jwt_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3Mzg4NTY3NjAsImp0aSI6IjAxSktCQ01BNDNGRFMwQjBXV0dXNVJYWjUxIiwiaWF0IjoxNzM4NzcwMzYwLCJzdWIiOiIwMUpLN1RSQlRRTTM1TlZaNjhGOUtWNzU5UiJ9.jim7UEguyB3jMVdmULpr39odDm0orM9grKPNVH-5akkxD_FI2cGEOegnc3y5oFV52EF278UtUcBr67Rv7LKah5gas45g30Ak4p3IlSjbTqOyMsJLhVtN7vrnhkPAYNlV-3ZqvHieSrZHvp5cXNMCddG6hHALYnDWFpIyZ6vkyMmhziXnmaEz_q516To8MqmlJR7uN-F6_mELAwRzu-vRCPZI8DZqD_LxJH8C8lxJ-wkFZo3FOu1-EVARdUsrtwib3gc3opPQM5yhqTyuQJHkJK4Bwf6_NShe-w05agNL7msDBeoTZpFv3ydgWivBSahs1_umlNQbDijueEjvsEuKqHMM9GschpqBKEaTRdCziLVCms1IOvTtz965fvCBTBrGNuGSYTDCcpzcibDmgaU6sbWuEpeiN1OLRqgYGL9HrgSjRJaQ6G0hU-Lt9zquBhsvzpON2oVB5SQ9Wx6mOqZ7hGbbak1-JjIvNNOZulbk91PW4p3JipPvi1Ig_pdG1_0MLZ_kgVgfoJaOdEd_-55fDciDpfXZz7ry2rKmF-5mPjMm3kBN7Yx3tMXpw1kmQVUsY2Q-0X-9SX-X7JFIGP9M49ZBnyiKgOaAjVlUcYeDHWYYldS9qzq7M1bl7sCTEYyT0UA42CWyWHGgZhRfkh3tK9hNMmOzYc6lRim2TwqqXtI"
    }
}

JWTトークンのペイロードには、タイムスタンプ(Issued at略してiatクレーム)が含まれているので、内容は毎回変更されます。

そのため、jwt_tokenフィールドの値を"jwt"文字列に固定化するようにしています。

LoginForTest()・LogoutForTest()

ほとんどのエンドポイントは、ログインしていないとミドルウェアによって弾かれてしまいます。

// ヘッダーにトークンを含めなかった場合
{
    "status": 401,
    "message": "authorization Header is missing"
}

そのため、ログイン状態をあらかじめセットアップしてからエンドポイントを叩くというようにします。

ここでいう「ログイン状態」とは、以下の2点が揃っていることです。

  • 有効なJWTトークンがAuthorizationヘッダーに含まれているか
  • Redisにおいて、ユーザーidに紐づく`JTI(JWTトークンのid)が保存されているか

これらをパスすれば認可制御ミドルウェアはリクエストを通します。

テスト内では以下のように使用します。

// Authorizationヘッダーを付加する
if tt.isLogin {
    jwtToken := testhelper.LoginForTest(t, "1")
    defer testhelper.LogoutForTest(t, "1")
    // リクエストヘッダーに Bearer Token方式でトークンを含める
    r.Header.Set("Authorization", "Bearer "+jwtToken)
}

テストケースによっては、未ログイン時の挙動を確かめたい時もあるため、tt.isLoginフラグがtrueの時のみログイン状態をセットアップするようにしています。

idが1であるユーザーがエンドポイントを叩いていると想定して、LoginForTest()の第二引数に"1"を渡しています。

LoginForTest()は、第二引数に渡ったユーザーidを元にJWTトークンを生成して返します。また、Redisにはユーザーidに紐づくJTIを保存します。こうすることにより、引数に入れたidを持つユーザーを「ログイン状態」にしています。

また、LogoutForTest()はRedisからユーザーidに紐づくJTIを削除することによって「ログアウト状態」を実現しています。

t.Helper()について

Goのテストにおいてヘルパー関数を定義する際に使用します。ヘルパー関数として定義した関数内にt.Helper()メソッドを記述すると、

ヘルパー関数内でエラー(t.Fatal()など)が発生した場合にヘルパー関数の呼び出し元をエラーメッセージの出力位置として表示するようになります。つまり、テスト内の行番号を返してくれるということです。

反対に、t.Helperを記述しないと、ヘルパー関数の行番号をエラーメッセージに表示するようになります。

「テストにおいてどの行で失敗したか」が知る方がデバッグしやすいのは想像に易いでしょう。

また、テスト用のヘルパー関数であることを明示的にするためにも、t.Helper()を記述するようにしています。

テストフィクスチャを用意する

テストフィクスチャについては、第8章にて説明しました。そのため、改めての説明は省きます。

エンドポイントの統合テストも、あらかじめテストデータを用意しておくことで、様々なケースをテストすることができます。

例えば、「既に登録済みのユーザー情報を使用して、再度登録するようにエンドポイントを叩いてみる」、とかですね。「既に登録済み」という状況をセットアップするためにはテストフィクスチャが必要です。

以下のように記述しておいてください。

app/infrastructure/api_test/testdata/fixturesディレクトリを作成し、そこにファイルを配置しましょう。

テストフィクスチャのコード

ユーザー

# app/infrastructure/api_test/testdata/fixtures/users.yml
- id: 1
  name: user1
  email: [email protected]
  # passwordという文字列をハッシュ化した値
  hashed_password: $2a$10$GJ6IGQeueUpFmo/kGXQEiOaVjD/Mma.3OlWwcr9P82L32gkUm6UqG%

- id: 2
  name: user2
  email: [email protected]
  hashed_password: $2a$10$GJ6IGQeueUpFmo/kGXQEiOaVjD/Mma.3OlWwcr9P82L32gkUm6UqG%

- id: 3
  name: user3
  email: [email protected]
  hashed_password: $2a$10$GJ6IGQeueUpFmo/kGXQEiOaVjD/Mma.3OlWwcr9P82L32gkUm6UqG%

- id: 4
  name: user4
  email: [email protected]
  hashed_password: $2a$10$GJ6IGQeueUpFmo/kGXQEiOaVjD/Mma.3OlWwcr9P82L32gkUm6UqG%

- id: 5
  name: user5
  email: [email protected]
  hashed_password: $2a$10$GJ6IGQeueUpFmo/kGXQEiOaVjD/Mma.3OlWwcr9P82L32gkUm6UqG%

- id: 6
  name: user6
  email: [email protected]
  hashed_password: $2a$10$GJ6IGQeueUpFmo/kGXQEiOaVjD/Mma.3OlWwcr9P82L32gkUm6UqG%

タスク

# app/infrastructure/api_test/testdata/fixtures/tasks.yml
- id: 1
  user_id: 1
  content: content
  state: 0

- id: 2
  user_id: 1
  content: content
  state: 0

- id: 3
  user_id: 2
  content: content
  state: 1

- id: 4
  user_id: 2
  content: content
  state: 1

ユーザーはハッシュ化されたパスワードを持っています。そのため、テストフィクスチャには"password"という文字列をハッシュ化した文字列を記述しています。

テスト記述

テストを記述していきましょう。

TestMainを記述

リポジトリのテストの際も同様のことを行いました。

*testing.Mを引数にとる以下のような関数には、同パッケージにおける共通処理を記述できるのでした。

func TestMain(m *testing.M) {
	// 前処理
	m.Run()   // 各テストケースの実行
    // 後処理
}

各エンドポイントテストケースにおいて共通処理としたいものは、以下の通りです。

  • テストDBコンテナの起動
  • DBへの接続処理
  • DBマイグレーションの適用
  • Redisへの接続処理
  • ルーティングの初期化

「ルーティングの初期化」以外はリポジトリの時と変わりません。

TestMainのコード
// app/infrastructure/api_test/integration/api_test.go
package integration

import (
	"log"
	"net/http"
	"testing"

	"github.com/kakkky/app/infrastructure/db"
	"github.com/kakkky/app/infrastructure/db/container"
	"github.com/kakkky/app/infrastructure/kvs"
	"github.com/kakkky/app/infrastructure/router"
)

var mux http.Handler

func TestMain(m *testing.M) {
	//dockertestコンテナを起動
	pool, resource := container.NewDockertestContainer()
	log.Println("success to start dockertest container")
	defer func() {
		container.RemoveDockertestContainer(pool, resource)
		log.Println("success to remove dockertest container")
	}()
	// DBに接続
	testDB := container.NewDB(pool, resource)
	log.Println("success to connect test-db")
	defer testDB.Close()
	// マイグレーションを適用させる
	container.SetupDB()
	log.Println("success to apply migrations")
	// dbパッケージ変数にテスト用DBをセット
	db.SetDB(testDB)
	log.Println("dockertest & test-db settings complete")

	// テスト用のredisサーバーを起動
	cli := kvs.NewRedisTestClient()
	defer cli.Close()

	// ルーティングを初期化
	mux = router.NewMux()

	m.Run()
}

「ルーティングの初期化」とは何をしているかというと、router.NewMux()の呼び出しです。これにより、ルーティングが登録されたマルチプレクサ(*http.ServeMux)が取得できます。

マルチプレクサは、以下のようにパッケージ変数として管理しています。

var mux http.Handler

次に記述する各エンドポイントのテストでは、ルーティングを登録したマルチプレクサ(mux変数)をテストケース毎に利用したいため、テスト関数内からアクセスできるようにしているのです。

テストケースごとにmux変数を利用する理由は、次で使用するnet/http/httptestパッケージの使い方に関連しています。

<net/http/httptestパッケージ>

実はすでに使用している(13章「ルーティングの実装」)ものですが、HTTPが絡むテストにはnet/http/httptestパッケージを使用するのが一般的です。

このパッケージを使うと、HTTPサーバーを起動せずともHTTPリクエスト(もどき)を送信し、ハンドラーの挙動を確かめることができます。

具体的には、ServeHTTP()メソッドの引数に渡すResponseWriter型と*Request型のモックを用意することができ、擬似的なHTTPリクエストを送信することができます。

// *Request型のモック
// HTTPメソッド、パス、リクエストボディ(`io.Writer`型)を引数に渡す
r := httptest.NewRequest(http.MethodPost, "/hoge", bytes.NewBuffer(b))
// ResponseWriter型のモック
rw := httptest.NewRecorder()
// マルチプレクサに対してHTTPリクエスト(擬似)を送信
mux.ServeHTTP(rw, r)

ResponseWriter型のrwにはレスポンス内容が書き込まれます。

ServeHTTP()呼び出されるタイミング

テストでは、マルチプレクサに対してServeHTTP()メソッドを呼び出すことで「HTTPリクエスト送信」していると述べました。

マルチプレクサも、「ルーティングハンドラー」であり、http.Handler型なので、ServeHTTP()メソッドが定義されているわけです。

ただ、実装したハンドラー(postUserHandler等)にもServeHTTP()メソッドを定義していましたね。http.Handler型を満たすために定義されるServeHTTP()メソッドですが、これはいつ呼び出されるのでしょうか。

サーバーを起動し、HTTPリクエストを受け付けるためのメソッドである*http.Server型の ListenAndServe()メソッドをコードリーディングして処理を追ってみましょう。

ListenAndServe()メソッドは、*http.Server型のServe()メソッドを呼び出しています。

// Serve は、リスナー l 上で着信接続を受け付け、それぞれの接続ごとに
// 新しいサービス用のゴルーチンを作成します。ゴルーチンはリクエストを読み取り、
// srv.Handler を呼び出してレスポンスを返します。
func (srv *Server) Serve(l net.Listener) error {
    // 省略
	for {
    	// 省略
		go c.serve(connCtx)
	}
}

srv.Handlerを呼び出してリクエストを処理するとありますね。このHandlerはServer構造体にあるHandlerフィールドのことであり、マルチプレクサが入っているのでしたね。

さらに内部を追ってみましょう。Serve()メソッド内では、*conn型のserve()メソッドが呼び出されています。

func (c *conn) serve(ctx context.Context) {
    // 省略
    serverHandler{c.server}.ServeHTTP(w, w.req)
    // 省略
}

ここで、serverHandler型のServeHTTP()メソッドが呼び出されています。serverHandlerは以下のように、*Server構造体をラップしたものです。

type serverHandler struct {
    srv *Server
}

serverHandler型のServeHTTP()メソッドの実装を見てみましょう。

func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
	handler := sh.srv.Handler
	if handler == nil {
		handler = DefaultServeMux
	}
	if !sh.srv.DisableGeneralOptionsHandler && req.RequestURI == "*" && req.Method == "OPTIONS" {
		handler = globalOptionsHandler{}
	}

	handler.ServeHTTP(rw, req)
}

sh.srv.Handler、つまりマルチプレクサがhadler変数に格納されています。ということは、以下のように呼び出されているServeHTTP()メソッドは、*http.ServeMux型のものであると考えられます。

handler.ServeHTTP(rw, req)

*http.ServeMux型に定義されたServeHTTP()メソッドの実装を見てみます。

func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {
	if r.RequestURI == "*" {
		if r.ProtoAtLeast(1, 1) {
			w.Header().Set("Connection", "close")
		}
		w.WriteHeader(StatusBadRequest)
		return
	}
	var h Handler
	if use121 {
		h, _ = mux.mux121.findHandler(r)
	} else {
		h, r.Pattern, r.pat, r.matches = mux.findHandler(r)
	}
	h.ServeHTTP(w, r)
}

*Request型からリクエスト情報を取得し、それを元にfindHandler()メソッドによって対応するハンドラーを探しています。マルチプレクサにはルーティングが登録されているのでしたね。そこから、リクエスト情報に紐づくハンドラーを検索することができます。

そして、対応するハンドラーのServeHTTP()メソッドが呼び出されています。これこそ、自身でハンドラーに実装したServeHTTPメソッドです。

以下に図解で表しておきました(適宜拡大してください🙏)。

テストコードに話を戻します。

mux.ServeHTTP(rw, r)

これはマルチプレクサに対してリクエストを送信することによって、リクエスト情報に紐づくハンドラーの処理(つまりServeHTTP()メソッド)を呼び出し、レスポンスを返しているというふうに整理することができます。

エンドポイントの統合テストにおける共通処理を記述できたので、各テストケースの記述をしていきましょう。

ユーザー系エンドポイントのテスト

テストコード
// app/infrastructure/api_test/integration/user_api_test.go
//go:build integration

package integration

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

	"github.com/kakkky/app/adapter/presentation/handler/user"
	"github.com/kakkky/app/infrastructure/api_test/testhelper"
	dbTesthelper "github.com/kakkky/app/infrastructure/db/testhelper"
	"github.com/sebdah/goldie/v2"
)

func TestUser_GetUsers(t *testing.T) {
	tests := []struct {
		name     string
		wantCode int
		gfName   string
		isLogin  bool //ログインさせるか
	}{
		{
			name:     "正常系",
			wantCode: http.StatusOK,
			gfName:   "get_users_nomal",
			isLogin:  true,
		},
		{
			name:     "準正常系:未ログイン",
			wantCode: http.StatusUnauthorized,
			gfName:   "get_users_seminomal_not_loggedin",
			isLogin:  false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			dbTesthelper.SetupFixtures(t, "../testdata/fixtures/users.yml")

			// リクエストボディをマーシャル(→json)
			r := httptest.NewRequest(http.MethodGet, "/users", nil)
			rw := httptest.NewRecorder()
			// ログイン状態をセットアップ
			// Authorizationヘッダーを付加する
			if tt.isLogin {
				jwtToken := testhelper.LoginForTest(t, "1")
				defer testhelper.LogoutForTest(t, "1")
				r.Header.Set("Authorization", "Bearer "+jwtToken)
			}
			// リクエストを送信
			mux.ServeHTTP(rw, r)
			// ステータスコードを検証
			if rw.Code != tt.wantCode {
				t.Errorf("got %d , but want Code %d", rw.Code, tt.wantCode)
			}
			resp := testhelper.FormatJSON(
				t,
				rw.Body.Bytes(),
			)
			g := goldie.New(
				t,
				goldie.WithFixtureDir("../testdata/golden/user"),
				goldie.WithNameSuffix(".golden.json"))
			g.Assert(t, tt.gfName, resp)
		})
	}
}

func TestUser_PostUser(t *testing.T) {
	tests := []struct {
		name     string
		wantCode int
		req      user.PostUserRequest
		gfName   string
	}{
		{
			name:     "正常系",
			wantCode: http.StatusCreated,
			req: user.PostUserRequest{
				Email:    "[email protected]",
				Name:     "user0",
				Password: "password",
			},
			gfName: "post_user_nomal",
		},
		{
			name:     "準正常系:すでに登録しているメールアドレス",
			wantCode: http.StatusBadRequest,
			req: user.PostUserRequest{
				Email:    "[email protected]",
				Name:     "user1",
				Password: "password",
			},
			gfName: "post_user_seminomal_dup_email",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			dbTesthelper.SetupFixtures(t, "../testdata/fixtures/users.yml")

			// リクエストボディをマーシャル(→json)
			b, _ := json.Marshal(tt.req)
			r := httptest.NewRequest(http.MethodPost, "/users", bytes.NewBuffer(b))
			rw := httptest.NewRecorder()
			mux.ServeHTTP(rw, r)
			// ステータスコードを検証
			if rw.Code != tt.wantCode {
				t.Errorf("got %d , but want Code %d", rw.Code, tt.wantCode)
			}
			resp := testhelper.FormatJSON(
				t,
				testhelper.NormalizeULID(t, rw.Body.Bytes()),
			)
			g := goldie.New(
				t,
				goldie.WithFixtureDir("../testdata/golden/user"),
				goldie.WithNameSuffix(".golden.json"))
			g.Assert(t, tt.gfName, resp)
		})
	}
}

func TestUser_UpdateUser(t *testing.T) {
	tests := []struct {
		name     string
		wantCode int
		req      user.UpdateUserRequest
		gfName   string
		isLogin  bool //ログインさせるか
	}{
		{
			name:     "正常系 : id=1のユーザーを更新する",
			wantCode: http.StatusOK,
			req: user.UpdateUserRequest{
				Email: "[email protected]",
				Name:  "updated",
			},
			gfName:  "update_user_nomal",
			isLogin: true,
		},
		{
			name:     "準正常系 : 未ログイン",
			wantCode: http.StatusUnauthorized,
			req: user.UpdateUserRequest{
				Email: "[email protected]",
				Name:  "updated",
			},
			gfName:  "update_user_seminormal_not_loggedin",
			isLogin: false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			dbTesthelper.SetupFixtures(t, "../testdata/fixtures/users.yml")

			// リクエストボディをマーシャル(→json)
			b, _ := json.Marshal(tt.req)
			r := httptest.NewRequest(http.MethodPatch, "/users/me", bytes.NewBuffer(b))
			rw := httptest.NewRecorder()
			// ログイン状態をセットアップ
			// Authorizationヘッダーを付加する
			if tt.isLogin {
				jwtToken := testhelper.LoginForTest(t, "1")
				defer testhelper.LogoutForTest(t, "1")
				r.Header.Set("Authorization", "Bearer "+jwtToken)
			}
			// リクエストを送信
			mux.ServeHTTP(rw, r)
			// ステータスコードを検証
			if rw.Code != tt.wantCode {
				t.Errorf("got %d , but want Code %d", rw.Code, tt.wantCode)
			}
			resp := testhelper.FormatJSON(
				t,
				rw.Body.Bytes(),
			)
			g := goldie.New(
				t,
				goldie.WithFixtureDir("../testdata/golden/user"),
				goldie.WithNameSuffix(".golden.json"))
			g.Assert(t, tt.gfName, resp)
		})
	}
}

func TestUser_DeleteUser(t *testing.T) {
	tests := []struct {
		name     string
		gfName   string
		wantCode int
		isLogin  bool
	}{
		{
			name:     "正常系:id=1のユーザーを削除",
			wantCode: http.StatusNoContent,
			gfName:   "delete_user_nomal",
			isLogin:  true,
		},
		{
			name:     "準正常系:未ログイン",
			wantCode: http.StatusUnauthorized,
			gfName:   "delete_user_seminomal_not_loggedin",
			isLogin:  false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			dbTesthelper.SetupFixtures(t, "../testdata/fixtures/users.yml")

			r := httptest.NewRequest(http.MethodDelete, "/users/me", nil)
			rw := httptest.NewRecorder()
			// ログイン状態をセットアップ
			// Authorizationヘッダーを付加する
			if tt.isLogin {
				jwtToken := testhelper.LoginForTest(t, "1")
				defer testhelper.LogoutForTest(t, "1")
				r.Header.Set("Authorization", "Bearer "+jwtToken)
			}
			mux.ServeHTTP(rw, r)
			// ステータスコードを検証
			if rw.Code != tt.wantCode {
				t.Errorf("got %d , but want Code %d", rw.Code, tt.wantCode)
			}
			// 204ステータスコードなら、JSONは返らないのでここで終了
			if rw.Code == http.StatusNoContent {
				return
			}
			resp := testhelper.FormatJSON(
				t,
				rw.Body.Bytes(),
			)
			g := goldie.New(
				t,
				goldie.WithFixtureDir("../testdata/golden/user"),
				goldie.WithNameSuffix(".golden.json"))
			g.Assert(t, tt.gfName, resp)
		})
	}
}

net/http/httptestを使っている点、定義したヘルパー関数を使用している点を確認してください。ここで取り上げたいのは以下の2点です。

  • sebdah/goldieパッケージの使用
  • integrationビルドタグ

sebdah/goldieパッケージの使用

以下の部分は、各テストケースに共通して記述している、ゴールデンテストに関わる部分です。

g := goldie.New(
            t,
            goldie.WithFixtureDir("../testdata/golden/user"),
            goldie.WithNameSuffix(".golden.json"))
g.Assert(t, tt.gfName, resp)

goldie.New()関数では、ゴールデンテストを行うためのインスタンスを生成して返します。

func goldie.New(t *testing.T, options ...goldie.Option) *goldie.Goldie

また、引数にはオプションを渡しています。

goldie.WithFixtureDir("../testdata/golden/user") //ゴールデンファイルを生成するディレクトリを指定
goldie.WithNameSuffix(".golden.json"))  // 生成されるゴールデンファイルの接尾辞を指定

この場合、ゴールデンファイルはapp/infrastructure/api_test/testdata/golden/userディレクトリに配置されます。ファイルの接尾辞も指定しているので、xxxxxxx.json.goldenといった名前のファイルが生成されます。

VSCodeで、.json.goldenという接尾辞のファイルが認識されない可能性があります。その場合は、Shift + ⌘ + pでsetting.jsonを開き、以下を追加するようにしてください。

// setting.json
"files.associations": {
    "*.json.golden": "json"
  }

g.Assert()メソッドは、実際に検証を行います。

g.Assert(t, tt.gfName, resp)

既に生成されているゴールデンファイルに記録されたレスポンスとテストにおいて出力されたレスポンスを比較し、差分が検出された場合にエラーを発生させます。引数には、「ゴールデンファイル名(tt.gfName)」「テスト内で出力されたレスポンス(resp)」を渡しています。

そして、このAssert()メソッドは、テスト実行時に-updateフラグをつけるかどうかで挙動が変わります。テスト実行時に-updateフラグをつけた場合、ゴールデンファイルを生成(または上書き)します。書き込まれるのは、テスト内で出力されたレスポンス(resp)です。

以下で挙動の違いを整理しておきました。

フラグ 挙動
-update ゴールデンファイルを新規作成、または既存ファイルを上書きする。
なし 既存のゴールデンファイルと比較し、差分があればテストを失敗させる。

integrationビルドタグ

ファイル上部に以下のようなアノテーションを記述しています。

//go:build integration

このような//go:build xxxxはビルドタグと呼ばれます。

このビルドタグによって、ソースコードをビルドに含めるかどうかを指定することができます。

例として、//go:build hogeというビルドタグをとあるソースコードに付けたとします。そのソースコードをビルドに含めたい場合は、-tags=hogeオプションをつけて実行します。反対に、フラグをつけないとソースコードはビルドに含まれません。

これを利用して、統合テストの実行を制御しています。今回の場合は、テストの実行に-tags=integrationオプションをつけないと、統合テストは行われません。

このようにしているのには、エンドポイントの統合テストの負荷が高いために、プロセスを分けて実行したいという狙いがあります。

make test-appコマンドでは、テストが全て行われるように内部でgo test ./...を実行していますが、-tagsオプションをつけていないため統合テストは実行されません。

統合テストのみを実行するためのタスクをMakefileに定義しています。-tags=integrationをつけていることを確認してください。

test-integration:
	$(eval TEST_PATH=$(or $(path),./...))
	$(eval TEST_TAGS=$(tags))
	$(eval TEST_OPTIONS=${opts})
	@echo "Running tests in integration"
	cd ./app/infrastructure/api_test/integration && $(call tests) -tags=integration
https://devlights.hatenablog.com/entry/2020/09/24/011502

ビルドタグに対して、VSCodeエディタ上でエラーメッセージが表示される可能性があります。Shift+⌘+pでsetting.jsonを開き、以下のように追加しておいてください。

// setting.json
  "go.buildFlags": [
    "-tags=integration"
  ],

CIの所要時間を短縮する

ビルドタグによってテストプロセスを分けて実行できるようにしていることで、CI全体の実行時間を短縮することができます。以下のように、単体テスト(unit-test)と統合テスト(integration-test) のジョブの二つを並行処理するようにしています。

jobs:
    # ユニットテスト
    unit-test:
        name: Run Unit Tests
        runs-on: ubuntu-latest
        steps:
            # 省略
            # appモジュールのテスト
            - name: Run App Test
              run: make test-app
            # pkgモジュールのテスト
            - name: Run Pkg Test
              run: make test-pkg
     # インテグレーションテスト
    integration-test:
        name: Run Integration Tests
        runs-on: ubuntu-latest
        steps:
            # 省略
            - name: Run Api Integration Test
              run: make test-integration

すべてのテストを一度に実行するよりも、このように並行処理で行うことでCI所要時間が1分近く短縮されます。※筆者の環境における観測範囲

また、今回は単体テストに含めていますが、リポジトリやクエリサービスのテストもテストDB環境を用意していたり負荷が高いので、そちらもビルドタグをつけてプロセスを分けてあげると更なる時間の短縮も見込めそうですね。

タスク系エンドポイントのテスト

ユーザー系エンドポイントのテストと同様、以下の2点がポイントです。

  • sebdah/goldieパッケージの使用
  • integrationビルドタグ

テストの実装はほとんど同じです。

テストコード
// app/infrastructure/api_test/integration/task_api_test.go
//go:build integration

package integration

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

	"github.com/kakkky/app/adapter/presentation/handler/task"
	"github.com/kakkky/app/infrastructure/api_test/testhelper"
	dbTesthelper "github.com/kakkky/app/infrastructure/db/testhelper"
	"github.com/sebdah/goldie/v2"
)

func TestTask_GetTask(t *testing.T) {
	tests := []struct {
		name      string
		pathParam string
		wantCode  int
		gfName    string
		isLogin   bool //ログインさせるか
	}{
		{
			name:      "正常系",
			wantCode:  http.StatusOK,
			pathParam: "1",
			gfName:    "get_task_nomal",
			isLogin:   true,
		},
		{
			name:      "準正常系:未ログイン",
			wantCode:  http.StatusUnauthorized,
			pathParam: "1",
			gfName:    "get_task_seminomal_not_loggedin",
			isLogin:   false,
		},
		{
			name:      "準正常系:存在しないタスク",
			pathParam: "0", //存在しないタスクのid
			wantCode:  http.StatusBadRequest,
			gfName:    "get_task_seminomal_not_found",
			isLogin:   true,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			dbTesthelper.SetupFixtures(t, "../testdata/fixtures/users.yml", "../testdata/fixtures/tasks.yml")
			// リクエストボディをマーシャル(→json)
			r := httptest.NewRequest(http.MethodGet, "/tasks/"+tt.pathParam, nil)
			rw := httptest.NewRecorder()
			// ログイン状態をセットアップ
			// Authorizationヘッダーを付加する
			if tt.isLogin {
				jwtToken := testhelper.LoginForTest(t, "1")
				defer testhelper.LogoutForTest(t, "1")
				r.Header.Set("Authorization", "Bearer "+jwtToken)
			}
			// リクエストを送信
			mux.ServeHTTP(rw, r)
			// ステータスコードを検証
			if rw.Code != tt.wantCode {
				t.Errorf("got %d , but want Code %d", rw.Code, tt.wantCode)
			}
			resp := testhelper.FormatJSON(
				t,
				rw.Body.Bytes(),
			)
			g := goldie.New(
				t,
				goldie.WithFixtureDir("../testdata/golden/task"),
				goldie.WithNameSuffix(".golden.json"))
			g.Assert(t, tt.gfName, resp)
		})
	}
}

func TestTask_GetTasks(t *testing.T) {
	tests := []struct {
		name     string
		wantCode int
		gfName   string
		isLogin  bool //ログインさせるか
	}{
		{
			name:     "正常系",
			wantCode: http.StatusOK,
			gfName:   "get_tasks_nomal",
			isLogin:  true,
		},
		{
			name:     "準正常系:未ログイン",
			wantCode: http.StatusUnauthorized,
			gfName:   "get_tasks_seminomal_not_loggedin",
			isLogin:  false,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			dbTesthelper.SetupFixtures(t, "../testdata/fixtures/users.yml", "../testdata/fixtures/tasks.yml")
			r := httptest.NewRequest(http.MethodGet, "/tasks", nil)
			rw := httptest.NewRecorder()
			// ログイン状態をセットアップ
			// Authorizationヘッダーを付加する
			if tt.isLogin {
				jwtToken := testhelper.LoginForTest(t, "1")
				defer testhelper.LogoutForTest(t, "1")
				r.Header.Set("Authorization", "Bearer "+jwtToken)
			}
			mux.ServeHTTP(rw, r)
			if rw.Code != tt.wantCode {
				t.Errorf("got %d , but want Code %d", rw.Code, tt.wantCode)
			}
			resp := testhelper.FormatJSON(
				t,
				rw.Body.Bytes(),
			)
			g := goldie.New(
				t,
				goldie.WithFixtureDir("../testdata/golden/task"),
				goldie.WithNameSuffix(".golden.json"))
			g.Assert(t, tt.gfName, resp)
		})

	}
}

func TestTask_GetUserTasks(t *testing.T) {
	tests := []struct {
		name     string
		wantCode int
		gfName   string
		isLogin  bool //ログインさせるか
	}{
		{
			name:     "正常系",
			wantCode: http.StatusOK,
			gfName:   "get_user_tasks_nomal",
			isLogin:  true,
		},
		{
			name:     "準正常系:未ログイン",
			wantCode: http.StatusUnauthorized,
			gfName:   "get_user_tasks_seminomal_not_loggedin",
			isLogin:  false,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			dbTesthelper.SetupFixtures(t, "../testdata/fixtures/users.yml", "../testdata/fixtures/tasks.yml")
			r := httptest.NewRequest(http.MethodGet, "/users/me/tasks", nil)
			rw := httptest.NewRecorder()
			// ログイン状態をセットアップ
			// Authorizationヘッダーを付加する
			if tt.isLogin {
				jwtToken := testhelper.LoginForTest(t, "1")
				defer testhelper.LogoutForTest(t, "1")
				r.Header.Set("Authorization", "Bearer "+jwtToken)
			}
			mux.ServeHTTP(rw, r)
			if rw.Code != tt.wantCode {
				t.Errorf("got %d , but want Code %d", rw.Code, tt.wantCode)
			}
			resp := testhelper.FormatJSON(
				t,
				rw.Body.Bytes(),
			)
			g := goldie.New(
				t,
				goldie.WithFixtureDir("../testdata/golden/task"),
				goldie.WithNameSuffix(".golden.json"))
			g.Assert(t, tt.gfName, resp)
		})
	}
}

func TestTask_PostTask(t *testing.T) {
	tests := []struct {
		name     string
		req      task.PostTaskRequest
		wantCode int
		gfName   string
		isLogin  bool //ログインさせるか
	}{
		{
			name:     "正常系",
			wantCode: http.StatusCreated,
			req: task.PostTaskRequest{
				Content: "content",
				State:   "todo",
			},
			gfName:  "post_task_nomal",
			isLogin: true,
		},
		{
			name:     "準正常系:未ログイン",
			wantCode: http.StatusUnauthorized,
			req: task.PostTaskRequest{
				Content: "content",
				State:   "todo",
			},
			gfName:  "post_task_seminomal_not_loggedin",
			isLogin: false,
		},
		{
			name:     "準正常系:タスクの内容が空",
			wantCode: http.StatusBadRequest,
			req: task.PostTaskRequest{
				Content: "",
				State:   "todo",
			},
			gfName:  "post_task_seminomal_content_empty",
			isLogin: true,
		},
		{
			name:     "準正常系:タスク状態の値が不正",
			wantCode: http.StatusBadRequest,
			req: task.PostTaskRequest{
				Content: "content",
				State:   "invalid",
			},
			gfName:  "post_task_seminomal_invalid_state",
			isLogin: true,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			dbTesthelper.SetupFixtures(t, "../testdata/fixtures/users.yml", "../testdata/fixtures/tasks.yml")
			// リクエストボディをマーシャル(→json)
			b, _ := json.Marshal(tt.req)
			r := httptest.NewRequest(http.MethodPost, "/tasks", bytes.NewBuffer(b))
			rw := httptest.NewRecorder()
			// ログイン状態をセットアップ
			// Authorizationヘッダーを付加する
			if tt.isLogin {
				jwtToken := testhelper.LoginForTest(t, "1")
				defer testhelper.LogoutForTest(t, "1")
				r.Header.Set("Authorization", "Bearer "+jwtToken)
			}
			mux.ServeHTTP(rw, r)
			if rw.Code != tt.wantCode {
				t.Errorf("got %d , but want Code %d", rw.Code, tt.wantCode)
			}
			resp := testhelper.FormatJSON(
				t,
				testhelper.NormalizeULID(t, rw.Body.Bytes()),
			)
			g := goldie.New(
				t,
				goldie.WithFixtureDir("../testdata/golden/task"),
				goldie.WithNameSuffix(".golden.json"))
			g.Assert(t, tt.gfName, resp)
		})
	}
}

func TestTask_UpdateTaskState(t *testing.T) {
	tests := []struct {
		name      string
		req       task.UpdateTaskStateRequest
		pathParam string
		wantCode  int
		gfName    string
		isLogin   bool //ログインさせるか
	}{
		{
			name:      "正常系",
			wantCode:  http.StatusOK,
			pathParam: "1",
			req: task.UpdateTaskStateRequest{
				State: "doing",
			},
			gfName:  "update_task_nomal",
			isLogin: true,
		},
		{
			name:      "準正常系:未ログイン",
			wantCode:  http.StatusUnauthorized,
			pathParam: "1",
			req: task.UpdateTaskStateRequest{
				State: "done",
			},
			gfName:  "update_task_seminomal_not_loggedin",
			isLogin: false,
		},
		{
			name:      "準正常系:タスク状態の値が不正",
			wantCode:  http.StatusBadRequest,
			pathParam: "1",
			req: task.UpdateTaskStateRequest{
				State: "invalid",
			},
			gfName:  "update_task_seminomal_invalid_state",
			isLogin: true,
		},
		{
			name:      "準正常系:他ユーザーのタスクは操作できない",
			wantCode:  http.StatusForbidden,
			pathParam: "3", //user2のタスク
			req: task.UpdateTaskStateRequest{
				State: "done",
			},
			gfName:  "update_task_seminomal_forbidden_operate_others_task",
			isLogin: true,
		},
		{
			name:      "準正常系:存在しないタスクはエラーが返る",
			wantCode:  http.StatusBadRequest,
			pathParam: "0", //存在しない
			req: task.UpdateTaskStateRequest{
				State: "done",
			},
			gfName:  "update_task_seminomal_not_found",
			isLogin: true,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			dbTesthelper.SetupFixtures(t, "../testdata/fixtures/users.yml", "../testdata/fixtures/tasks.yml")
			// リクエストボディをマーシャル(→json)
			b, _ := json.Marshal(tt.req)
			r := httptest.NewRequest(http.MethodPatch, "/tasks/"+tt.pathParam+"/state", bytes.NewBuffer(b))
			rw := httptest.NewRecorder()
			// ログイン状態をセットアップ
			// Authorizationヘッダーを付加する
			if tt.isLogin {
				jwtToken := testhelper.LoginForTest(t, "1")
				defer testhelper.LogoutForTest(t, "1")
				r.Header.Set("Authorization", "Bearer "+jwtToken)
			}
			mux.ServeHTTP(rw, r)
			if rw.Code != tt.wantCode {
				t.Errorf("got %d , but want Code %d", rw.Code, tt.wantCode)
			}
			resp := testhelper.FormatJSON(
				t,
				rw.Body.Bytes(),
			)
			g := goldie.New(
				t,
				goldie.WithFixtureDir("../testdata/golden/task"),
				goldie.WithNameSuffix(".golden.json"))
			g.Assert(t, tt.gfName, resp)
		})
	}
}

func TestTask_DeleteTask(t *testing.T) {
	tests := []struct {
		name      string
		pathParam string
		wantCode  int
		gfName    string
		isLogin   bool //ログインさせるか
	}{
		{
			name:      "正常系",
			wantCode:  http.StatusNoContent,
			pathParam: "1",
			gfName:    "delete_task_nomal",
			isLogin:   true,
		},
		{
			name:      "準正常系:未ログイン",
			wantCode:  http.StatusUnauthorized,
			pathParam: "1",
			gfName:    "delete_task_seminomal_not_loggedin",
			isLogin:   false,
		},
		{
			name:      "準正常系:他ユーザーのタスクは操作できない",
			wantCode:  http.StatusForbidden,
			pathParam: "3",
			gfName:    "delete_task_seminomal_forbidden_operate_others_task",
			isLogin:   true,
		},
		{
			name:      "準正常系:存在しないタスクはエラーが返る",
			wantCode:  http.StatusBadRequest,
			pathParam: "0",
			gfName:    "delete_task_seminomal_not_found",
			isLogin:   true,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			dbTesthelper.SetupFixtures(t, "../testdata/fixtures/users.yml", "../testdata/fixtures/tasks.yml")

			r := httptest.NewRequest(http.MethodDelete, "/tasks/"+tt.pathParam, nil)
			rw := httptest.NewRecorder()
			// ログイン状態をセットアップ
			// Authorizationヘッダーを付加する
			if tt.isLogin {
				jwtToken := testhelper.LoginForTest(t, "1")
				defer testhelper.LogoutForTest(t, "1")
				r.Header.Set("Authorization", "Bearer "+jwtToken)
			}
			mux.ServeHTTP(rw, r)
			if rw.Code != tt.wantCode {
				t.Errorf("got %d , but want Code %d", rw.Code, tt.wantCode)
			}
			// 204ステータスコードなら、JSONは返らないのでここで終了
			if rw.Code == http.StatusNoContent {
				return
			}
			resp := testhelper.FormatJSON(
				t,
				rw.Body.Bytes(),
			)
			g := goldie.New(
				t,
				goldie.WithFixtureDir("../testdata/golden/task"),
				goldie.WithNameSuffix(".golden.json"))
			g.Assert(t, tt.gfName, resp)
		})
	}
}

認証系エンドポイントのテスト

ユーザー系エンドポイントのテストと同様、以下の2点がポイントです。

  • sebdah/goldieパッケージの使用
  • integrationビルドタグ

テストの実装はほとんど同じです。

テストコード
// app/infrastructure/api_test/integration/auth_api_test.go
//go:build integration

package integration

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

	"github.com/kakkky/app/adapter/presentation/handler/auth"
	"github.com/kakkky/app/infrastructure/api_test/testhelper"
	dbTesthelper "github.com/kakkky/app/infrastructure/db/testhelper"
	"github.com/sebdah/goldie/v2"
)

func TestAuth_Login(t *testing.T) {
	tests := []struct {
		name     string
		wantCode int
		req      auth.LoginRequest
		gfName   string
	}{
		{
			name:     "正常系",
			wantCode: http.StatusOK,
			req: auth.LoginRequest{
				Email:    "[email protected]",
				Password: "password",
			},
			gfName: "login_nomal",
		},
		{
			name:     "準正常系:パスワードが異なる",
			wantCode: http.StatusUnauthorized,
			req: auth.LoginRequest{
				Email:    "[email protected]",
				Password: "invalid",
			},
			gfName: "login_seminomal_password_mismatch",
		},
		{
			name:     "準正常系:指定のemailは存在しない",
			wantCode: http.StatusUnauthorized,
			req: auth.LoginRequest{
				Email:    "[email protected]",
				Password: "notfound",
			},
			gfName: "login_seminomal_email_notfound",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// テストデータを投入
			dbTesthelper.SetupFixtures(t, "../testdata/fixtures/users.yml")
			// リクエストボディをマーシャル(→json)
			b, _ := json.Marshal(tt.req)
			r := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(b))
			rw := httptest.NewRecorder()
			mux.ServeHTTP(rw, r)
			// ステータスコードを検証
			if rw.Code != tt.wantCode {
				t.Errorf("got %d , but want Code %d", rw.Code, tt.wantCode)
			}
			resp := testhelper.FormatJSON(
				t,
				testhelper.NormalizeJWT(t, rw.Body.Bytes()),
			)
			g := goldie.New(
				t,
				goldie.WithFixtureDir("../testdata/golden/auth"),
				goldie.WithNameSuffix(".golden.json"))
			g.Assert(t, tt.gfName, resp)
		})
	}
}

func TestAuth_Logout(t *testing.T) {
	tests := []struct {
		name     string
		wantCode int
		gfName   string
		isLogin  bool
	}{
		{
			name:     "正常系",
			wantCode: http.StatusNoContent,
			gfName:   "logout_nomal",
			isLogin:  true,
		},
		{
			name:     "準正常系:ログインしていないとログアウトできない",
			wantCode: http.StatusUnauthorized,
			gfName:   "logout_seminomal_not_loggedin",
			isLogin:  false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			dbTesthelper.SetupFixtures(t, "../testdata/fixtures/users.yml")
			r := httptest.NewRequest(http.MethodDelete, "/logout", nil)
			rw := httptest.NewRecorder()
			// ログイン状態をセットアップ
			// Authorizationヘッダーを付加する
			if tt.isLogin {
				jwtToken := testhelper.LoginForTest(t, "1")
				r.Header.Set("Authorization", "Bearer "+jwtToken)
			}
			// リクエストを送信
			mux.ServeHTTP(rw, r)
			// ステータスコードを検証
			if rw.Code != tt.wantCode {
				t.Errorf("got %d , but want Code %d", rw.Code, tt.wantCode)
			}
			// 204ステータスコードなら、JSONは返らないのでここで終了
			if rw.Code == http.StatusNoContent {
				return
			}
			resp := testhelper.FormatJSON(
				t,
				rw.Body.Bytes(),
			)
			g := goldie.New(
				t,
				goldie.WithFixtureDir("../testdata/golden/auth"),
				goldie.WithNameSuffix(".golden.json"))
			g.Assert(t, tt.gfName, resp)
		})
	}
}

テスト実行

テストの記述ができたので、エンドポイントの統合テストを実行しましょう。

  • -updateフラグをつけてゴールデンファイルを生成
  • フラグなしで実行してレスポンスを比較

の2ステップでテストを完了させましょう。

ゴールデンテスト用テストデータを生成する

以下のコマンドを打ってください。

make test-integration opts="-update"

/testdata配下に、以下のようにディレクトリ・ファイルが生成されていることを確認してください。

└── golden
    ├── auth
    │   ├── login_nomal.golden.json
    │   ├── login_seminomal_email_notfound.golden.json
    │   ├── login_seminomal_password_mismatch.golden.json
    │   └── logout_seminomal_not_loggedin.golden.json
    ├── task
    │   ├── delete_task_seminomal_forbidden_operate_others_task.golden.json
    │   ├── delete_task_seminomal_not_found.golden.json
    │   ├── delete_task_seminomal_not_loggedin.golden.json
    │   ├── get_task_nomal.golden.json
    │   ├── get_task_seminomal_not_found.golden.json
    │   ├── get_task_seminomal_not_loggedin.golden.json
    │   ├── get_tasks_nomal.golden.json
    │   ├── get_tasks_seminomal_not_loggedin.golden.json
    │   ├── get_user_tasks_nomal.golden.json
    │   ├── get_user_tasks_seminomal_not_loggedin.golden.json
    │   ├── post_task_nomal.golden.json
    │   ├── post_task_seminomal_content_empty.golden.json
    │   ├── post_task_seminomal_invalid_state.golden.json
    │   ├── post_task_seminomal_not_loggedin.golden.json
    │   ├── update_task_nomal.golden.json
    │   ├── update_task_seminomal_forbidden_operate_others_task.golden.json
    │   ├── update_task_seminomal_invalid_state.golden.json
    │   ├── update_task_seminomal_not_found.golden.json
    │   └── update_task_seminomal_not_loggedin.golden.json
    └── user
        ├── delete_user_seminomal_not_loggedin.golden.json
        ├── get_users_nomal.golden.json
        ├── get_users_seminomal_not_loggedin.golden.json
        ├── post_user_nomal.golden.json
        ├── post_user_seminomal_dup_email.golden.json
        ├── update_user_nomal.golden.json
        └── update_user_seminormal_not_loggedin.golden.json

ファイルには、出力されたレスポンスが記録されています。

ファイルの中も覗いてみてください。

例えば、POST /userエンドポイントのテストの正常系から出力されたレスポンスは以下のようになっています。ヘルパー関数NormalizeULID()によって"id"フィールドが"ulid"という文字列になっていることも確認できます。

// post_user_nomal.golden.json
{
  "status": 201,
  "data": {
    "id": "ulid",
    "email": "[email protected]",
    "name": "user0"
  }
}

実際にテストを実行する

次はフラグなしで実行してみましょう。 2回目の実行でもレスポンスが変わらないことを確認します。

make test-integration

今後このAPIに追加実装などをしていく場合、ゴールデンテスト手法を用いたリグレッションテストを実行することで、変更による意図しない影響を防ぐことができます。