お題
前回の続きで実践編。
ただ、ここでは、本来の「BDD(振る舞い駆動開発)」に準ずることを目的とはしていない。
ある解決したい課題があり、「Ginkgo」というツールがもたらす体裁(BDDテストフレームワーク)が、その解決に使えるのでは?という直感のもと使おうとしているだけなので。
使い方の感覚としては、どちらかというと、「ユースケース駆動開発」と言えるかもしれない。
(ただ、「ユースケース」という言葉とこの記事が提示している説明や具体例にも乖離があるかもしれない。(実際に「ユースケース駆動開発」の経験がないため))
「ユースケース駆動開発」のようにロバストネス分析を行ってといったアプローチも取らない。
【解決したい課題】
機能を実装するスピードが最優先という状況下での品質担保。
<想定するプロジェクトの性質>
・ システム内部のプログラムから外部への接続(クラウドや外部システムのAPI)が多い。
・ システムの構成としてバックエンドはWebAPIの形式をとる。
<課題解決のためのアプローチ>
上記を想定した上で、以下のようなアプローチをとる。
・ 設計を省くことはできない。テストファースト=設計ファーストとして、テストケースは機能実装前に書く。
・ ただし、1テストケースの粒度はTDDで言うそれとは大きく異なり、1ユースケースというくらいのものとする。
(例:「システム管理者は、表示期間を指定して『お知らせ』を登録することができる。」)
・ 個別項目に対する境界値チェックやフォーマットのチェックなどの細かいケースは後回しとする。
(その手の仕様は実装中でさえ、要件レベルでころころ変わりうる、ないし、認識・確認漏れが発生するので、まずはブレない(にくい)部分からテストコードにおこす。)
<テストケース作成単位>
先述した通り、1ユースケース相当。
正直なところ、案件の内容やプロジェクトの性質、及び機能間でも粒度にブレが出る恐れは十分ある。
イメージとしては、クリーン・アーキテクチャで言う「Use Cases(Application Business Rules)」に当たり、DDDで言う「アプリケーション・サービス」に当たる。
開発環境
# OS
$ cat /etc/os-release
NAME="Ubuntu"
VERSION="18.04.2 LTS (Bionic Beaver)"
# Golang
$ go version
go version go1.11.4 linux/amd64
# Ginkgo/Gomega
$ cat go.mod
module gobdd
require (
github.com/onsi/ginkgo v1.8.0
github.com/onsi/gomega v1.5.0
)
実践
前段
「ginkgo」の導入とひな型作成は以下を参照。
https://qiita.com/sky0621/items/a0af8f292b516160c8cd#導入
今回対象とするプロジェクトの全ソースについては下記参照。
https://github.com/sky0621/gobdd/tree/19b34328101f6ba70190f8dde506efe258fa4e5f
1ユースケースの設計・実装
想定するユースケース
想定するユースケースは「システム管理者は、表示期間を指定して『お知らせ』を登録することができる。」とする。
まずは、このユースケースをシンプルに表現するテストコードの実装を目指す。
Step.01: Ginkgoでの事前準備
Ginkgoに備わっているテストコードのひな型を作成するコマンドによって『お知らせ』に関するユースケースを表現するためのテストファイルを自動生成。
$ ginkgo generate notice
Generating ginkgo test for Notice in:
notice_test.go
package gobdd_test
import (
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
. "github.com/sky0621/gobdd"
)
var _ = Describe("Notice", func() {
})
Step.02: ユースケースの説明
まずは、何に関するテストケースなのかを説明するコードを追加。
ここでは、記事冒頭で提示したユースケース例である「システム管理者は、表示期間を指定して『お知らせ』を登録することができる。」を適用。
package gobdd_test
import (
. "github.com/onsi/ginkgo"
)
var _ = Describe("Notice", func() {
Describe("システム管理者は、表示期間を指定して『お知らせ』を登録することができる。", func() {})
})
では、テスト再実行。
$ ginkgo
Running Suite: Gobdd Suite
==========================
Random Seed: 1556554048
Will run 0 of 0 specs
Ran 0 of 0 Specs in 0.000 seconds
SUCCESS! -- 0 Passed | 0 Failed | 0 Pending | 0 Skipped
PASS
Ginkgo ran 1 suite in 932.41912ms
Test Suite Passed
特にエラーなくテストスイートがPassすることは変わりなし。ただ、テストケース自体カウントされていない。
実際、notice_test.go
単体をテストしても、no tests to run
となる。
つまり、テストケースで説明文だけを記載しても、テストケースとしてはカウントされないということか。
$ go test -v notice_test.go
testing: warning: no tests to run
PASS
ok command-line-arguments 0.003s [no tests to run]
Step.03: ユースケースのコンテキスト
以下のように説明のみ記述していたテストケースを
var _ = Describe("Notice", func() {
Describe("システム管理者は、表示期間を指定して『お知らせ』を登録することができる。", func() {})
})
どういう条件でどうなるべきかを明示できるよう修正。
var _ = Describe("Notice", func() {
Describe("『お知らせ』の登録", func() {
Context("主体が「システム管理者」である場合", func() {
It("表示期間を指定して『お知らせ』を登録できる。", func() {
// FIXME: ここに具体的な検証ロジックを簡潔に記載する。
})
})
})
})
Step.04: コンパイルエラーケースから実装
TDDの流儀にならうなら、まずはビルドすら通らないテストケースを実装する。
実装内容(テストコードのみ)
package gobdd_test
import (
"gobdd/usecase"
usecasemodel "gobdd/usecase/model"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("Notice", func() {
Describe("『お知らせ』の登録", func() {
Context("主体が「システム管理者」である場合", func() {
var (
// 登録対象の『お知らせ』情報
noticeForCreateParam *usecasemodel.Notice
)
It("表示期間を指定して『お知らせ』を登録できる。", func() {
id, err := usecase.NewNotice().Create(noticeForCreateParam)
Expect(id).To(Equal("DUMMY"))
Expect(err).To(BeNil())
})
})
})
})
Expect
という関数がGomegaというテスト用ライブラリのアサーション用途として提供されているので利用。
Expect
関数の引数に対してテスト対象関数ないしメソッドの実行結果を渡し、To
という関数内で期待値とのマッチングを行う。
さて、この(コンパイルが通らない)テストコードでは何をしているのか?
usecase.NewNotice().Create(noticeForCreateParam)
により、ユースケースパッケージの『お知らせ』ユースケースを担う構造体(usecase.Notice
)を用いて『お知らせ』情報を登録する。
パラメータは、usecasemodel.Notice
(ユースケースモデルパッケージのNotice
構造体)とする。
構造体の定義を持つことで、同パッケージにて『お知らせ』情報の詳細は隠蔽する。
また、Create
メソッドは永続化した情報に付与されたID(情報をユニークに特定するもの)とエラー有無を返却するため、その返却値と事前に定義した期待値をマッチング。
テスト結果
テスト対象ソースが未実装なので、当然、テストは失敗する。
$ ginkgo
Failed to compile gobdd:
# gobdd
notice_test.go:4:2: unknown import path "gobdd/usecase": cannot find module providing package gobdd/usecase
FAIL gobdd [setup failed]
Ginkgo ran 1 suite in 670.28508ms
Test Suite Failed
考察
テスト対象ソースを実装する前にテストはコードを実装、そして、ユースケースを練ることが重要。
大概は、テストファーストと言っても、テストコードを書く時点で、ある程度の実装の算段はついている。
たとえば、以下のようなイメージ。(このへんの想定は人によって当然異なる。その人の参画プロジェクトないし技術背景に大きく依存するので。)
- 使うWebフレームワークは「Echo」
- O/Rマッパーは「Gorm」
- Logユーティリティは「Zap」
- クラウド(GCP)上にデプロイ
- デプロイ先サービスは「Google App Engine」
- 永続化は「Cloud SQL」を利用
- バッチ処理で生成したCSVファイル等は「Cloud Storage」に格納
上記の事例と大きく異なろうとも、ある程度の想定技術、環境、ミドルウェアの選定は済んでいたりする。
そうなると、永続化はRDBを用いる、そして使うサービスはこれと決まった時点で、テストコードを書こうにも、そうした選定技術の詳細に依存した作りになる。
”ユースケース”をベースとした”テストファースト”な設計・実装を目指すはずが、以下のような、ユースケースと直接関係のない事柄から実装を始めてしまうことになりがち。
『RDBなのでリクエスト受ける前にコネクション張って、ある程度プールしておく。』
『プールしたコネクション情報は各HTTPリクエストで使いまわせるよう、HTTPリクエスト受信時に利用できる形で保持しておく。』
『Echoフレームワークを使うので、Echoコンテキストに情報をいろいろ積めば、各HTTPリクエスト処理で効果的に使える。』
『ログをGCPのStackdriverLoggingにログレベル別に表示させるためにZapをラップしたロガーを作っておこう。』
etc...
こうした技術詳細に囚われないようにするために、まずは、純粋に要件をユースケースという形に落とし込む。
それをテストコードという形で表現する。ただし、この過程でテスト対象コードのことも当然考える必要に迫られる。
それに対し、この時点では、極力抽象化した構造での実装を試みる。
たとえば、「システム管理者は、表示期間を指定して『お知らせ』を登録することができる。」というユースケースに対し、(なまじ開発者としての経験があるゆえ、また、既にテストコードを書き始めているがゆえに)以下のように、すぐに実装詳細の検討に入ろうとしてしまう。
『登録するのだからユニークに特定するIDが必要だ。』
『表示期間は年月日のフォーマットか? タイムゾーン考慮は必要か? 面倒だからUnixタイムスタンプで持つか。』
『システム管理者というのは”役割(ロール)”と捉えるべきか。他にはロールはあるか。任意で増減はあるか。ロールIDというのを定義して持たせればよいか。』
これを、ぐっと押しとどめる。とにかく、ただユースケースのことだけを考える。
『そもそもこのユースケースは妥当か?』
『お知らせを登録する頻度はどれくらいあるのか?』
『他の機能と比べて、優先度の高い、ないし、必須の機能なのか?』
『システム管理者のみとする要件に変更が入る可能性はどれくらいあるか?』
『お知らせ1件1件をユニークに特定できる必要はあるか?(単純に、存在するお知らせが新しいものから順に出ていればいいだけとは言えないか)』
『高度なフィルタリングが必要になり得るか?』
『お知らせ毎に見せる相手を分ける必要はあるか?』
こうした掘り下げを行った結果として、以下のような要件に変わるかもしれない。
「お知らせは、権限のある運用担当者がCSV形式で最新化し、Cloud Storageにアップロードする。プログラム側ではCSVの更新を検知したら、画面の表示を最新化する。」
RDBに永続化する方式とどちらが楽(現在、そして未来)かは一概には言い切れない。
ただ、最初からRDBありきで、他の機能にも高度な検索フィルタ機能が付いているがゆえに、顧客から「他の画面と同じように保存してるなら、使うかわからないけど『お知らせ』にも付けといて。」と言われるのは避けたい。
”こういう理由で必要だから”実装するようにしたい。(ないしは、”確実とは言えないが、こういう仮説で、求められている機能だと考えられるから”実装するようにしたい。)
Step.05: コンパイルを通す
ユースケースについては検討し、いったん下記のままで進めることとする。
「システム管理者は、表示期間を指定して『お知らせ』を登録することができる。」
実装内容(プロダクトコード)
テストコードで登場させ、未実装だったプロダクトコードを実装する。
まず、下記の通り、 usecase
パッケージ、及び、usecase/model => usecasemodel
パッケージを実装する。
$ tree
.
├── gobdd_suite_test.go
├── go.mod
├── go.sum
├── notice_test.go
└── usecase
├── model
│ └── notice.go <- ★これ
└── notice.go <- ★これ
新規追加したプロダクトコードについてはコンパイル可能な最低限の実装を行っておく。
package usecase
import (
usecasemodel "gobdd/usecase/model"
)
func NewNotice() Notice {
return ¬ice{}
}
type Notice interface {
Create(noticeModel *usecasemodel.Notice) (string, error)
}
type notice struct {
}
func (n *notice) Create(noticeModel *usecasemodel.Notice) (string, error) {
// FIXME: ユースケースに合わせて実装
return "", nil
}
package usecasemodel
// 入力値用『お知らせ』定義
type Notice struct {
Title string // お知らせのタイトル
Text string // お知らせの文章(現時点はテキストのみサポート)
PublishFrom int // お知らせの掲載開始日時
PublishTo int // お知らせの掲載終了日時
}
テスト結果
この状態でテスト実行すると、こうなる。テスト失敗という点は変わりないが、テスト対象のテストケースが存在する状態での初実行となる。
$ ginkgo
Running Suite: Gobdd Suite
==========================
Random Seed: 1556771790
Will run 1 of 1 specs
• Failure [0.000 seconds]
Notice
/home/sky0621/work/src/go111/src/github.com/sky0621/gobdd/notice_test.go:11
『お知らせ』の登録
/home/sky0621/work/src/go111/src/github.com/sky0621/gobdd/notice_test.go:12
主体が「システム管理者」である場合
/home/sky0621/work/src/go111/src/github.com/sky0621/gobdd/notice_test.go:13
表示期間を指定して『お知らせ』を登録できる。 [It]
/home/sky0621/work/src/go111/src/github.com/sky0621/gobdd/notice_test.go:18
Expected
<string>:
to equal
<string>: DUMMY
/home/sky0621/work/src/go111/src/github.com/sky0621/gobdd/notice_test.go:20
------------------------------
Summarizing 1 Failure:
[Fail] Notice 『お知らせ』の登録 主体が「システム管理者」である場合 [It] 表示期間を指定して『お知らせ』を登録できる。
/home/sky0621/work/src/go111/src/github.com/sky0621/gobdd/notice_test.go:20
Ran 1 of 1 Specs in 0.001 seconds
FAIL! -- 0 Passed | 1 Failed | 0 Pending | 0 Skipped
--- FAIL: TestGobdd (0.00s)
FAIL
Ginkgo ran 1 suite in 881.789325ms
Test Suite Failed
現状はCreate
メソッドが常に空文字とnil
を返す実装になっており、期待値として現状設定している内容と異なるためにテストが失敗する。
Step.06: ユースケースを練る
テストコードにて明確にパラメータへセットする値を定義する。
そして、期待値としても求める形を明確に定義する。
上記にて明確に(とは言え、『お知らせ』が持つ属性としても現時点で顧客からヒアリングできている情報のみを使った形となる)定義したユースケースに合致するように実装する。
ただし、今の時点では、明確に決めたユースケースに必ず合致するように実装するだけ。
やはり、永続化のことなどは考えない。
実装内容(テストコード)
package gobdd_test
import (
"gobdd/usecase"
usecasemodel "gobdd/usecase/model"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("Notice", func() {
Describe("『お知らせ』の登録", func() {
Context("主体が「システム管理者」である場合", func() {
var (
// 登録対象の『お知らせ』情報
noticeForCreateParam *usecasemodel.Notice
)
+ BeforeEach(func() {
+ noticeForCreateParam = &usecasemodel.Notice{
+ Title: "お知らせ1",
+ Text: "これはお知らせ1です。",
+ PublishFrom: 1556636400,
+ PublishTo: 1557327599,
+ }
+ })
It("表示期間を指定して『お知らせ』を登録できる。", func() {
id, err := usecase.NewNotice().Create(noticeForCreateParam)
Expect(id).To(Equal("ef5198df-5c04-42ba-9fbe-2beb2794468a"))
Expect(err).To(BeNil())
})
})
})
})
テスト対象のCreate
メソッドに渡すパラメータに具体的な値をセット。
実装内容(プロダクトコード)
次は、ユースケース実行ロジックに手を入れる。
ただし、あくまでテストケースを成功させるためだけの修正のため、テストコードが期待する値を返すだけの実装にする。
package usecase
〜〜 省略 〜〜
func (n *notice) Create(noticeModel *usecasemodel.Notice) (string, error) {
* // FIXME: 【テストコードを通すための実装】ここでは渡された『お知らせ』情報と固定のIDを返却する。
* return "ef5198df-5c04-42ba-9fbe-2beb2794468a", nil
}
テスト結果
$ ginkgo
Running Suite: Gobdd Suite
==========================
Random Seed: 1556771998
Will run 1 of 1 specs
•
Ran 1 of 1 Specs in 0.000 seconds
SUCCESS! -- 1 Passed | 0 Failed | 0 Pending | 0 Skipped
PASS
Ginkgo ran 1 suite in 929.463711ms
Test Suite Passed
ちゃんとテストケース1件が認識されて、1件成功している。
実装自体はもちろん永続化もされていないし、IDも固定値を返しているので完成とは到底言えないけど、ひとまずユースケースを満たすコードを(当然修正が必要なので「FIXME
コメント」付きで)書いてテストをPASSしたということで、まずは一安心。
考察
「コンパイルエラー」→「テスト失敗(RED)」→「テスト成功(GREEN)」の変遷を辿ることで設計・実装・テストのリズムが出来るというのがTDDのウリのひとつだと思う。
通常の感覚であれば「固定のIDと渡された構造体を返すだけでテストOKなんて、ただのバグだろう!」と思うところを、ぐっと飲み込む必要がある。
現時点では、永続化など考えていないということもあり、このテストコード並びにプロダクトコードは、(基本的に)いつ、どの環境で動かしても、Goの同じVersionのランタイムさえあれば実行できるはず。
ただ、こういういかにもテストしやすいコードに関しては、正直なところあれこれ検討することもなく、さらっとテストコード書けてしまうもの。
問題は、ここから。
今回のユースケースに関して、検討の結果、登録リクエストされた『お知らせ』情報はRDBで永続化することにした。
実サービス上ではGCPの「Cloud SQL」を想定し、タイプはMySQLなので、例えばGCPへのデプロイ前にローカル環境で動作確認をする場合はローカル環境上にMySQLをインストール(最近はDockerコンテナ使う方が多いのかな)してデータベースを構築し、プログラムからはそこに接続して永続化を行うようにすると思われる。
さて、永続化を考え、ミドルウェアを使うことを考えた時点で、一気に技術の詳細を意識せざるを得なくなった。
この時点で、(永続化を行うミドルウェアに接続するプログラムを完成後に)いつでもどの環境でもテストコードが流せるというのは無理がある。
この事態に対して取るアプローチとして経験があるのは以下の2つ。
①ローカル環境だろうとCI環境だろうと、本番と(極力)同じミドルウェアを適宜インストールしてテストコードを流す。
②アプリ外と接続する部分は全てスタブ化してテストコードを流す。
当然のことながら両者それぞれにメリット・デメリットはあり、どちらを採用するかは現場の方針によって決まっていた印象。
(テストに関する大ベテランがいた現場ではないため、どの現場も両者どちらにすべきか、割と揺れ動いていた記憶あり。(まあ、主に自分が・・・。))
思いっきり粗くメリ・デメを書く。
No | 内容 | メリット | デメリット |
---|---|---|---|
① | 環境毎にミドルウェアを用意 | テストと本番とで結果差異が少ない(*1) | べき等性担保が条件付きになる(*2) |
② | ミドルウェア接続をスタブ化 | 純粋にロジックのテストが可能 | テストOKでも本番NGケースが発生する(*3) |
*1: 本番DBだけに存在するレコードの影響やミドルウェアのマイナーバージョンの違いで結果に差異が生じる可能性は0ではない。
*2: ミドルウェアとの接続は通信を介するため、成功テストケースが必ず成功するとは限らない。
*3: テストケースは、あくまで「こういう結果をミドルウェアは返すはず」という想定でスタブ化するため、本番ミドルウェアの挙動が想定外となってエラーが起きる可能性はある。
TDDを扱いはじめの頃は、ユニットテストなので外部との(通信を介する)接続部分は全てスタブ化して、純粋に単体機能の挙動をテストすべきと考えていた。
ただ、昨今のクラウドに載せる前提のアプリの場合、単体機能といえど、重要なロジックのIN/OUT、及び、その結果に応じたロジックに至るまで、クラウドサービスとの通信の結果(そして、それは各サービスの技術特性が大きく関わる)が大きく作用するようになっており、そこをスタブ化するとロジックの大部分がテストの意味を持たなくなる機能まで出てくる。
こうなってくると、テストコードにおいて、頑なに”ユニット”であること、また、べき等であることにこだわるよりも、極力本番と同じサービス(の別インスタンス)に(テストコードからも)接続する方が意味のあるテストになるのではないか。
そのような考えから、
スタブは用いずテストコードからも外部サービスとの接続前提としたことがあった。
その結果、外部サービスがRDBぐらいであればおそらく問題なかったが、キャッシュはRedis、ファイルのアップロード・ダウンロードにストレージサービス(AWSで言うS3、GCPで言うCloud Storage)、機能によっては別のWebAPIサービスから情報を取得といったように”外部”が増えてくると、テストコードの実行が必要な環境毎にそれぞれの外部サービスの(テスト用)インスタンスを用意する必要に迫られ、そのあたりの情報の管理コストが(メリットを上回った?と思えるほど)肥大化した。
結局、今の考えとしては、以下に落ち着いている。
あまり細かい(技術詳細に依存する)部分までテストコードにおこすのではなく、業務用件の本質部分というかエッセンスというか、今回題材としている”ユースケース”といった部分を仕様としてテストケースにおこす。
あとは、実装詳細が”外部サービス”とのやりとりに関する部分であれば、基本的なパターンのみスタブを設け、それ以外のケース(特に”外部サービス”固有の癖のある部分)は結合テスト(※できれば、その前段として”単一機能内の外部サービスとの結合テスト”という名目のフェーズを設けたい。)に委ねる。
Step.07: 永続化ロジックの実装(インターフェース呼び出し部分)
登録リクエストを受けた『お知らせ』情報をRDB(Cloud SQL)に永続化するコードを書く。
具体的には下記を”実際に永続化する”実装に変える。
func (n *notice) Create(noticeModel *usecasemodel.Notice) (string, error) {
// FIXME: 【テストコードを通すための実装】ここでは渡された『お知らせ』情報と固定のIDを返却する。
return "ef5198df-5c04-42ba-9fbe-2beb2794468a", nil
}
事前検討
Step.06 の考察で”ミドルウェアとの接続ロジックはスタブで基本的なパターンをテストする”とした。
スタブを実装するには”スタブでない実装”も当然必要になるわけで、必然的に「お知らせ情報を永続化する」機能に関して、呼び出し状況に応じて詳細な実装を切り替えられる仕組みを実装することになる。
Goにはインターフェースがあるので、まずはインターフェースを定義し、その後、本来のプロダクトコード(実際にRDBに接続するロジック)とテストコード(スタブ)のそれぞれを実装する。
ソース追加後ツリービュー
$ tree
.
+ ├── domain
+ │ ├── model
+ │ │ └── notice.go
+ │ └── notice.go
├── gobdd_suite_test.go
├── go.mod
├── go.sum
* ├── notice_test.go
└── usecase
├── model
│ └── notice.go
* └── notice.go
これまでのユースケース層のパッケージから実装の詳細を呼び出すに際し、上述の通り、まずはインターフェースを定義する。
(パッケージは「domain
」とする。)
そして、ユースケース層からそのインターフェースを呼び出すコードを書く。
実装内容(ドメイン層のインターフェース)
package domain
import (
domainmodel "gobdd/domain/model"
)
type Notice interface {
Create(noticeModel *domainmodel.Notice) (string, error)
}
package domainmodel
type Notice struct {
ID string
Title string
Text string
PublishFrom int
PublishTo int
CreatedAt int
UpdatedAt int
}
実装内容(ユースケース層)
package usecase
import (
"gobdd/domain"
domainmodel "gobdd/domain/model"
usecasemodel "gobdd/usecase/model"
"github.com/google/uuid"
)
* func NewNotice(noticeDomain domain.Notice) Notice {
* return ¬ice{noticeDomain: noticeDomain}
}
type Notice interface {
Create(noticeModel *usecasemodel.Notice) (string, error)
}
type notice struct {
* noticeDomain domain.Notice
}
func (n *notice) Create(noticeModel *usecasemodel.Notice) (string, error) {
* // ドメインモデルへの変換(ユースケース層独自の構造からドメイン層独自の構造への変換(例:日時の持ち方や「姓」と「名」別持ちから「姓名」等))
+ domainModel := &domainmodel.Notice{
+ ID: uuid.New().String(),
+ Title: noticeModel.Title,
+ Text: noticeModel.Text,
+ PublishFrom: noticeModel.PublishFrom,
+ PublishTo: noticeModel.PublishTo,
+ }
* return n.noticeDomain.Create(domainModel)
}
「お知らせ情報を永続化する」機能の具体的な処理はドメイン層に委譲させた。
ドメイン層のロジックを呼び出すために、NewNotice
関数を改良して、具体的なドメイン層実装ロジックを受け取れるようにしておく。
こうすることで、実際にどこに接続(ローカルなのかクラウドなのか外部APIなのか)するかを意識せずにユースケースをテストすることができる。
外部に依存する部分はNewNotice
関数に渡すドメインロジックの構造体が担っているので、スタブ用の構造体を渡すことでどのようにでも制御できる。
(正直なところ、これだけでは呼び出すドメイン層のロジックがロジックの全てになるので、「じゃあ、テストする意味ないのでは?」と思われてしまう。前段にバリデーションや(複数のドメインロジックを呼び出す想定で)トランザクション制御などが入ると有用性が感じられると思うが、いったんこのままで。)
ツリー構造ふたたび
ドメイン層にはインターフェースしかないので、当然具体的な実装ロジックが必要。
それらは、実プロダクト用としてはもちろん、テスト時のスタブ用、または、ローカル環境で動かす時専用だったりと(同じインターフェースを持つ)さまざまな具象ロジックが想定される。
以下の通り、adapter
というパッケージを作り、今回で言うと”永続化”をどう実装するかのパターンに応じてサブパッケージを作ることにした。
このあたりのパッケージの切り方、Goファイル名の付け方には、アプリの規模やサービスの特性等さまざまな要因から最適(でなくても、よりベター)な切り口を見つける必要がある。
どんなアプリかにより、ある程度のパターンやプラクティスは見つけられると思うが、すべてに通用するものを見つけるのは難しいと思われる。(今ベストと思われるものも、時の洗礼を受けた結果どうなるかはわからない。)
$ tree
.
+ ├── adapter
+ │ └── gateway
+ │ ├── gcp
+ │ │ └── notice.go
+ │ ├── local
+ │ │ └── notice.go
+ │ └── test
+ │ └── notice.go
├── domain
│ ├── model
│ │ └── notice.go
│ └── notice.go
〜〜 省略 〜〜
実装内容(domain
パッケージのインターフェースを実装するテスト用のスタブ)
実際に永続化を行うのではなく、テストがしやすいように期待値をあらかじめセットすることができる作りにする。
package testgateway
import domainmodel "gobdd/domain/model"
type NoticeImpl struct {
ExpectID string
ExpectError error
}
func (n *NoticeImpl) Create(noticeModel *domainmodel.Notice) (string, error) {
// 事前にセットされた期待値を返すだけ
return n.ExpectID, n.ExpectError
}
実装内容(テストコード)
package gobdd_test
import (
testgateway "gobdd/adapter/gateway/test"
"gobdd/domain"
"gobdd/usecase"
usecasemodel "gobdd/usecase/model"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("Notice", func() {
Describe("『お知らせ』の登録", func() {
Context("主体が「システム管理者」である場合", func() {
+ const (
+ ExpectID = "ef5198df-5c04-42ba-9fbe-2beb2794468a"
+ )
var (
+ // 『お知らせ』情報を登録するロジック
+ noticeDomain domain.Notice
// 登録対象の『お知らせ』情報
noticeForCreateParam *usecasemodel.Notice
)
BeforeEach(func() {
+ // 期待値をセットできるテスト用のスタブドメインロジックを使うことで、外部サービス接続ロジックを回避したテストが可能
+ noticeDomain = &testgateway.NoticeImpl{
+ ExpectID: ExpectID,
+ ExpectError: nil,
+ }
noticeForCreateParam = &usecasemodel.Notice{
Title: "お知らせ1",
Text: "これはお知らせ1です。",
PublishFrom: 1556636400,
PublishTo: 1557327599,
}
})
It("表示期間を指定して『お知らせ』を登録できる。", func() {
id, err := usecase.NewNotice(noticeDomain).Create(noticeForCreateParam)
* Expect(id).To(Equal(ExpectID))
Expect(err).To(BeNil())
})
})
})
})
テスト結果
期待値通りにセットしたドメイン層のロジックを使っているのだから当然ではあるが、テストは成功する。
$ ginkgo
Running Suite: Gobdd Suite
==========================
Random Seed: 1556778608
Will run 1 of 1 specs
•
Ran 1 of 1 Specs in 0.000 seconds
SUCCESS! -- 1 Passed | 0 Failed | 0 Pending | 0 Skipped
PASS
Ginkgo ran 1 suite in 1.000235799s
Test Suite Passed
考察
レイヤーの切り方やパッケージ名についてはクリーン・アーキテクチャやヘキサゴナルアーキテクチャを意識している。
今回は、domain
パッケージにインターフェースを定義して、その具体的な実装をadapter/gateway/xxx
パッケージに持たせた。
今回くらいのアプリの規模感であればこの切り方でも良いと思うが、もう少し規模が大きい(ないし大きくなる)ことが想定される場合は、domain
パッケージ下をもう1段階掘り下げた方がいい。
戦術的な要素だけ取り入れるのはよくないとは言われているものの、DDDを参考にするなら以下のようなイメージ。
以下の中の「repository
」部分が外部サービスとの接続ロジックに相当するので、そこをインターフェースにして切り替えるイメージ。
※その他のパッケージについてはDDDの説明になるので説明は省略。以下を参照されたし。
https://codezine.jp/article/detail/9546
└── domain
├── aggregate
│ └── notice.go
├── entity
│ └── notice.go
├── factory
│ └── notice.go
├── repository
│ └── notice.go
├── service
│ └── notice.go
└── valueobject
└── xxxx.go
まとめ
Ginkgoをツールとして使ったテストファースト開発の試行について、ここまでで、外部依存するケースのインターフェースを用いたロジック切り替えを実現。いったんの区切りまでは到達したのと、だいぶ長くなってきたので、今回はここまで。
ただし、実際に外部依存するロジックを実装する側については未実装なので、次回はそこを実装。
このあたりは、いわゆるDI(Dependency Injection)が必要になる世界。
以下で書いたGoogleのwireを利用してみようと思う。
「go1.11+google/wireによるDependency Injection」
また、そもそもユースケースにあった「システム管理者は」の部分と「表示期間を指定して」の部分に関するテストケースが設けられていないので、そこも次回以降で。
1ユースケースについて練り上げていく過程を出しただけでは、当然のことながら実運用で適用できるものとは言い切れない。
このあたりは、ユースケースをなるべく業務レベルで起こり得そうな規模まで増やしてみることが必要だけど、出来うるなら、実業務でトライする機会が欲しいところ。
今時点のソース全量は↓
https://github.com/sky0621/gobdd/tree/19b34328101f6ba70190f8dde506efe258fa4e5f