ユニットテスト

ご存知の通り、コンピュータプログラムを実行するあらゆるデバイスにはソフトウェアのバグが含まれている。ソフトウェアのバグは、最も単純なデバイスから最も複雑なデバイスまで、あらゆるコンピュータデバイスに存在する。ソフトウェアのバグのデバッグと修正は、プログラマーの日常業務の中でも特に好ましくない作業のひとつだ。

バグの原因

ソフトウェアのバグが発生する理由は多岐にわたる。以下は、プログラムの設計段階から実際のコーディングに至るまでの、不完全なリストだ。

残念ながら、プログラムが常に正しく動作することを保証するソフトウェア開発手法はまだ存在しない。これは、10年ごとに有望な解決策が登場する、ソフトウェア工学のホットなテーマだ。

バグの発見

ソフトウェアのバグは、プログラムのライフサイクルのさまざまな段階で、さまざまな種類のツールや人々によって発見される。以下は、バグが発見される可能性のある時期を、最も早いものから遅いものの順に一部挙げたものだ。

バグをできるだけ早く検出することで、金銭的損失、時間の損失、場合によっては人命の損失も減らすことができる。さらに、エンドユーザーによって発見されたバグの原因を特定することは、開発段階で早期に発見されたバグの原因を特定するよりも困難だ。

バグ検出のためのユニットテスト

プログラムはプログラマーによって記述され、Dはコンパイル言語であるため、バグを発見するのは常にプログラマーとコンパイラだ。この2つを除けば、バグを最も早く、そしてその理由もあって最も効果的に発見する方法は、ユニットテストだ。

ユニットテストは現代のプログラミングにおいて不可欠な要素だ。コーディングエラーを削減する最も効果的な方法だ。一部の開発手法では、ユニットテストで保護されていないコードはバグのあるコードとみなされる。

残念ながら、逆は真ではない:ユニットテストはコードがバグフリーであることを保証しない。非常に効果的ではあるが、バグのリスクを軽減するだけだ。

また、ユニットテストにより、コードのリファクタリング(つまり、コードの改善)を簡単かつ自信を持って行うことができる。そうしないと、プログラムに新しい機能を追加する際に、既存の機能の一部を誤って破壊してしまうことがよくある。この種のバグは、回帰バグと呼ばれる。ユニットテストを行わない場合、回帰バグは、次のリリースのQAテストの段階で発見されることもあれば、さらに悪い場合には、エンドユーザーによって発見されることもある。

回帰のリスクはプログラマーがコードのリファクタリングを避ける原因となり、変数の名前を修正するといった簡単な改善すら行えなくなることがある。これにより、コードが次第に維持困難な状態になる"コードの劣化"が発生する。例えば、複数の場所から呼び出すために、一部のコード行を新しく定義した関数に移動したほうがいい場合でも、回帰を恐れて、プログラマは既存のコード行を他の場所にコピー&ペーストしてしまい、コードの重複という問題が発生する。

"壊れていないなら直すな"といったフレーズは、回帰への恐怖と関連している。これらのガイドラインは知恵を伝えているように見えるが、実際にはコードを徐々に劣化させ、手をつけられない混乱状態に陥らせる原因となる。

現代のプログラミングは、このような"知恵"を拒否する。逆に、バグの原因にならないように、コードは"容赦なくリファクタリング"されるべきだ。この現代的なアプローチの最も強力なツールがユニットテストだ。

ユニットテストは、コードの最小単位を独立してテストするプロセスだ。コードの単位が独立してテストされると、その単位を使用する上位のコードにバグがある可能性が低くなる。各部分が正しく動作すれば、全体も正しく動作する可能性が高くなる。

ユニットテストは、他の言語ではライブラリソリューションとして提供されている(JUnit、CppUnit、Unittest++など)。Dでは、ユニットテストは言語のコア機能だ。ユニットテストには、ライブラリソリューションと言語機能、どちらが適しているかは議論の余地がある。Dは、ユニットテストライブラリに一般的に見られる機能の一部を提供していないため、ライブラリソリューションも検討する価値があるかもしれない。

Dのユニットテスト機能は、unittestブロックにassertチェックを挿入するだけのシンプルなものだ。

ユニットテストの有効化

ユニットテストはプログラムの実行の一部ではない。明示的に要求された開発中にのみ有効化すべきだ。

ユニットテストを有効にするdmdコンパイラスイッチは‑unittestだ。

