🗂

Go言語を基礎から徹底的に叩き込む〜#4-4 クリーンアーキテクチャ〜

2024/04/29に公開

概要

この記事の続きです!(#4-2#4-3
今回は Go を用いたクリーンアーキテクチャについてです!

クリーンアーキテクチャ

クリーンアーキテクチャについては、以前、こちらの記事でがっつりまとめていましたので、今回は復習も兼ねて簡単にまとめようと思います!
概念的なところは飛ばしてどうゆう実装になるかをまとめていきますので、詳細は上記の記事を参考にしてください。
今回の本題はクリーンアーキテクチャでのテストコードをどうするかというところです!

clean_architecture
上図の各層の呼び方はそのプロジェクトによって、まちまちになりますが、今回は

  • controller 層:controller
  • usecase 層(ビジネスロジック層):usecase
  • Gateway 層(DB やり取りを行う層):repository
  • entity 層:model

として記載します!今回は簡単に実装したため、ルーティングは main.go 内で実装します。

クリーンアーキテクチャでの実装

クリーンアーキテクチャを含め、設計で重要なのは「変更容易性」、「拡張性」だと思っています。
クリーンアーキテクチャでは「依存関係逆転の原則」とか、「依存性の注入」とか難しい言葉が並びますが、要は

  • 各層毎に関心ごとを適切に分離し、各層の依存先を抽象型(interface)に向けることで、依存先(円の内側)の具体的な実装が変更になったとしても外側のソースコードに影響を及ばさない。(ただし、Usecase→Eintities の依存関係は Interface ではないので、Entities の構造体に変更があると、外側の層の該当箇所は修正が必要)
  • また、依存先を必ず円の内側に向けてやることで、円の内側は、外側の変更(ソースコードの変更、ライブラリの変更)の影響を受けない。

かなと思います。また、その中で、repository と usecase は依存関係が処理の流れと逆向きになるので、逆転させる必要がある。これを「依存関係逆転の原則」と言っているが、やってることは依存先を抽象型(interface)に向けているだけ。

  • ディレクトリ構造
.
├── controller
│   └── task_controller.go
├── model
│   └── task.go
├── repository
│   └── task_repository.go
├── usecase
│   └── task_usecase.go
├── go.mod
├── go.sum
└── main.go
  • main.go
package main

import (
	"base/controller"
	"base/repository"
	"base/usecase"
	"database/sql"
	"fmt"
	"log"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func initDB() (*sql.DB, error) {
	db, err := sql.Open("sqlite3", "./test.db")
	return db, err
}

func main() {
	db, err := initDB()
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(db)

	e := echo.New()
	e.Use(middleware.Logger())
	e.Use(middleware.Recover())

	taskRepository := repository.NewTaskRepository(db)
	taskUsecase := usecase.NewTaskUsecase(taskRepository)
	taskController := controller.NewTaskController(taskUsecase)
	e.GET("/tasks/:id", taskController.Get)
	e.POST("/tasks", taskController.Create)

	e.Start(":8080")
}
  • model
package model

import "errors"

type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
}

func (t *Task) Validate() error {
	if t.Title == "" {
		return errors.New("title is required")
	}
	return nil
}
  • controller
package controller

import (
	"base/model"
	"base/usecase"
	"fmt"
	"net/http"
	"strconv"

	"github.com/labstack/echo/v4"
)

type ITaskController interface {
	Get(c echo.Context) error
	Create(c echo.Context) error
}

type taskController struct {
	tu usecase.ITaskUsecase
}

func NewTaskController(tu usecase.ITaskUsecase) ITaskController {
	return &taskController{tu: tu}
}

func (tc *taskController) Get(c echo.Context) error {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		errMsg := fmt.Errorf("parse error: %v", err.Error())
		return c.JSON(http.StatusBadRequest, errMsg.Error())
	}
	task, err := tc.tu.GetTask(id)
	if err != nil {
		return c.JSON(http.StatusInternalServerError, err)
	}
	return c.JSON(http.StatusOK, task)
}

func (tc *taskController) Create(c echo.Context) error {
	var task model.Task
	if err := c.Bind(&task); err != nil {
		return c.JSON(http.StatusBadRequest, err)
	}

	createdId, err := tc.tu.CreateTask(task.Title)
	if err != nil {
		return c.JSON(http.StatusInternalServerError, err)
	}
	return c.JSON(http.StatusOK, createdId)
}
  • usecase
package usecase

import (
	"base/model"
	"base/repository"
)

type ITaskUsecase interface {
	CreateTask(title string) (int, error)
	GetTask(id int) (*model.Task, error)
}

type taskUsecase struct {
	r repository.ITaskRepository
}

func NewTaskUsecase(r repository.ITaskRepository) ITaskUsecase {
	return &taskUsecase{r: r}
}

func (u *taskUsecase) CreateTask(title string) (int, error) {
	task := model.Task{Title: title}
	err := task.Validate()
	if err != nil {
		return 0, err
	}

	id, err := u.r.Create(&task)
	if err != nil {
		return 0, err
	}
	return id, nil
}

