この章でやること

  • ユースケースを実装する
    • DTOを定義する
    • リポジトリ・クエリサービスのインターフェースを定義する
    • リポジトリ・クエリサービスをモック化して単体テストを行う

実装するユースケース一覧

第3章 ドメイン駆動設計にて簡易的なドメインモデリングを行う際に、ユースケースを洗い出しましたね。それらを元に、ユビキタス言語で表現し直し、それを集約できって羅列してみました。この章では、以下のようなユースケースを実装します。

ユーザー系

  • ユーザー登録機能
  • ユーザー退会機能
  • ユーザープロフィール更新機能
  • ユーザー一覧取得機能

タスク系

  • タスク作成機能
  • タスク削除機能
  • タスク状態更新機能
  • タスク取得機能
  • タスク一覧取得機能
  • ユーザータスク一覧取得機能

認証系に関してもユースケースを実装するのですが、それは第8章に譲ります。

ユースケースの責務

実装に入る前に、ユースケースの持つ責務について理解しておきましょう。

クリーンアーキテクチャにおいては、円の「内側」にあることを第二章では説明しました。アプリケーション固有のビジネスルール、ロジックを含む場所であり、アプリケーションが持つ「機能」を実現します。

ユースケースは、円のもう一つ外側に位置するインターフェースアダプター層の中でも特にハンドラーから依存されます。

機能を実現するために、ハンドラーからデータを受け取り、ドメイン層に定義されたロジックを操作します。また、処理結果などをハンドラーに返す処理も担います。つまり、

データを受け取って、ロジックを実行し、またそのデータを返すという、円の内側↔︎インタフェースアダプター層間のI/O処理の役割があると言えます。

どのように実装すればいいかを具体的に理解するために、次に具体例を用いて考えてみます。

ユースケースの実装方針

例:ユーザー登録ユースケース

「ユーザー登録ユースケース」を例にすると、以下のようになります。

コンポーネントのインターフェースに依存させる

これらの機能を実現するには、さまざまなコンポーネントが必要になります。

エンティティ、ドメインサービス、リポジトリを操作する必要があるのですね。エンティティは、ユースケースの内側にあるものであり、直接の依存が許されています。

そして「DBに保存する」ためには、ユーザー登録ユースケースはリポジトリを利用する必要があります。 しかし、ユースケースがインターフェースアダプター層にあるリポジトリを直接利用してしまうと、依存の方向が外側を向いてしまい、クリーンアーキテクチャにおいて重要な依存性のルールを侵してしまいます。

ユースケースはフレームワークやUI、DBなど外部の影響を受けるべきではありません。

この問題を解決するのが、インターフェースでしたね。第二章でも掲載した、依存関係を逆転させた図です。

ユースケースは、リポジトリに直接依存させるのではなく、ドメイン層に定義したリポジトリの「振る舞い」(つまり、インターフェース)に依存させるようにします。

インターフェースに依存させるメリット

「クリーンアーキテクチャが依存性のルールを推しているから、インターフェースに依存させています...」というように暗黙的にルールに従っているわけではありません。

インターフェース(振る舞い)を定義し、それに依存することは、以下のようなメリットがあります。

  • モジュールの差し替えによる単体テストの容易性
  • 抽象への依存による安定性

モジュール差し替えによる単体テストの容易性

インターフェースは、振る舞いです。以下は、ユーザーリポジトリインターフェースのコードです。ユーザー登録であれば、ユーザーを「保存する」メソッドが必要になるでしょう。

type UserRepository interface {
	Save(ctx context.Context, user *User) error
}

ここで定義されているのは、context.Context型のctxと*User型のuserを受け取り、errorを返すという「振る舞い」なのです。これにより、このインターフェースを使用するユーザ登録ユースケースは、リポジトリの具体的な実装の中身を知らずに利用することができるのです。

これが単体テスト容易性につながります。

このメリットを実感するには、インターフェースに依存せず、リポジトリの具体実装に依存している場合を考えるとわかりやすいです。

リポジトリの具体実装に依存してしまうと、

  • リポジトリの実装にバグがあるとそれを利用しているユースケースのテストも失敗する
  • リポジトリがDB操作を行うためのテストDBを用意する必要がある

といったデメリットがあります。

ユースケースのテストを行っているのにテスト結果がリポジトリの実装にも左右されているとなると、果たしてそれはユースケースの単体テストと言えるのか??となります。

ユースケースのテストをピュアに行うために、リポジトリには期待通りの挙動をしてもらう必要があります。そこで、インターフェースに利用すると、モジュールをモックに差し替えることにより、期待通りの動きをするリポジトリを用意できます。これで、ユースケースのロジックのみを確かめるテストを行えるようになります。

ドメインサービスはユースケースよりも内側にあるものですが、インターフェースに依存させているのはこのメリットを享受するためです。ユースケースのテストにおいては、ドメインサービスのロジックはテスト対象になりません。さらに、ドメインサービスは内部でリポジトリ(インターフェース)を利用しているので、テストが複雑になる可能性があります。ドメインサービスをモックで置き換えればシンプルにテストを行うことができるのです。

モックとは

テストを実装(実行)するときに、テストする対象ではない箇所に差し替える都合の良いダミーです。モックという言葉自体には、模倣物・擬似物という意味合いがあります。「振る舞い」だけを実装した、見かけだけのものということです。

抽象への依存による安定性

インターフェースを「抽象型」、そして具体的な実装を「具体型」と表現することがあります。そして、抽象型は具体型よりも変化しにくいという傾向があります。また、新しい機能を実装する際にはインターフェースの変更なしで済ませることが方針として推奨されているようです。

今回でいえば、「ユーザーを保存する」という振る舞いを持つUserRepositoryインターフェースを定義していますが、ユーザー登録ユースケースにおいて、この振る舞いが変更されることは少ないでしょう。

例として、何らかの理由で保存先がMySQLといったRDBからRedisなどのNoSQLに変わったとします。ユーザーリポジトリの持つSaveメソッドの具体実装は勿論変更されることでしょう。しかし、ユースケースはインターフェースという「振る舞い」に依存していることにより、具体実装に関しては知りません。よって、そのような変更の影響を受けずに、ユースケースのコンポーネントを安定させることができるのです。

アプリケーション層の中のユースケース

この章では、「アプリケーション層の実装」と謳いながらも、ほとんどがユースケースの実装です。それにクリーンアーキテクチャの文脈では、「ユースケース層」としてこのレイヤーは定義されることが多いです。ではなぜこのレイヤーをアプリケーション層として、そしてその中にユースケースを配置したのかを述べておきます。

筆者は、

アプリケーション層とは、アプリケーション(API)固有の要件を満たすロジックを配置する場所

と捉えています。アプリケーション層という呼び名はレイヤードアーキテクチャの文脈でよく使われているものです。

これならば、アプリケーション層 ≒ ユースケースであり、「ユースケース層」としちゃえばいいじゃん!と言えそうですね。実際、それも合理的であり、正しい設計といえます。

では、なぜアプリケーション層の中にユースケースを配置する構造にしたのかというと、

このレイヤーで扱いたいのはユースケース(アプリケーションが実現する「機能」のロジック)だけではないから

です。

