0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【備忘録】テストコード作成時のキモチ

Last updated at Posted at 2025-03-16

テストコードを書くということ

「テストコードを作成する」 = 「テスト対象コードの正しい動作を検証するため、仮データやモックを用いて実行する検証プログラムを作成すること」

image.png

テスト対象

usecase/create_account.go

package usecase

import (
	"context"
	"time"

	"github.com/gsabadini/go-clean-architecture/domain"
)

type (
	// CreateAccountUseCase defines the input port
	CreateAccountUseCase interface {
		Execute(context.Context, CreateAccountInput) (CreateAccountOutput, error)
	}

	// CreateAccountInput represents the input data
	CreateAccountInput struct {
		Name    string `json:"name" validate:"required"`
		CPF     string `json:"cpf" validate:"required"`
		Balance int64  `json:"balance" validate:"gt=0,required"`
	}

	// CreateAccountPresenter defines the output port
	CreateAccountPresenter interface {
		Output(domain.Account) CreateAccountOutput
	}

	// CreateAccountOutput represents the output data
	CreateAccountOutput struct {
		ID        string  `json:"id"`
		Name      string  `json:"name"`
		CPF       string  `json:"cpf"`
		Balance   float64 `json:"balance"`
		CreatedAt string  `json:"created_at"`
	}

	createAccountInteractor struct {
		repo       domain.AccountRepository
		presenter  CreateAccountPresenter
		ctxTimeout time.Duration
	}
)

// NewCreateAccountInteractor initializes a new instance of createAccountInteractor
func NewCreateAccountInteractor(
	repo domain.AccountRepository,
	presenter CreateAccountPresenter,
	t time.Duration,
) CreateAccountUseCase {
	return createAccountInteractor{
		repo:       repo,
		presenter:  presenter,
		ctxTimeout: t,
	}
}

// Execute executes the use case logic
func (a createAccountInteractor) Execute(ctx context.Context, input CreateAccountInput) (CreateAccountOutput, error) {
	ctx, cancel := context.WithTimeout(ctx, a.ctxTimeout)
	defer cancel()

	account := domain.NewAccount(
		domain.AccountID(domain.NewUUID()),
		input.Name,
		input.CPF,
		domain.Money(input.Balance),
		time.Now(),
	)

	account, err := a.repo.Create(ctx, account)
	if err != nil {
		return a.presenter.Output(domain.Account{}), err
	}

	return a.presenter.Output(account), nil
}

テスト作成手順

1. テスト対象ファイルの決定

テスト対象は usecase/create_account.go

2. テストコードファイルの作成

テスト対象のファイル名に _test を追加し、usecase/create_account_test.go を作成。

3. テスト対象メソッドの決定

createAccountInteractor 構造体の Execute メソッドをテスト対象とする。

4. テストメソッドの定義

Go のテストメソッドは Test プレフィックスを付けるため、以下のように命名。

usecase/create_account_test.go

package usecase

import "testing"

func TestCreateAccountInteractor_Execute(t *testing.T) {
    // テストコードを記述
}

5. テストに必要な要素の特定

createAccountInteractor 構造体の Execute メソッドをテストするには、まず createAccountInteractor オブジェクトを生成する必要がある。そのために NewCreateAccountInteractor メソッドを実行する。

5.1 NewCreateAccountInteractor の引数

以下のメソッドから、オブジェクトの生成に必要な要素を確認する。

// NewCreateAccountInteractor initializes a new createAccountInteractor instance
func NewCreateAccountInteractor(
	repo domain.AccountRepository,
	presenter CreateAccountPresenter,
	t time.Duration,
) CreateAccountUseCase {
	return createAccountInteractor{
		repo:       repo,
		presenter:  presenter,
		ctxTimeout: t,
	}
}

このメソッドの引数から、以下の 3 つの要素が必要になる。

  • repo (domain.AccountRepository)
  • presenter (CreateAccountPresenter)
  • t (time.Duration)

5.2 Execute メソッドの引数

createAccountInteractor オブジェクトを生成した後、テスト対象である Execute メソッドを実行する。

// Execute executes the use case logic
func (a createAccountInteractor) Execute(ctx context.Context, input CreateAccountInput) (CreateAccountOutput, error) {
	// (省略)
}

