🏅

goldenファイルを使用したテスト

2024/11/08に公開

はじめに

はじめまして、株式会社バニッシュ・スタンダードでエンジニアをやっているkzzzmです。
急に寒くなってきましたねー
僕は冬の方が好きなので、嬉しいです!

さて今回は個人プロダクトで使っているgoldenファイルを使用したテストについて少し触れていきたいと思います。
各社、様々なテストの流派があるかと思いますので参考まで見ていただければ幸いです!

テストにおいて、ある出力が期待通りかを検証するのは非常に重要です。
特にAPIレスポンスやファイル出力のテストでは、正確な内容が求められますが、頻繁に出力内容が変わるとテストの管理が複雑になりがちです。

そこで役立つのが、goldenファイルを使ったテストです。
この手法では、前回の結果をファイルに保存し、テスト実行時にそのファイルと実際の出力を比較します。
これにより、変更があったときに差分を簡単に確認できるため、効率よくテストを管理できます。

goldenファイルとは?

goldenファイル とは、テストにおいて期待される出力結果を保存したファイルのことです。このファイルは、あらかじめ期待されるデータ(goldenデータ)を含んでおり、テスト時に実際の出力と比較されます。

例えば、APIのレスポンスが期待通りであることを確認したい場合、前回のJSONレスポンスをgoldenファイルとして保存します。テストでは、このgoldenファイルとAPIの実際のレスポンスを比較することで、出力が予期しない変更をしていないかを検証できます。
※goldenファイルというライブラリを使っているわけではありません。

ゴールデンファイルを使うメリット

  • 再現性のあるテスト:goldenファイルを使用すると、出力が変わっていないかを簡単に確認でき、テストの再現性が向上します。

  • メンテナンスの簡略化:出力が変更された場合、-update フラグを使ってテストを実行すれば、新しいgoldenファイルが自動で生成され、期待値の更新が簡単に行えます。

  • 差分の可視化:ファイルの内容が異なる場合、具体的な差分が表示されるため、変更内容の確認や修正がしやすくなります。

実装例

まず今回簡易的に作ったディレクトリ構成です。

├── project
│   ├── aerr
│   │   ├── aerror.go
│   │   ├── annotate.go
│   │   ├── level.go
│   │   ├── originerror.go
│   │   └── stack_trace.go
│   ├── apperr
│   │   ├── apierror.go
│   │   ├── apierror_define.go
│   │   └── apierror_detail.go
│   ├── config
│   │   └── context.go
│   ├── controller
│   │   ├── context.go
│   │   ├── error.go
│   │   ├── success.go
│   │   └── user
│   │       ├── fetch_user_controller.go
│   │       ├── fetch_user_controller_test.go
│   │       └── testdata
│   │           └── response
│   │               └── TestFetchUserController_FetchUserController
│   │                   ├── response-200.json.golden
│   │                   ├── response-400.json.golden
│   │                   └── response-404.json.golden
│   ├── domain
│   │   └── user
│   │       ├── entity.go
│   │       └── repositoty.go
│   ├── infrastructure
│   │   └── persistence
│   │       └── user_repository_impl.go
│   ├── main.go
│   ├── mock
│   │   ├── mockcontroller
│   │   │   └── mockcontext
│   │   │       └── mock_context.go
│   │   ├── mockrepository
│   │   │   └── mockuserrepository
│   │   │       └── mock_user_repositoty.go
│   │   └── mockusecase
│   │       └── mockuserusecase
│   │           └── mock_fetch_user_usecase.go
│   ├── testutil
│   │   └── golden.go // 今回ここが重要
│   └── usecase
│       └── userusecase
│           ├── fetch_user_usecase.go
│           ├── input
│           │   └── fetch_user_input.go
│           └── output
│               └── fetch_user_output.go
├── Makefile
├── README.md
├── go.mod
├── go.sum

以下は、HTTPレスポンスをgoldenファイルと比較するためのコードです。