プログラムがdeneme.dという単一のソースファイルに記述されている場合、そのユニットテストは次のコマンドで有効化できる。

dmd deneme.d -w -unittest
Bash

‑unittestスイッチでビルドされたプログラムを実行すると、まずユニットテストブロックが実行される。すべてのユニットテストが成功した場合にのみ、プログラムの実行はmain()で継続される。

unittestブロック

ユニットテストを含むコード行は、unittestブロック内に記述される。これらのブロックは、ユニットテストを含むこと以外のプログラム上の意味はない:

unittest {
    /* ... テストおよびそれをサポートするコード ... */
}
D

unittestブロックは任意の場所に配置できるが、テスト対象のコードの直後に定義すると便利だ。

例として、指定された数値を"1st"、"2nd"などの順序形式で返す関数をテストしよう。この関数のunittestブロックには、関数の戻り値を期待値と比較するassert文を記述するだけでよい。次の関数は、4つの異なる期待結果でテストされている。

string ordinal(size_t number) {
    // ...
}

unittest {
    assert(ordinal(1) == "1st");
    assert(ordinal(2) == "2nd");
    assert(ordinal(3) == "3rd");
    assert(ordinal(10) == "10th");
}
D

上記の4つのテストでは、関数を4回呼び出し、返された値を期待値と比較することで、少なくとも1、2、3、10の値に対して関数が正しく動作することをテストしている。

ユニットテストはassertのチェックに基づいているが、unittestブロックには任意のDコードを含めることができる。これにより、テストを実際に開始する前に準備を行ったり、テストに必要なその他のサポートコードを記述したりすることができる。例えば、次のブロックでは、コードの重複を削減するために、まず変数を定義している。

dstring toFront(dstring str, dchar letter) {
    // ...
}

unittest {
    immutable str = "hello"d;

    assert(toFront(str, 'h') == "hello");
    assert(toFront(str, 'o') == "ohell");
    assert(toFront(str, 'l') == "llheo");
}
D

上記の3つのassertチェックは、toFront()が仕様通りに動作することをテストしている。

これらの例が示すように、ユニットテストは、特定の関数をどのように呼び出すべきかの例としても役立つ。通常、ユニットテストを読むだけで、その関数の機能について簡単に理解することができる。

例外のテスト

特定の条件下でスローすべき、あるいはスローしてはならない例外の型について、コードをテストすることはよくある。std.exceptionモジュールには、例外のテストに役立つ2つの関数が含まれている。

例えば、2つのスライスパラメータの長さが等しく、空のスライスでも動作することを要求する関数は、次のテストでテストできる。

import std.exception;

int[] average(int[] a, int[] b) {
    // ...
}

unittest {
    /* 不均一なスライスでは必ずスローしなければならない */
    assertThrown(average([1], [1, 2]));

    /* 空のスライスではスローしてはならない */
    assertNotThrown(average([], []));
}
D

通常、assertThrownは、その例外の実際の型に関係なく、何らかの型の例外がスローされることを保証する。必要に応じて、特定の例外型に対してテストすることもできる。同様に、assertNotThrownは、例外がまったくスローされないことを保証するが、特定の例外型がスローされないことをテストするように指示することもできる。特定の例外型は、これらの関数のテンプレートパラメータとして指定する。

/* 不均等なスライスに対してはUnequalLengthsをスローしなければならない */
assertThrown!UnequalLengths(average([1], [1, 2]));

/* 空のスライスに対してはRangeErrorをスローしてはならない
 * (他の型の例外をスローする可能性がある) */
assertNotThrown!RangeError(average([], []));
D

テンプレートについては後の章で説明する。

これらの関数の主な目的は、コードをより簡潔で読みやすくすることだ。例えば、次のassertThrown行は、その下の長いコードと同等だ。

assertThrown(average([1], [1, 2]));

// ...

/* 上の行と同等 */
{
    auto isThrown = false;

    try {
        average([1], [1, 2]);

    } catch (Exception exc) {
        isThrown = true;
    }

    assert(isThrown);
}
D
テスト駆動開発

テスト駆動開発(TDD)は、機能を実装する前にユニットテストを書くことを規定したソフトウェア開発手法だ。TDDでは、ユニットテストに重点が置かれる。コーディングは、テストを合格させるための二次的な活動だ。

TDDに従って、上記のordinal()関数は、まず意図的に間違って実装することができる。