例えば、今回は実装しませんがトランザクションがそれにあたります。トランザクションは、そのスコープの定義がアプリケーションの要件に基づくものとしてこのレイヤーにインターフェースが置かれることがあります。具体実装はインフラストラクチャ層に置かれるようです。

  • [どのレイヤー(層)でトランザクションを実装すべきか](

https://zenn.dev/cloud_ace/articles/transaction-architecture)

トランザクションは、各ユースケース内で使用されるものであり、ユースケースを実現させる構成要素であることは間違いありません。しかし、トランザクションはAPIの「機能」としては成立しません。

そこで、ユースケースという概念の抽象度をあげてアプリケーション層とし、その中にユースケース・トランザクションが配置できるようになると、見通しが良くなるのではないかなと考えたという次第です。今回はトランザクションを実装しないのですが😢(再喝)

Data Transfer Object

ユースケースがあるアプリケーション層までが、クリーンアーキテクチャにおける円の「内側」でした。そしてユースケースは、インターフェースアダプター層におけるハンドラとの間でデータをやり取りします。この時、例えば、先の例である「ユーザー登録ユースケース」が返り値としてUserオブジェクトを返し、ハンドラに渡したとしましょう。処理としては単純明快で、ぱっと見何もおかしくありません。

しかし、これではハンドラの中でドメインオブジェクト(エンティティ)が操作可能になってしまいます。ドメインオブジェクトがどこからでも操作できてしまう状況は、意図しないバグを生む危険性があります。

プレゼンテーション(ハンドラ)はドメインオブジェクトを「知っててもいい」けど「知る必要はない」ため、不必要な依存は避けるべきです。

そこで、ユースケースとプレゼンテーションのやり取り(I/O)は、データ転送用オブジェクトであるData Transfer Object(DTO) を使って行うようにします。 DTOは単なるデータクラスです。ロジックを持たせることはしません。

package user

type RegisterUsecaseInputDTO struct {
	Name     string
	Email    string
	Password string
}

type RegisterUsecaseOutputDTO struct {
	ID    string
	Name  string
	Email string
}

プレゼンテーションからデータを受け取るためのInputDTO、プレゼンテーションにデータを渡すためのOutputDTOを定義しています。具体的な使い方は、実装されたコードを追う中で理解していきましょう。

ユースケースの実装

ユースケースをそれぞれ実装していきましょう。app/application/usecase/user、app/application/usecase/taskディレクトリを作成してください。また、テストコードもまとめて載せていますが、gomockでモックを扱っています。その使用方法については次のセクションで述べています。

ユーザー系

ユーザーリポジトリのインターフェースを定義

前章ではFindByEmail()のみ記述していました。

// app/domain/user/interface_user_repository.go
package user

import "context"

//go:generate mockgen -package=user -source=./interface_user_repository.go -destination=./mock_user_repository.go
type UserRepository interface {
	Save(ctx context.Context, user *User) error
	FindByEmail(ctx context.Context, email Email) (*User, error)
	FindById(ctx context.Context, id string) (*User, error)
	FetchAllUsers(ctx context.Context) (Users, error)
	Update(ctx context.Context, user *User) error
	Delete(ctx context.Context, user *User) error
}

ユーザー登録

register_usecase.go の実装
// app/application/usecase/user/register_usecase.go
package user

import (
	"context"

	"github.com/kakkky/app/domain/errors"
	"github.com/kakkky/app/domain/user"
)

type RegisterUsecase struct {
	userRepository    user.UserRepository
	userDomainService user.UserDomainService
}

func NewRegisterUsecase(
	userRepository user.UserRepository,
	userDomainService user.UserDomainService,
) *RegisterUsecase {
	return &RegisterUsecase{
		userRepository:    userRepository,
		userDomainService: userDomainService,
	}
}

func (ru *RegisterUsecase) Run(ctx context.Context, input RegisterUsecaseInputDTO) (*RegisterUsecaseOutputDTO, error) {
	// userインスタンスを生成
	u, err := user.NewUser(
		input.Email,
		input.Name,
		input.Password,
	)
	if err != nil {
		return nil, err
	}
	//userがすでに登録済みでないか?
	ok, err := ru.userDomainService.IsExists(ctx, u.GetEmail())
	if err != nil {
		return nil, err
	}
	// emailがすでに存在していた場合
	if ok {
		return nil, errors.ErrAlreadyRegisterd
	}
	if err := ru.userRepository.Save(ctx, u); err != nil {
		return nil, err
	}
	// DTOに詰め替えて返す
	return &RegisterUsecaseOutputDTO{
		ID:    u.GetID(),
		Name:  u.GetName(),
		Email: u.GetEmail().Value(),
	}, nil
}

DTOを用意

// app/application/usecase/user/register_usecase_dto.go
package user

type RegisterUsecaseInputDTO struct {
	Name     string
	Email    string
	Password string
}

type RegisterUsecaseOutputDTO struct {
	ID    string
	Name  string
	Email string
}

テストコード

// app/application/usecase/user/register_usecase_test.go
package user

import (
	"context"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"
	"github.com/kakkky/app/domain/errors"
	"github.com/kakkky/app/domain/user"
	"go.uber.org/mock/gomock"
)

func TestUser_RegisterUsecase_Run(t *testing.T) {
	t.Parallel()
	tests := []struct {
		name    string
		mockFn  func(mr *user.MockUserRepository, mds *user.MockUserDomainService)
		input   RegisterUsecaseInputDTO
		errType error
		want    *RegisterUsecaseOutputDTO
		wantErr bool
	}{
		{
			name: "正常系:ユーザーを登録できる",
			mockFn: func(mr *user.MockUserRepository, mds *user.MockUserDomainService) {
				mds.EXPECT().IsExists(gomock.Any(), gomock.Any()).Return(false, nil)
				mr.EXPECT().Save(gomock.Any(), gomock.Any()).Return(nil)
			},
			input: RegisterUsecaseInputDTO{
				Name:     "user",
				Email:    "[email protected]",
				Password: "password",
			},
			want: &RegisterUsecaseOutputDTO{
				Name:  "user",
				Email: "[email protected]",
			},
			wantErr: false,
		},
		{
			name: "準正常系:重複した登録は阻止する",
			mockFn: func(mr *user.MockUserRepository, mds *user.MockUserDomainService) {
				// emailで検索すると、DBに存在していたケース
				mds.EXPECT().IsExists(gomock.Any(), gomock.Any()).Return(true, nil)
			},
			input: RegisterUsecaseInputDTO{
				Name:     "user",
				Email:    "[email protected]",
				Password: "password",
			},
			errType: errors.ErrAlreadyRegisterd,
			wantErr: true,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			// モックを設定
			ctrl := gomock.NewController(t)
			mockUserRepository := user.NewMockUserRepository(ctrl)
			mockUserDomainService := user.NewMockUserDomainService(ctrl)
			tt.mockFn(mockUserRepository, mockUserDomainService)
			// ユースケースオブジェクト
			registerUsecase := NewRegisterUsecase(mockUserRepository, mockUserDomainService)
			ctx := context.Background()
			got, err := registerUsecase.Run(ctx, tt.input)
			// 期待するエラー型を設定していた場合はエラー型を比較して検証する
			if tt.errType != nil && !errors.Is(err, tt.errType) {
				t.Errorf("registerUsecase.Run = error:%v,want errYType:%v", err, tt.errType)
				return
			}
			if (err != nil) != tt.wantErr {
				t.Errorf("registerUsecase.Run = error:%v,wantErr:%v", err, tt.wantErr)
				return
			}
			if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreFields(RegisterUsecaseOutputDTO{}, "ID")); diff != "" {
				t.Errorf("registerUsecase.Run() -got,+want :%v ", diff)
			}
		})
	}
}

ユースケース構造体

RegisterUsecaseは、UserRepositoryとUserDomainServiceを利用します。それを、フィールドに持たせることで表現しています。具体型ではなく、それぞれはインターフェースです。第12章にて、ハンドラ構造体を初期化する際に具体型をコンストラクタインジェクションによってDI(依存性の注入)します(NewRegisterUsecase())。

type RegisterUsecase struct {
	userRepository    user.UserRepository
	userDomainService user.UserDomainService
}

