😺

PureScript で TDD + Clean Architecture (with pmock)

2023/05/06に公開

はじめに

前回、PureScriptのmockライブラリpmockの紹介をさせていただいたわけですが、小さな使用例の説明に終始したため、実際にどう使うのかわかりづらい面があったかと思います。
https://zenn.dev/funnycat/articles/b699fdb54bb8d8
そこで今回はある程度具体的なユースケースを考え、そのユースケースをTDD + Clean Architectureで作りつつpmockの説明を加えていきたいと思います。

ちなみにソースコードはこちらになります。
https://github.com/pujoheadsoft/purescript-cleanarchitecture-using-records

作るもの

こんな感じでやります

  1. とあるユースケースを実現するコードを書きます
  2. TDDで開発をします
  3. アーキテクチャはClean Architectureを採用します

前提

この記事で説明しているアプローチは、あくまでアプローチの一つに過ぎません。
私自身も未だより良いアプローチを模索中です。

ユースケース

例として扱うユースケースは
『指定されたユーザーIDのユーザーに紐づくTODOのリストのうちステータスが『完了』のものを出力する』
というものにしてみようと思います。

データの取得に使用するAPI

テスト本体とはあまり関係がありませんが、実装するにあたってpublicなREST APIを利用します。
今回使用するAPIは、JSONPlaceholderです。
https://jsonplaceholder.typicode.com
このAPIの/users/${userId}/todosを使うこととします。
ちなみにこのパスは次のようなJSONを返します。

