関数型言語Elmでテスト駆動開発(第1~4章)

  • 49
    Like
  • 2
    Comment

遂に新訳「テスト駆動開発」が発売されましたね!今回の記事では第一部のJavaで書かれているコードを関数型言語Elmでテスト駆動開発(TDD)に挑戦してみたいと思います!その狙いは、以下の通りです。

  • 写経ではなく別言語で書き換えることで、TDDの習得を目指す
  • Elm自体の勉強
  • オブジェクト指向プログラミングとの違いを明らかにする
  • 関数型言語がTDDに適しているかを検証する
  • 関数型言語やElm自体の布教

目的のほとんどが検証・学習や布教目的ですが、今回の記事の内容を書いている時点で既に関数型のパワーを大きく実感しております。初回の内容にして1~4章なのは、その力が強大がゆえです。どれほどElm-TDDを続けるかわかりませんが、なるべく第一部の内容完結を目指したいと思います。

方針と注意事項

この記事の方針ですが本文の内容やコードは、著作権を考慮して極力載せすに、ElmによるコードとテストのTODOリストだけでやっていきたいと思っています。もし関係者の方々から注意喚起があれば即記事を削除する予定なので、ご了承ください。また言語が異なるので進め方が多少異なる場合があります。書籍「テスト駆動開発」はとても良い本なので、是非ご購入頂いて本記事と見比べて頂けると様々な発見があると思われます。Elmがわからない方でも極力基礎文法の話を踏まえつつ解説をしていきたいと思います。それでは良ければ記事をお楽しみください。

事前準備

まずはじめに、Elmのテストが動く環境を用意しましょう。今回は男らしくHtmlによる表示もビルドもできない本当にテストをするだけの環境を用意しました。みなさんがテスト実行に必要なのは、npm(Node.js環境)のみです。

全体

重要なのは、以下のファイルのみです。

src
└── Hello.elm
tests
├── TestExp.elm
└── Tests.elm

helloは、String型を返す関数です。"hello"を返すだけですね。Elmはファイル単位でモジュールを提供することができます。Helloというモジュールで、関数helloのみを外部に公開しています。

src/Hello.elm

module Hello exposing (hello)


hello : String
hello =
    "hello"

テストコードと実行には、elm-community/elm-testを利用しています。実質Elm公式のテストフレームワークだと認識しています。ただ、デフォルトでは若干可読性に欠けるため、hosomichiさんのelm-testのテストコード改良に挑戦してみますという記事を参考にさせていただいています。tests/TestExp.elmがその実装コードとなります。テストは配列の形で列挙でき、"テスト名" => actual === expectedの形で記述できます。とてもシンプルで強力なassertで気に入っています。

tests/Tests.elm

module Tests exposing (..)

import Test exposing (..)
import TestExp exposing (..)


-- Test target modules

import Hello as Hello


all : Test
all =
    describe "Hello Test"
        [ "Hello"
            => Hello.hello
            === "hello"
        ]

npm i && npm testを実行すると、テストが実行できます。無事最初のテストは通ります。

第1章 仮実装

ものすごく大雑把に説明するとUSドルやフランを扱うことができる仮想通貨システムをTDDで作り上げていくことになります。最初の作るべき機能として、以下のTODOリストが挙げられます。もっとも簡単な機能としてドルの掛け算を実装していきます。

TODOリスト

  • \$5 + 10 CHF=\$10 (レートが2:1の場合)
  • \$5 * 2 = \$10

最初のテスト

TODOの内容をそのままテストに落とし込みます。テストのみで実装コードは含まず、コンパイルエラー(失敗)に終わります。

全体

いきなり難しいかもしれませんが、頑張りましょう。

Let式は、letでローカル変数を定義し、inで最終的な計算式を書くと考えるとわかりやすいでしょうか。ちなみにletで定義された変数は書き換え不可能となります。

