CLIアプリのE2Eテストを行うためのライブラリー main-testerをリリースしました

たまにはHaskellらしからぬ(?)テストも書いてみよう!

Posted by Yuji Yamamoto(@igrep) on April 9, 2018

こんにちは。みなさん、テストは書いてますか?
Haskellライブラリ所感2016」という記事でも紹介されているとおり、Haskellにも様々なテスト用ライブラリーがあります。
今回は、「Haskellライブラリ所感2016」でも紹介されているsilentlyというパッケージにインスパイアされた、新しいテスト用ライブラリーを作りました。
タイトルにも書きましたがmain-testerといいます。

Link to
here
main-testerができること

main-testerは名前の通り、main関数のテストをサポートするライブラリーです。
Haskell製のプログラムを起動すると最初に実行される、あのmain関数です。

main関数はIO ()という型であるとおり、原則として必ず入出力を伴うので、自動テストがしにくい関数です。
一般的なベストプラクティスとしては、できるだけIOでない、純粋な関数を中心にテストを書いていくのが普通でしょう。
それでも敢えてmain関数の自動テストを書くのには、以下のメリットがあります。

  1. main関数をテストすると言うことは、作っているコマンドの、ユーザーの要求に最も近いレベルのテスト、E2Eテスト(end-to-end テスト)をすることができる。
  2. main関数(や、その他のIOを伴う関数)に対するテストは、データベースやファイルシステムなど、外部のソフトウェアとの「組み合わせ」で起こるバグを検出できる。
    • 経験上、特に単純なアプリケーションでは、そうした外部のソフトウェアに対する「誤解」が原因となったバグが比較的多いように感じています。
  3. 私の個人的な都合ですが、趣味では小さなアプリケーションを書くことが多いので、そうしたE2Eテストの方が効果的だったりする。

このように、main関数をはじめとする、IOな関数に対して敢えて自動テストを書くことには、様々なメリットがあります。
main-testerはそうしたIOな関数をテストする際に伴う、2つの問題を解決しました。

  1. 標準出力・標準エラー出力に出力した文字列がテストしにくい
    • ➡️ captureProcessResultという関数で、標準出力・標準エラー出力に出力した文字列をそれぞれByteStringとして取得することができます。
  2. 標準入力から文字列を読み出そうとすると、テストの実行が停止してしまう。
    • ➡️ withStdinという関数で、標準入力に与えたい文字列をByteStringとして与えることができます。

ここに書いたことは、ビルドした実行ファイルを子プロセスとして呼び出すことによってもできます。
入出力の順番など、標準出力や標準エラー出力のより細かい挙動をテストするにはその方がいいでしょう1
しかし、テストのためにPATHを分離させる必要があったり、そのためにstack execを使ったらめっちゃ遅いという問題があったり、そもそも子プロセス呼び出しはそれだけでオーバーヘッドがあったりと、様々な問題があります。
物事をよりシンプルにするには、main関数を直接呼び出した方がよいでしょう。
main-testerは、CLIアプリケーションのE2Eテストにおける、そうした子プロセスの呼び出しの問題と、より大きな関数をテストしたいというニーズに応えるためのライブラリーなのです。

Link to
here
ほかのライブラリーとの違い

silentlyというパッケージにインスパイアされた」と冒頭で申しましたとおり、前節で紹介した機能は、実はすでにほかのライブラリーに似たものがあります。
silentlyに加え、imperative-edslというパッケージに含まれる、System.IO.Fakeというモジュールです(ほかにもあったらすみません!🙇🙇🙇)
これらとmain-testerとの違いは何でしょう?

第一に、先ほども触れましたが、main-testercaptureProcessResult関数やwithStdin関数は、標準出力・標準エラー出力・標準入力でやりとりする文字列をstrictByteStringでやりとりします。
silentlySystem.IO.Fakeは、Stringなのです。
ByteStringは文字通り任意のバイト列を扱うことができるので、「Unicodeの文字のリスト」であるStringよりも、多様なデータを扱うことができます。

これは、特に複数の種類の文字コードを扱うとき、非常に重要な機能となります。
以前の記事で取り上げた、Invalid characterというエラーを再現させる場合も、ないと大変やりづらいでしょう。

第二に、main-testercaptureProcessResult関数は、main関数の終了コードもExitCodeの値として取得できます。
main関数の中でexitFailure等の関数を呼び出すと、ExitCodeが例外として投げられます。
既存のライブラリーでこれを行うと、ExitCodeが例外として処理されるため、テストしたいmain関数の実行が終了してしまいます。
結果、main関数が標準出力・標準エラー出力に書き込んだ文字列を取得することができないのです。
「○○というエラーメッセージを出力して異常終了する」といったことをテストしたい場合、これでは使いづらいでしょう。
main関数のE2Eテストを行うためのライブラリーである」という観点から、必須の機能であると判断し、実装しました。 ちなみに、ExitCode以外の例外についてはそのまま投げられます。仕様を単純にするために、これはユーザーのテストコードの中で処理することとしています。

Link to
here
使い方・バグ報告

機能は非常にシンプルなので、使い方についてはドキュメントのサンプルコードを読めば大体わかるかなぁと思いますが、簡単にサンプルを載せておきましょう。

例えばこんなソース👇のプログラムがあった場合、

ExampleMain.hs:

module ExampleMain where

import Data.List
import System.Exit

main :: IO ()
main = do
  putStr "What's your name?: "
  name <- getLine
  if "Yuji" `isInfixOf` name
    then putStrLn "Nice name!"
    else die $ name ++ "? Sorry I don't know such a guy!"

main-testerを使えば、次のようにHspecでテストできます。

ExampleSpec.hs:

{-# LANGUAGE OverloadedStrings #-}

import System.Exit
import Test.Main
import Test.Hspec
import qualified ExampleMain
import qualified Data.ByteString as B

main = hspec $
  describe "your-cool-command" $ do
    context "Given 'Yuji' to stdin" $
      it "prints a string including 'Nice name' without an error" $ do
        result <- withStdin "Yuji"$ captureProcessResult ExampleMain.main
        prExitCode result `shouldBe` ExitSuccess
        prStderr result `shouldSatisfy` B.null
        prStdout result `shouldSatisfy` ("Nice name" `B.isInfixOf`)

    context "Given other name to stdin" $
      it "prints an error message" $ do
        result <- withStdin "other name" $ captureProcessResult ExampleMain.main
        prExitCode result `shouldBe` ExitFailure 1
        prStderr result `shouldSatisfy` (not . B.null)

それぞれのファイルを同じディレクトリーに置いた上で、次のように実行すれば試せるはずです cabalユーザーの皆さんは適当に読み替えてください…)

> stack build hspec main-tester
> stack exec runghc -- --ghc-arg=-i. ExampleSpec.hs

your-cool-command
  Given 'Yuji' to stdin
    prints a string including 'Nice name' without an error
  Given other name to stdin
    prints an error message

Finished in 0.0130 seconds
2 examples, 0 failures

バグを見つけたらこちらのGitLabIssueに報告してください(最近の個人的な判官贔屓により、敢えてGitLabにしております 😏)
それではこの春はmain-testerHappy Haskell Testing!! 💚💚💚


  1. main関数を子スレッドとしてforkIOすることで同じことが恐らくできますが、テスト結果の報告に使うべき、標準出力・標準エラー出力を食い合うことになってしまうので、非常にやりづらいと思います。↩︎