PureScript で TDD + Clean Architecture (with pmock)
はじめに
前回、PureScriptのmockライブラリpmockの紹介をさせていただいたわけですが、小さな使用例の説明に終始したため、実際にどう使うのかわかりづらい面があったかと思います。
そこで今回はある程度具体的なユースケースを考え、そのユースケースをTDD + Clean Architectureで作りつつpmock
の説明を加えていきたいと思います。
ちなみにソースコードはこちらになります。
作るもの
こんな感じでやります
- とあるユースケースを実現するコードを書きます
- TDDで開発をします
- アーキテクチャはClean Architectureを採用します
前提
この記事で説明しているアプローチは、あくまでアプローチの一つに過ぎません。
私自身も未だより良いアプローチを模索中です。
ユースケース
例として扱うユースケースは
『指定されたユーザーIDのユーザーに紐づくTODOのリストのうちステータスが『完了』のものを出力する』
というものにしてみようと思います。
データの取得に使用するAPI
テスト本体とはあまり関係がありませんが、実装するにあたってpublicなREST APIを利用します。
今回使用するAPIは、JSONPlaceholderです。
このAPIの/users/${userId}/todosを使うこととします。
ちなみにこのパスは次のようなJSONを返します。
[
{
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": true
},
{
"userId": 1,
"id": 2,
"title": "quis ut nam facilis et officia qui",
"completed": false
}
]
completed
というフィールドを参照すれば『完了』しているかどうかわかりそうです。
出力の形式
上記の情報を取得したら、次のように出力を行います。
[Completed Todo Title]
delectus aut autem
quis ut nam facilis et officia qui
Let's TDD
レイヤーを決める
今回はClean Architectureを採用するので、まずあの有名な図を見ながらレイヤーを決めます。
出典: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
今回は以下の名前のレイヤーを切ることにします。
- Domains (図でいうとEntitiesの役割)
- Use Cases
- Gateways
- Presenters
Domains以外は図に合わせました。
また、次のようにしたいと思います。
-
Use Case
をInterface
とInteractor
に分けない。 -
Controllers
のレイヤーは切らず、main
関数をControllerとして扱う。 - つまり
main
関数から直接Use Case
を呼び出す。
Clean Architecture自体がよくわからないという読者の方はひとまず以下を念頭に読み進めていただけたらと思います。
- 責務によってレイヤーを分けている
- レイヤー同士の依存関係は円の外側から内側に向けて一方通行である(例えばUseCasesはEntitiesを参照していいが、その逆は駄目)
- 内側から外側に向かうには
Dependency Inversion Principle
(依存関係逆転の原則)に従い、依存関係を逆転させる
ではやっていきましょう。
Use Case
TDDではアプリケーションの外側から内側に向かってテストと実装を繰り返していきます。
今回でいうとmain
のテストは書かないので、Use Case
が一番外側になります。
テストを書く
さて今回の説明において、ここが一番重要なテストであると言っても過言ではありません。
ここができたら勝ちみたいなものです。
テストケースとしてはこんな感じでしょうか。
spec :: Spec Unit
spec = do
describe "DisplayCompletedTodos Test" do
it "指定されたユーザーIDに紐づくTodoのうち完了したTodoをすべて表示する" do
続いて「このテストで何を検証するか?」を考えます。
今回の場合は「完了したTodoをすべて表示する」というケースなので、そのまんま「完了したTodo」が「表示」されればよさそうです。
表示自体はPresenter
に任せればよいでしょう。関数名はdisplay
とでもしておきます。
つまりUse Case
からPresenter
のdisplay
関数を呼び出すことになるのですが、このdisplay
をmockとして生成し、pmock
のverify
を使って検証します。
spec :: Spec Unit
spec = do
describe "DisplayCompletedTodos Test" do
it "指定されたユーザーIDに紐づくTodoのうち完了したTodoをすべて表示する" do
let
displayMock = PresenterのdisplayのMockを生成
-- 完了済みのToDoを引数にmockが呼ばれたことを検証
verify displayMock completedTodos
Use Case
の関数の呼び出しも書きましょう。モジュール名でUse Case
の役割を表しているので関数名は単にexecute
としました。引数としてユーザーIDを受け取れるようにします。
spec :: Spec Unit
spec = do
describe "DisplayCompletedTodos Test" do
it "指定されたユーザーIDに紐づくTodoのうち完了したTodoをすべて表示する" do
let
displayMock = PresenterのdisplayのMockを生成
-- 実行
_ <- execute ユーザーID
-- 完了済みのToDoを引数にmockが呼ばれたことを検証
verify displayMock completedTodos
さて、ここで次のことを考えないといけません。
- 依存関係を逆転させるにはどうしたらいいか
- 完了済みのToDoをどうやって手に入れるか
依存関係を逆転させるにはどうしたらいいか
順番にいきましょう。まず依存関係を逆転させる(Presenter
をUse Case
に依存させる)ために、Use Case
にTodoOutputPort
というtype
を定義し、処理を実行するexecute
に渡すようにします。
type TodoOutputPort = {
display :: Todos -> Aff Unit
}
execute
:: UserId
-> TodoOutputPort
-> Aff Unit
execute id outputPort = pure unit -- 一旦unitを返す
そしてPresenter
からTodoOutputPort
を参照するようにします(説明の都合上先に用意しましたが、こいつはまだ不要です)。
createOutputPort :: TodoOutputPort Aff
createOutputPort = {
display: unsafeCoerce
}
Use Case
を呼び出すときには、createOutputPort
で作ったTodoOutputPort
を渡せばOKです(これも本当はまだ不要)。
これで依存関係を逆転できました。
main :: Effect Unit
main =
launchAff_ do
let
outputPort = createOutputPort
execute (UserId 1) outputPort
あわせてDomain
も作りましょう。
newtype UserId = UserId Int
-- show, eq instance
derive newtype instance showUserId :: Show UserId
derive newtype instance eqUserId :: Eq UserId
newtype TodoTitle = TodoTitle String
data TodoStatus = Completed | InCompleted
newtype Todo = Todo {
title :: TodoTitle,
status :: TodoStatus
}
todo :: TodoTitle -> TodoStatus -> Todo
todo title status = Todo {title, status}
type Todos = Array Todo
-- show, eq instance
derive newtype instance showTodo :: Show Todo
derive newtype instance eqTodo :: Eq Todo
derive newtype instance showTitle :: Show TodoTitle
derive newtype instance eqTitle :: Eq TodoTitle
derive instance Generic TodoStatus _
instance Show TodoStatus where
show = genericShow
instance Eq TodoStatus where
eq = genericEq
-- 自前のエラー型の方が都合がよいので作成(型名が既存の型とかぶるので変えた方がよかったかもしれない)
newtype Error = Error String
derive newtype instance showError :: Show Error
derive newtype instance eqError :: Eq Error
完了済みのToDoをどうやって手に入れるか
そもそも、まずUserId
に紐づくTodo
をすべて取得する必要がありますね。
その後、状態を参照して『完了済み』のTodo
だけに絞り込めばよいでしょう。
Todo
を返すのはGateway
に任せましょう。
先ほどと同じように依存関係を逆転させればよさそうです。
続いて、完了済みのものに絞り込む所謂フィルターの処理はどうすればよいでしょうか?
これはドメインロジックと考えられるので、処理の本体をUse Case
内に書いてしまうのではなく、ドメインであるTodo
に持たせたいところです。
先にドメインロジックのテストを書き、ドメインロジックを実装しておいてから、Use Case
実装の段階で組み込むというやり方もできるのですが、今回はドメインロジック自体もMock関数で置き換えられるようにしたいと思います。
すなわちドメインロジック自体をUse Case
に渡します。
-- ドメインロジックをまとめる
type Logics = {
completed :: Todos -> Todos
}
-- 生成(まだ不要)
logics :: Logics
logics = {
completed: unsafeCoerce
}
-- Todoを取得するPort
type TodoPort = {
-- Errorが発生する可能性があるため、Eitherにする
findTodos :: UserId -> Aff (Either Error Todos)
}
type TodoOutputPort = {
display :: Todos -> Aff Unit
}
execute
:: UserId
-> Logics
-> TodoPort
-> TodoOutputPort
-> Aff Unit
execute id logics todoPort outputPort = pure unit
そして再びテストへ
これまでをまとめるとテストはこのようになるでしょう。
この時点ではUse Case
もGateway
もPresenter
もドメインロジックも未実装です。
spec :: Spec Unit
spec = do
describe "DisplayCompletedTodos Test" do
it "指定されたユーザーIDに紐づくTodoのうち完了したTodoをすべて表示する" do
let
userId = UserId 1
-- ToDoの定義。mockから使われるだけなので正直値は何でもいい。
todo1 = todo (TodoTitle "Todo1") Completed
todo2 = todo (TodoTitle "Todo2") InCompleted
-- todoPortが返すToDoの配列
todos = [todo1, todo2]
-- ドメインロジックが返すToDoの配列
completedTodos = [todo1]
-- Todoの配列を返すmock関数
findTodosFun = mockFun $ userId :> (pure $ Right todos :: Aff (Either Error Todos))
-- 完了済みのToDoの配列を返すドメインロジックのmock関数
completedTodosFun = mockFun $ todos :> completedTodos
-- 表示を行うmock
displayMock = mock $ completedTodos :> (pure unit :: Aff Unit)
logics = { completed: completedTodosFun }
todoPort = { findTodos: findTodosFun }
todoOutputPort = { display: fun displayMock }
-- 処理の実行
_ <- execute userId logics todoPort todoOutputPort
-- 検証
verify displayMock completedTodos
テストを実行してみましょう。
DisplayCompletedTodos Test
✗ 指定されたユーザーIDに紐づくTodoのうち完了したTodoをすべて表示する:
Function was not called with expected arguments.
expected: [(Todo { status: Completed, title: (TodoTitle "Todo1") })]
but was : []
落ちました。
ということで次にUse Case
を実装します。
さぁ実装だ
ToDo
の取得でエラーが発生したパターンのテストはまだ書いていないのでそちらの実装箇所はpure unit
を返すようにしています。
execute
:: UserId
-> Logics
-> TodoPort
-> TodoOutputPort
-> Aff Unit
execute id logics todoPort outputPort = do
result <- todoPort.findTodos id
case result of
Right todos -> outputPort.display $ logics.completed todos
Left _ -> pure unit
テスト実行すると
DisplayCompletedTodos Test
✓︎ 指定されたユーザーIDに紐づくTodoのうち完了したTodoをすべて表示する
やった!通りました!
Domain
次はDomainにいきましょう。
さきほどunsafeCoerce
にしていたcompleted
のテストを書きます。
ここは純粋な関数のテストなのでMockを使う必要がありません。
テストを書く
import Prelude
import Domains.Todo (TodoStatus(..), TodoTitle(..), todo, completed)
import Test.Spec (Spec, describe, it)
import Test.Spec.Assertions (shouldEqual)
spec :: Spec Unit
spec = do
describe "Todo Test" do
it "完了済みのTodoを返す" do
let
todo1 = todo (TodoTitle "Todo1") Completed
todo2 = todo (TodoTitle "Todo2") InCompleted
todo3 = todo (TodoTitle "Todo3") Completed
completed [todo1, todo2, todo3] `shouldEqual` [todo1, todo3]
テストが落ちることを確認したら実装にいきます。
実装しよう
実装ついでにLogics
を返す関数も作っておきます。
completed :: Todos -> Todos
completed = filter \(Todo {status}) -> status == Completed
type Logics = {
completed :: Todos -> Todos
}
logics :: Logics
logics = {
completed: completed
}
Gateway
今回はGateways
のレイヤーから外部にアクセスすることにしたため、ここは実装のみです。
外部と通信するような副作用を持つ処理はここに閉じられます。
更にレイヤーを追加して、このレイヤーは変換処理に留めることもできるでしょう。
type TodoJson = {
title :: String,
completed :: Boolean
}
type TodosJson = Array TodoJson
createTodoPort :: TodoPort
createTodoPort = { findTodos: findTodos' }
findTodos' :: UserId -> Aff (Either Error Todos)
findTodos' (UserId id) = do
res <- get string $ "https://jsonplaceholder.typicode.com/users/" <> show id <> "/todos"
case res of
Left err -> do
pure $ Left $ Error $ "GET /api response failed to decode: " <> printError err
Right response -> do
case readJSON response.body of
Right (todos :: TodosJson) -> do
pure $ Right $ todos <#> (\{title, completed} -> todo (TodoTitle title) if completed then Completed else InCompleted)
Left e -> do
pure $ Left $ Error $ "Can't parse JSON. " <> show e
Presenter
ここもGatewayと同じく実装のみです。
createOutputPort :: TodoOutputPort
createOutputPort = {
display: display
}
display :: Todos -> Aff Unit
display todos = do
affLog $ "[Completed Todo Title]\n" <> joinWith "\n" (todos <#> (\(Todo {title: TodoTitle t}) -> t))
affLog :: String -> Aff Unit
affLog = liftEffect <<< log
動かす
これまで用意してきた関数を利用してmain
関数を書きます。
main :: Effect Unit
main =
launchAff_ do
let
todoLogics = logics
todoPort = createTodoPort
outputPort = createOutputPort
execute (UserId 1) todoLogics todoPort outputPort
spago run
[Completed Todo Title]
et porro tempora
quo adipisci enim quam ut ab
illo est ratione doloremque quia maiores aut
vero rerum temporibus dolor
ipsa repellendus fugit nisi
repellendus sunt dolores architecto voluptatum
ab voluptatum amet voluptas
accusamus eos facilis sint et aut voluptatem
quo laboriosam deleniti aut qui
molestiae ipsa aut voluptatibus pariatur dolor nihil
ullam nobis libero sapiente ad optio sint
動きましたね!
例外ケースも追加しよう
Use Caseのテスト
正常系のテストと実装が終わったので、異常系(例外ケース)のテストも書いちゃいましょう。
it "Todoの取得でエラーが発生した場合、エラーメッセージを表示する" do
let
userId = UserId 1
findTodosFun = mockFun $ userId :> (pure $ Left $ Error "todo find error" :: Aff (Either Error Todos))
displayMock = mock $ (any :: Param Todos) :> (pure unit :: Aff Unit)
displayErrorMock = mock $ Error "todo find error" :> (pure unit :: Aff Unit)
logics = { completed: unsafeCoerce }
todoPort = { findTodos: findTodosFun }
todoOutputPort = {
display: fun displayMock,
displayError: fun displayErrorMock
}
_ <- execute userId logics todoPort todoOutputPort
verify displayErrorMock $ Error "todo find error"
verifyCount displayMock 0 (any :: Param Todos)
findTodosFun
の箇所を見てください。
ここでmock関数はLeft $ Error ...
のようにError
を返しています。
更にpresenter
にdisplayError
関数が追加されていて、verify
ではこの関数が呼ばれたことを検証しています。
一方で、正常系のときに呼ばれていたdisplayMock
の方はverifyCount
を用いた検証により、一度も呼ばれていないことの検証を行っています。
TodoOutputPort
の定義も変更します。
type TodoOutputPort = {
display :: Todos -> Aff Unit,
displayError :: Error -> Aff Unit
}
こうすると定義を変更したことにより、正常系のテストがコンパイルエラーになるのでpresenter
のあたりをこのように修正します。
presenter = {
display: fun displayMock,
displayError: unsafeCoerce -- 未実装
}
_ <- execute userId logics todoPort presenter
同じくTodoPresenter
もあわせて修正します。
createOutputPort :: TodoOutputPort
createOutputPort = {
display: display,
displayError: unsafeCoerce -- 未実装
}
さてテストを実行してみましょう。
✗ Todoの取得でエラーが発生した場合、エラーメッセージを表示する:
Function was not called with expected arguments.
expected: (Error "todo find error")
but was : Never been called.
期待通り落ちました。
では実装に参りましょう。
Use Caseの実装
これは一瞬で終わります。エラーだったらoutputPort.displayError
を呼ぶだけです。
execute
:: UserId
-> Logics
-> TodoPort
-> TodoOutputPort
-> Aff Unit
execute id logics todoPort outputPort = do
result <- todoPort.findTodos id
case result of
Right todos -> outputPort.display $ logics.completed todos
Left e -> outputPort.displayError e
テストを流しましょう。
✓︎ Todoの取得でエラーが発生した場合、エラーメッセージを表示する
Presenterの実装
あとはPresenter
を実装して終わりです。ログ出力だけなので簡単です。
createOutputPort :: TodoOutputPort
createOutputPort = {
display: display,
displayError: displayError
}
displayError :: Error -> Aff Unit
displayError (Error e) = do
affLog e
実行
わざとエラーを発生させるため、雑にREST APIの接続先をこんなふうに書き換えてみます。
https://jsonplaceholder.typicode.xxx/users/
この状態で実行すると
GET /api response failed to decode: There was a problem making the request: request failed
正しくエラーメッセージが出力されました。
以上でシンプルなUse Case
ですが一通り設計~テスト~実装が終わりました。
発展的な話題
いまはGateway
が接続しているREST APIのエンドポイントはハードコードされています。
単体テストの際はこれでもいいかもしれませんが、CI/CD
環境でE2E
を行う際などは恐らくMock Serverを立てて向き先をそちらに変えたいでしょうから、これでは困りますね。
ということを考えると、こんな感じでPort
を返す際に環境変数を渡せるようにしておくとよいかと思います。
-- 環境変数(本当は定義場所は別にした方がいい)
type Environment = { apiEndpoint :: String }
{-
環境変数を渡してportを返す
portのfindTodosの定義は以前のままで、実際呼び出す関数に環境変数を渡す。
-}
createTodoPort :: Environment -> TodoPort
createTodoPort env = { findTodos: \userId -> findTodos' userId env }
-- 既存の関数を環境変数を受け取るように変える
findTodos' :: UserId -> Environment -> Aff (Either Error Todos)
findTodos' (UserId id) env = do
res <- get string $ env.apiEndpoint <> "/users/" <> show id <> "/todos"
case res of
Left err -> do
pure $ Left $ Error $ "GET /api response failed to decode: " <> printError err
Right response -> do
case readJSON response.body of
Right (todos :: TodosJson) -> do
pure $ Right $ todos <#> (\{title, completed} -> todo (TodoTitle title) if completed then Completed else InCompleted)
Left e -> do
pure $ Left $ Error $ "Can't parse JSON. " <> show e
portのfindTodosの定義は以前のままにしていますが、これを環境変数を渡すようにしてしまうと(あるいはReaderモナドで包むなど)、他の箇所にも環境変数が出現することになり、環境変数を配置するレイヤー次第ですがあまりクリーンでなくなってしまうように感じます。
また、今回はモナドを合成する必要がないシンプルなケースでしたが、合成する必要が生じたらモナド変換子や、purescript-run
などを使うことを検討してみてはいかがでしょうか。
Tagless Finalにしないの?
Use Case
にはtype
として依存関係TodoPort
, TodoOutputPort
を渡しています。
こんなことするならTagless Final
にした方がよいのでは?
と思った方もおられると思います。
つまり
class Monad m <= TodoPort m where
findTodos :: UserId -> m (Either Error Todos)
class Monad m <= TodoOutputPort m where
display :: Todos -> m Unit
displayError :: Error -> m Unit
class Monad m <= Usecase m where
execute :: UserId -> m Unit
instance interactor :: (TodoPort m, TodoOutputPort m) => Usecase m where
execute userId = ...
のように定義します。
これはシンプルで良いですね。
しかしこれだと、Gateway
のモジュールの方で
instance gateway :: TodoPort Aff where
findTodos userId = unsafeCoerce
としたときOrphan instance
となってコンパイルエラーになります。
ということでtype
を渡しています。
2023/05/07 追記
extensible-effects
を利用した版を作ってみました。
Use Case
のexecute
関数にportを渡す必要がなくなりました。
これならTagless Final
を利用しなくてもいいかな。
execute
:: UserId
-> Logics
-> Run (TODO_PORT + TODO_OUTPUT_PORT + AFF + ()) Unit
execute id logics = do
result <- findTodos id
case result of
Right todos -> display $ logics.completed todos
Left e -> displayError e
ちなみに実行の際はこうなります。
main :: Effect Unit
main =
launchAff_ do
let
todoLogics = logics
execute (UserId 1) todoLogics
# runPort createTodoPort
# runOutputPort createOutputPort
# runBaseAff
テストも変わりなく行えます。
これは別記事に書きました。
終わりに
PureScriptでTDD + Clean Architecture やってみた感想ですが、TDD特にモックに関して言えば、かなり意図的にモックを差し込む余地を作っておいてやる必要があるなと感じました。
Use Caseから呼び出すドメインロジックなんかは、関数を渡さずに外側からモック化できると嬉しいなぁ、と。
黒魔術でどうにかできないですかね。
クリーンアーキテクチャに関して言えば、自分はPureScriptでも採用したいと思います。今回のようにシンプルなケースだとあまり有り難みを感じないかもですが、大規模化していき関わるメンバーが増えたときに真価を発揮すると思います。
Discussion