Dollar 5これは、5ドルのインスタンスを生成しています。|>(パイプ演算子)は、x |> f == f x左の値を右の関数に渡すだけのシンプルなものです。オブジェクト指向言語のメソッドチェーンに似た考えで実際に同じような感覚で仕様することができます。ちなみに逆向き(<|)のパイプ演算子も定義されています。Dollar.timesDollar.amountは、Dollarモジュールで定義された関数となっています。

tests/Tests.elm

module Tests exposing (..)

import Test exposing (..)
import TestExp exposing (..)


-- Test target modules

import Dollar exposing (..)


all : Test
all =
    describe "Money Test"
        [ "Multiplication"
            => let
                amount =
                    Dollar 5 |> Dollar.times 2 |> Dollar.amount
               in
                amount
                    === 10
        ]

当然Dollarモジュールはまだ定義していないので、コンパイルエラーとなります。

コンパイルエラーを無くそう

それでは、適当なデータ型とモジュール関数を定義していってコンパイルエラーを解消しましょう。

全体

Dollarモジュールは以下のようになります。実はこの時点で既に関数型の力が発揮されます。Dollar型の定義は、type Dollar = Dollar Intとなります。見た目で何となく意味は取れると思います。他言語で言うところの列挙型(Java)、Union(TypeScript)の強化版が1行(フォーマットの都合で2行ですが...)で手に入ります。型は別名でaliasを付けることができるので、type alias Amount = Intとしておきましょう。これも地味にElmが強力なポイントです。

timesの型は、Int -> Dollar -> Dollarとなっています。これは、Int(第1引数)とDollar(第2引数)を受け取って、Dollar型を返す関数と読むことができます。書籍の方では、timesはDollarのインスタンスメソッドで、void型のはずでしたが、Elmは純粋関数型言語で副作用が発生する部分とそうでない部分をキッチリ分ける必要があります。一応無理くり値を返さないコードを書くこともできたのですが、そうしてしまうとamount関数を実行することができなくなってしまうため、Dollar型の値を返すコードとなりました。また、これもElmの利点なのですが、Dollar型がAmountを設定しないということが許されないため、ここではDollar 0を明示的に返しています。コンパイラにより強制的に型や初期値を意識させられるのは、とても良いことだと思います。

amountの型は、Dollar -> IntでDollar型を受け取り、Intを返す関数となっております。あとで気づいたのですが、これはIntではなく、Amount型と書いたほうがより説明的なコードになりますね。今回の最後にその修正を加えたいと思います。ここで直接0を返しても良かったのですが、もうDollar型からAmountを取り出すコードを書いてしまいましょう。Union Typesから値を取り出すときは、case式を使い、パターンマッチを行います。パターンに漏れがあった場合には、コンパイルエラーが発生します。とても安全ですね。

src/Dollar.elm

module Dollar exposing (..)


type alias Amount =
    Int


type Dollar
    = Dollar Amount


times : Int -> Dollar -> Dollar
times multiplier dollar =
    Dollar 0


amount : Dollar -> Int
amount dollar =
    case dollar of
        Dollar amount ->
            amount

パターンマッチがちょっと冗長に見えませんか?安心してください。引数でパターンマッチを行う方法もあります。リファクタリングをおこないました。

src/Dollar.elm

-- amount関数のみ抜粋
amount : Dollar -> Int
amount (Dollar amount) =
    amount

テストを通そう

先ほどのコードでは何を入れても何を掛けてもtimesの結果は0なので、テストが通りません。\$10を返すように無理やり実装を変えることにしましょう。

これでテストが通りました! しかし実際には、これはテストを無理やり通しただけに過ぎません。

src/Dollar.elm

-- timesを一部抜粋
times : Int -> Dollar -> Dollar
times multiplier dollar =
    Dollar <| 5 * 2

正しい実装をしましょう。multiplieramountを使うのです。

全体

amount関数と同じ要領で、引数パターンマッチを行います(というかここまで来るとamount関数の存在は意味がありませんね...)。

Dollar.elm

times : Int -> Dollar -> Dollar
times multiplier (Dollar amount) =
    Dollar <| multiplier * amount

テストを実行すると無事通っています。

dollar-impl.png

