ユニットテストのための言語設計

最終更新:

記事 プログラミング言語 型システム

ユニットテストとは,おそらくご存知の通り各コンポーネントが単独で操作的に意図通りの振舞いをしているかを具体例により確認する営みである.

「ユニットテストはどのように書かれるべきか」といった議論が為されるとき,もちろん言語横断的な議論が中心となるものの,しばしば特定の計算機言語やその処理系の性質を所与とした議論が含まれやすい.だが,言語仕様や処理系が天から降ってきたものではない以上,原理的にはむしろ言語こそが目的に応じて適切に設計されるべきものだ.

したがってここでは,必ずしも明瞭な結論に到達するわけではないものの,「ユニットテストとは普遍的に何をするための仕組みなのか,そしてユニットテストをやりやすく意義のあるものにするためには計算機言語はどんな設計であるべきなのか」ということに関して考え,大枠のアイディアを練ってみたい.ここで触れている内容の一部はおそらくソフトウェア工学の文脈でとっくに議論されていながら筆者が把握していないだけだったりするのではないかとも思うが,ひとまず荒削りな思案を書き留めておいて適宜追記する形を取りたいと思う.

ユニットテストの対象となるコンポーネント

まずはユニットテストの対象となる “コンポーネント” について念のためもう少し定義を明確にしておく.ユニットテストが対象にするのは以下でいうところのモジュール (module) か,或いはそのメンバ (member) 単独である:

  • モジュール (module): 実装の詳細を外部に漏らさないための抽象化の単位.有限個のメンバ (member) と呼ばれる内容からなり,各メンバは函数だったり,型だったり,或いは入れ子のモジュールだったりする.モジュールはメンバのうち一部の存在を非公開にしたり,型のメンバの定義を抽象化して外部に提供したりする.
    • モジュールは別のモジュールに依存して実装することができる.多くの場合はモジュール間の依存関係がDAG(=有向非巡回グラフ)になっていることが要請されがちだが,しばしば再帰モジュール (recursive module) の機構を使用するなどして相互依存なモジュール群を扱える言語もある.
    • ML(OCamlやStandard MLなど)のモジュールが典型的な定式化である.その他,Rust・Erlang・Haskell・Elmなどいくつもの具体的な言語でもここでいうモジュール相当の機構が実際に「モジュール」と呼ばれているが,ややこしいことにGo言語では「パッケージ」と呼ばれる.
    • 本稿では触れないが,モジュールはしばしば分割コンパイルの単位を兼ねたりもする.さらにErlangではホットコードローディングの単位にもなっている.

ちなみに,モジュールに加えて以下に掲げるパッケージ (package) というコンポーネントの単位も多くの言語が持ちあわせている.パッケージもモジュールと同様にカプセル化を提供する機構だが,目的が違っている:

  • パッケージ (package): リリースの単位.特にインターフェイスの互換性を制御するカプセル化の単位であり,同時に変更されるモジュールが集まっている.
    • パッケージも他のパッケージに依存することができ,また大抵の言語に於いてはその依存関係がDAGにならねばならなかったりする.
    • こちらも多くの言語でそのまま「パッケージ」と呼ばれているが,やはりややこしいことにGo言語(の1.11以降)では逆にこちらが「モジュール」と呼ばれる.
    • Rustではパッケージの下にさらにクレート (crate) という単位があり,こちらがリリースの単位かもしれない.ただし,1つのパッケージは1つ以上の(ライブラリまたはバイナリの)クレートからなり,かつライブラリクレートを高々1つしか含んではならないので,ライブラリの場合にはパッケージとクレートは実質的に同じ単位である.

なお,パッケージもそれに属する各モジュールをパッケージ外部に対して公開するか否かというカプセル化の機能をもつことがある.外部に対して公開されないモジュールとは,すなわちそのパッケージ内の別モジュールによってのみ使用されることを想定したモジュールである.こうした機能をパッケージが有している場合は,パッケージとモジュールとの役割分担が少しだけ曖昧になっているといってもよいかもしれない.