func NewRegisterUsecase(
	userRepository user.UserRepository,
	userDomainService user.UserDomainService,
) *RegisterUsecase {
	return &RegisterUsecase{
		userRepository:    userRepository,
		userDomainService: userDomainService,
	}
}

InputDTOとOutputDTO

プレゼンテーションからInputDTOを受け取り、OutputDTOに詰め替えてプレゼンテーションに渡すようにしています。

func (ru *RegisterUsecase) Run(ctx context.Context, input RegisterUsecaseInputDTO) (*RegisterUsecaseOutputDTO, error) {
	// userインスタンスを生成
	u, err := user.NewUser(
		input.Email,
		input.Name,
		input.Password,
	)

    // 省略

	// DTOに詰め替えて返す
	return &RegisterUsecaseOutputDTO{
		ID:    u.GetID(),
		Name:  u.GetName(),
		Email: u.GetEmail().Value(),
	}, nil
}

ユーザー退会

unregister_usecase.go の実装
// app/application/usecase/user/unregister_usecase.go
package user

import (
	"context"

	"github.com/kakkky/app/domain/user"
)

type UnregisterUsecase struct {
	userRepository user.UserRepository
}

func NewUnregisterUsecase(
	userRepository user.UserRepository,
) *UnregisterUsecase {
	return &UnregisterUsecase{
		userRepository: userRepository,
	}
}

func (uu *UnregisterUsecase) Run(ctx context.Context, input UnregisterUsecaseInputDTO) error {
	// 存在しているユーザーしか削除できない
	u, err := uu.userRepository.FindById(ctx, input.ID)
	// エラーかユーザーがnilの場合はエラー
	if err != nil || u == nil {
		return err
	}
	if err := uu.userRepository.Delete(ctx, u); err != nil {
		return err
	}
	return nil
}

DTOを用意

// app/application/usecase/user/unregister_usecase_dto.go
package user

type UnregisterUsecaseInputDTO struct {
	ID string
}

テストコード

// app/application/usecase/user/unregister_usecase_test.go
package user

import (
	"context"
	"testing"

	"github.com/kakkky/app/domain/errors"
	"github.com/kakkky/app/domain/user"
	"go.uber.org/mock/gomock"
)

func TestUser_UnregisterUsease_Run(t *testing.T) {
	tests := []struct {
		name    string
		mockFn  func(mr *user.MockUserRepository)
		input   UnregisterUsecaseInputDTO
		wantErr bool
	}{
		{
			name: "正常系: ユーザーを削除できる",
			mockFn: func(mr *user.MockUserRepository) {
				mr.EXPECT().FindById(gomock.Any(), gomock.Any()).Return(user.ReconstructUser("1", "", "", ""), nil)
				mr.EXPECT().Delete(gomock.Any(), gomock.Any()).Return(nil)
			},
			input: UnregisterUsecaseInputDTO{
				ID: "1",
			},
			wantErr: false,
		},
		{
			name: "準正常系: ユーザーが見つからない場合はエラーを返す",
			mockFn: func(mr *user.MockUserRepository) {
				mr.EXPECT().FindById(gomock.Any(), gomock.Any()).Return(nil, errors.ErrNotFoundUser)
			},
			input: UnregisterUsecaseInputDTO{
				ID: "0",
			},
			wantErr: true,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			// モックを設定
			ctrl := gomock.NewController(t)
			mockUserRepository := user.NewMockUserRepository(ctrl)
			tt.mockFn(mockUserRepository)
			// ユースケースオブジェクト
			unregisterUsecase := NewUnregisterUsecase(mockUserRepository)
			ctx := context.Background()
			err := unregisterUsecase.Run(ctx, tt.input)
			if (err != nil) != tt.wantErr {
				t.Errorf("unregisterUsecase.Run = error:%v,wantErr:%v", err, tt.wantErr)
			}
		})
	}
}

ユーザープロフィール更新

update_profile_usecase.go の実装
// app/application/usecase/user/update_profile_usecase.go
package user

import (
	"context"

	"github.com/kakkky/app/domain/user"
)

type UpdateProfileUsecase struct {
	userRepository user.UserRepository
}

func NewUpdateProfileUsecase(
	userRepository user.UserRepository,
) *UpdateProfileUsecase {
	return &UpdateProfileUsecase{
		userRepository: userRepository,
	}
}

func (epu *UpdateProfileUsecase) Run(ctx context.Context, input UpdateProfileUsecaseInputDTO) (
	*UpdateProfileUsecaseOutputDTO, error,
) {
	// 存在しているユーザーしか編集できない
	u, err := epu.userRepository.FindById(ctx, input.ID)
	// エラーかユーザーがnilの場合はエラー
	if err != nil || u == nil {
		return nil, err
	}
	// 値が空の場合は既存情報を入れる
	if input.Name == "" {
		input.Name = u.GetName()
	}
	if input.Email == "" {
		input.Email = u.GetEmail().Value()
	}
	// input情報をもとに、更新情報を反映したインスタンスを作成
	updatedUser, err := u.UpdateUser(
		input.Email,
		input.Name,
	)
	if err != nil {
		return nil, err
	}
	if err := epu.userRepository.Update(ctx, updatedUser); err != nil {
		return nil, err
	}
	// 更新したオブジェクトをDTOに詰め替える
	return &UpdateProfileUsecaseOutputDTO{
		ID:    updatedUser.GetID(),
		Email: updatedUser.GetEmail().Value(),
		Name:  updatedUser.GetName(),
	}, nil
}

DTOを用意

// app/application/usecase/user/update_profile_usecase_dto.go
package user

type UpdateProfileUsecaseInputDTO struct {
	ID    string
	Name  string
	Email string
}
type UpdateProfileUsecaseOutputDTO struct {
	ID    string
	Name  string
	Email string
}

テストコード

// app/application/usecase/user/update_profile_usecase_test.go
package user

import (
	"context"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/kakkky/app/domain/errors"
	"github.com/kakkky/app/domain/user"
	"go.uber.org/mock/gomock"
)

func TestUser_UpdateProfileUsecase_Run(t *testing.T) {
	tests := []struct {
		name    string
		mockFn  func(mr *user.MockUserRepository)
		input   UpdateProfileUsecaseInputDTO
		want    *UpdateProfileUsecaseOutputDTO
		wantErr bool
	}{
		{
			name: "正常形:プロフィールを編集できる",
			mockFn: func(mr *user.MockUserRepository) {
				mr.EXPECT().FindById(gomock.Any(), gomock.Any()).Return(user.ReconstructUser("1", "[email protected]", "testuser", ""), nil)
				mr.EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil)

			},
			input: UpdateProfileUsecaseInputDTO{
				ID:    "1",
				Email: "[email protected]",
				Name:  "updatedUser",
			},
			want: &UpdateProfileUsecaseOutputDTO{
				ID:    "1",
				Email: "[email protected]",
				Name:  "updatedUser",
			},
			wantErr: false,
		},
		{
			name: "正常形:空のフィールドは更新されない",
			mockFn: func(mr *user.MockUserRepository) {
				mr.EXPECT().FindById(gomock.Any(), gomock.Any()).Return(user.ReconstructUser("1", "[email protected]", "testuser", ""), nil)
				mr.EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil)

			},
			input: UpdateProfileUsecaseInputDTO{
				ID:   "1",
				Name: "updatedUser",
			},
			want: &UpdateProfileUsecaseOutputDTO{
				ID:    "1",
				Email: "[email protected]",
				Name:  "updatedUser",
			},
			wantErr: false,
		},
		{
			name: "準正常形:存在しないユーザーは編集できない",
			mockFn: func(mr *user.MockUserRepository) {
				mr.EXPECT().FindById(gomock.Any(), gomock.Any()).Return(nil, errors.ErrNotFoundUser)
			},
			input: UpdateProfileUsecaseInputDTO{
				ID:    "0",
				Email: "[email protected]",
				Name:  "noexitstent",
			},
			wantErr: true,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			// モックを設定
			ctrl := gomock.NewController(t)
			mockUserRepository := user.NewMockUserRepository(ctrl)
			tt.mockFn(mockUserRepository)
			// ユースケースオブジェクト
			UpdateProfileUsecase := NewUpdateProfileUsecase(mockUserRepository)
			ctx := context.Background()
			got, err := UpdateProfileUsecase.Run(ctx, tt.input)
			if (err != nil) != tt.wantErr {
				t.Errorf("UpdateProfileUsecase.Run = error:%v,wantErr:%v", err, tt.wantErr)
			}
			if diff := cmp.Diff(got, tt.want); diff != "" {
				t.Errorf("UpdateProfileUsecase.Run() -got,+want :%v ", diff)
			}
		})
	}
}

