話題の記事

Go言語で基本的なCRUD操作を行うREST APIを作成

Javaのエンジニアだった私がGo言語でREST APIを作る上で学んだことをまとめています。 プロジェクト構成、単体テスト、Dockerイメージの作成など実際にREST APIを開発する上で必要だと思われる要素を盛り込みつつサンプルプロジェクトを作成していきます。
2021.09.22

はじめに

Javaのエンジニアだった私がGo言語でREST APIを作る上で学んだことをまとめています。
プロジェクト構成、単体テスト、Dockerイメージの作成など実際にREST APIを開発する上で必要だと思われる要素を盛り込みつつサンプルプロジェクトを作成していきます。
今回はできるだけ外部ライブラリやフレームワークを使わずにGo言語の標準機能のみで開発しました。
これからバックエンドにGo言語を使用することを検討されている方の参考になれば幸いです。
※この記事は既にGo言語の開発環境をセットアップ済みで基本的な文法を学習済みの方を想定しています。

動作環境

今回使用した動作環境は以下のとおりです。

  • PC : Mac M1(Apple Silicon)チップ
  • OS : macOS Big Sir 11.5.2
  • Go : 1.17.1
  • Docker Desktop : 4.0.0 Engine : 20.10.8
  • MySQL : 8.0.25

プロジェクト構成

ネット上で見るGo言語のプロジェクト構成はクリーンアーキテクチャの影響を受けているものが多い印象ですが、今回は慣れ親しんだMVCアーキテクチャ風の構成にしました。

一部抜粋

sample-api/  ルートディレクトリ
  ┣ build/  Dockerfileなど
  ┃ ┣ db/ 動作確認用DB
  ┃ ┃  ┣ sql/ DDLとテストデータ投入用SQL
  ┃ ┃ ┗ Dockerfile
  ┃ ┣ sample-api/ 
  ┃ ┃  ┗ Dockerfile このサンプルプロジェクトの実行ファイルを含んだイメージを作成するためのDockerfile
  ┃ ┗ docker-compose.yml
  ┣ cmd/
  ┃  ┗ sample-api
  ┃  ┗ main.go メイン処理
  ┣ controller/
  ┃ ┣ dto/ リクエスト/レスポンス用のDTOファイルを配置する
  ┃ ┣ router.go HTTPメソッドを元にコントローラの各処理へのルーティングを行う
  ┃  ┣ router_test.go `router.go`のテストファイル
  ┃  ┣ todo_controller.go リクエストを元にモデルの各処理を呼び出しレスポンスを返却する
  ┃ ┗ todo_controller_test.go `todo_controller.go`のテストファイル 
  ┣ model/
  ┃  ┣ entity/ 
  ┃  ┗ repository/
  ┣ test/
          ┗ mock.go 単体テスト用のモック
  ┣ test_results/ 単体テストのカバレッジファイルを配置する            
  ┣ Makefile
  ┣ go.mod
  ┗ go.sum

個人的にプロジェクト構成でポイントだと感じたのは以下の3点です。

  • メイン処理を持ったコードはcmdフォルダ配下に実行可能ファイルの名前と一致したフォルダを作成し配置するのが一般的
  • Javaを経験しているとsrcフォルダを作りたくなるが、Go言語ではGOPATH配下に置かれるsrcフォルダと混乱をきたすため作成すべきではない
  • テストコードはテスト対象ファイルと同じフォルダ階層に配置するのが基本(テスト対象ファイルの公開されていない変数/関数にアクセスできるため)

Standard Go Project LayoutというGo言語でのメジャーなプロジェクトルールもありますが、規模の小さいプロジェクトだとやりすぎな感があるので、やはりプロジェクトの開発規模にあった構成を各自検討する必要がありそうです。

その他プロジェクト構成については以下の記事が勉強になりました。

サンプルプロジェクト

このサンプルプロジェクトは基本的なCRUD操作を行うREST APIです。TODOアプリのバックエンドのイメージで、TODO(タイトルと内容)の取得/追加/更新/削除が行えます。
ここでは一部コードを抜粋して説明していきます。全ファイルは私のGithubリポジトリをご参照ください。

main.go

package main