import std.string;

string ordinal(size_t number) {
    return "";    // ← 意図的に間違っている
}

unittest {
    assert(ordinal(1) == "1st");
    assert(ordinal(2) == "2nd");
    assert(ordinal(3) == "3rd");
    assert(ordinal(10) == "10th");
}

void main() {
}
D
unit_testing.1

この関数は明らかに間違っているが、次のステップでは、ユニットテストを実行して、この関数の問題が実際にテストで検出されるかどうかを確認する。

dmd deneme.d -w -unittest
./deneme
core.exception.AssertError@deneme(10): ユニットテストの失敗
Bash

関数は、失敗を確認した後、テストに合格するためだけに実装すべきだ。以下は、テストに合格する実装の1つだ。

import std.string;

string ordinal(size_t number) {
    string suffix;

    switch (number) {
    case  1: suffix = "st"; break;
    case  2: suffix = "nd"; break;
    case  3: suffix = "rd"; break;
    default: suffix = "th"; break;
    }

    return format("%s%s", number, suffix);
}

unittest {
    assert(ordinal(1) == "1st");
    assert(ordinal(2) == "2nd");
    assert(ordinal(3) == "3rd");
    assert(ordinal(10) == "10th");
}

void main() {
}
D
unit_testing.2

上記の実装はユニットテストに合格しているので、ordinal()関数が正しいと信頼する理由がある。テストによって保証されているため、この関数の実装は、自信を持ってさまざまな方法で変更することができる。

バグ修正前のユニットテスト

ユニットテストは万能薬ではない。バグは必ず存在する。プログラムを実行中にバグが発見された場合、ユニットテストが不十分であったことを示す兆候と見なすことができる。そのため、まずバグを再現するユニットテストを書き、その後バグを修正して新しいテストを通過させる方が望ましい。

dstringで指定された数字の序数形式のスペルを返す次の関数を見てみよう。

import std.exception;
import std.string;

dstring ordinalSpelled(dstring number) {
    enforce(number.length, "number cannot be empty");

    dstring[dstring] exceptions = [
        "one": "first", "two" : "second", "three" : "third",
        "five" : "fifth", "eight": "eighth", "nine" : "ninth",
        "twelve" : "twelfth"
    ];

    dstring result;

    if (number in exceptions) {
        result = exceptions[number];

    } else {
        result = number ~ "th";
    }

    return result;
}

unittest {
    assert(ordinalSpelled("one") == "first");
    assert(ordinalSpelled("two") == "second");
    assert(ordinalSpelled("three") == "third");
    assert(ordinalSpelled("ten") == "tenth");
}

void main() {
}
D

この関数は、例外的なスペルも処理し、そのためのユニットテストも備わっている。しかし、この関数にはまだ発見されていないバグがある。

import std.stdio;

void main() {
    writefln("He came the %s in the race.",
             ordinalSpelled("twenty"));
}
D

プログラムの出力におけるスペルエラーは、ordinalSpelled()のバグによるもので、そのユニットテストでは検出されていない:

彼はレースで20位でゴールした。

この関数は、yで終わる数字のスペルを正しく生成していないことは簡単にわかるが、TDDでは、実際にバグを修正する前に、まずバグを再現するユニットテストを作成することが義務付けられている。

unittest {
// ...
    assert(ordinalSpelled("twenty") == "twentieth");
}
D

テストを改善することで、開発中にこの関数のバグが検出されるようになった。

core.exception.AssertError@deneme(3274338): ユニットテストの失敗
Undefined

関数は、その時点で初めて修正すべきだ。

dstring ordinalSpelled(dstring number) {
// ...
    if (number in exceptions) {
        result = exceptions[number];

    } else {
        if (number[$-1] == 'y') {
            result = number[0..$-1] ~ "ieth";

        } else {
            result = number ~ "th";
        }
    }

    return result;
}
D
演習

TDDに従ってtoFront()を実装しよう。以下の意図的に不完全な実装から始めてみよう。ユニットテストが失敗することを確認し、テストに合格する実装を提供しよう。

dstring toFront(dstring str, dchar letter) {
    dstring result;
    return result;
}

unittest {
    immutable str = "hello"d;

    assert(toFront(str, 'h') == "hello");
    assert(toFront(str, 'o') == "ohell");
    assert(toFront(str, 'l') == "llheo");
}

void main() {
}
D
unit_testing.5