ユーザー一覧取得

fetch_users_usecase.go の実装
// app/application/usecase/user/fetch_users_usecase.go
package user

import (
	"context"

	"github.com/kakkky/app/domain/user"
)

type FetchUsersUsecase struct {
	userRepository user.UserRepository
}

func NewFetchUsersUsecase(
	userRepository user.UserRepository,
) *FetchUsersUsecase {
	return &FetchUsersUsecase{
		userRepository: userRepository,
	}
}

func (fuu *FetchUsersUsecase) Run(ctx context.Context) ([]*FetchUsersUsecaseOutputDTO, error) {
	us, err := fuu.userRepository.FetchAllUsers(ctx)
	if err != nil {
		return nil, err
	}
	outputs := make([]*FetchUserUsecaseOutputDTO, 0, len(us))
	for _, u := range us {
		outputs = append(outputs, &FetchUserUsecaseOutputDTO{ID: u.GetID(), Name: u.GetName()})
	}
	return outputs, nil
}

DTOを用意

// app/application/usecase/user/fetch_user_usecase_dto.go
package user

type FetchUserUsecaseOutputDTO struct {
	ID   string
	Name string
}

テストコード

// app/application/usecase/user/fetch_users_usecase_test.go
package user

import (
	"context"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/kakkky/app/domain/user"
	"go.uber.org/mock/gomock"
)

func TestUser_FetchUsersUsecase_Run(t *testing.T) {
	tests := []struct {
		name    string
		mockFn  func(mr *user.MockUserRepository)
		want    []*FetchUserUsecaseOutputDTO
		wantErr bool
	}{
		{
			name: "正常形: 登録されている全てのユーザーを取得する",
			mockFn: func(mr *user.MockUserRepository) {
				mr.EXPECT().FetchAllUsers(gomock.Any()).Return(
					user.Users{
						user.ReconstructUser("1", "", "user1", ""),
						user.ReconstructUser("2", "", "user2", ""),
						user.ReconstructUser("3", "", "user3", ""),
					}, nil,
				)
			},
			want: []*FetchUserUsecaseOutputDTO{
				{
					ID:   "1",
					Name: "user1",
				},
				{
					ID:   "2",
					Name: "user2",
				},
				{
					ID:   "3",
					Name: "user3",
				},
			},
			wantErr: false,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			// モックを設定
			ctrl := gomock.NewController(t)
			mockUserRepository := user.NewMockUserRepository(ctrl)
			tt.mockFn(mockUserRepository)
			FetchUsersUsecase := NewFetchUsersUsecase(mockUserRepository)
			ctx := context.Background()
			got, err := FetchUsersUsecase.Run(ctx)
			if (err != nil) != tt.wantErr {
				t.Errorf("FetchUsersUsecase.Run = error:%v,wantErr:%v", err, tt.wantErr)
			}
			if diff := cmp.Diff(got, tt.want); diff != "" {
				t.Errorf("FetchUsersUsecase.Run() -got,+want :%v ", diff)
			}

		})
	}
}

タスク系

タスクリポジトリのインターフェースを定義

タスクリポジトリのインターフェースも定義しましょう。

// app/domain/task/interface_task_repository.go
package task

import "context"

//go:generate mockgen -package=task -source=./interface_task_repository.go -destination=./mock_task_repository.go
type TaskRepository interface {
	FindById(ctx context.Context, id string) (*Task, error)
	Save(ctx context.Context, task *Task) error
	Update(ctx context.Context, task *Task) error
	Delete(ctx context.Context, task *Task) error
}

タスククエリサービスのインターフェースを定義

タスククエリサービスのインターフェースも定義します。クエリサービスについては、第9章にて取り扱うので、コピペだけしておいてください。/domain/taskではなく/usecase/taskに定義することに注意してください。

// app/application/usecase/task/interface_task_queryservice.go
package task

import "context"

//go:generate mockgen -package=task -source=./interface_task_queryservice.go -destination=./mock_task_queryservice.go
type TaskQueryService interface {
	FetchTaskById(ctx context.Context, id string) (*FetchTaskDTO, error)
	FetchUserTasks(ctx context.Context, userId string) ([]*FetchTaskDTO, error)
	FetchAllTasks(ctx context.Context) ([]*FetchTaskDTO, error)
}

type FetchTaskDTO struct {
	ID       string
	UserName string
	UserId   string
	Content  string
	State    string
}

タスク作成

create_task_usecase.go の実装
// app/application/usecase/task/create_task_usecase.go
package task

import (
	"context"

	"github.com/kakkky/app/domain/task"
)

type CreateTaskUsecase struct {
	taskRepository task.TaskRepository
}

func NewCreateTaskUsecase(taskRepository task.TaskRepository) *CreateTaskUsecase {
	return &CreateTaskUsecase{
		taskRepository: taskRepository,
	}
}

func (ctu *CreateTaskUsecase) Run(ctx context.Context, input CreateTaskUsecaseInputDTO) (
	*CreateTaskUsecaseOutputDTO, error) {
	t, err := task.NewTask(input.UserId, input.Content, input.State)
	if err != nil {
		return nil, err
	}
	if err := ctu.taskRepository.Save(ctx, t); err != nil {
		return nil, err
	}
	return &CreateTaskUsecaseOutputDTO{
		ID:      t.GetID(),
		UserId:  t.GetUserId(),
		Content: t.GetContent().Value(),
		State:   t.GetState().StrValue(),
	}, nil
}
// app/application/usecase/task/create_task_usecase_dto.go
package task

type CreateTaskUsecaseInputDTO struct {
	UserId  string
	Content string
	State   string
}

type CreateTaskUsecaseOutputDTO struct {
	ID      string
	UserId  string
	Content string
	State   string
}
// app/application/usecase/task/create_task_usecase_test.go
package task

import (
	"context"

	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"
	"github.com/kakkky/app/domain/errors"
	"github.com/kakkky/app/domain/task"
	"go.uber.org/mock/gomock"
)