import (
	"net/http"

	"github.com/koga456/sample-api/controller"
	"github.com/koga456/sample-api/model/repository"
)

var tr = repository.NewTodoRepository()
var tc = controller.NewTodoController(tr)
var ro = controller.NewRouter(tc)

func main() {
	server := http.Server{
		Addr: ":8080", 
	}
	http.HandleFunc("/todos/", ro.HandleTodosRequest)
	server.ListenAndServe()
}
  • 10~12行目はコンストラクタインジェクションでDIを行っています。
  • 16行目はサーバが起動するポート番号を設定しています。この設定の場合、localhost:8080で起動します。
  • 18行目はlocalhost:8080/todos/に届いたリクエストをHandleTodosRequestで処理するように設定しています。
  • 19行目で実際にサーバが起動します。

Go言語のHTTPサーバとDIについては以下の記事が勉強になりました。

database.go

package repository

import (
	"database/sql"
	"fmt"
)

var Db *sql.DB

func init() {
	var err error
	dataSourceName := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8",
		"todo-app", "todo-password", "sample-api-db:3306", "todo",
	)
	Db, err = sql.Open("mysql", dataSourceName)
	if err != nil {
		panic(err)
	}
}
  • init()はパッケージの初期化処理などに使われます。このサンプルプロジェクトの場合github.com/koga456/sample-api/model/repositoryがimportされたタイミングで動作し、main.goのメイン処理より先に実行されます。
  • DSNは[username[:password]@][protocol[(address)]]/dbname[?param1=value1&...&paramN=valueN]の仕様に従って設定します。ここで設定しているusernamepasswordaddressdbnameは動作確認用DBのdocker-compose.ymlDockerfileの値を設定しています。またaddressは今回はコンテナ同士の通信になるので動作確認用DBのコンテナ名を設定します。その他のパラメータについてはgithub.com/go-sql-driver/mysqlをご参照ください。
  • 15行目のようにドライバとDSNを指定するとDBのコネクションを取得できます。

router.go

package controller

import (
	"net/http"
)

// 外部パッケージに公開するインタフェース
type Router interface {
	HandleTodosRequest(w http.ResponseWriter, r *http.Request)
}

// 非公開のRouter構造体
type router struct {
	tc TodoController
}

// Routerのコンストラクタ。引数にTodoControllerを受け取り、Router構造体のポインタを返却する。
func NewRouter(tc TodoController) Router {
	return &router{tc}
}

func (ro *router) HandleTodosRequest(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case "GET":
		ro.tc.GetTodos(w, r)
	case "POST":
		ro.tc.PostTodo(w, r)
	case "PUT":
		ro.tc.PutTodo(w, r)
	case "DELETE":
		ro.tc.DeleteTodo(w, r)
	default:
		w.WriteHeader(405)
	}
}
  • HTTPメソッドを元にコントローラの各処理を呼び出すハンドラ関数です。不正なHTTPメソッドの場合は、405エラーを返却します。

以下はTODOのテーブル定義と投入するテストデータです。

db/sql/01_todo.sql

CREATE TABLE IF NOT EXISTS todo (
    id             BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    title          VARCHAR(40) NOT NULL,
    content       VARCHAR(100) NOT NULL,
    created_at     TIMESTAMP NOT NULL DEFAULT current_timestamp,
    updated_at     TIMESTAMP NOT NULL DEFAULT current_timestamp ON UPDATE current_timestamp
)

db/sql/99_insert_data.sql

INSERT INTO todo (title, content) VALUES ('買い物', '今日の帰りに夕食の材料を買う');
INSERT INTO todo (title, content) VALUES ('勉強', 'TOEICの勉強を1時間やる');
INSERT INTO todo (title, content) VALUES ('ゴミ出し', '次の火曜日は燃えないゴミの日なので忘れないように');

以下は上記のテーブル定義を元に作成したDTOとEntityファイルです。

todo_dto.go

package dto

type TodoResponse struct {
	Id      int    `json:"id"`
	Title   string `json:"title"`
	Content string `json:"content"`
}

type TodoRequest struct {
	Title   string `json:"title"`
	Content string `json:"content"`
}

type TodosResponse struct {
	Todos []TodoResponse `json:"todos"`
}
  • JSONデコード/エンコード用のDTOは構造体で定義します。構造体の各フィールドの末尾json:"XXX"のXXXがJSONのフィールド名になります。

