BEAMエコシステムにおけるテスト

この記事は英語の原文を日本語に翻訳したものです。原文: https://blog.stenmans.org/theBeamBook/#CH-Testing

翻訳元: happi/theBeamBook 7998e22e78417dbe20e5136b9aee862a1ecaa404(コミット 7998e22

イントロダクション

開発中にErlangコードの信頼性と堅牢性をどう確保するか、本章のテーマです。 まず取り上げるのはEUnitです。 アプリケーションに対するテストの記述と実行を容易にする、定番のテストフレームワークです。

EUnit

EUnitは、個々のプログラム単位をテストするためのErlang用単体テストフレームワークです。 テスト対象は関数やモジュールから、プロセス、アプリケーション全体にまで及びます。 EUnitを使えば、テストの記述と実行、そして結果の分析までを一貫して行え、コードの正しさと信頼性を確認できます。

基本とセットアップ

Erlangモジュールの中でEUnitを使うには、-module宣言の後に次の行を追加します。

-include_lib("eunit/include/eunit.hrl").

この行を追加すると、EUnitの機能が使えるようになり、モジュール内の全単体テストを実行するtest()関数がエクスポートされます。

テストケースとテストスイートの記述

単純なテスト関数を作るには、引数を取らず、名前が_test()で終わる関数を定義します。 値を返せば成功、例外を投げれば失敗として扱われます。

より高度なテストケースには、=によるパターンマッチングを使います。 例えば次のように書けます。

reverse_nil_test() -> [] = lists:reverse([]).

あるいは、?assert(Expression)マクロを使って、式を評価するテストケースを書くこともできます。

length_test() -> ?assert(length([1,2,3]) =:= 3).

テストの実行と結果の分析

モジュールにEUnitの宣言を追加したら、モジュールをコンパイルして、自動的にエクスポートされるtest()関数を実行します。 例えばモジュール名がmであれば、m:test()を呼び出すことでモジュール内の全テストをEUnitで実行できます。

EUnitはeunit:test/1関数を使ってテストを実行することもできます。 例えばeunit:test(m)の呼び出しはm:test()の呼び出しと等価です。

テストコードを通常のコードから分離するには、モジュール名がmであればm_testsという名前のモジュールにテスト関数を書きます。 モジュールmのテストをEUnitに依頼すると、モジュールm_testsも探し出してそちらのテストも実行します。

EUnitはテスト関数からの標準出力を捕捉するため、テストコードが標準出力に書き込んでも、そのテキストはコンソールには表示されません。 これを回避するには、EUnitのデバッグ用マクロを使うか、io:format(user, "~w", [Term])のようにユーザー出力ストリームへ書き込みます。

テスト対象が生成する出力を確認する方法についてさらに詳しく知りたい場合は、出力確認用のマクロを解説したEUnitのドキュメントを参照してください。

Common Test

基本とセットアップ

Common Test(CT)は、Erlang/OTPに含まれる強力なテストフレームワークで、テストスイートの記述と実行を行います。 ホワイトボックステストとブラックボックステストの両方に対応しており、Erlangアプリケーションや分散システムのテスト、さらには外部システムとの連携テストにも柔軟に使えます。

Common Testのインストール

Erlangをインストール済みであれば、Common Testはすでに含まれているはずです。 ただし、最小構成のインストールやカスタムビルドを使っている場合は、common_testアプリケーションを依存関係に追加することで利用できるようにします。

common_testがインストールされているかを確認するには、次のようにします。

1> application:ensure_all_started(common_test).

rebar3を使う場合は、依存関係に追加されていることを確認します。

{deps, [common_test]}.

基本的なテストスイートの作成

Common Testのスイートは、特定の構造に従った単なるErlangモジュールです。 まずtest/ディレクトリにsample_SUITE.erlという名前のファイルを作成します。

sample_SUITE.erlの例を示します。

-module(sample_SUITE).
-compile(export_all).

% Common Test includes these callbacks
-include_lib("common_test/include/ct.hrl").

% Test suite information
suite() ->
    [{timetrap, {seconds, 10}}].  % Time limit for tests

init_per_suite(Config) ->
    io:format("Initializing test suite...~n"),
    Config.

end_per_suite(Config) ->
    io:format("Cleaning up test suite...~n"),
    Config.

% Define test cases
all() -> [simple_test].

simple_test(_Config) ->
    io:format("Running simple test case...~n"),
    ?assertEqual(42, 21 + 21).

このテストスイートは、suite/0でスイート自体の情報を定義し、init_per_suite/1end_per_suite/1でセットアップと後処理を行い、simple_test/1というテストケースで21 + 21 = 42を検証しています。

テストケースとテストスイートの記述

Common Testにおけるテストケースは、アサーションとチェックを実行する関数です。 単純な命名規則に従い、all/0関数に列挙されている必要があります。

Common Testはct.hrlの中でアサーション用のマクロを提供しています。

?assert(Expression).
?assertEqual(Expected, Actual).
?assertNotEqual(Unexpected, Actual).
?assertMatch(Pattern, Expression).
?assertNotMatch(Pattern, Expression).

複数のテストケースの例を示します。

all() -> [math_test, string_test].

math_test(_Config) ->
    ?assertEqual(10, 5 * 2).

string_test(_Config) ->
    ?assertEqual("hello", string:to_lower("HELLO")).

テストケースは、設定ファイルや前段のセットアップ関数から引数を受け取ることもできます。

init_per_testcase(math_test, Config) ->
    [{base_value, 10} | Config];
init_per_testcase(_, Config) -> Config.

math_test(Config) ->
    Base = proplists:get_value(base_value, Config),
    ?assertEqual(Base * 2, 20).

テストの実行と結果の分析

テストはct_runまたはrebar3で実行します。

Erlangシェルから実行する場合は次のようにします。

ct:run_test([{dir, "test/"}]).

rebar3を使う場合は次のようにします。

rebar3 ct

テスト出力の見方

Common Testは_build/test/logs/ディレクトリに詳細なログを生成します。

主なログファイルは次のとおりです。

  • ct_run.*.log - テスト実行全体のメインログ
  • suite.log - 個々のテストスイートの実行ログ
  • testcase.log - 個々のテストケースの実行ログ

リアルタイムでデバッグしたい場合は、詳細出力を有効にできます。

ct:run_test([{dir, "test/"}, {verbosity, high}]).

その他のテストフレームワークと技法

Common TestはErlang/OTPに含まれる標準のテストフレームワークですが、開発者はテストのカバレッジや信頼性、自動化を高めるために、他にもさまざまなテストフレームワークや手法を利用しています。 これらのツールはプロパティベーステストモックといった追加の機能を提供し、Erlangアプリケーションにおける複雑な振る舞いや相互作用の検証を助けます。 これらの技法を詳しく掘り下げることは本書の範囲を超えますが、テストの手札を広げたい読者のために、この節で簡単に紹介します。

プロパティベーステスト

プロパティベーステストは、システムが常に満たすべき性質(プロパティ)に基づいて大量のテストケースを生成する点で、従来の単体テストとは異なります。 個々のテストケースを書く代わりに、開発者はプロパティを定義し、フレームワークがそのプロパティが幅広いシナリオで成り立つことを検証するための入力を自動生成します。 このアプローチは、手書きのテストではカバーしきれないエッジケースを見つけ出すのに特に有効です。

Erlangエコシステムで広く使われているプロパティベーステストのライブラリには、QuickCheckPropErの2つがあります。

  • QuickCheck(商用版とオープンソース版がある)は、ランダムなテストケースを生成し、失敗したケースを最小の反例へと絞り込む強力なツールです。
  • PropEr(Property-Based Testing for Erlang)は、型仕様やジェネレータ、ステートフルテストに対応したプロパティ定義をサポートする、同等の機能を持つオープンソースの代替ツールです。

プロパティベーステストは、アルゴリズムやプロトコル、入力空間が複雑なシステムの検証に極めて効果的です。 ただし、個別のテストケースを書く発想から、システムの不変条件や制約を定義する発想への転換が求められます。

モック

モックは、依存関係や外部コンポーネントを制御可能な代役に置き換えるテスト技法です。 これは特に単体テストで有用です。 関数やモジュールを依存関係から切り離すことで、システム全体を統合しなくても特定の振る舞いをテストしやすくなります。

  • モックによってコードを分離した状態でテストでき、依存先の実装がどうであれ、関数が正しく振る舞うことを確認できます。
  • データベースやネットワークサービスといった外部システムとのやり取りを避けることで、テストの実行を高速化します。
  • タイムアウトや失敗、依存先からの予期しない応答のシミュレーションなど、エッジケースを制御下でテストできます。

モックフレームワークが一般的なオブジェクト指向言語とは異なり、Erlangの関数型の性質とメッセージパッシングモデルには、モックに対する別のアプローチが必要です。 よく使われる戦略には次のようなものがあります。

  • 関数オーバーライドによる手動モック:高階関数や明示的なモジュール置き換えを使う方法です。
  • meckの利用:実行時にモジュール関数を置き換えて制御されたテストを可能にする、人気のモックライブラリです。
  • プロセスベースのモック:あらかじめ定めた応答を返す軽量なプロセスで外部システムを模擬する方法です。

モックのベストプラクティス

Erlangのテストでモックを使う際は、次のベストプラクティスを心がけます。

  • モックは必要な場合に限って使う。統合テストが可能なら、実際の実装を優先する。
  • モック化した振る舞いは現実的に保ち、誤解を招くテスト結果を避ける。
  • 可能な場合はプロパティベーステストとモックを組み合わせ、特定の依存関係を制御しつつ、より広いテストカバレッジを確保する。

プロパティベーステストとモックはある種のテスト場面で非常に有用ですが、効果的に使うにはより深い理解とベストプラクティスの実践が求められます。 これらのテーマをより詳しく学びたい読者には、“Property-Based Testing with PropEr, Erlang, and Elixir”Meckといった専門の資料を参照することをお勧めします。