テストケースを動的に生成してJUnitで実行する

Unit Testing
125件のシェア(ちょっぴり話題の記事)

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

AWSチームに参画して2ヶ月ほど経ちました。ところが、AWSの構築などにはあまり関わらず、ひたすらAWSに関連するプロダクトの開発を行う毎日です。そんな折、ボスより次のようなリクエストをいただきました。

ユーザが参照できない情報について、参照できないことを検証して欲しい

・・・「出来ないことの検証」です。

「出来ることの検証」であれば、その例をテストケースとして記述してテストを実行すれば検証出来ます。しかし、出来ないことを証明することは非常に困難です。ただ、情報は有限なんで、総当たりにでもやればできるかもしれません

!?

システムのインフラは当然のようにAWSです。テストのためのリソースが足りなければ増やせばいいじゃないですか。時間がかかるならば並列化すればいいじゃないですか。テストの時だけ増やせばいいんです。

ならば、総当たりでテストしよう

という方針になりました。そして、ブログのネタ決定です。

JUnitの設計思想

総当たりのテストケースを個別に書いていくのは生産的ではありません。また、情報は更新されますから、常に最新の状態で総当たりテストをしたいです。つまり、テストケースは動的に生成しなければなりません。ところが、動的に生成されるテストケースとJUnitのフレームワークとしての設計思想は相反します。

はじめにJUnitの設計思想を確認しましょう。

JUnitはxUnit系のテスティングフレームワークです。テストケースはテストクラスのテストメソッドとして定義し、テストを実行します。これは非常にシンプルで有効な設計です。

JUnitを使ってテストコードを書く場合、テスト対象クラスに対して、ペアとなるテストクラスを作成します。このため、テスト対象クラスとテストクラスで見通しが良くなります。また、テストケース毎にテストメソッドが作成されるため、IDEなどでテストケースの一覧を参照する事も容易です。これは、JUnitがクラスやメソッドを対象として、それらが期待される振る舞いをするかを検証するユニットテストのために作られたフレームワークだからです。

テスト対象クラスも特になく、動的にテストケースを生成して実行するならば、JUnitを使わないことも選択肢のひとつです。ですが、JUnitは周辺のツールが充実しているため、JUnitの仕組みに乗せてしまった方が都合が良いのです。特にJUnitは、IDEやJenkinsなどのCIツールとの相性が良いため、強引にJUnitの仕組みに乗せる価値があります。ただし、テストケースがクラス毎にメソッド単位で定義されているという設計思想を念頭におく必要があります。

Runner

JUnitのorg.junit.runner.Runnerクラスは、どのようなテストケースを実行するかを制御するクラスです。また、テスト実行時にイベントをorg.junit.runner.notification.RunNotiferオブジェクトに通知します。今回はカスタムのRunnerを作成して動的なテストケースを実行させてみたいと思います。

はじめにRunnerクラスのサブクラスを作りましょう。

public class DynamicTestsRunner extends Runner {

    public DynamicTestsRunner(Class<?> testClass) {
    }

    @Override
    public Description getDescription() {
        // TODO Auto-generated method stub
        return null;
    }

    @Override
    public void run(RunNotifier notifier) {
        // TODO Auto-generated method stub
    }
}

コンストラクタ

テストクラスのクラスオブジェクトを引数に持つコンストラクタが必要です。JUnitの設計思想を踏まえればテストクラスにテストケースに関する情報が宣言されていることになります。今回は無視します。

getDescriptionメソッド

RunnerクラスのgetDescriptionメソッドは、テストの情報をorg.junit.runner.Description.Descriptionオブジェクトとして返すメソッドです。詳しくは後述します。

runメソッド

Runnerクラスのrunメソッドは、テストを実行するメソッドです。引数としてRunNotifierオブジェクトを取り、テストの実行前や実行後、失敗時などにイベントを発火しなければなりません。RunNotifierオブジェクトのイベントを発火すると、テスト結果の集計などを行うリスナーオブジェクトに伝搬されます。

getDescriptionメソッドの実装

Descriptionクラスは、テストケースやテストスイートの情報を表します。しかし、テストケースの場合とテストスイートの場合で扱いが異なります。

Descriptionオブジェクトがテストケースの場合createTestDescriptionメソッドを利用して生成します。createTestDescriptionメソッドはDescriptionのstaticファクトリメソッドで、第1引数にテストクラス名、第2引数にテストメソッド名を指定します。ここでは、動的なテストケースを作成するので、仮のテストクラス名と仮のテストメソッド名を次のように指定することにします。

Description.createTestDescription("DynamicTests", "Test-1");

Descriptionオブジェクトがテストスイートの場合createSuiteDescriptionメソッドを利用して生成します。createSuiteDescriptionメソッドはDescriptionのstaticファクトリメソッドで、第1引数にテストクラス名を指定します。先ほどと同様に仮のクラス名を指定すると次のようになります。

Description.createSuiteDescription("DynamicTests");

さらに、テストスイートの場合、子となるテストケース(またはテストスイート)のDescriptionオブジェクトを入れ子として登録しなければなりません

これらを整理すると次のようになります。

    @Override
    public Description getDescription() {
        Description desc = Description.createSuiteDescription("DynamicTests");
        desc.addChild(getDescription("Test-1"));
        desc.addChild(getDescription("Test-2"));
        desc.addChild(getDescription("Test-3"));
        return desc;
    }
    
    private Description getDescription(String testName) {
        return Description.createTestDescription("DynamicTests", testName);
    }

runメソッドの実装

runメソッドを実装する場合に注意すべきことは、RunNotifierに適切なイベントを通知することです。IDEやJenkinsなどではRunNotifierオブジェクトから伝搬されるリスナーがテストの実行状態を監視しています。したがって、イベントの通知が不完全であると、テスト結果が期待通りに表示されません