ここで、この時点でのJavaバージョンのTODOリストを見てみましょう。既に気づいている方は途中で気づいていることでしょう。Elmは現時点でその猛威を振るっています。

TODOリスト

  • \$5 + 10 CHF=\$10 (レートが2:1の場合)
  • \$5 * 2 = \$10
  • amountをprivateにする
  • Dollarの副作用をどうする?
  • Moneyの丸め処理をどうする?

次がElm TDDでの正しいTODOリストとなります。

  • \$5 + 10 CHF=\$10 (レートが2:1の場合)
  • \$5 * 2 = \$10
  • amountをprivateにする(最初から)
  • Dollarの副作用をどうする?(最初から)
  • Moneyの丸め処理をどうする?

第2章

これ第1章の最後で既にその実装を終えています。チーン。

第3章

2章で終わりだと思いましたか・・・?実はすでに3章の内容も終えているのです・・・。Javaでは、equalshashCodeの比較を行う必要がありますが、Elmのtypeによって宣言された型は、参照ではなく宣言時に厳密に等価性を保証してくれています。これは、DollarがIntのような型ではなく、ListやRecord(JSで言うオブジェクト)のような型であっても同じです。特殊な比較ライブラリや関数はありません。==での等価比較が可能となっています。加えてnullは存在すらしません。型が違う値と比較しようとすれば、たちまちコンパイルエラーとなります。つまり、以下のようなTODOリストになります。

  • \$5 + 10 CHF=\$10 (レートが2:1の場合)
  • \$5 * 2 = \$10
  • amountをprivateにする(最初から)
  • Dollarの副作用をどうする?(最初から)
  • Moneyの丸め処理をどうする?
  • equals()(最初から)
  • hashCode()(最初から)
  • nullと等価性比較
  • 他のオブジェクトと等価比較(最初から)

実装コードは一切触れませんが、テストコードは以下のように変わります。

module Tests exposing (..)

import Test exposing (..)
import TestExp exposing (..)


-- Test target modules

import Dollar exposing (..)


all : Test
all =
    describe "Money Test"
        [ "Multiplication1"
            => let
                ten =
                    Dollar 5 |> Dollar.times 2
               in
                ten
                    === Dollar 10
        , "Multiplication2"
            => let
                fifteen =
                    Dollar 5 |> Dollar.times 3
               in
                fifteen
                    === Dollar 15
        ]

第4章

ええ。ここまで来たら4章までやってしまいましょう。Elmではデータ型の等価比較が言語で保証されているので、最初のテストすら要らなくなりますね。

tests/Tests.elm

module Tests exposing (..)

import Test exposing (..)
import TestExp exposing (..)


-- Test target modules

import Dollar exposing (..)


all : Test
all =
    describe "Money Test"
        [ "equivalence"
            => Dollar 5
            === Dollar 5
        , "Multiplication"
            => (Dollar 5 |> Dollar.times 3)
            === Dollar 15
        ]

最終的なコード全体

src/Dollar.elm

```haskell
module Dollar exposing (..)


type alias Amount =
    Int


type Dollar
    = Dollar Amount


times : Int -> Dollar -> Dollar
times multiplier (Dollar amount) =
    Dollar <| multiplier * amount


amount : Dollar -> Int
amount (Dollar amount) =
    amount
tests/Test.elm

```haskell
module Tests exposing (..)

import Test exposing (..)
import TestExp exposing (..)


-- Test target modules

import Dollar exposing (..)


all : Test
all =
    describe "Money Test"
        [ "equivalence"
            => Dollar 5
            === Dollar 5
        , "Multiplication"
            => (Dollar 5 |> Dollar.times 3)
            === Dollar 15
        ]

まとめ

道中でこれでもかというほどアピールしたので、ほとんど何も言うことがありません。最初、関数型で書籍のような内容のものを上手く実装できるか、TDD向きであるかと一瞬考えましたが杞憂でした。関数型言語Elmの力は強大で、気持ちよくテスト駆動開発をおこなうことができます!

次の記事へ ->