todo_entity.go

package entity

type TodoEntity struct {
	Id      int
	Title   string
	Content string
}

以下が実際にCRUD処理を行うコントローラとリポジトリです。

todo_controller.go

package controller

import (
	"encoding/json"
	"net/http"
	"path"
	"strconv"

	"github.com/koga456/sample-api/controller/dto"
	"github.com/koga456/sample-api/model/entity"
	"github.com/koga456/sample-api/model/repository"
)

// 外部パッケージに公開するインタフェース
type TodoController interface {
	GetTodos(w http.ResponseWriter, r *http.Request)
	PostTodo(w http.ResponseWriter, r *http.Request)
	PutTodo(w http.ResponseWriter, r *http.Request)
	DeleteTodo(w http.ResponseWriter, r *http.Request)
}

// 非公開のTodoController構造体
type todoController struct {
	tr repository.TodoRepository
}

// TodoControllerのコンストラクタ。
// 引数にTodoRepositoryを受け取り、TodoController構造体のポインタを返却する。
func NewTodoController(tr repository.TodoRepository) TodoController {
	return &todoController{tr}
}

// TODOの取得
func (tc *todoController) GetTodos(w http.ResponseWriter, r *http.Request) {
	// リポジトリの取得処理呼び出し
	todos, err := tc.tr.GetTodos()
	if err != nil {
		w.WriteHeader(500)
		return
	}

	// 取得したTODOのentityをDTOに詰め替え
	var todoResponses []dto.TodoResponse
	for _, v := range todos {
		todoResponses = append(todoResponses, dto.TodoResponse{Id: v.Id, Title: v.Title, Content: v.Content})
	}

	var todosResponse dto.TodosResponse
	todosResponse.Todos = todoResponses

	// JSONに変換
	output, _ := json.MarshalIndent(todosResponse.Todos, "", "\t\t")

	// JSONを返却
	w.Header().Set("Content-Type", "application/json")
	w.Write(output)
}

// TODOの追加
func (tc *todoController) PostTodo(w http.ResponseWriter, r *http.Request) {
	// リクエストbodyのJSONをDTOにマッピング
	body := make([]byte, r.ContentLength)
	r.Body.Read(body)
	var todoRequest dto.TodoRequest
	json.Unmarshal(body, &todoRequest)

	// DTOをTODOのEntityに変換
	todo := entity.TodoEntity{Title: todoRequest.Title, Content: todoRequest.Content}
	
	// リポジトリの追加処理呼び出し
	id, err := tc.tr.InsertTodo(todo)
	if err != nil {
		w.WriteHeader(500)
		return
	}

	// LocationにリソースのPATHを設定し、ステータスコード201を返却
	w.Header().Set("Location", r.Host+r.URL.Path+strconv.Itoa(id))
	w.WriteHeader(201)
}

// TODOの更新
func (tc *todoController) PutTodo(w http.ResponseWriter, r *http.Request) {
	// URLのPATHに含まれるTODOのIDを取得
	todoId, err := strconv.Atoi(path.Base(r.URL.Path))
	if err != nil {
		w.WriteHeader(400)
		return
	}

	// リクエストbodyのJSONをDTOにマッピング
	body := make([]byte, r.ContentLength)
	r.Body.Read(body)
	var todoRequest dto.TodoRequest
	json.Unmarshal(body, &todoRequest)

	// DTOをTODOのEntityに変換
	todo := entity.TodoEntity{Id: todoId, Title: todoRequest.Title, Content: todoRequest.Content}
	
	// リポジトリの更新処理呼び出し
	err = tc.tr.UpdateTodo(todo)
	if err != nil {
		w.WriteHeader(500)
		return
	}

	// ステータスコード204を返却
	w.WriteHeader(204)
}

// TODOの削除
func (tc *todoController) DeleteTodo(w http.ResponseWriter, r *http.Request) {
	// URLのPATHに含まれるTODOのIDを取得
	todoId, err := strconv.Atoi(path.Base(r.URL.Path))
	if err != nil {
		w.WriteHeader(400)
		return
	}

	// リポジトリの削除処理呼び出し
	err = tc.tr.DeleteTodo(todoId)
	if err != nil {
		w.WriteHeader(500)
		return
	}

	// ステータスコード204を返却
	w.WriteHeader(204)
}

