test
doctest
Elm

Doctest大好きマンによる布教

Doctest

Doctestというテスト記述スタイル/テストコード生成手法があります。Wikipediaによると、

doctest is a module included in the Python programming language's standard library that allows the easy generation of tests based on output from the standard Python interpreter shell, cut and pasted into docstrings.

とあります。つまり、Python界で生まれた手法で、

  • 関数やメソッドのdocstringに、
  • Interpreter shell1での入出力と同じフォーマットで記述された内容から、
  • テストコードを生成する機能

ということになります。

私は個人的にこれが好きで、ちょくちょく使います(言語はElixirとElm)。本記事ではdoctestを推します。

前提として、その言語でdocstringを付与する標準的な方法が規定されていること(言語標準で、あるいは慣習で)が必要になりますが、最近はどの言語もこれに関してはなにかしらあるんじゃないかなーという肌感です。DocstringのWikipediaを読むと、

Unlike conventional source code comments, or even specifically formatted comments like Javadoc documentation, docstrings are not stripped from the source tree when it is parsed, but are retained throughout the runtime of the program.

とあり、runtimeに(REPLなどから)参照可能な形で保持されることが重要な違いなのだ、と書かれていました。が、ことdoctestの生成に関していえば、最悪ソースコードをパースしてdocstring内からdoctest部分を読み出せればいいので、とりあえず統一的なdocstringの形式さえあれば前提としては満たされていると思います。(実際、Elmのelm-verify-examplesはソースコードから自前パースしてテストを生成します)

REPLと同じフォーマットで、という部分についても、必ずしもMUSTではなさそうな気はしていますが、REPLの「prompt + 入力 + 出力」からなるフォーマットを踏襲すれば、

  • 言語を知っている人であればほぼ誰にでも意図が伝わる
  • REPLにコピペして実行したとき、docの記述と実際の出力が一致するのでわかりやすい

という利便性があるでしょう。(REPLが存在しない言語を主戦場としている人は、この部分についてはピンとこないと思われる)

Doctestは主に以下の言語で標準的な実装が存在しています(Wikipediaにリンクがあった言語)。他にもあるかもしれません。他言語のdoctestライブラリ等で有名なものがあったらコメント等で是非教えてください。ざっとググって日本語解説記事があったものは付記。

Doctestのいいところ

Doctestには以下のようないいところがあるので是非活用しましょう。

  • ドキュメントである。つまり、利用者が容易に読めるところに存在するサンプルコードである。
    • すでに前提として述べましたが、doctestが書けるということは、標準的にdocstringを記述する方式が確立されているということ、更に追加で、それを読みやすい形でpublishする方法までちゃんとセットになっていることを概ね意味している感触。
    • そういう言語は開発も利用もしやすいし、ありがたいですよね。ありがたいからドキュメントをどんどん書こうという気にもなるし、どうせならdoctestも書きたくなるよね。ならない? なれ。
  • テストである。したがって、実際に動く。
    • テスト実行機構によって実際にdoctestを実行して検証する仕組みがあり、それによってサンプルコードは正しいものになるし、陳腐化するのも防げます。
    • Rustのdocにいい文章があります:

Nothing is better than documentation with examples. Nothing is worse than examples that don't actually work, because the code has changed since the documentation has been written.

  • これらの合わせ技として、実際の流れを踏まえた「利用例」をわかりやすく記述できる。しかも動作検証付きで。
    • もちろん、その他任意のxxxxx testを記述・実行する機構でも、利用例を踏まえたテストコードは書けますが、大抵の場合はユーザの目に触れにくいソースコードレポジトリの中に閉じています。
  • ソースコード as 仕様をリアルに実現できる。

Doctestの注意

一方でdoctestには以下のような注意事項がありますので気をつけましょう。

  • 記述できる内容に制限があることが多い
    • これが一番大きいと思います。たとえば、REPL上で実行可能なコードしか書けない、別のライブラリと組み合わせたテストを書くのに制限がある(importrequire等の仕組みが使えない)、など。
    • Doctestで書く場合と、実際に想定される使用方法とで、記述スタイルに差が出てしまうケース等もありえます。
    • Property-based testや、setup/teardownコールバックなど、unit testフレームワークに付属する便利な機能は基本的には利用できません。
  • Docstring、したがってソースファイルが肥大化する。
    • これもけっこう実際的な問題で、調子に乗ってdoctestを書きまくったりしてドキュメントを充実させすぎると、気づいたらエディタ上で表示されているバッファがすべて灰色になったりします(docstringのシンタックスハイライトはコメント相当=暗灰色系であることが多いので)。
    • 充実することは悪いことではありませんが、レビューコストもあるし、利用者がpublishされているdocを見るときの心理的障壁も考えられるので、あまりにもヘビーすぎるのは考えものです。
  • テストコードとしては必ずしも読みやすくない
    • あくまでドキュメントであることを忘れてはいけません。
    • 特にREPLフォーマットを強制される場合は、量にもよりますが、節度を持たないと体感では読みづらくなりがちです。
    • 他のtestフレームワークのほうが読みやすいテストコードになるような場合(網羅的なテストを書く場合など)では普通にそちらを使うべきだと思います。
    • 実際の利用例とは離れた、滅多にないコーナーケースのテストなどはdoctestに書こうとはしないほうがいいでしょう(間違えやすいケースを注記するような場合は除くとして)。