今回、runメソッドでは複数のテストケースを実行します。このため、はじめにテストスイートのDescriptionオブジェクトを指定してfireTestStartedメソッドを呼び出します。その後は、各テスト毎にfireTestStartedメソッドを実行します。テストが終わったならばfireTestFinishedメソッドを実行し、すべてのテストが実行したならばテストスイートのDescriptionオブジェクトを指定してfireTestFinishedメソッドを実行します。なお、テストが失敗した場合はAssertionErrorがthrowされるので、catchしてfireTestFailureメソッドを実行します。

    @Override
    public void run(RunNotifier notifier) {
        Description desc = getDescription();
        notifier.fireTestStarted(desc);
        invokeTest(notifier, "Test-1");
        invokeTest(notifier, "Test-2");
        invokeTest(notifier, "Test-3");
        notifier.fireTestFinished(desc);
    }
    
    private void invokeTest(RunNotifier notifier, String testCase) {
        Description desc = getDescription(testCase);
        notifier.fireTestStarted(desc);
        try {
            // TODO 実際にテストする
            System.out.println("Execute: " + desc);
        } catch (AssertionError e) {
            notifier.fireTestFailure(new Failure(desc, e));
        } finally {
            notifier.fireTestFinished(desc);
        }
    }

最後にテストケースをコンストラクタで生成するようにリファクタリングすると、次のようになります。

public class DynamicTestsRunner extends Runner {

    List<String> testCases = new LinkedList<>();

    public DynamicTestsRunner(Class<?> testClass) {
        for (int i = 0; i < 20; i++) {
            testCases.add("Test-" + i);
        }
    }
    
    @Override
    public Description getDescription() {
        Description desc = Description.createSuiteDescription("DynamicTests");
        for (String testCase : testCases) {
            desc.addChild(getDescription(testCase));
        }
        return desc;
    }
    
    @Override
    public void run(RunNotifier notifier) {
        Description desc = getDescription();
        notifier.fireTestStarted(desc);
        for (String testCase : testCases) {
            invokeTest(notifier, testCase);
        }
        notifier.fireTestFinished(desc);
    }
    
    private Description getDescription(String testName) {
        return Description.createTestDescription("DynamicTests", testName);
    }

    private void invokeTest(RunNotifier notifier, String testCase) {
        Description desc = getDescription(testCase);
        notifier.fireTestStarted(desc);
        try {
            if (testCase.equals("Test-4")) fail("sorry failed.");
            System.out.println("Execute: " + desc);
        } catch (AssertionError e) {
            notifier.fireTestFailure(new Failure(desc, e));
        } finally {
            notifier.fireTestFinished(desc);
        }
    }
}

これで、テストケースを動的に生成し実行するテストランナーが完成しました。失敗も確認出来るようにしています。

テストの実行

テストの実行を行うにはダミーとなるテストクラスを用意し、RunWithアノテーションでテストランナーを指定すればと、IDEなどのツールで簡単に実行できます。右クリックして「Run with JUnit Test」です。

@RunWith(DynamicTestsRunner.class)
public class DynamicTests {
}

DynamicTestsRunnerはテストメソッドからテストケースを実行するテストランナーではないため、テストメソッドは必要ありません。

Eclipseで実行すると次のように表示されます。

スクリーンショット_2013_10_23_0_14

後は、全ての情報を取り込んで、テストケースを動的に作れば、総当たりのテストがJUnitで実行出来ます。実行はJenkinsのジョブとして登録しておけば、ボスも安心して眠れることでしょう。そして、アラートメールで起きる事にならないことを祈ります。

なお、今回行ったテストはセキュリティのテストです。実際のテストコードでは、HttpClientなどを利用して本番システムにHTTPアクセスし、「参照できない情報にアクセスできないこと」を順次チェックするような実装となっています。その目的はセキュリティの検証であり、対象はAWS上の完全なシステムです。

このように、JUnitはユニットテストに最適なテスティングフレームワークですが、ユニットテスト以外のテストにも利用することができます。JUnitは便利なツールですね!

というわけで、今回はRunnerクラスを使い動的にテストケースを作成する方法を紹介しました。JUnit実践入門では扱いきれなかった応用的な話題はこれからも紹介していく予定です。

  • disqus_pny7rbBBnt

    こんにちは。小島と申します。

    先日JUnit4のソースコードを読んだお陰で、こちらの記事が大変良くわかり面白かったです。
    ありがとうございます。

    私がJUnit4のソースコードを読んでいたのは、まさにカスタムランナーの実装方法について調べる為でした。
    JUnitCoreクラスからBlockJUnit4ClassRunnerのrunChildのオーバーライドに至るまでしか読んでいなかったですた、その中でmethodBlockの要素コメントにこんな記述がありました。

    「カスタムランナーを作るにはBlockJUnitClassRunnerのサブクラスでmethodBlockをオーバーライドするか、個別にmethodInvokerやwithBefores等のstatementをオーバーライドして」

    このコメントから、私はカスタムランナーを実装するときは、BlockJUnit4ClassRunnerを継承してmethodBlockメソッドやsub-statementをオーバーライドすればいいのかなと思っていました。

    しかし、この記事(Runnerを継承しているカスタムランナーの作り方)を読んでカスタムランナーの実装方法はランナーにどのような目的を持たせるかによって違うのか―と気づいたのですが、渡辺さんのご意見をお伺いできますでしょうか?

    ふと疑問に思いましてコメントさせて頂きました。
    お忙しいところ恐れ入りますが、よろしければご意見をお聞かせください。