func TestTask_CreateTaskUsecase_Run(t *testing.T) {
	t.Parallel()
	tests := []struct {
		name    string
		mockFn  func(mr *task.MockTaskRepository)
		input   CreateTaskUsecaseInputDTO
		errType error
		want    *CreateTaskUsecaseOutputDTO
		wantErr bool
	}{
		{
			name: "正常系",
			mockFn: func(mr *task.MockTaskRepository) {
				mr.EXPECT().Save(gomock.Any(), gomock.Any()).Return(nil)
			},
			input: CreateTaskUsecaseInputDTO{
				UserId:  "user_id",
				Content: "this is content",
				State:   "todo",
			},
			want: &CreateTaskUsecaseOutputDTO{
				UserId:  "user_id",
				Content: "this is content",
				State:   "todo",
			},
			wantErr: false,
		},
		{
			name: "準正常系:タスク内容が空っぽ",
			mockFn: func(mr *task.MockTaskRepository) {
				// Saveメソッドは呼ばれない
			},
			input: CreateTaskUsecaseInputDTO{
				UserId:  "user_id",
				Content: "",
				State:   "todo",
			},
			errType: errors.ErrContentEmpty,
			wantErr: true,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			// モックを設定
			ctrl := gomock.NewController(t)
			mockTaskRepository := task.NewMockTaskRepository(ctrl)
			tt.mockFn(mockTaskRepository)
			// ユースケースオブジェクト
			sut := NewCreateTaskUsecase(mockTaskRepository)
			ctx := context.Background()
			got, err := sut.Run(ctx, tt.input)
			// 期待するエラー型を設定していた場合はエラー型を比較して検証する
			if tt.errType != nil && !errors.Is(err, tt.errType) {
				t.Errorf("createTaskUsecase.Run = error:%v,want errYType:%v", err, tt.errType)
				return
			}
			if (err != nil) != tt.wantErr {
				t.Errorf("createTaskUsecase.Run = error:%v,wantErr:%v", err, tt.wantErr)
				return
			}
			if diff := cmp.Diff(got, tt.want, cmpopts.IgnoreFields(CreateTaskUsecaseOutputDTO{}, "ID")); diff != "" {
				t.Errorf("createTaskUsecase.Run() -got,+want :%v ", diff)
			}
		})
	}
}

タスク削除

delete_task_usecase.go の実装
// app/application/usecase/task/delete_task_usecase.go
package task

import (
	"context"

	"github.com/kakkky/app/domain/task"
)

type DeleteTaskUsecase struct {
	taskRepository task.TaskRepository
}

func NewDeleteTaskUsecase(taskRepository task.TaskRepository) *DeleteTaskUsecase {
	return &DeleteTaskUsecase{
		taskRepository: taskRepository,
	}
}

func (dtu *DeleteTaskUsecase) Run(ctx context.Context, input DeleteTaskUsecaseInputDTO) error {
	t, err := dtu.taskRepository.FindById(ctx, input.ID)
	if err != nil || t == nil {
		return err
	}
	// ログインしているユーザーIDと一致したら
	if err := t.IsOperableBy(input.UserId); err != nil {
		return err
	}
	if err := dtu.taskRepository.Delete(ctx, t); err != nil {
		return err
	}
	return nil
}
// app/application/usecase/task/delete_task_usecase_dto.go
package task

type DeleteTaskUsecaseInputDTO struct {
	ID     string
	UserId string
}
// app/application/usecase/task/delete_task_usecase_test.go
package task

import (
	"context"
	"testing"

	"github.com/kakkky/app/domain/errors"
	"github.com/kakkky/app/domain/task"
	"go.uber.org/mock/gomock"
)

func TestTask_DeleteTaskUsecase_Run(t *testing.T) {
	t.Parallel()
	tests := []struct {
		name    string
		mockFn  func(mr *task.MockTaskRepository)
		input   DeleteTaskUsecaseInputDTO
		errType error
		wantErr bool
	}{
		{
			name: "正常系",
			mockFn: func(mr *task.MockTaskRepository) {
				mr.EXPECT().FindById(gomock.Any(), gomock.Any()).Return(
					task.ReconstructTask("id", "user_id", "this is content", 0),
					nil,
				)
				mr.EXPECT().Delete(gomock.Any(), gomock.Any()).Return(nil)
			},
			input: DeleteTaskUsecaseInputDTO{
				UserId: "user_id",
				ID:     "id",
			},
			wantErr: false,
		},
		{
			name: "準正常系:指定したidを持つタスクが存在しない",
			mockFn: func(mr *task.MockTaskRepository) {
				mr.EXPECT().FindById(gomock.Any(), gomock.Any()).Return(
					nil,
					errors.ErrNotFoundTask,
				)
			},
			input: DeleteTaskUsecaseInputDTO{
				UserId: "user_id",
				ID:     "id",
			},
			wantErr: true,
		},
		{
			name: "準正常系:異なるuser_idで操作するとエラーが返る",
			mockFn: func(mr *task.MockTaskRepository) {
				mr.EXPECT().FindById(gomock.Any(), gomock.Any()).Return(
					task.ReconstructTask("id", "user_id", "this is content", 0),
					nil,
				)
			},
			input: DeleteTaskUsecaseInputDTO{
				UserId: "orther_user_id",
				ID:     "id",
			},
			wantErr: true,
			errType: errors.ErrForbiddenTaskOperation,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			// モックを設定
			ctrl := gomock.NewController(t)
			mockTaskRepository := task.NewMockTaskRepository(ctrl)
			tt.mockFn(mockTaskRepository)
			// ユースケースオブジェクト
			sut := NewDeleteTaskUsecase(mockTaskRepository)
			ctx := context.Background()
			err := sut.Run(ctx, tt.input)
			// 期待するエラー型を設定していた場合はエラー型を比較して検証する
			if tt.errType != nil && !errors.Is(err, tt.errType) {
				t.Errorf("deleteTaskUsecase.Run = error:%v,want errYType:%v", err, tt.errType)
				return
			}
			if (err != nil) != tt.wantErr {
				t.Errorf("deleteTaskUsecase.Run = error:%v,wantErr:%v", err, tt.wantErr)
				return
			}
		})
	}
}

タスク状態更新

update_task_state_usecase.go の実装
// app/application/usecase/task/update_task_state_usecase.go
package task

import (
	"context"

	"github.com/kakkky/app/domain/task"
)

type UpdateTaskStateUsecase struct {
	taskRepository task.TaskRepository
}

func NewUpdateTaskStateUsecase(taskRepository task.TaskRepository) *UpdateTaskStateUsecase {
	return &UpdateTaskStateUsecase{
		taskRepository: taskRepository,
	}
}

func (utu *UpdateTaskStateUsecase) Run(ctx context.Context, input UpdateTaskStateUsecaseInputDTO) (
	*UpdateTaskStateUsecaseOutputDTO, error,
) {
	t, err := utu.taskRepository.FindById(ctx, input.ID)
	if err != nil || t == nil {
		return nil, err
	}
	// ログインしているユーザーIDと一致したら
	if err := t.IsOperableBy(input.UserId); err != nil {
		return nil, err
	}
	updatedTask, err := t.UpdateState(input.State)
	if err != nil {
		return nil, err
	}
	if err := utu.taskRepository.Update(ctx, updatedTask); err != nil {
		return nil, err
	}
	return &UpdateTaskStateUsecaseOutputDTO{
		ID:      updatedTask.GetID(),
		UserId:  updatedTask.GetUserId(),
		Content: updatedTask.GetContent().Value(),
		State:   updatedTask.GetState().StrValue(),
	}, nil
}
// app/application/usecase/task/update_task_state_usecase_dto.go
package task

type UpdateTaskStateUsecaseInputDTO struct {
	ID     string
	UserId string
	State  string
}

type UpdateTaskStateUsecaseOutputDTO struct {
	ID      string
	UserId  string
	Content string
	State   string
}
// app/application/usecase/task/update_task_state_usecase_test.go
package task

import (
	"context"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/kakkky/app/domain/errors"
	"github.com/kakkky/app/domain/task"
	"go.uber.org/mock/gomock"
)