いずれとしても,おそらくパッケージという単位はユニットテストの対象ではないと考えてよい.もしパッケージに対するテストがあるとすれば,それは結局パッケージに属してい(てかつ外部に向けて公開されてい)るモジュールに対するテストと看なしてよいからだ.したがって以降はここで定めた意味でのモジュールとメンバのみについて扱う.

「公開の函数のみをテストすべし」は本当か?

観察としては,ユニットテストを行ないたい単位は大体モジュールまたはそのメンバごとであり,公開の函数のみをテストすれば十分そうだと漠然と感じることはたしかによくある.実際に「公開の函数のみをテストすべし」という旨の記述は下記のようにしばしば見るし,また何を根拠としてそのように主張されているのかについてTwitterで疑問を提示してみたところ「もしかするとこういうことかも」といくつかの見解を頂いた:

  1. Google Testing Blog: Testing on the Toilet: What Makes a Good Test?

    Tests shouldn’t refer directly to implementation details. The names of a class’s tests should say everything the class does, and the tests themselves should serve as examples for how to use the class.

  2. プライベートメソッドのテストは書かないもの? - t-wadaのブログ

    短くまとめると、プライベートなメソッドのテストを書く必要は 無い と考えています。

    ほとんどのプライベートメソッドはパブリックメソッド経由でテストできるからです。プライベートメソッドは実装の詳細であり、自動テストのターゲットとなる「外部から見た振る舞い」ではありません。

    ただし、この議論にはプロダクトコードもテストコードも自分で書いていることという前提があります。プロダクトコードに手を入れられず、テストコードも無いレガシーコードに対しては、リフレクションは強力な手段です。

    • そもそも「プロダクトコード」を書いた人が自ら書く「自動テスト」の対象は「「外部から見た振る舞い」」であるということを前提している.
  3. 「非公開の函数をテストのためだけに公開するということが行なわれがちで,それがカプセル化を壊してしまうため,避けておくべき」ということかも

  4. 「テストはインターフェイスに対して行なわれるべきものであって,それより真に強いことを保証する必要はない.内部実装に対するテストがあると,実装の変更可能性が阻害される」ということかも

  5. 変わり種としては「実装の詳細の正しさについてはプログラム検証とか形式手法といった網羅的手法で示すべき」という考え方もありうるかもしれない

さて,個々の根拠については後で吟味するとして,テストとは本質的にモジュールという抽象化の単位に基づいた公開の函数のみを対象に行なうべきものだろうか?

結論から言うと,おそらくそうではないと思う.公開の函数をターゲットとするテストであることもあるし,そうでないテストもあってよさそうだ.大別すれば,ユニットテストには2種類ある.それぞれの概念には既に名前がついている:

  • ブラックボックステスト (black-box testing)
    • モジュールによる抽象化の殻の外からのテストであり,公開の函数のみが対象になる.このテストはAPI設計に基づいて決められるべきもの.
  • ホワイトボックステスト (white-box testing)
    • 内部実装に対する,“ソースコードの水準の” テスト.抽象化の殻の内側にあり,実装と一緒に変更されるべきもの.

「公開の函数のみをテストすべき」とはすなわち,ここでいうブラックボックステストだけを書くべしという立場である.つまり,ユニットテストはAPIに対するもののみということになる.この場合,内部実装が適切に網羅的にチェックされているかをテストが概念上感知しないという点に問題が生じる.技術的には勿論カバレッジを取るなどして網羅性を定量的に推測することが可能だが,もしカバレッジの不足に応じてテストを追加するというリアクションをとるのであれば,それは結局どんなテストを用意すべきかが内部実装に依存しているということであり,幾分か自家撞着的だ.また,一般には内部実装が変わればカバレッジも変わってしまい,さらにテストを細分化する必要性が出てきたりする.要するに “事実上ホワイトボックステストも回りくどい方法でやっている” のではなかろうか.