todo_controller.go

package repository

import (
	"log"

	_ "github.com/go-sql-driver/mysql"

	"github.com/koga456/sample-api/model/entity"
)

// 外部パッケージに公開するインタフェース
type TodoRepository interface {
	GetTodos() (todos []entity.TodoEntity, err error)
	InsertTodo(todo entity.TodoEntity) (id int, err error)
	UpdateTodo(todo entity.TodoEntity) (err error)
	DeleteTodo(id int) (err error)
}

// 非公開のTodoRepository構造体
type todoRepository struct {
}

// TodoRepositoryのコンストラクタ。TodoRepository構造体のポインタを返却する。
func NewTodoRepository() TodoRepository {
	return &todoRepository{}
}

// TODO取得処理
func (tr *todoRepository) GetTodos() (todos []entity.TodoEntity, err error) {
	todos = []entity.TodoEntity{}
	
	// DBから全てのTODOを取得
	rows, err := Db.
		Query("SELECT id, title, content FROM todo ORDER BY id DESC")
	if err != nil {
		log.Print(err)
		return
	}

	// 1行ごとTODOのEntityにマッピングし、返却用のスライスに追加
	for rows.Next() {
		todo := entity.TodoEntity{}
		err = rows.Scan(&todo.Id, &todo.Title, &todo.Content)
		if err != nil {
			log.Print(err)
			return
		}
		todos = append(todos, todo)
	}

	return
}

// TODO追加処理
func (tr *todoRepository) InsertTodo(todo entity.TodoEntity) (id int, err error) {
	// 引数で受け取ったEntityの値を元にDBに追加
	_, err = Db.Exec("INSERT INTO todo (title, content) VALUES (?, ?)", todo.Title, todo.Content)
	if err != nil {
		log.Print(err)
		return
	}
	// created_atが最新のTODOのIDを返却
	err = Db.QueryRow("SELECT id FROM todo ORDER BY id DESC LIMIT 1").Scan(&id)
	return
}

// TODO更新処理
func (tr *todoRepository) UpdateTodo(todo entity.TodoEntity) (err error) {
	// 引数で受け取ったEntityの値を元にDBを更新
	_, err = Db.Exec("UPDATE todo SET title = ?, content = ? WHERE id = ?", todo.Title, todo.Content, todo.Id)
	return
}

// TODO削除処理
func (tr *todoRepository) DeleteTodo(id int) (err error) {
	// 引数で受け取ったIDの値を元にDBから削除
	_, err = Db.Exec("DELETE FROM todo WHERE id = ?", id)
	return
}

その他Go言語のDB/SQL関連については以下の記事が勉強になりました。

build/sample-api/Dockerfile

FROM golang:1.17.1-alpine as builder

WORKDIR /build
COPY ../../go.mod ../../go.sum ./
RUN go mod download
COPY ../../  ./

ARG CGO_ENABLED=0
ARG GOOS=linux
ARG GOARCH=amd64
RUN go build -ldflags '-s -w' ./cmd/sample-api

FROM alpine
COPY --from=builder /build/sample-api /opt/app/
ENTRYPOINT ["/opt/app/sample-api"]

Dockerfileに関しては以下の記事を参考にさせて頂きました。

Makefile

format:
	@find . -print | grep --regex '.*\.go' | xargs goimports -w -local "github.com/koga456/sample-api"
verify:
	@staticcheck ./... && go vet ./...
unit-test:
	@go test ./... -coverprofile=./test_results/cover.out && go tool cover -html=./test_results/cover.out -o ./test_results/cover.html
serve:
	@docker-compose -f build/docker-compose.yml up

フォーマット、静的解析、単体テスト、起動の4つのコマンドを定義しています。

実行方法 

Githubリポジトリからこのサンプルプロジェクトをダウンロード後任意のディレクトリに配置し、ルートディレクトリで下記コマンドを実行してください。
Makefileに記載されたコマンドが実行され、ビルドされた実行ファイルを含むDockerイメージを作成後、動作確認用DBと共にコンテナとして起動します。