func TestTask_UpdateTaskStateUsecase_Run(t *testing.T) {
	t.Parallel()
	tests := []struct {
		name    string
		mockFn  func(mr *task.MockTaskRepository)
		input   UpdateTaskStateUsecaseInputDTO
		errType error
		want    *UpdateTaskStateUsecaseOutputDTO
		wantErr bool
	}{
		{
			name: "正常系",
			mockFn: func(mr *task.MockTaskRepository) {
				mr.EXPECT().FindById(gomock.Any(), gomock.Any()).Return(
					task.ReconstructTask("id", "user_id", "this is content", 0),
					nil,
				)
				mr.EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil)
			},
			input: UpdateTaskStateUsecaseInputDTO{
				ID:     "id",
				UserId: "user_id",
				State:  "done",
			},
			want: &UpdateTaskStateUsecaseOutputDTO{
				ID:      "id",
				UserId:  "user_id",
				Content: "this is content",
				State:   "done",
			},
			wantErr: false,
		},
		{
			name: "準正常系:指定したstateが不正",
			mockFn: func(mr *task.MockTaskRepository) {
				mr.EXPECT().FindById(gomock.Any(), gomock.Any()).Return(
					task.ReconstructTask("id", "user_id", "this is content", 0),
					nil,
				)
			},
			errType: errors.ErrInvalidTaskState,
			input: UpdateTaskStateUsecaseInputDTO{
				ID:     "id",
				UserId: "user_id",
				State:  "invalid",
			},
			wantErr: true,
		},
		{
			name: "準正常系:指定したidを持つタスクが存在しない",
			mockFn: func(mr *task.MockTaskRepository) {
				mr.EXPECT().FindById(gomock.Any(), gomock.Any()).Return(
					nil,
					errors.ErrNotFoundTask,
				)
			},
			errType: errors.ErrNotFoundTask,
			input: UpdateTaskStateUsecaseInputDTO{
				ID:     "id",
				UserId: "user_id",
				State:  "doing",
			},
			wantErr: true,
		},
		{
			name: "準正常系:異なるuser_idで操作するとエラーが返る",
			mockFn: func(mr *task.MockTaskRepository) {
				mr.EXPECT().FindById(gomock.Any(), gomock.Any()).Return(
					task.ReconstructTask("id", "user_id", "this is content", 0),
					nil,
				)
			},
			errType: errors.ErrForbiddenTaskOperation,
			input: UpdateTaskStateUsecaseInputDTO{
				ID:     "id",
				UserId: "other_user_id",
				State:  "doing",
			},
			wantErr: true,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			// モックを設定
			ctrl := gomock.NewController(t)
			mockTaskRepository := task.NewMockTaskRepository(ctrl)
			tt.mockFn(mockTaskRepository)
			// ユースケースオブジェクト
			sut := NewUpdateTaskStateUsecase(mockTaskRepository)
			ctx := context.Background()
			got, err := sut.Run(ctx, tt.input)
			// 期待するエラー型を設定していた場合はエラー型を比較して検証する
			if tt.errType != nil && !errors.Is(err, tt.errType) {
				t.Errorf("updateTaskState.Run = error:%v,want errYType:%v", err, tt.errType)
				return
			}
			if (err != nil) != tt.wantErr {
				t.Errorf("updateTaskState.Run = error:%v,wantErr:%v", err, tt.wantErr)
				return
			}
			if diff := cmp.Diff(got, tt.want); diff != "" {
				t.Errorf("updateTaskState.Run() -got,+want :%v ", diff)
			}
		})
	}
}

タスク取得

fetch_task_usecase.go の実装
// app/application/usecase/task/fetch_task_usecase.go
package task

import "context"

type FetchTaskUsease struct {
	taskQueryService TaskQueryService
}

func NewFetchTaskUsease(taskQueryService TaskQueryService) *FetchTaskUsease {
	return &FetchTaskUsease{
		taskQueryService: taskQueryService,
	}
}

func (ftu *FetchTaskUsease) Run(ctx context.Context, input FetchTaskUsecaseInputDTO) (
	*FetchTaskUsecaseOutputDTO, error,
) {
	dto, err := ftu.taskQueryService.FetchTaskById(ctx, input.ID)
	if err != nil {
		return nil, err
	}
	// クエリサービスから得たdtoを詰め替える
	return &FetchTaskUsecaseOutputDTO{
		ID:       dto.ID,
		UserId:   dto.UserId,
		UserName: dto.UserName,
		Content:  dto.Content,
		State:    dto.State,
	}, nil
}
// app/application/usecase/task/fetch_task_usecase_dto.go
package task

type FetchTaskUsecaseInputDTO struct {
	ID string
}

type FetchTaskUsecaseOutputDTO struct {
	ID       string
	UserName string
	UserId   string
	Content  string
	State    string
}
// app/application/usecase/task/fetch_task_usecase_test.go
package task

import (
	"context"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/kakkky/app/domain/errors"
	"go.uber.org/mock/gomock"
)

func TestTask_FetchTaskUsecase_Run(t *testing.T) {
	t.Parallel()
	tests := []struct {
		name    string
		mockFn  func(mq *MockTaskQueryService)
		input   FetchTaskUsecaseInputDTO
		errType error
		want    *FetchTaskUsecaseOutputDTO
		wantErr bool
	}{
		{
			name: "正常系",
			mockFn: func(mq *MockTaskQueryService) {
				mq.EXPECT().FetchTaskById(gomock.Any(), gomock.Any()).Return(
					&FetchTaskDTO{
						ID:       "id",
						UserName: "user",
						UserId:   "user_id",
						Content:  "content",
						State:    "todo",
					}, nil,
				)
			},
			input: FetchTaskUsecaseInputDTO{
				ID: "id",
			},
			want: &FetchTaskUsecaseOutputDTO{
				ID:       "id",
				UserName: "user",
				UserId:   "user_id",
				Content:  "content",
				State:    "todo",
			},
			wantErr: false,
		},
		{
			name: "準正常系:指定のidを持つtaskが見つからない",
			mockFn: func(mq *MockTaskQueryService) {
				mq.EXPECT().FetchTaskById(gomock.Any(), gomock.Any()).Return(
					nil, errors.ErrNotFoundTask,
				)
			},
			input: FetchTaskUsecaseInputDTO{
				ID: "id",
			},
			errType: errors.ErrNotFoundTask,
			wantErr: true,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			ctrl := gomock.NewController(t)
			mockTaskQueryService := NewMockTaskQueryService(ctrl)
			tt.mockFn(mockTaskQueryService)
			// ユースケースオブジェクト
			sut := NewFetchTaskUsease(mockTaskQueryService)
			ctx := context.Background()
			got, err := sut.Run(ctx, tt.input)
			// 期待するエラー型を設定していた場合はエラー型を比較して検証する
			if tt.errType != nil && !errors.Is(err, tt.errType) {
				t.Errorf("fetchTaskUsecase.Run = error:%v,want errYType:%v", err, tt.errType)
				return
			}
			if (err != nil) != tt.wantErr {
				t.Errorf("fetchTaskUsecase.Run = error:%v,wantErr:%v", err, tt.wantErr)
				return
			}
			if diff := cmp.Diff(got, tt.want); diff != "" {
				t.Errorf("fetchTaskUsecase.Run() -got,+want :%v ", diff)
			}
		})
	}
}

タスク一覧取得

fetch_tasks_usecase.go の実装
// app/application/usecase/task/fetch_tasks_usecase.go
package task

import "context"

type FetchTasksUsease struct {
	taskQueryService TaskQueryService
}

func NewFetchTasksUsease(taskQueryService TaskQueryService) *FetchTasksUsease {
	return &FetchTasksUsease{
		taskQueryService: taskQueryService,
	}
}

func (ftu *FetchTasksUsease) Run(ctx context.Context) (
	[]*FetchTaskUsecaseOutputDTO, error,
) {
	dtos, err := ftu.taskQueryService.FetchAllTasks(ctx)
	if err != nil {
		return nil, err
	}
	// クエリサービスから得たdtoを詰め替える
	outputs := make([]*FetchTaskUsecaseOutputDTO, 0, len(dtos))
	for _, dto := range dtos {
		outputs = append(outputs, &FetchTaskUsecaseOutputDTO{
			ID:       dto.ID,
			UserId:   dto.UserId,
			UserName: dto.UserName,
			Content:  dto.Content,
			State:    dto.State,
		})
	}
	return outputs, nil
}
// app/application/usecase/task/fetch_tasks_usecase_test.go
package task

