「関数型ドメインモデリング」にテストを書こう(前編)

熱が冷めないうちにいろいろやっとこうの構え。 本書でもテスト容易性についての記述はあり、サンプルテストはありますが、実はリポジトリにはテストがありません。

https://github.com/swlaschin/DomainModelingMadeFunctional

「テスト書いてないとか(ry」という天の声が聞こえる気がするので書いていきましょう。 今回は導入編ということで、シンプルな汎用型のとこだけ。

今回は書籍でも名前だけ紹介されていた Expecto を使います。

open Expecto

module Common =
    open OrderTaking.Common

    module String50 =
        let create =
            testList
                "create"
                [ test "should return value when string is shorter than or equal to 50 characters" {
                      let subject = String50.create "name" "foo"
                      let value = Expect.wantOk subject "should be Ok" |> String50.value
                      Expect.equal value "should equal" "foo"
                  } ]

[<EntryPoint>]
let main args =
    runTestsWithCLIArgs [] args (testList "OrderTaking" [ Common.tests ])

まぁ普通ですね。でもなんかこう F# らしさが足りない? そんなときは Expecto.Flip を使うといかにもそれっぽい。

open Expecto
open Expecto.Flip

module Common =
    open OrderTaking.Common

    module String50 =
        let create =
            testList
                "create"
                [ test "should return value when string is shorter than or equal to 50 characters" {
                      String50.create "name" "foo"
                      |> Expect.wantOk "should be Ok"
                      |> String50.value
                      |> Expect.equal "should equal" "foo"
                  } ]

じゃあ続きを書いていこうか。

open FsToolkit.ErrorHandling
open Expecto
open Expecto.Flip

module Common =
    open OrderTaking.Common

    module String50 =
        let create =
            testList
                "create"
                [ test "should return value when string is shorter than or equal to 50 characters" {
                      String50.create "name" "foo"
                      |> Expect.wantOk "should be Ok"
                      |> String50.value
                      |> Expect.equal "should equal" "foo"
                  }

                  test "should return message when string is empty" {
                      String50.create "name" ""
                      |> Expect.wantError "should be Error"
                      |> Expect.equal "should equal" "name must not be null or empty"
                  }

                  test "should return message when string is longer than 50 characters" {
                      String50.create "name" (String.replicate 51 "x")
                      |> Expect.wantError "should be Error"
                      |> Expect.equal "should equal" "name must not be more than 50 chars"
                  } ]

        let createOption =
            testList
                "createOption"
                [ test "should return value when string is longer than 50 characters" {
                      String50.createOption "name" "foo"
                      |> Expect.wantOk "should be Ok"
                      |> Expect.wantSome "should be Some"
                      |> String50.value
                      |> Expect.equal "should equal" "foo"
                  }

                  test "should return None when string is empty" {
                      String50.createOption "name" ""
                      |> Expect.wantOk "should be Ok"
                      |> Expect.isNone "should be None"
                  }

                  test "should return message when string is longer than 50 characters" {
                      String50.createOption "name" (String.replicate 51 "x")
                      |> Expect.wantError "should be Error"
                      |> Expect.equal "should equal" "name must not be more than 50 chars"
                  } ]

        let tests = testList "String50" [ create; createOption ]

    module BillingAmount =
        let create =
            testList
                "create"
                [ test "should return value when amount is between 0 and 10000" {
                      BillingAmount.create 10000M
                      |> Expect.wantOk "should be Ok"
                      |> BillingAmount.value
                      |> Expect.equal "should equal" 10000M
                  }

                  test "should return message when amount isn't between 0 and 10000" {
                      BillingAmount.create -1M
                      |> Expect.wantError "should be Error"
                      |> Expect.equal "should equal" "BillingAmount: Must not be less than 0.0"
                  } ]

        let sumPrices =
            testList
                "sumPrices"
                [ test "should return total price when sum is betweeen 0 and 10000" {
                      let prices =
                          [ 100M; 200M; 300M ]
                          |> List.traverseResultM Price.create
                          |> Expect.wantOk "should be Ok"

                      BillingAmount.sumPrices prices
                      |> Expect.wantOk "should be Ok"
                      |> BillingAmount.value
                      |> Expect.equal "should equal" 600M
                  }

                  test "should return message when sum isn't betweeen 0 and 10000" {
                      let prices =
                          List.replicate 10001 (Price.create 1M)
                          |> List.sequenceResultM
                          |> Expect.wantOk "should be Ok"

                      BillingAmount.sumPrices prices
                      |> Expect.wantError "should be Error"
                      |> Expect.equal "should equal" "BillingAmount: Must not be greater than 10000"
                  } ]

        let tests = testList "BillingAmount" [ create; sumPrices ]

    let tests = testList "Common" [ String50.tests; BillingAmount.tests ]

module PlaceOrder =
    open OrderTaking.PlaceOrder

    let tests =
        testList
            "PlaceOrder"
            [
            // 後で
            ]

[<EntryPoint>]
let main args =
    runTestsWithCLIArgs [] args (testList "OrderTaking" [ Common.tests; PlaceOrder.tests ])

モジュールや testList の構成がまだ手探りだけど、概ねいい感じ。 関数ごとに分割しなくても、全部まとめてネストさせることはできるけど、階層が深くなりすぎて見た目がアレになったので分けてみた。

Common 名前空間については後はだいたい同じになるだろうから省略。

しれっと FsToolKit.ErrorHandling なんてのも使っている。 今回は List.traverseResultMList.sequenceResultM を使って、list |> List.map Price.createResult<Price, string> list になって不便なところを、 list |> List.traverseResultM Price.create とすることによって Result<Price list, string> になって便利とだけ雰囲気を感じてもらえればよい。 書籍の方でも sequncetraverse について解説されてたので見てくれ。

次はドメインのコア部分について本当にテストが容易なのかを確かめてみよう。