テスト用のユーティリティ関数
まず、testutil パッケージにgoldenファイルとの比較を行う関数を用意します。この関数は、レスポンスボディを読み込んでJSON形式に整形し、goldenファイルと比較します。
構造体比較のためにgo-cmpを使用しています。

golden
package testutil

import (
	"bytes"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/google/go-cmp/cmp"
	"golang.org/x/xerrors"
)

const (
	baseFixtureDir = "testdata"

	// folder name for where the response fixtures are stored
	responseFixtureDir = "response"

	// golden file name info
	goldenFileNamePrefix = "response"
	goldenFileNameSuffix = ".json.golden"

	// permissions
	filePerms os.FileMode = 0644
	dirPerms  os.FileMode = 0755
)

var (
	update = flag.Bool("update", false, "update golden test files")
	clean  = flag.Bool("clean", false, "clean old golden test files")
)

func AssertResponseBody(t *testing.T, res *http.Response, fileNameOpts ...string) {
	t.Helper()

	goldenFile := newGoldenFilePath(t, fileNameOpts...)

	// 204 NoContentの場合は空ファイルを作成
	if *update && res.StatusCode == http.StatusNoContent {
		goldenFileDir := filepath.Dir(goldenFile)

		if err := ensureDir(goldenFileDir); err != nil {
			t.Fatalf("unexpected error by ensureDir '%v'", err)
		}

		if err := os.WriteFile(goldenFile, nil, filePerms); err != nil {
			t.Fatalf("unexpected error by os.WriteFile '%v'", err)
		}

		return
	}

	if res.StatusCode == http.StatusNoContent {
		return
	}

	gotJSON, err := newGotJson(res)
	if err != nil {
		t.Fatalf("unexpected error by newGotJson '%v'", err)
	}

	if *update {
		if err = updateGoldenFile(goldenFile, gotJSON.Bytes()); err != nil {
			t.Fatalf("unexpected error by updateGoldenFile '%v'", err)
		}
	}

	wantJSON, err := newWantJson(goldenFile)
	if err != nil {
		t.Fatalf("unexpected error by newGotJson '%v'", err)
	}

	if diff := cmp.Diff(wantJSON.String(), gotJSON.String()); len(diff) != 0 {
		t.Errorf("differs: (-want +got)\n%s", diff)
	}
}

func newGoldenFilePath(t *testing.T, fileNameOpts ...string) string {
	var goldenFilePath string
	testFuncName := strings.Split(t.Name(), "/")[0]

	if len(fileNameOpts) == 0 {
		// file path example: ./testdata/response/{testFuncName}/response.json.golden
		goldenFilePath = filepath.Join(
			baseFixtureDir,
			responseFixtureDir,
			testFuncName,
			fmt.Sprintf("%s%s", goldenFileNamePrefix, goldenFileNameSuffix),
		)
	} else {
		optsStr := strings.Join(fileNameOpts, "-")

		// file path example: ./testdata/response/{testFuncName}/response-{fileNameOpts}.json.golden
		goldenFilePath = filepath.Join(
			baseFixtureDir,
			responseFixtureDir,
			testFuncName,
			fmt.Sprintf("%s-%s%s", goldenFileNamePrefix, optsStr, goldenFileNameSuffix),
		)
	}

	return goldenFilePath
}

func newGotJson(res *http.Response) (*bytes.Buffer, error) {
	b, err := io.ReadAll(res.Body)
	if err != nil {
		return nil, err
	}

	var compactJson bytes.Buffer
	if err = json.Compact(&compactJson, b); err != nil {
		return nil, err
	}

	var prettyJSON bytes.Buffer
	if err = json.Indent(&prettyJSON, compactJson.Bytes(), "", "  "); err != nil {
		return nil, err
	}

	return &prettyJSON, nil
}