もし「ホワイトボックステストが書きたくなるなら,それはさらにその部分を別モジュールに分けてブラックボックステストをすべきということである」という主張ならありうるかもしれないとは思うが,それほど細かくモジュールを分けるべきだということの妥当性はよくわからない.前述の通りモジュールとは抽象化の単位であり,テストすべき境界が抽象化の境界とカノニカルに完全一致する概念であるとまでは即断できそうにない.

再度上記の各根拠をそれぞれ見て,対応する疑問を呈したい:

  1. Tests shouldn’t refer directly to implementation details.
    • →これははっきりとした根拠が示されているわけではないのでよくわからない.
  2. ほとんどのプライベートメソッドはパブリックメソッド経由でテストできる
    • →「非公開の函数のテストも公開の函数のテスト経由で行なわれるので問題ない」といっても,既に述べたように “非公開函数のあるべきテストケース” をブラックボックステストだけで網羅できていることの保証は原理的に存在しない.もし存在するなら,それは内部実装を切り替えた際に網羅性を気にして公開されている函数のテストを変更せねばならないことを意味するからだ.したがって,この主張にはやや無理があるように思えてしまう.
  3. 非公開の函数をテストのためだけに公開するということが行なわれがちで,それがカプセル化を壊してしまうため,避けておくべきであるから
    • →勿論 “局所的な理屈” としては納得するが,それは最初に述べたように計算機言語のカプセル化機構が所与の場合の話で,原理的には「テストをこう書きたいので言語機能がこうであるべき」という推論によって言語設計が行なわれてしかるべきなので,あくまで “対症療法” であって普遍的な考え方とはいえなさそうに見える.
  4. 内部実装に対するテストがあると,実装の変更可能性が阻害されるから
    • →ホワイトボックステストは実装を変える時に一緒に書き換えればよい(というか本質的にそういう性質のものである)のであって,よほど煩雑なテストになるのでない限りそうしたテストがあってはならない理由にはなりそうにない.
  5. 実装の詳細の正しさについてはプログラム検証とか形式手法といった網羅的手法で示すべきかも?
    • →たしかにもし数理的手法でのチェックが簡便に実現できれば開発手法としてエレガントだが,その手法の存在がホワイトボックステストの存在を許さない理由になる根拠はよくわからない.篩型の型検査などはそれほど高速ではないしスケールしないので,開発中は結局ホワイトボックステストを頻繁に回したくなるのではなかろうか.

というわけで,現状の個人的所感としては,ホワイトボックステストを書いてはいけないという主張はどれも今ひとつ理由が釈然としない.したがって,ここでは次のように考えたい: 言語またはそのエコシステムがテストのために提供する仕組みでは,ブラックボックステストとホワイトボックステストが共に書ける,ないし書きやすい必要がある

既存言語でのユニットテストの定式化

  • Rustでは,ブラックボックステストは tests/ ディレクトリ以下に,ホワイトボックステストはテスト対象のソースと同ファイル内に #[cfg(test)] をつけて書くことがそれぞれ慣習的なようだが,ブラックボックステストもホワイトボックステストと同様にソースファイル内に書く用例もある1
  • OCamlはそもそも産業的に使用している組織が限られていたり,テストは静的検証に比して子供騙し的だというイメージを抱く人が多いからなのか,テストを書く文化が相対的にあまり発達していない気もするが,勿論テストを行なう方法自体は整備されており,ビルドツールであるDuneに組み込まれている.ブラックボックステストはテスト対象とは別モジュールに書かれる.ホワイトボックステストを書く方法は広く共有されていないが, ppx_inline_test などを使ってモジュール内に書ける.これはPPXによるメタプログラミング的な方法で実現されていて,勿論テストコードはビルド結果に含まれないようになっている.OCamlに於けるユニットテストについては別の機会に記事にしたい.
  • Go言語はここでいうモジュール(Go言語の用語ではパッケージ)が1ファイルとは限らず,複数ファイル間で “抽象化の内側” を共有できるようになっている.一般に foo.go に書かれている函数に対するテストはブラックボックステスト・ホワイトボックステストによらず foo_test.go に書くのが慣習的である.ソースとテストを別ファイルに分けて書ける方が良いという美意識があるなら言語設計として参考にできるかもしれない.
  • ErlangのEUnitでは慣習的にいずれの種類のテストもソースとは別のファイルに書く.ビルドツールであるRebar3にはテストの場合だけモジュールの抽象化を無効にできる仕組みがあるので,ホワイトボックステストでも別ファイルで可能.meckという超絶強力なモック用ライブラリがある.