% make serve

フォアグラウンド処理となるので停止したい場合は、controlキー + cを押してください。
別のターミナルを立ち上げcurlコマンドを叩くと下記のようにTODOに対するCRUD操作が行えます。

[注意]もしM1チップ以外のMacで実行する場合は、下記ファイルの--platform=linux/amd64の部分を削除してください。

build/db/Dockerfile

FROM --platform=linux/amd64 library/mysql:8.0.25

ENV MYSQL_DATABASE todo

COPY custom.cnf /etc/mysql/conf.d/

COPY sql /docker-entrypoint-initdb.d

TODO取得

% curl -i localhost:8080/todos/
Content-Type: application/json
Content-Length: 346
[
	{
		"id": 3,
		"title": "ゴミ出し",
		"content": "次の火曜日は燃えないゴミの日なので忘れないように"
	},
	{
		"id": 2,
		"title": "勉強",
		"content": "TOEICの勉強を1時間やる"
	},
	{
		"id": 1,
		"title": "買い物",
		"content": "今日の帰りに夕食の材料を買う"
	}
]

TODO追加

% curl -i -X POST -H "Content-Type: application/json" -d '{"title":"test", "content":"テストです。"}' localhost:8080/todos/
HTTP/1.1 201 Created
Location: localhost:8080/todos/4
Content-Length: 0

%  curl -i localhost:8080/todos/
Content-Type: application/json
Content-Length: 425
[
	{
		"id": 4,
		"title": "test",
		"content": "テストです。"
	},
	{
		"id": 3,
		"title": "ゴミ出し",
		"content": "次の火曜日は燃えないゴミの日なので忘れないように"
	},
	{
		"id": 2,
		"title": "勉強",
		"content": "TOEICの勉強を1時間やる"
	},
	{
		"id": 1,
		"title": "買い物",
		"content": "今日の帰りに夕食の材料を買う"
	}
]

TODO更新

% curl -i -X PUT -H "Content-Type: application/json" -d '{"title":"test", "content":"変更テスト"}' localhost:8080/todos/4
HTTP/1.1 204 No Content

%  curl -i localhost:8080/todos/
Content-Type: application/json
Content-Length: 422
[
	{
		"id": 4,
		"title": "test",
		"content": "変更テスト"
	},
	{
		"id": 3,
		"title": "ゴミ出し",
		"content": "次の火曜日は燃えないゴミの日なので忘れないように"
	},
	{
		"id": 2,
		"title": "勉強",
		"content": "TOEICの勉強を1時間やる"
	},
	{
		"id": 1,
		"title": "買い物",
		"content": "今日の帰りに夕食の材料を買う"
	}
]

TODO削除

% curl -i -X DELETE localhost:8080/todos/4
HTTP/1.1 204 No Content

%  curl -i localhost:8080/todos/
Content-Type: application/json
Content-Length: 346
[
	{
		"id": 3,
		"title": "ゴミ出し",
		"content": "次の火曜日は燃えないゴミの日なので忘れないように"
	},
	{
		"id": 2,
		"title": "勉強",
		"content": "TOEICの勉強を1時間やる"
	},
	{
		"id": 1,
		"title": "買い物",
		"content": "今日の帰りに夕食の材料を買う"
	}
]

単体テストについて

サンプルプロジェクトで作成した単体テストについて説明していきます。
controllerパッケージに関してはカバレッジ100%を満たすように単体テストを作成していますが、ここでは一番シンプルなrouter.goの単体テストを一部抜粋します。

テスト対象

router.goから一部抜粋

type Router interface {
	HandleTodosRequest(w http.ResponseWriter, r *http.Request)
}

type router struct {
	tc TodoController
}

func NewRouter(tc TodoController) Router {
	return &router{tc}
}

func (ro *router) HandleTodosRequest(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case "GET":
		ro.tc.GetTodos(w, r)
	case "POST":
		ro.tc.PostTodo(w, r)
	case "PUT":
		ro.tc.PutTodo(w, r)
	case "DELETE":
		ro.tc.DeleteTodo(w, r)
	default:
		w.WriteHeader(405)
	}
}

テスト用モック

mock.goから一部抜粋

package test