func newWantJson(goldenFile string) (*bytes.Buffer, error) {
	b, err := os.ReadFile(goldenFile)
	if err != nil {
		return nil, err
	}

	var compactJson bytes.Buffer
	if err = json.Compact(&compactJson, b); err != nil {
		return nil, err
	}

	var prettyJSON bytes.Buffer
	if err = json.Indent(&prettyJSON, compactJson.Bytes(), "", "  "); err != nil {
		return nil, err
	}

	return &prettyJSON, nil
}

func updateGoldenFile(goldenFile string, actualData []byte) error {
	goldenFileDir := filepath.Dir(goldenFile)

	if err := ensureDir(goldenFileDir); err != nil {
		return err
	}

	// 最終行に改行挿入
	actualData = append(actualData, '\n')

	if err := os.WriteFile(goldenFile, actualData, filePerms); err != nil {
		return err
	}

	now := time.Now()
	if err := os.Chtimes(goldenFileDir, now, now); err != nil {
		return err
	}

	return nil
}

func ensureDir(goldenFileDir string) error {
	s, err := os.Stat(goldenFileDir)
	defer func() {
		*clean = false
	}()

	switch {
	case err != nil && os.IsNotExist(err):
		return os.MkdirAll(goldenFileDir, dirPerms)

	case err == nil && s.IsDir() && *clean:
		// ./testdata/response folder削除
		if err = os.RemoveAll(filepath.Join(baseFixtureDir, responseFixtureDir)); err != nil {
			return err
		}
		return os.MkdirAll(goldenFileDir, dirPerms)

	case err == nil && !s.IsDir():
		return xerrors.Errorf("fixture folder is a file: %s", goldenFileDir)
	}

	return err
}

このコードでは、AssertResponseBody 関数がHTTPレスポンスとgoldenファイルを比較します。
-update フラグが指定されている場合、goldenファイルが更新され、最新のレスポンスが新しい期待値として保存されます。

実際のcontroller実装

(簡易的なユーザー取得APIを作成しました)

fetch_user_controller
package user

import (
	"net/http"
	"strconv"
	"x/project/aerr"
	"x/project/apperr"
	"x/project/controller"
	"x/project/usecase/userusecase"
	"x/project/usecase/userusecase/input"
)

type FetchUserController struct {
	fetchUserUsecase userusecase.FetchUserUsecase
}

func NewFetchUserController(fetchUserUsecase userusecase.FetchUserUsecase) *FetchUserController {
	return &FetchUserController{
		fetchUserUsecase: fetchUserUsecase,
	}
}

func (c *FetchUserController) FetchUserController(context controller.Context) error {
	userID, err := strconv.Atoi(context.Param("userID"))
	if err != nil {
		return controller.ErrorJSON(context, aerr.Wrap(err, aerr.WithOriginError(apperr.InvalidParameter)))
	}

	in := &input.FetchUserInput{
		UserID: userID,
	}

	out, err := c.fetchUserUsecase.FetchUser(*in)
	if err != nil {
		return controller.ErrorJSON(context, aerr.Wrap(err))
	}

	return controller.JSON(context, http.StatusOK, out)
}

実際のcontrollerテスト実装

fetch_user_controller_test
package user

import (
	"net/http"
	"net/http/httptest"
	"testing"
	"x/project/aerr"
	"x/project/apperr"
	"x/project/mock/mockcontroller/mockcontext"
	"x/project/mock/mockusecase/mockuserusecase"
	"x/project/testutil"
	"x/project/usecase/userusecase/input"
	"x/project/usecase/userusecase/output"

	"github.com/golang/mock/gomock"
	"github.com/labstack/echo/v4"
)