このメソッドの引数から、以下の 2 つの要素が必要になる。

  • ctx (context.Context)
  • input (CreateAccountInput)

テストを実行するために必要な要素は、以下の 5 つ。

項目 用途
repo domain.AccountRepository データの保存・取得
presenter CreateAccountPresenter 出力データの整形
t time.Duration コンテキストのタイムアウト設定
ctx context.Context 実行時のコンテキスト管理
input CreateAccountInput 入力データ

この 5 つの要素を準備すれば、createAccountInteractorExecute メソッドをテストできる。

6. 必要な要素を用意する

特定した必要要素を用意する。

6.1 domain.AccountRepositoryrepo の用意

createAccountInteractorExecute メソッドをテストするために、domain.AccountRepository をモック化する。以下の手順で repo を準備する。


6.1.1 型の定義

テストケースの構造体で、リポジトリの型を定義する。

repository domain.AccountRepository // リポジトリの型を定義

6.1.2 モックリポジトリの作成

実際の domain.AccountRepository の代わりとなる、テスト用のモッククラス mockAccountRepoStore を定義する。

type mockAccountRepoStore struct {
	domain.AccountRepository

	result domain.Account
	err    error
}

なぜ mockAccountRepository ではなく mockAccountRepoStore なのか?
mockAccountRepoStore という名前にすることで、データの入出力は Repository の動作と同じだが、厳密にはリポジトリの働きをしていない ことを明示している。


6.1.3 Create メソッドのモック化

Execute メソッド内で repo.Create(ctx, account) が呼ばれるため、Create メソッドを擬似的に再現する必要がある。

func (m mockAccountRepoStore) Create(_ context.Context, _ domain.Account) (domain.Account, error) {
	return m.result, m.err
}

このメソッドにより、テスト時に Create メソッドが呼ばれると、事前に設定した result(ダミーの Account オブジェクト)と err を返すようになる。


6.1.4 実際のリポジトリインスタンスを生成

作成した mockAccountRepoStore を利用し、テストケースで使用するリポジトリを作成する。

{
	repository: mockAccountRepoStore{
		result: domain.NewAccount(
			"3c096a40-ccba-4b58-93ed-57379ab04680",
			"Test",
			"02815517078",
			19944,
			time.Time{},
		),
		err: nil,
	}
}

この設定により、repository.Create() が呼ばれると、固定の Account データが返される。


現状のテストコード

package usecase

import (
	"context"
	"testing"
	"time"

	"github.com/gsabadini/go-clean-architecture/domain"
)



// 2. リポジトリの代わりとなるモッククラスを定義
type mockAccountRepoStore struct {
	domain.AccountRepository

	result domain.Account
	err    error
}

// 3. Create メソッドのモック化
func (m mockAccountRepoStore) Create(_ context.Context, _ domain.Account) (domain.Account, error) {
	return m.result, m.err
}

func TestCreateAccountInteractor_Execute(t *testing.T) {
	tests := []struct {
		repository domain.AccountRepository // 1. 型の定義
	}{
		// 4. モックリポジトリのインスタンスを作成
		{
			repository: mockAccountRepoStore{
				result: domain.NewAccount(
					"3c096a40-ccba-4b58-93ed-57379ab04680",
					"Test",
					"02815517078",
					19944,
					time.Time{},
				),
				err: nil,
			},
		},
	}
}

6.2 CreateAccountPresenterpresenterの用意

createAccountInteractorExecute メソッドをテストするために、CreateAccountPresenter をモック化する。以下の手順で presenter を準備する。


6.2.1 型の定義

テストケースの構造体で、プレゼンターの型を定義する。

presenter CreateAccountPresenter // プレゼンターの型を定義

6.2.2 モックプレゼンターの作成

実際の CreateAccountPresenter の代わりとなる、テスト用のモッククラス mockCreateAccountPresenter を定義する。

type mockCreateAccountPresenter struct {
	result CreateAccountOutput
}

なぜ mockCreateAccountPresenter を作成するのか?
mockCreateAccountPresenter を作成することで、Execute メソッドの presenter.Output(account) が呼ばれた際に、事前に定義した result(ダミーの CreateAccountOutput オブジェクト)を返すことができる。


6.2.3 Output メソッドのモック化

Execute メソッド内で presenter.Output(account) が呼ばれるため、Output メソッドを擬似的に再現する必要がある。