import (
	"errors"
	"net/http"

	"github.com/koga456/sample-api/model/entity"
)

type MockTodoController struct {
}

func (mtc *MockTodoController) GetTodos(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(200)
}

func (mtc *MockTodoController) PostTodo(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(201)
}

func (mtc *MockTodoController) PutTodo(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(204)
}

func (mtc *MockTodoController) DeleteTodo(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(204)
}

MockTodoControllerTodoControllerのインタフェースに定義された関数を全て実装しています。処理はhttp.ResponseWriterにステータスコードを設定するのみです。

テストファイル

router_test.goから一部抜粋

package controller

import (
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
	"testing" // Go言語標準のテスト用パッケージです。

	"github.com/koga456/sample-api/test"
)

// URLとハンドラ関数を関連付けるマルチプレクサと呼ばれる構造体。
// 複数のテストで共通して使うのでパッケージ変数として定義しています。
var mux *http.ServeMux

// 前/後処理のようなテストのフロー制御を行うための関数です。
// 前/後処理を行う必要がない場合は不要です。
func TestMain(m *testing.M) {
	setUp()
	// 各テストケースを実行します。今回だと`TestGetTodos`と`TestPostTodo`です。
	code := m.Run()
	os.Exit(code)
}

// 前処理用の関数です。関数名は他の名前でも問題ありません。
func setUp() {
	// テスト用のモックをDIし`Router`のポインタを取得しています。
	// `MockTodoController`は`TodoController`インタフェースの関数を全て実装しているのでDI可能です。
	target := NewRouter(&test.MockTodoController{})
	// テストを実行するマルチプレクサを生成
	mux = http.NewServeMux()
	// マルチプレクサにURLとテスト対象のハンドラ関数を関連付けます。
	mux.HandleFunc("/todos/", target.HandleTodosRequest)
}


func TestGetTodos(t *testing.T) { // Goのテスト関数は`*testing.T`を引数に受け取ります。
	// リクエストの生成
	r, _ := http.NewRequest("GET", "/todos/", nil)
	// レスポンスを取得するための処理
	w := httptest.NewRecorder()

	// テスト対象のハンドラ関数にリクエストを送信
	mux.ServeHTTP(w, r)

	// `MockTodoController`で設定しているステータスコード200が設定されていることを確認します。
	if w.Code != 200 {
		// ステータスコードが200以外が設定されている場合、
		// テスト失敗なのでエラーを出力(後続のテストは継続される)
		t.Errorf("Response cod is %v", w.Code)
	}
}

func TestPostTodo(t *testing.T) {
	// bodyにJSONを設定したリクエストの生成
	json := strings.NewReader(`{"title":"test-title","content":"test-content"}`)
	r, _ := http.NewRequest("POST", "/todos/", json)
	
	w := httptest.NewRecorder()

	mux.ServeHTTP(w, r)

	if w.Code != 201 {
		t.Errorf("Response cod is %v", w.Code)
	}
}

テストの実行方法とカバレッジの出し方

テストは下記コマンドで実行します。

% go test       # カレントディレクトリのファイルを対象に実行 
% go test ./..  # カレントディレクトリ配下の全てのファイルを対象に実行

下記のオプションを指定するとカバレッジが出力されます。

% go test -cover

さらに下記コマンドを実行するとより詳細なカバレッジが出力されます。

% go test -coverprofile

カバレッジをファイルに出力後、html形式で確認することもできます。

% go test -coverprofile=cover.out
% go tool cover -html=cover.out -o cover.html
% open cover.html

このプロジェクトでの単体テストの実行方法

Makefileにコマンドを定義しているので、ルートディレクトリで下記コマンドを実行すると全てのテストを実行され、test_resultsフォルダ配下にhtml形式のカバレッジ測定結果が出力されます。

% make unit-test

最後に

標準機能のみの少ないコード量で複雑な設定ファイルなどもなく、簡単にAPIサーバが立ち上げれるのはGo言語の魅力の1つだと改めて思いました。
今回は標準機能のみで開発しましたが、マルチプレクサやハンドラ関数でのルーティング、DI、mock、テストでのassertなど標準機能のみだと少し辛いなと感じる部分もあったので、そのあたりを次回以降外部ライブラリなどを使い改善していきたいです。