func TestFetchUserController_FetchUserController(t *testing.T) {
	type fields struct {
		fetchUserUsecase *mockuserusecase.MockFetchUserUsecase
	}
	type args struct {
		ctx *mockcontext.MockContext
	}
	tests := []struct {
		name               string
		fileSuffix         string
		args               args
		prepareMockUsecase func(f *fields)
		prepareMockRequest func(ctx *mockcontext.MockContext, r *http.Request, w *httptest.ResponseRecorder)
	}{
		{
			name:       "正常",
			fileSuffix: "200",
			args: args{
				ctx: &mockcontext.MockContext{},
			},
			prepareMockUsecase: func(f *fields) {
				in := input.FetchUserInput{
					UserID: 1,
				}

				out := &output.FetchUserOutput{
					ID:   1,
					Name: "カズレーザー",
				}

				f.fetchUserUsecase.EXPECT().FetchUser(in).Return(out, nil)
			},
			prepareMockRequest: func(ctx *mockcontext.MockContext, r *http.Request, w *httptest.ResponseRecorder) {
				e := echo.New()
				echoCtx := e.NewContext(r, w)
				echoCtx.SetParamNames("userID")
				echoCtx.SetParamValues("1")
				ctx.Context = echoCtx
			},
		},
		{
			name:       "ユーザーID不正",
			fileSuffix: "400",
			args: args{
				ctx: &mockcontext.MockContext{},
			},
			prepareMockUsecase: nil,
			prepareMockRequest: func(ctx *mockcontext.MockContext, r *http.Request, w *httptest.ResponseRecorder) {
				e := echo.New()
				echoCtx := e.NewContext(r, w)
				echoCtx.SetParamNames("userID")
				echoCtx.SetParamValues("InvalidParameter request")
				ctx.Context = echoCtx
			},
		},
		{
			name:       "指定したユーザーが存在しない場合",
			fileSuffix: "404",
			args: args{
				ctx: &mockcontext.MockContext{},
			},
			prepareMockUsecase: func(f *fields) {
				in := input.FetchUserInput{
					UserID: 2,
				}

				err := aerr.Wrap(nil, aerr.WithOriginError(apperr.UserNotFound))

				f.fetchUserUsecase.EXPECT().FetchUser(in).Return(nil, err)
			},
			prepareMockRequest: func(ctx *mockcontext.MockContext, r *http.Request, w *httptest.ResponseRecorder) {
				e := echo.New()
				echoCtx := e.NewContext(r, w)
				echoCtx.SetParamNames("userID")
				echoCtx.SetParamValues("2")
				ctx.Context = echoCtx
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			gmctrl := gomock.NewController(t)

			f := fields{
				fetchUserUsecase: mockuserusecase.NewMockFetchUserUsecase(gmctrl),
			}

			if tt.prepareMockUsecase != nil {
				tt.prepareMockUsecase(&f)
			}

			h := NewFetchUserController(f.fetchUserUsecase)

			r := httptest.NewRequest(http.MethodGet, "/user/:userID", nil)
			w := httptest.NewRecorder()

			if tt.prepareMockRequest != nil {
				tt.prepareMockRequest(tt.args.ctx, r, w)
			}

			if err := h.FetchUserController(tt.args.ctx); err != nil {
				t.Fatalf("FetchUserController() error = %v", err)
			}

			res := w.Result()

			testutil.AssertResponseBody(t, res, tt.fileSuffix)
		})
	}
}

自動生成されたレスポンス

Makefileに書いたコマンド

# goldenテストデータを更新
test-update:
	@ go test ./project/controller/... -update

# goldenテストデータを削除して新規作成
test-clean:
	@ go test project/controller/... -update -clean

まとめ

いかがでしたかね?
テストをもっと良くしていきたいと悩んでた方やたまーーーーーにテスト書くのが憂鬱になる時などの一つの選択肢になれば幸いです!

最後に

弊社「株式会社バニッシュ・スタンダード」では、現在エンジニアおよびデザイナーを積極的に募集しています。

私も、幅広い技術領域に携わることができ、日々充実した仕事ができています。
技術レベルの高いエンジニアが多く、成長意欲のある方には特におすすめです。
また、みんなが仕事に対して責任感を持ち、親切な人ばかりなので、非常に働きやすい環境です。

少しでも興味を持たれた方は、ご応募をお待ちしています。

Discussion