import (
	"context"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/kakkky/app/domain/errors"
	"go.uber.org/mock/gomock"
)

func TestTask_FetchTasksUsecase_Run(t *testing.T) {
	t.Parallel()
	tests := []struct {
		name    string
		mockFn  func(mq *MockTaskQueryService)
		errType error
		want    []*FetchTaskUsecaseOutputDTO
		wantErr bool
	}{
		{
			name: "正常系",
			mockFn: func(mq *MockTaskQueryService) {
				mq.EXPECT().FetchAllTasks(gomock.Any()).Return(
					[]*FetchTaskDTO{
						{
							ID:       "id",
							UserName: "user",
							UserId:   "user_id",
							Content:  "content",
							State:    "todo",
						},
						{
							ID:       "id2",
							UserName: "user2",
							UserId:   "user_id2",
							Content:  "content",
							State:    "todo",
						},
					}, nil,
				)
			},
			want: []*FetchTaskUsecaseOutputDTO{
				{
					ID:       "id",
					UserName: "user",
					UserId:   "user_id",
					Content:  "content",
					State:    "todo",
				},
				{
					ID:       "id2",
					UserName: "user2",
					UserId:   "user_id2",
					Content:  "content",
					State:    "todo",
				},
			},
			wantErr: false,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			ctrl := gomock.NewController(t)
			mockTaskQueryService := NewMockTaskQueryService(ctrl)
			tt.mockFn(mockTaskQueryService)
			// ユースケースオブジェクト
			sut := NewFetchTasksUsease(mockTaskQueryService)
			ctx := context.Background()
			got, err := sut.Run(ctx)
			// 期待するエラー型を設定していた場合はエラー型を比較して検証する
			if tt.errType != nil && !errors.Is(err, tt.errType) {
				t.Errorf("fetchTasksUsecase.Run = error:%v,want errYType:%v", err, tt.errType)
				return
			}
			if (err != nil) != tt.wantErr {
				t.Errorf("fetchTasksUsecase.Run = error:%v,wantErr:%v", err, tt.wantErr)
				return
			}
			if diff := cmp.Diff(got, tt.want); diff != "" {
				t.Errorf("fetchTasksUsecase.Run() -got,+want :%v ", diff)
			}
		})
	}
}

ユーザータスク一覧取得

fetch_user_tasks_usecase.go の実装
// app/application/usecase/task/fetch_user_tasks_usecase.go
package task

import "context"

type FetchUserTasksUsecase struct {
	taskQueryService TaskQueryService
}

func NewFetchUserTasksUsecase(taskQueryService TaskQueryService) *FetchUserTasksUsecase {
	return &FetchUserTasksUsecase{
		taskQueryService: taskQueryService,
	}
}

func (fltu *FetchUserTasksUsecase) Run(ctx context.Context, input FetchUserTasksUsecaseInputDTO) (
	[]*FetchUserTasksUsecaseOutputDTO, error,
) {
	dtos, err := fltu.taskQueryService.FetchUserTasks(ctx, input.UserId)
	if err != nil {
		return nil, err
	}
	// クエリサービスから得たdtoを詰め替える
	outputs := make([]*FetchUserTasksUsecaseOutputDTO, 0, len(dtos))
	for _, dto := range dtos {
		outputs = append(outputs, &FetchUserTasksUsecaseOutputDTO{
			ID:      dto.ID,
			Content: dto.Content,
			State:   dto.State,
		})
	}
	return outputs, nil
}
// app/application/usecase/task/fetch_user_tasks_usecase_dto.go
package task

type FetchUserTasksUsecaseInputDTO struct {
	UserId string
}
type FetchUserTasksUsecaseOutputDTO struct {
	ID      string
	Content string
	State   string
}
// app/application/usecase/task/fetch_user_tasks_usecase_test.go
package task

import (
	"context"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/kakkky/app/domain/errors"
	"go.uber.org/mock/gomock"
)

func TestTask_FetchUserTasksUsecase_Run(t *testing.T) {
	t.Parallel()
	tests := []struct {
		name    string
		mockFn  func(mq *MockTaskQueryService)
		input   FetchUserTasksUsecaseInputDTO
		errType error
		want    []*FetchUserTasksUsecaseOutputDTO
		wantErr bool
	}{
		{
			name: "正常系",
			mockFn: func(mq *MockTaskQueryService) {
				mq.EXPECT().FetchUserTasks(gomock.Any(), gomock.Any()).Return(
					[]*FetchTaskDTO{
						{
							ID:       "id",
							UserName: "user",
							UserId:   "user_id",
							Content:  "content",
							State:    "todo",
						},
						{
							ID:       "id2",
							UserName: "user",
							UserId:   "user_id",
							Content:  "content",
							State:    "todo",
						},
					}, nil,
				)
			},
			input: FetchUserTasksUsecaseInputDTO{
				UserId: "user_id",
			},
			want: []*FetchUserTasksUsecaseOutputDTO{
				{
					ID:      "id",
					Content: "content",
					State:   "todo",
				},
				{
					ID:      "id2",
					Content: "content",
					State:   "todo",
				},
			},
			wantErr: false,
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			ctrl := gomock.NewController(t)
			mockTaskQueryService := NewMockTaskQueryService(ctrl)
			tt.mockFn(mockTaskQueryService)
			// ユースケースオブジェクト
			sut := NewFetchUserTasksUsecase(mockTaskQueryService)
			ctx := context.Background()
			got, err := sut.Run(ctx, tt.input)
			// 期待するエラー型を設定していた場合はエラー型を比較して検証する
			if tt.errType != nil && !errors.Is(err, tt.errType) {
				t.Errorf("FetchUserTasksUsecase.Run = error:%v,want errYType:%v", err, tt.errType)
				return
			}
			if (err != nil) != tt.wantErr {
				t.Errorf("FetchUserTasksUsecase.Run = error:%v,wantErr:%v", err, tt.wantErr)
				return
			}
			if diff := cmp.Diff(got, tt.want); diff != "" {
				t.Errorf("FetchUserTasksUsecase.Run() -got,+want :%v ", diff)
			}
		})
	}
}

gomock(モック)を用いた単体テスト

テストコードでは、gomockパッケージを用いてモックを用意しています。

https://github.com/uber-go/mock

「gomock」というワードで検索すると以下のリポジトリがヒットします。

▶︎ https://github.com/golang/mock

しかし、2023年6月にメンテナンスが終了しています。

そのため代わりに、このリポジトリをフォークしてメンテナンスを継続させているgo.uber.org/mock/gomockパッケージを使用します。

第1章では、Dockerfile.appの以下の部分でインストールしていました。

RUN go install go.uber.org/mock/[email protected]

基本的な使い方を知ってから実際にテストを回してみましょう。

基本的な使い方

モックを生成する

定義したインターフェースの上に//go:generateディレクティブによって実行するコマンドを記述しています。

package user

import "context"

//go:generate mockgen -package=user -source=./interface_user_repository.go -destination=./mock_user_repository.go
type UserRepository interface {
	Save(ctx context.Context, user *User) error
	FindByEmail(ctx context.Context, email Email) (*User, error)
	FindById(ctx context.Context, id string) (*User, error)
	FetchAllUsers(ctx context.Context) (Users, error)
	Update(ctx context.Context, user *User) error
	Delete(ctx context.Context, user *User) error
}

go generate

//go:generate xxxxxxxといったように、 xxxxxの中にコマンドを記述しておくと、go generateコマンドによって実行することができます。いちいち、よく実行するようなコマンドを手打ちで実行する必要もなくなりますし、それによる打ち間違いもなくなるので便利です。