func (m mockCreateAccountPresenter) Output(_ domain.Account) CreateAccountOutput {
	return m.result
}

このメソッドにより、テスト時に Output メソッドが呼ばれると、事前に設定した result(ダミーの CreateAccountOutput オブジェクト)を返すようになる。


6.2.4 実際のプレゼンターインスタンスを生成

作成した mockCreateAccountPresenter を利用し、テストケースで使用するプレゼンターを作成する。

{
	presenter: mockCreateAccountPresenter{
		result: CreateAccountOutput{
			ID:        "3c096a40-ccba-4b58-93ed-57379ab04680",
			Name:      "Test",
			CPF:       "02815517078",
			Balance:   199.44,
			CreatedAt: time.Time{}.String(),
		},
	},
}

この設定により、presenter.Output() が呼ばれると、固定の CreateAccountOutput データが返される。


現状のテストコード

package usecase

import (
	"context"
	"testing"
	"time"

	"github.com/gsabadini/go-clean-architecture/domain"
)

type mockAccountRepoStore struct {
	domain.AccountRepository

	result domain.Account
	err    error
}

func (m mockAccountRepoStore) Create(_ context.Context, _ domain.Account) (domain.Account, error) {
	return m.result, m.err
}

// 2. プレゼンターの代わりとなるモッククラスを定義
type mockCreateAccountPresenter struct {
	result CreateAccountOutput
}

// 3. Output メソッドのモック化
func (m mockCreateAccountPresenter) Output(_ domain.Account) CreateAccountOutput {
	return m.result
}

func TestCreateAccountInteractor_Execute(t *testing.T) {
	tests := []struct {
        repository domain.AccountRepository
		presenter CreateAccountPresenter // 1. 型の定義
	}{
		// 4. モックプレゼンターのインスタンスを作成
		{
            repository: mockAccountRepoStore{
				result: domain.NewAccount(
					"3c096a40-ccba-4b58-93ed-57379ab04680",
					"Test",
					"02815517078",
					19944,
					time.Time{},
				),
				err: nil,
			},
			presenter: mockCreateAccountPresenter{
				result: CreateAccountOutput{
					ID:        "3c096a40-ccba-4b58-93ed-57379ab04680",
					Name:      "Test",
					CPF:       "02815517078",
					Balance:   199.44,
					CreatedAt: time.Time{}.String(),
				},
			},
		},
	}
}

6.3 time.Durationtの用意

timeパッケージのものを使うので、改めて用意する必要はない。

6.4 context.Contextctxの用意

contextパッケージのものを使うので、改めて用意する必要はない。

6.5 CreateAccountInputinputの用意

createAccountInteractorExecute メソッドをテストするために、CreateAccountInput を準備する。以下の手順で input を定義する。


6.5.1 型の定義

テストケースの構造体で、args 型の args を定義する。

args args // 1. 入力データの型を定義

args(arguments)の正式名称
args"arguments"(引数) の略で、関数に渡す引数をまとめるための構造体。

メリット

  1. 可読性向上

    • テストケースごとに引数を整理し、統一感を持たせる。
    • フィールドが増えても見やすい構造を維持できる。
  2. 引数の追加・変更が容易

    • args にまとめることで、引数が増えても最小限の修正で対応可能。

6.5.2 CreateAccountInput の作成

Execute メソッドのテストを行うため、入力データのモックを作成する。
CreateAccountInputNameCPFBalance の 3 つのフィールドを持っているため、テストケースで適切な値を設定する。

{
    args: args{
        input: CreateAccountInput{
            Name:    "Test",
            CPF:     "02815517078",
            Balance: 19944,
        },
    },
}

この設定により、テスト時に Execute メソッドへ渡される input が適切な値を持つようになる。


現状のテストコード

package usecase

import (
	"context"
	"testing"
	"time"

	"github.com/gsabadini/go-clean-architecture/domain"
)

type mockAccountRepoStore struct {
	domain.AccountRepository

	result domain.Account
	err    error
}

func (m mockAccountRepoStore) Create(_ context.Context, _ domain.Account) (domain.Account, error) {
	return m.result, m.err
}

type mockCreateAccountPresenter struct {
	result CreateAccountOutput
}