ここで挙げたいずれの言語に於いても,ブラックボックステストとホワイトボックステストが共に書けるようになっている.

良くも悪くもブラックボックステストはホワイトボックステストとしても書けるので,これらをどの程度仕組み上区別すべきかについては言語ごとに慣習やポリシーが違っていそうだ.どちらが正解ということは多分ないが,Rustの tests/ の慣習やOCamlの Dune + ppx_inline_test のように意識的に区別できるような設計を理想としてもよいのではないかと個人的には思う.究極的には,ブラックボックステストとすべきテストがホワイトボックステストとして書かれていた場合には「このテスト,ブラックボックステストにしませんか?」と処理系が警告を出せたりするとよさそうだ.

モックのための言語設計

ユニットテストには「オンメモリで完結しなければならない」という大原則がある.しばしば実際にファイルへの書き込みを行なったり,DBサーバを立ち上げてクエリを叩いたり,外部サーバへの通信を行なったりするユニットテストがあったりするが,それらは並行に複数のテストが走った時に同一ファイルに書き込みをして競合で確率的に失敗したり,外部サーバがダウンしている際にテストを実行できないなど,テストの安定性に支障をもたらすからだ.例えば以下の記事はそうしたテストの問題点を指摘している:

通常,そうしたユニットテストを書かないようにするには人間が気をつけて避ける必要がある.だが,工学的に言えばそうしたテストを走らせないことを人間によってではなく機械的に保証できる方が望ましい.つまり,言語自体の設計によってオンメモリで完結しないテストが書けないように制限されていてほしい.もっと欲を言えば,テストが書きやすいようにしかプログラムを実装できないようであってほしい.

こうした考えを推し進めると,「I/Oや通信などを行なうためにモックせねばならない函数は,ユニットテスト中で直接呼び出すと型検査に通らない」という仕組みをつくりたくなってくる.特に,各函数がI/Oや通信を行なうか否かをエフェクトシステム (effect system) によって表現する案が浮かぶ.例えば,HTTPリクエストを飛ばす函数 Http.do_request は以下のようなeffect註釈のある型をつけて扱うとよさそうだ:

module Http :> sig
  type method
  val get : method
  val put : method
  val post : method

  type header = map string (list string)

  type request
  val make_request : method -> header -> string -> request

  type response
  ...

  val do_request : request -{E}-> async response
  ...
end

ここで -{E}-> がディスクアクセスや通信などオンメモリで完結しない操作を含む処理であってテストではモックせねばならないことを表すeffect註釈だ.effect註釈は \(φ \Coloneqq \mathrm{N}\ |\ \mathrm{E}\) の形式で, \(\mathrm{N}\) がオンメモリの操作のみであることを,\(\mathrm{E}\) がオンメモリで完結しない操作であることをそれぞれ表す.函数型は \(τ \stackrel{φ}{→} τ\) というeffect註釈のついた形に一般化され,通常の \(τ → τ^{\prime}\) は \(τ \stackrel{\mathrm{N}}{→} τ^{\prime}\) の糖衣構文である.一般的なエフェクトシステムと同様に,型判定は通常の \(\mathit{Γ} ⊢ e : τ\) から \(\mathit{Γ} ⊢ e : τ \mathrel{/} φ\) に一般化される(これは “型環境 \(\mathit{Γ}\) の下で式 \(e\) には \(τ\) 型がつき,かつ \(φ\) 扱いとすべきである” と読む).函数適用に対する型つけ規則は以下のような要領で定義するとよい:

\[\begin{align*} \begin{array}{c} \mathit{Γ} ⊢ e_1 : τ \stackrel{φ}{→} τ' \mathrel{/} φ_1 \qquad \mathit{Γ} ⊢ e_2 : τ \mathrel{/} φ_2 \\\hline \mathit{Γ} ⊢ e_1\ e_2 : τ' \mathrel{/} φ_1 ⊔ φ_2 ⊔ φ \end{array} \end{align*}\]

ただし,\(⊔\) は以下で定義される:

\[\begin{align*} \mathrm{E} ⊔ φ &\coloneqq \mathrm{E}, & φ ⊔ \mathrm{E} &\coloneqq \mathrm{E}, & \mathrm{N} ⊔ \mathrm{N} &\coloneqq \mathrm{N} \end{align*}\]

すなわち,\(⊔\) は \(\{\mathrm{N}, \mathrm{E}\}\) を台集合とし \(\mathrm{N} \mathrel{⋤} \mathrm{E}\) で順序が入った束のjoinである.

このeffect註釈 \(φ\) は通常のプログラムに対する型検査としては特に意味をもたないが,テストコードに対する型検査で意味をもつ.すなわち,テストコード \(e\) に対して \(\mathit{Γ} ⊢ e : τ \mathrel{/} \mathrm{N}\) が成り立たなければそもそも適切なテストでないとして静的に弾くのである.こうすると,effect註釈 \(\mathrm{E}\) のつく Http.do_request のような函数はテスト中で使うと弾かれ,モックせねばならないことがわかる.さらには Http.do_request に依存している函数も \(\mathrm{E}\) 扱いとなるので,そうした函数をテストするにはモックが必要だということに内部実装を精査せずとも気づけるようになる.こうした伝搬は函数抽象に対する型つけ規則によって実現される:

\[\begin{align*} \begin{array}{c} \mathit{Γ}, x : τ ⊢ e : τ' \mathrel{/} φ \\\hline \mathit{Γ} ⊢ (λx : τ.\ e) : τ \stackrel{φ}{→} τ' \mathrel{/} \mathrm{N} \end{array} \end{align*}\]

函数抽象自体は(即座に値であるので)評価が必ずオンメモリで完結し,したがって \(\mathrm{N}\) 扱いとするが,本体 \(e\) が \(\mathrm{E}\) と扱われるべき式ならその函数抽象には \(τ \stackrel{\mathrm{E}}{→} τ^{\prime}\) という型がつき,したがってその函数を適用した式は \(\mathrm{E}\) 扱いになるのである.

まとめ

かなり荒削りではあるが,本記事では「ユニットテストを適切に行なうために,計算機言語は少なくともどんな設計であることが望ましいか」ということについて思案し,大枠の方向性を与えた.大雑把にまとめると以下のようなことを書いた:

  • ユニットテストを行なう対象は抽象化の単位であるところのモジュール,およびそれに属する(内部実装を含む)個々のメンバである.
  • ユニットテストとしては,モジュールのインターフェイスに対するものであるブラックボックステストと,内部実装に対するホワイトボックステストの両方があってよい.したがって,言語ないしエコシステムの設計としては,これらいずれの種別のテストも書けるようになっているべきである.両者をどの程度意識的に区別して書かせるかについてはエコシステム設計の自由度があるが,個人的にはどちらかといえば分かれている方が自然な気はする.
  • 究極的には,実装中のどこをモックしてテストせねばならないかは人間が判断するのではなく,型システムなどの機械的仕組みによって達成されてほしい.実際,エフェクトシステムを援用すればそのような仕組みが実現可能そうだ.

  1. 当初「Rustはブラックボックステストもホワイトボックステストもソースと同ファイルに記述するのが慣習的」と記載していたが,tests/ にブラックボックステストを分けて置いている例も多く見られるので誤認として訂正.@qnighy さんからのご指摘より. ↩︎