/users/1/todos
[
  {
    "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 CaseInterfaceInteractorに分けない。
  • Controllersのレイヤーは切らず、main関数をControllerとして扱う。
  • つまりmain関数から直接Use Caseを呼び出す。

Clean Architecture自体がよくわからないという読者の方はひとまず以下を念頭に読み進めていただけたらと思います。

  1. 責務によってレイヤーを分けている
  2. レイヤー同士の依存関係は円の外側から内側に向けて一方通行である(例えばUseCasesはEntitiesを参照していいが、その逆は駄目)
  3. 内側から外側に向かうにはDependency Inversion Principle(依存関係逆転の原則)に従い、依存関係を逆転させる

ではやっていきましょう。

Use Case

TDDではアプリケーションの外側から内側に向かってテストと実装を繰り返していきます。
今回でいうとmainのテストは書かないので、Use Caseが一番外側になります。

テストを書く

さて今回の説明において、ここが一番重要なテストであると言っても過言ではありません。
ここができたら勝ちみたいなものです。

テストケースとしてはこんな感じでしょうか。

Usecases.DisplayCompletedTodosSpec
spec :: Spec Unit
spec = do
  describe "DisplayCompletedTodos Test" do
    it "指定されたユーザーIDに紐づくTodoのうち完了したTodoをすべて表示する" do

続いて「このテストで何を検証するか?」を考えます。
今回の場合は「完了したTodoをすべて表示する」というケースなので、そのまんま「完了したTodo」が「表示」されればよさそうです。
表示自体はPresenterに任せればよいでしょう。関数名はdisplayとでもしておきます。

つまりUse CaseからPresenterdisplay関数を呼び出すことになるのですが、このdisplayをmockとして生成し、pmockverifyを使って検証します。

Usecases.DisplayCompletedTodosSpec
spec :: Spec Unit
spec = do
  describe "DisplayCompletedTodos Test" do
    it "指定されたユーザーIDに紐づくTodoのうち完了したTodoをすべて表示する" do
      let
        displayMock = PresenterdisplayMockを生成
      -- 完了済みのToDoを引数にmockが呼ばれたことを検証
      verify displayMock completedTodos

Use Caseの関数の呼び出しも書きましょう。モジュール名でUse Caseの役割を表しているので関数名は単にexecuteとしました。引数としてユーザーIDを受け取れるようにします。

Usecases.DisplayCompletedTodosSpec
spec :: Spec Unit
spec = do
  describe "DisplayCompletedTodos Test" do
    it "指定されたユーザーIDに紐づくTodoのうち完了したTodoをすべて表示する" do
      let
        displayMock = PresenterdisplayMockを生成
      -- 実行
      _ <- execute ユーザーID
      -- 完了済みのToDoを引数にmockが呼ばれたことを検証
      verify displayMock completedTodos

さて、ここで次のことを考えないといけません。

  1. 依存関係を逆転させるにはどうしたらいいか
  2. 完了済みのToDoをどうやって手に入れるか

依存関係を逆転させるにはどうしたらいいか

順番にいきましょう。まず依存関係を逆転させる(PresenterUse Caseに依存させる)ために、Use CaseTodoOutputPortというtypeを定義し、処理を実行するexecuteに渡すようにします。

Usecases.DisplayCompletedTodos
type TodoOutputPort = {
  display :: Todos -> Aff Unit
}

execute 
  :: UserId
  -> TodoOutputPort
  -> Aff Unit
execute id outputPort = pure unit -- 一旦unitを返す

そしてPresenterからTodoOutputPortを参照するようにします(説明の都合上先に用意しましたが、こいつはまだ不要です)。

Presenters.TodoPresenter
createOutputPort :: TodoOutputPort Aff
createOutputPort = {
  display: unsafeCoerce
}

Use Caseを呼び出すときには、createOutputPortで作ったTodoOutputPortを渡せばOKです(これも本当はまだ不要)。
これで依存関係を逆転できました。

main
main :: Effect Unit
main =
  launchAff_ do
    let
      outputPort = createOutputPort
    execute (UserId 1) outputPort

あわせてDomainも作りましょう。

Domains.User
newtype UserId = UserId Int

-- show, eq instance
derive newtype instance showUserId :: Show UserId
derive newtype instance eqUserId :: Eq UserId
Domains.Todo
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
Domains.Error
-- 自前のエラー型の方が都合がよいので作成(型名が既存の型とかぶるので変えた方がよかったかもしれない)
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に渡します。

Domains.Todo
-- ドメインロジックをまとめる
type Logics = {
  completed :: Todos -> Todos
}

-- 生成(まだ不要)
logics :: Logics
logics = {
  completed: unsafeCoerce
}
Usecases.DisplayCompletedTodos
-- 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 CaseGatewayPresenterもドメインロジックも未実装です。

Usecases.DisplayCompletedTodosSpec
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を返すようにしています。

Usecases.DisplayCompletedTodos
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を使う必要がありません。

テストを書く

Domains.TodoSpec
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を返す関数も作っておきます。

Domains.Todo
completed :: Todos -> Todos
completed = filter \(Todo {status}) -> status == Completed

type Logics = {
  completed :: Todos -> Todos
}

logics :: Logics
logics = {
  completed: completed
}

Gateway

今回はGatewaysのレイヤーから外部にアクセスすることにしたため、ここは実装のみです。
外部と通信するような副作用を持つ処理はここに閉じられます。
更にレイヤーを追加して、このレイヤーは変換処理に留めることもできるでしょう。

Gateways.TodoGateway
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と同じく実装のみです。

Presenters.TodoPresenter
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
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のテスト

正常系のテストと実装が終わったので、異常系(例外ケース)のテストも書いちゃいましょう。

Usecases.DisplayCompletedTodosSpec
    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を返しています。
更にpresenterdisplayError関数が追加されていて、verifyではこの関数が呼ばれたことを検証しています。
一方で、正常系のときに呼ばれていたdisplayMockの方はverifyCountを用いた検証により、一度も呼ばれていないことの検証を行っています。

TodoOutputPortの定義も変更します。

Usecases.DisplayCompletedTodos
type TodoOutputPort = {
  display :: Todos -> Aff Unit,
  displayError :: Error -> Aff Unit
}

こうすると定義を変更したことにより、正常系のテストがコンパイルエラーになるのでpresenterのあたりをこのように修正します。

Usecases.DisplayCompletedTodosSpec
presenter = { 
  display: fun displayMock,
  displayError: unsafeCoerce -- 未実装
}

_ <- execute userId logics todoPort presenter

同じくTodoPresenterもあわせて修正します。

Presenters.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を呼ぶだけです。

Usecases.DisplayCompletedTodos
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を実装して終わりです。ログ出力だけなので簡単です。

Presenters.TodoPresenter
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を返す際に環境変数を渡せるようにしておくとよいかと思います。

Gateways.TodoGateway
-- 環境変数(本当は定義場所は別にした方がいい)
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 Caseexecute関数に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

https://github.com/pujoheadsoft/purescript-pmock-example-extensible-effects
ちなみに実行の際はこうなります。

main :: Effect Unit
main =
  launchAff_ do
    let
      todoLogics = logics
    execute (UserId 1) todoLogics
      # runPort createTodoPort
      # runOutputPort createOutputPort
      # runBaseAff

テストも変わりなく行えます。
これは別記事に書きました。
https://zenn.dev/funnycat/articles/f012b0429d8304

終わりに

PureScriptでTDD + Clean Architecture やってみた感想ですが、TDD特にモックに関して言えば、かなり意図的にモックを差し込む余地を作っておいてやる必要があるなと感じました。
Use Caseから呼び出すドメインロジックなんかは、関数を渡さずに外側からモック化できると嬉しいなぁ、と。
黒魔術でどうにかできないですかね。

クリーンアーキテクチャに関して言えば、自分はPureScriptでも採用したいと思います。今回のようにシンプルなケースだとあまり有り難みを感じないかもですが、大規模化していき関わるメンバーが増えたときに真価を発揮すると思います。

Discussion