func (m mockCreateAccountPresenter) Output(_ domain.Account) CreateAccountOutput {
	return m.result
}

func TestCreateAccountInteractor_Execute(t *testing.T) {
	tests := []struct {
        args args //1. 入力データの型を定義
        repository domain.AccountRepository
		presenter CreateAccountPresenter
	}{
		{
            //2. CreateAccountInputの作成
            args: args{
				input: CreateAccountInput{
					Name:    "Test",
					CPF:     "02815517078",
					Balance: 19944,
				},
			},
            repository: mockAccountRepoStore{
				result: domain.NewAccount(
					"3c096a40-ccba-4b58-93ed-57379ab04680",
					"Test",
					"02815517078",
					19944,
					time.Time{},
				),
				err: nil,
			},
			presenter: mockCreateAccountPresenter{
				result: CreateAccountOutput{
					ID:        "3c096a40-ccba-4b58-93ed-57379ab04680",
					Name:      "Test",
					CPF:       "02815517078",
					Balance:   199.44,
					CreatedAt: time.Time{}.String(),
				},
			},
		},
	}
}

7. テストを実行する処理を追加

ここまでで createAccountInteractorExecute メソッドをテストする準備が整った。
次に、テストケースをループで実行し、期待通りの動作をしているかを検証する。


7.1 テストケースのループ処理

定義した tests スライスの各テストケースを順番に実行する。

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        var uc = NewCreateAccountInteractor(tt.repository, tt.presenter, time.Second)

        result, err := uc.Execute(context.TODO(), tt.args.input)

        if (err != nil) && (err.Error() != tt.expectedError) {
            t.Errorf("[TestCase '%s'] Result: '%v' | ExpectedError: '%v'", tt.name, err, tt.expectedError)
        }

        if !reflect.DeepEqual(result, tt.expected) {
            t.Errorf("[TestCase '%s'] Result: '%v' | Expected: '%v'", tt.name, result, tt.expected)
        }
    })
}

以下で細かく解説。


7.2 t.Run で各テストケースを独立実行

testing.TRun メソッドを使い、各テストケースをサブテストとして管理する。
これにより、どのテストケースが失敗したかを明確に特定できる。


7.3 NewCreateAccountInteractor のインスタンスを生成

各テストケースごとに CreateAccountInteractor の新しいインスタンスを作成する。

var uc = NewCreateAccountInteractor(tt.repository, tt.presenter, time.Second)
  • tt.repository: モックリポジトリを注入
  • tt.presenter: モックプレゼンターを注入
  • time.Second: タイムアウト設定

7.4 Execute メソッドを実行

作成した uc インスタンスで Execute メソッドを実行し、結果 (result) とエラー (err) を取得する。

result, err := uc.Execute(context.TODO(), tt.args.input)
  • context.TODO() はテスト環境で context.Context を渡すために使用

7.5 エラーの検証

if (err != nil) && (err.Error() != tt.expectedError) {
    t.Errorf("[TestCase '%s'] Result: '%v' | ExpectedError: '%v'", tt.name, err, tt.expectedError)
}
  • err が発生した場合、期待されるエラーメッセージ (tt.expectedError) と比較する。
  • tt.expectedError はテストケース内で設定されており、例えば以下のように指定される。
expectedError: "error",
  • 一致しない場合は t.Errorf でテスト失敗として記録。

7.6 期待される出力との比較

if !reflect.DeepEqual(result, tt.expected) {
    t.Errorf("[TestCase '%s'] Result: '%v' | Expected: '%v'", tt.name, result, tt.expected)
}
  • reflect.DeepEqual を使って、Execute メソッドの戻り値 (result) が期待値 (tt.expected) と一致するかをチェック。
  • tt.expected はテストケース内で設定されており、例えば以下のように指定される。
expected: CreateAccountOutput{
    ID:        "3c096a40-ccba-4b58-93ed-57379ab04680",
    Name:      "Test",
    CPF:       "02815517078",
    Balance:   199.44,
    CreatedAt: time.Time{}.String(),
},
  • 期待値と Execute の戻り値 (result) が異なる場合、t.Errorf でエラーメッセージを出力する。

7.7 テストの並列実行

TestCreateAccountInteractor_Execute の最初で t.Parallel() を指定しているため、各サブテスト (t.Run) は並列実行される。
これにより、テストの実行時間が短縮される。