モックのコードは以下のコマンドによって生成されます。

mockgen -package=user -source=./interface_user_repository.go -destination=./mock_user_repository.go

使用しているmockgenコマンドのフラグは以下の通りです。

フラグ 説明
-package 生成されるファイルに明記するパッケージ名
-source モックが満たすべきインターフェースが記述されているファイル
-destination 生成するコードを書き込むファイル

Makefileには、/appディレクトリに降りてgo generate ./...を実行するタスクを定義しているため、以下のコードを打ち込んでコードを生成しましょう。

make go-gen

モックをテストで扱う

ユーザー登録ユースケースの単体テストを例にみていきましょう。

func TestUser_RegisterUsecase_Run(t *testing.T) {
	t.Parallel()
	tests := []struct {
		// 省略
		mockFn  func(mr *user.MockUserRepository, mds *user.MockUserDomainService)
		// 省略
	}{
		{
			name: "正常系:ユーザーを登録できる",
			mockFn: func(mr *user.MockUserRepository, mds *user.MockUserDomainService) {
				mds.EXPECT().IsExists(gomock.Any(), gomock.Any()).Return(false, nil)
				mr.EXPECT().Save(gomock.Any(), gomock.Any()).Return(nil)
			},
        // 省略
		},
        {
			name: "準正常系:重複した登録は阻止する",
			mockFn: func(mr *user.MockUserRepository, mds *user.MockUserDomainService) {
				// emailで検索すると、DBに存在していたケース
				mds.EXPECT().IsExists(gomock.Any(), gomock.Any()).Return(true, nil)
			},
        // 省略
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			// モックを設定
			ctrl := gomock.NewController(t)
			mockUserRepository := user.NewMockUserRepository(ctrl)
			mockUserDomainService := user.NewMockUserDomainService(ctrl)
			tt.mockFn(mockUserRepository, mockUserDomainService)
			// ユースケースオブジェクト
			registerUsecase := NewRegisterUsecase(mockUserRepository, mockUserDomainService)
			ctx := context.Background()
			got, err := registerUsecase.Run(ctx, tt.input)
            // 省略
	}
}

テストテーブルの中には以下の関数型をmockFnとして定義しています。MockUserRepositoryとMockUserDomainServiceはどちらもgomockにより生成されたモック(オブジェクト)です。

mockFn  func(mr *user.MockUserRepository, mds *user.MockUserDomainService)

テストケースによって、モックの挙動を変えられるようにしています。モック.EXPECT().メソッド名().Return(期待する返り値)のように書くことによって、挙動をコントロールすることができます。

        {
			name: "正常系:ユーザーを登録できる",
			mockFn: func(mr *user.MockUserRepository, mds *user.MockUserDomainService) {
				mds.EXPECT().IsExists(gomock.Any(), gomock.Any()).Return(false, nil)
				mr.EXPECT().Save(gomock.Any(), gomock.Any()).Return(nil)
			},
        },

上のコードだと、ユーザーを正常に登録できるケースをテストしたいので、IsExists()はfalse(つまりユーザー重複しない)と、エラーがないという意味でnilを返すようにしています。また、正常に保存処理がされる前提でいるので、Save()はエラーなし、つまりnilを返すようにしているというわけです。

引数にはgomock.Any()を入れています。引数に何が入っているかの検証は特に行わないようにしています。Any()関数は何でもマッチするマッチャーを返しているようです。

// Any returns a matcher that always matches.
func Any() Matcher { return anyMatcher{} }

引数に何か具体的な値を入れれば、引数に渡っている値も正しいかどうかを検証することができます。興味のある方はぜひやってみてください。

以下の部分でモックを初期化し、モックの挙動を記述したmockFnを呼び出し、ユースケース構造体にDI(依存性の注入)しています。

// モックを設定
ctrl := gomock.NewController(t)
mockUserRepository := user.NewMockUserRepository(ctrl)
mockUserDomainService := user.NewMockUserDomainService(ctrl)
// ここでモックの挙動が決定される
tt.mockFn(mockUserRepository, mockUserDomainService)
// ユースケースオブジェクトにDI
registerUsecase := NewRegisterUsecase(mockUserRepository, mockUserDomainService)

テストを実行

ユースケースのテストを実行してみましょう。ユースケースのテストを全て回せるタスクを定義しているので、以下のコマンドを打ってみましょう。

make test-usecase

テストが成功すれば、この章は終了です。

現段階のディレクトリ

.
├── .github
│   └── workflows
│       └── test.yml
├── .gitignore
├── Dockerfile.app
├── Makefile
├── app
│   ├── .air.toml
│   ├── application
│   │   └── usecase
│   │       ├── task
│   │       │   ├── create_task_usecase.go
│   │       │   ├── create_task_usecase_dto.go
│   │       │   ├── create_task_usecase_test.go
│   │       │   ├── delete_task_usecase.go
│   │       │   ├── delete_task_usecase_dto.go
│   │       │   ├── delete_task_usecase_test.go
│   │       │   ├── fetch_task_usecase.go
│   │       │   ├── fetch_task_usecase_dto.go
│   │       │   ├── fetch_task_usecase_test.go
│   │       │   ├── fetch_tasks_usecase.go
│   │       │   ├── fetch_tasks_usecase_test.go
│   │       │   ├── fetch_user_tasks_usecase.go
│   │       │   ├── fetch_user_tasks_usecase_dto.go
│   │       │   ├── fetch_user_tasks_usecase_test.go
│   │       │   ├── interface_task_query_service.go
│   │       │   ├── mock_task_query_service.go
│   │       │   ├── update_task_state_usecase.go
│   │       │   ├── update_task_state_usecase_dto.go
│   │       │   └── update_task_state_usecase_test.go
│   │       └── user
│   │           ├── fetch_users_usecase.go
│   │           ├── fetch_users_usecase_dto.go
│   │           ├── fetch_users_usecase_test.go
│   │           ├── register_usecase.go
│   │           ├── register_usecase_dto.go
│   │           ├── register_usecase_test.go
│   │           ├── unregister_usecase.go
│   │           ├── unregister_usecase_dto.go
│   │           ├── unregister_usecase_test.go
│   │           ├── update_profile_usecase.go
│   │           ├── update_profile_usecase_dto.go
│   │           └── update_profile_usecase_test.go
│   ├── domain
│   │   ├── errors
│   │   │   ├── errors.go
│   │   │   └── errors_test.go
│   │   ├── task
│   │   │   ├── interface_task_repository.go
│   │   │   ├── mock_task_repository.go
│   │   │   ├── task.go
│   │   │   ├── task_content.go
│   │   │   ├── task_state.go
│   │   │   └── task_test.go
│   │   └── user
│   │       ├── interface_user_domain_service.go
│   │       ├── interface_user_repository.go
│   │       ├── mock_user_domain_service.go
│   │       ├── mock_user_repository.go
│   │       ├── user.go
│   │       ├── user_domain_service.go
│   │       ├── user_domain_service_test.go
│   │       ├── user_email.go
│   │       ├── user_hashed_password.go
│   │       ├── user_test.go
│   │       └── users.go
│   ├── go.mod
│   ├── go.sum
│   ├── infrastructure
│   │   └── db
│   │       └── mysql
│   │           └── conf.d
│   │               └── my.conf
│   ├── main.go
│   └── tmp
│       ├── build-errors.log
│       └── main
├── compose.yml
├── go.work
├── go.work.sum
└── pkg
    ├── go.mod
    ├── go.sum
    ├── hash
    │   ├── hash.go
    │   └── hash_test.go
    └── ulid
        ├── ulid.go
        └── ulid_test.go