以上踏まえつつ、doctestを書ける環境にある皆様はぜひとも利用しましょう。

おまけ:Elmでdoctestを書く

ここではElmでelm-verify-examples(旧elm-doc-test)を使ってdoctestを書く方法を紹介します。といってもだいたいREADMEのまんまですが。

Elmプロジェクトを開発中のディレクトリで、以下のようにインストール:

$ npm i elm-test elm-verify-examples -D
$ node_modules/.bin/elm-test init # `elm-test`の初期化をまだしていなければ初期化

グローバルインストールする場合は、node_modules/.bin/は不要です。

以下のように設定ファイルをJSONで記述します。

tests/elm-verify-examples.json
{
  "root": "../src",
  "tests": [
    "MyModule",
    "MyModule.Foo.Bar.Moo"
  ]
}

満を持してdoctestを書きます。

example
{-| reverses the list

    rev
        [ 41
        , 1
        ]
    --> [ 1
    --> , 41
    --> ]

    rev [1, 2, 3]
        |> List.map toString
        |> String.join ""
    --> "321"
-}
rev : List a -> List a
rev =
    List.reverse

Real world exampleとして、自作のymtszw/elm-xml-decodeでの例も載せておきます。

{-| Decodes an [`XmlParser.Xml`][xpx] value into other type of Elm value.



It discards Document Type Definitoin (DTD) and Processing Instruction in XML,
only cares about root XML node.

    import XmlParser exposing (Xml, Node(..))

    exampleDecoder : Decoder ( String, List Int )
    exampleDecoder =
        map2 (,)
            (path [ "string", "value" ] (single string))
            (path [ "int", "values" ] (list int))

    decodeXml exampleDecoder <|
        Xml [] Nothing <|
            Element "root" []
                [ Element "string" []
                    [ Element "value" [] [ Text "SomeString" ]
                    ]
                , Element "int" []
                    [ Element "values" [] [ Text "1" ]
                    , Element "values" [] [ Text "2" ]
                    ]
                ]
    --> Ok ( "SomeString", [ 1, 2 ] )

-}
decodeXml : Decoder a -> Xml -> Result Error a
decodeXml decoder { root } =
    decoder root

幾つか特徴があることがわかります:

  • elm-replのフォーマットではありません。出力は-->のあとに、実行コードは単なるコードブロックとして書きます。
  • 実行コードと一連の出力までで1exampleとなり、exampleの間には空行が必要です。
  • importも使えます
  • -->による出力の記述がないブロックは、後続のブロックのための前提条件になります。
  • SinglelineでもOKですが、multilineにも対応しています。

以下のように実行:

$ node_modules/.bin/elm-verify-examples && node_modules/.bin/elm-test

elm-verify-examplesは、単純にelm-test形式のテストコードをデフォルトではtests/Doc/以下に生成します。したがってtests/Doc/はgitignoreすることが推奨されています。

生成されるテストコードを見てみると、仕組みがすぐにわかります。

Test.describe "#decodeXml" <|
            let
                exampleDecoder : Decoder ( String, List Int )
                exampleDecoder =
                    map2 (,)
                        (path [ "string", "value" ] (single string))
                        (path [ "int", "values" ] (list int))
            in
            [
            Test.test "Example: 1 -- `decodeXml exampleDecoder <| Xml [] No...`" <|
                \() ->
                    Expect.equal
                        (
                            decodeXml exampleDecoder <|
                                Xml [] Nothing <|
                                    Element "root" []
                                        [ Element "string" []
                                            [ Element "value" [] [ Text "SomeString" ]
                                            ]
                                        , Element "int" []
                                            [ Element "values" [] [ Text "1" ]
                                            , Element "values" [] [ Text "2" ]
                                            ]
                                        ]
                        )
                        (
                            Ok ( "SomeString", [ 1, 2 ] )
                        )
            ]

つまり、let節で前提条件を定義した上で、最終的な入出力をin節に記述してequalでassertするというシンプルな作りになっています。

importだけ特別扱いしてファイル先頭に移動していますが、それ以外でlet節内に記述できない内容は記述不可です。例えば別moduletypeを定義したりといったことはできません。typeを定義できないのはサンプルコードを記述するにあたっては不便なことがよくあるので、今後の拡張に期待。

ElmのテストをCIで実行する場合はこちらのtipsも活用ください。


  1. ここではREPLと概ね同義と思って良さそう