完成したテストコード

テストケースが2つ増えていることに注意。
usecase/create_account_test.go

package usecase

import (
	"context"
	"errors"
	"reflect"
	"testing"
	"time"

	"github.com/gsabadini/go-clean-architecture/domain"
)

type mockAccountRepoStore struct {
	domain.AccountRepository

	result domain.Account
	err    error
}

func (m mockAccountRepoStore) Create(_ context.Context, _ domain.Account) (domain.Account, error) {
	return m.result, m.err
}

type mockCreateAccountPresenter struct {
	result CreateAccountOutput
}

func (m mockCreateAccountPresenter) Output(_ domain.Account) CreateAccountOutput {
	return m.result
}

func TestCreateAccountInteractor_Execute(t *testing.T) {
	t.Parallel()

	type args struct {
		input CreateAccountInput
	}

	tests := []struct {
		name          string
		args          args
		repository    domain.AccountRepository
		presenter     CreateAccountPresenter
		expected      CreateAccountOutput
		expectedError interface{}
	}{
		{
			name: "Create account successful",// テストケースの名称
			args: args{
				input: CreateAccountInput{
					Name:    "Test",
					CPF:     "02815517078",
					Balance: 19944,
				},
			},
			repository: mockAccountRepoStore{
				result: domain.NewAccount(
					"3c096a40-ccba-4b58-93ed-57379ab04680",
					"Test",
					"02815517078",
					19944,
					time.Time{},
				),
				err: nil,
			},
			presenter: mockCreateAccountPresenter{
				result: CreateAccountOutput{
					ID:        "3c096a40-ccba-4b58-93ed-57379ab04680",
					Name:      "Test",
					CPF:       "02815517078",
					Balance:   199.44,
					CreatedAt: time.Time{}.String(),
				},
			},
            // 7.6 期待される出力として使用されるデータ
			expected: CreateAccountOutput{
				ID:        "3c096a40-ccba-4b58-93ed-57379ab04680",
				Name:      "Test",
				CPF:       "02815517078",
				Balance:   199.44,
				CreatedAt: time.Time{}.String(),
			},
		},
		{
			name: "Create account successful",
			args: args{
				input: CreateAccountInput{
					Name:    "Test",
					CPF:     "02815517078",
					Balance: 2350,
				},
			},
			repository: mockAccountRepoStore{
				result: domain.NewAccount(
					"3c096a40-ccba-4b58-93ed-57379ab04680",
					"Test",
					"02815517078",
					2350,
					time.Time{},
				),
				err: nil,
			},
			presenter: mockCreateAccountPresenter{
				result: CreateAccountOutput{
					ID:        "3c096a40-ccba-4b58-93ed-57379ab04680",
					Name:      "Test",
					CPF:       "02815517078",
					Balance:   23.5,
					CreatedAt: time.Time{}.String(),
				},
			},
			expected: CreateAccountOutput{
				ID:        "3c096a40-ccba-4b58-93ed-57379ab04680",
				Name:      "Test",
				CPF:       "02815517078",
				Balance:   23.5,
				CreatedAt: time.Time{}.String(),
			},
		},
		{
			name: "Create account generic error",
			args: args{
				input: CreateAccountInput{
					Name:    "",
					CPF:     "",
					Balance: 0,
				},
			},
			repository: mockAccountRepoStore{
				result: domain.Account{},
				err:    errors.New("error"),
			},
			presenter: mockCreateAccountPresenter{
				result: CreateAccountOutput{},
			},
            // 7.5 エラーの検証で使用される期待されるエラー
			expectedError: "error",
			expected:      CreateAccountOutput{},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			var uc = NewCreateAccountInteractor(tt.repository, tt.presenter, time.Second)

			result, err := uc.Execute(context.TODO(), tt.args.input)

            // 7.6 期待される出力との比較
			if (err != nil) && (err.Error() != tt.expectedError) {
				t.Errorf("[TestCase '%s'] Result: '%v' | ExpectedError: '%v'", tt.name, err, tt.expectedError)
			}

            // 7.5 エラーの検証
			if !reflect.DeepEqual(result, tt.expected) {
				t.Errorf("[TestCase '%s'] Result: '%v' | Expected: '%v'", tt.name, result, tt.expected)
			}
		})
	}
}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?