func (u *taskUsecase) GetTask(id int) (*model.Task, error) {
	t, err := u.r.Read(id)
	if err != nil {
		return nil, err
	}

	return t, nil
}
  • repository
package repository

import (
	"base/model"
	"database/sql"
)

type ITaskRepository interface {
	Create(task *model.Task) (int, error)
	Read(id int) (*model.Task, error)
}

type taskRepository struct {
	db *sql.DB
}

func NewTaskRepository(db *sql.DB) ITaskRepository {
	return &taskRepository{db: db}
}

func (r *taskRepository) Create(task *model.Task) (int, error) {
	stmt := `INSERT INTO tasks (title) VALUES (?) RETURNING id`
	err := r.db.QueryRow(stmt, task.Title).Scan(&task.ID)
	return task.ID, err
}

func (r *taskRepository) Read(id int) (*model.Task, error) {
	stmt := `SELECT id, title FROM tasks WHERE id = ?`
	task := model.Task{}
	err := r.db.QueryRow(stmt, id).Scan(&task.ID, &task.Title)
	return &task, err
}

クリーンアーキテクチャのテスト

クリーンアーキテクチャでは各層が依存先のメソッドを内部で使用するため、それらのモックが必要となる。
例えば、usecase 層で言うと、リポジトリのメソッドを使用するため、それらのメソッドのモックを用意する。
今回は、"github.com/stretchr/testify/mock"のライブラリを使用して実装

// ITaskRepositoryのモック
type ITaskRepositoryMock struct {
	mock.Mock
}

// taskRepositoryのCreateメソッドのモック
func (m *ITaskRepositoryMock) Create(task *model.Task) (int, error) {
	args := m.Called(task)
	return args.Int(0), args.Error(1)
}

// taskRepositoryのReadメソッドのモック
func (m *ITaskRepositoryMock) Read(id int) (*model.Task, error) {
	args := m.Called(id)
	return args.Get(0).(*model.Task), args.Error(1)
}

上記のモックを利用し、usecase のテストケースを実装した例が以下

// CreateTaskのテストケース
func TestTaskUsecase_CreateTask(t *testing.T) {
	mockRepo := new(ITaskRepositoryMock)
	taskUsecase := usecase.NewTaskUsecase(mockRepo)

	task := model.Task{Title: "test"}

	// モックの戻り値を設定
	mockRepo.On("Create", &task).Return(1, nil)

	id, err := taskUsecase.CreateTask(task.Title)
	assert.NoError(t, err)
	assert.Equal(t, 1, id)
}

// GetTaskのテストケース
func TestTaskUsecase_GetTask(t *testing.T) {
	mockRepo := new(ITaskRepositoryMock)
	taskUsecase := usecase.NewTaskUsecase(mockRepo)

	task := model.Task{ID: 1, Title: "test"}

	// モックの戻り値を設定
	mockRepo.On("Read", 1).Return(&task, nil)

	result, err := taskUsecase.GetTask(1)
	assert.NoError(t, err)
	assert.Equal(t, task.ID, result.ID)
	assert.Equal(t, task.Title, result.Title)
}

ファイル全体が以下

  • task_usecase_test.go
package usecase_test

import (
	"base/model"
	"base/usecase"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
)

// ITaskRepositoryのモック
type ITaskRepositoryMock struct {
	mock.Mock
}

// taskRepositoryのCreateメソッドのモック
func (m *ITaskRepositoryMock) Create(task *model.Task) (int, error) {
	args := m.Called(task)
	return args.Int(0), args.Error(1)
}

// taskRepositoryのReadメソッドのモック
func (m *ITaskRepositoryMock) Read(id int) (*model.Task, error) {
	args := m.Called(id)
	return args.Get(0).(*model.Task), args.Error(1)
}

// **********************
// ここからTaskUsecaseのテストケース
// **********************

// CreateTaskのテストケース
func TestTaskUsecase_CreateTask(t *testing.T) {
	mockRepo := new(ITaskRepositoryMock)
	taskUsecase := usecase.NewTaskUsecase(mockRepo)

	task := model.Task{Title: "test"}

	// モックの戻り値を設定
	mockRepo.On("Create", &task).Return(1, nil)

	id, err := taskUsecase.CreateTask(task.Title)
	assert.NoError(t, err)
	assert.Equal(t, 1, id)
}

// GetTaskのテストケース
func TestTaskUsecase_GetTask(t *testing.T) {
	mockRepo := new(ITaskRepositoryMock)
	taskUsecase := usecase.NewTaskUsecase(mockRepo)

	task := model.Task{ID: 1, Title: "test"}

	mockRepo.On("Read", 1).Return(&task, nil)

	result, err := taskUsecase.GetTask(1)
	assert.NoError(t, err)
	assert.Equal(t, task.ID, result.ID)
	assert.Equal(t, task.Title, result.Title)
}

まとめ

今回で一旦、Go の基本的な学習は以上にしようと思います。
引き続き Go の学習を進めていきますが、その都度新たな知見はまとめていきたいと思います。

GitHubで編集を提案

Discussion