テストコードを書くということ
「テストコードを作成する」 = 「テスト対象コードの正しい動作を検証するため、仮データやモックを用いて実行する検証プログラムを作成すること」
テスト対象
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 つの要素を準備すれば、createAccountInteractor
の Execute
メソッドをテストできる。
6. 必要な要素を用意する
特定した必要要素を用意する。
6.1 domain.AccountRepository
型 repo
の用意
createAccountInteractor
の Execute
メソッドをテストするために、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 CreateAccountPresenter
型 presenter
の用意
createAccountInteractor
の Execute
メソッドをテストするために、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.Duration
型 t
の用意
time
パッケージのものを使うので、改めて用意する必要はない。
6.4 context.Context
型 ctx
の用意
context
パッケージのものを使うので、改めて用意する必要はない。
6.5 CreateAccountInput
型 input
の用意
createAccountInteractor
の Execute
メソッドをテストするために、CreateAccountInput
を準備する。以下の手順で input
を定義する。
6.5.1 型の定義
テストケースの構造体で、args
型の args
を定義する。
args args // 1. 入力データの型を定義
args
(arguments)の正式名称
args
は "arguments"(引数) の略で、関数に渡す引数をまとめるための構造体。
メリット
-
可読性向上
- テストケースごとに引数を整理し、統一感を持たせる。
- フィールドが増えても見やすい構造を維持できる。
-
引数の追加・変更が容易
-
args
にまとめることで、引数が増えても最小限の修正で対応可能。
-
6.5.2 CreateAccountInput
の作成
Execute
メソッドのテストを行うため、入力データのモックを作成する。
CreateAccountInput
は Name
、CPF
、Balance
の 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. テストを実行する処理を追加
ここまでで createAccountInteractor
の Execute
メソッドをテストする準備が整った。
次に、テストケースをループで実行し、期待通りの動作をしているかを検証する。
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.T
の Run
メソッドを使い、各テストケースをサブテストとして管理する。
これにより、どのテストケースが失敗したかを明確に特定できる。
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)
}
})
}
}