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

前回の続き。 書籍では、依存性の注入は関数の部分適用によってなされるためドメインのコア部分はテストが容易、とのことなので実際そうなのかを確かめてみる。

単純ではあるが長くなったので適当に省略しつつ placeOrder ワークフローのエントリポイントの一部を抜粋する。

open FsToolkit.ErrorHandling
open Expecto
open Expecto.Flip

module PlaceOrder =
    open OrderTaking.Common
    open OrderTaking.PlaceOrder
    open OrderTaking.PlaceOrder.Implementation

    let placeOrder =
        let checkProductExists _ = true
        let checkAddressExists address = asyncResult { return CheckedAddress address }
        let getProductPrice _ = Price.unsafeCreate 10M
        let createOrderAcknowledgmentLetter _ = HtmlString ""
        let sendOrderAcknowledgment _ = Sent

        let unvalidatedOrder = {
            UnvalidatedOrder.OrderId = "1"
            CustomerInfo = {
                FirstName = "foo"
                LastName = "bar"
                EmailAddress = "foobar@example.com"
            }
            ShippingAddress = {
                AddressLine1 = "hoge"
                AddressLine2 = ""
                AddressLine3 = ""
                AddressLine4 = ""
                City = "fuga"
                ZipCode = "12345"
            }
            BillingAddress = {
                AddressLine1 = "piyo"
                AddressLine2 = ""
                AddressLine3 = ""
                AddressLine4 = ""
                City = "poyo"
                ZipCode = "67890"
            }
            Lines = [
                {
                    OrderLineId = "1"
                    ProductCode = "W9999"
                    Quantity = 2M
                }
                {
                    OrderLineId = "2"
                    ProductCode = "G999"
                    Quantity = 3M
                }
            ]
        }

        testList "placeOrder" [
            testAsync "should return events AcknowledgmentSent, OrderPlaced and BillableOrderPlaced with valid order" {
                let! result =
                    placeOrder
                        checkProductExists
                        checkAddressExists
                        getProductPrice
                        createOrderAcknowledgmentLetter
                        sendOrderAcknowledgment
                        unvalidatedOrder

                result
                |> Expect.wantOk "should be OK"
                |> Expect.equal "should equal" [
                    AcknowledgmentSent {
                        OrderId = OrderId.create "" "1" |> Expect.wantOk ""
                        EmailAddress = EmailAddress.create "" "foobar@example.com" |> Expect.wantOk ""
                    }
                    OrderPlaced {
                        OrderId = OrderId.create "" "1" |> Expect.wantOk ""
                        CustomerInfo = {
                            Name = {
                                FirstName = String50.create "" "foo" |> Expect.wantOk ""
                                LastName = String50.create "" "bar" |> Expect.wantOk ""
                            }
                            EmailAddress = EmailAddress.create "" "foobar@example.com" |> Expect.wantOk ""
                        }
                        ShippingAddress = {
                            AddressLine1 = String50.create "" "hoge" |> Expect.wantOk ""
                            AddressLine2 = None
                            AddressLine3 = None
                            AddressLine4 = None
                            City = String50.create "" "fuga" |> Expect.wantOk ""
                            ZipCode = ZipCode.create "" "12345" |> Expect.wantOk ""
                        }
                        BillingAddress = {
                            AddressLine1 = String50.create "" "piyo" |> Expect.wantOk ""
                            AddressLine2 = None
                            AddressLine3 = None
                            AddressLine4 = None
                            City = String50.create "" "poyo" |> Expect.wantOk ""
                            ZipCode = ZipCode.create "" "67890" |> Expect.wantOk ""
                        }
                        AmountToBill = BillingAmount.create 50M |> Expect.wantOk ""
                        Lines = [
                            {
                                OrderLineId = OrderLineId.create "" "1" |> Expect.wantOk ""
                                ProductCode = WidgetCode.create "" "W9999" |> Expect.wantOk "" |> Widget
                                Quantity = UnitQuantity.create "" 2 |> Expect.wantOk "" |> Unit
                                LinePrice = Price.unsafeCreate 20M
                            }
                            {
                                OrderLineId = OrderLineId.create "" "2" |> Expect.wantOk ""
                                ProductCode = GizmoCode.create "" "G999" |> Expect.wantOk "" |> Gizmo
                                Quantity = KilogramQuantity.create "" 3M |> Expect.wantOk "" |> Kilogram
                                LinePrice = Price.unsafeCreate 30M
                            }
                        ]
                    }
                    BillableOrderPlaced {
                        OrderId = OrderId.create "" "1" |> Expect.wantOk ""
                        BillingAddress = {
                            AddressLine1 = String50.create "" "piyo" |> Expect.wantOk ""
                            AddressLine2 = None
                            AddressLine3 = None
                            AddressLine4 = None
                            City = String50.create "" "poyo" |> Expect.wantOk ""
                            ZipCode = ZipCode.create "" "67890" |> Expect.wantOk ""
                        }
                        AmountToBill = BillingAmount.create 50M |> Expect.wantOk ""
                    }
                ]
            }

            testAsync "should return ValidationError when product not found" {
                let checkProductExists _ = false

                let! result =
                    placeOrder
                        checkProductExists
                        checkAddressExists
                        getProductPrice
                        createOrderAcknowledgmentLetter
                        sendOrderAcknowledgment
                        unvalidatedOrder

                result
                |> Expect.wantError "should be Error"
                |> Expect.equal "should equal" (Validation(ValidationError "Invalid: Widget WidgetCode \"W9999\""))
            }
        ]

    let tests = testList "PlaceOrder" [ placeOrder ]

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

まぁ普通にそうなるよねって感じだった。

Expecto 固有の話だが、Expect.wantOk がそこそこ煩く感じる。 とはいえドメイン型の正当性を保証するためなら仕方ないか。 テストでなら Price.unsafeCreate のような裏道を意識して使うのは全然アリだろう。