jcunit's blog

JCUnitの開発日誌(ログ)です。"その時点での"JCUnit作者の理解や考え、開発状況を日本語で書きます。

(第三回)JCUnitで2次方程式を解くプログラムをテストする。

はじめに

本稿執筆中に、JCUnit 0.6.0にバグがあることとユーザビリティを向上する必要があることがわかり、0.6.2をリリースした。 本稿を試すにあたっては以下のようにpom.xmlを更新して欲しい。

    <dependencies>
        <dependency>
            <groupId>com.github.dakusui</groupId>
            <artifactId>jcunit</artifactId>
            <version>[0.6.2,)</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.hamcrest</groupId>
            <artifactId>hamcrest-library</artifactId>
            <version>1.3</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

hamcrestライブラリへの依存は今回のJCUnitへの変更とは関係ないのだが、本稿の記述範囲で使用するためにここに含めた。

お手数をおかけするがよろしくお願い申し上げたい。 また、今回からここに例題のソースコードを格納することにした。 本稿の記事を読む折には、git clone https://github.com/jcunit-team/jcunit-examples.gitとして欲しい。 もしなにかあればコメント欄などでご連絡頂きたい。

(第三回)JCUnitで2次方程式を解くプログラムをテストする。

第一回で多くのテストが失敗していたが、その結果を分析して二次方程式ソルバーの仕様書を更新した。(「出力される解」については言い回しを変えた)

QuadraticEquationクラス仕様 version 2

  • 入力:三つの整数a,b,c

    • 制約1: aが0の場合、例外が送出される。(問題4:a==0)
    • 制約2: a,b,cのいずれかの絶対値が100より大きい場合、例外が創出される。(問題3:巨大な係数)
    • 制約3: a,b,cb * b - 4 * c * a >= 0を満たさない場合、例外が送出される。(問題1:虚数)
  • 出力:二次方程式a x^2 + b x + c = 0を満たす実数x1x2

    • 出力される解x1またはx2を上述の2次方程式に代入した時、その誤差は0.01を越えない。(問題2:丸め誤差)

これをJCUnitを用いたテストとして実現してみよう。 なお、今回ステップ・バイ・ステップで作るJCUnitのテストは以下の場所に完成版がおいてある。

このコードの解説記事としてお読みいただくと時間の節約になるかもしれない。

因子と水準を設計・実装する。

a,b,cの三つはいずれも整数で境界値は100-100である。 これら一個一個を因子(Factor)とし、それらについてテストに用いるべき水準を列挙するとこんなふうになろうか。

水準 種別
1 正常
0 異常(a),正常(b,c)
-1 正常
100 境界
101 異常
-100 境界
-101 異常
Integer.MAX_VALUE 異常
Integer.MIN_VALUE 異常

ソフトウェアテストHAYST法入門」3などで"FL表"と呼ばれている表にあたる。 ここでは各水準は100やInteger.MIN_VALUEといった具体的な値だが、実務の大規模で複雑なテストにおいては、各水準は抽象的に定義しなけらばならない場合が多い。

また、a,b,cはそれぞれ別の因子なので、二次方程式ソルバーのような簡単なソフトウェアをテストするならともかく、普通は別の表にまとめたほうがよかろうと思う。もしも、テスト設計書をドキュメントとして作るならば、だが。

水準0についてだが、a == 0の場合は異常値(制約1)だが、b, cについてはこの制約は関係ないので正常値である。

それはともかく、これをJCUnitの記法で書くならこうなる。

  @FactorField(intLevels = {1, 0, -1, 100, 101, -100, -101, Integer.MAX_VALUE, Integer.MIN_VALUE})
  public int   a;
  @FactorField(intLevels = {1, 0, -1, 100, 101, -100, -101, Integer.MAX_VALUE, Integer.MIN_VALUE})
  public int   b;
  @FactorField(intLevels = {1, 0, -1, 100, 101, -100, -101, Integer.MAX_VALUE, Integer.MIN_VALUE})
  public int   c;

非常に単純で上で作った表をintLevels = {...}の中に写しとっているだけだ。

制約を設計・実装する。

まず、制約1を実装する。

制約1: aが0の場合、例外が送出される。(問題4:a==0)

ここで使うアノテーション@Conditionである。 因子aにはその水準が0の場合は異常であるという制約がある。 これをJavaメソッドとして実現すると以下のようになる。

  @Condition
  public boolean aIsNonZero() {
    return this.a != 0;
  }

戻り値はその制約に従っている(違反していない)場合にtrue, 違反している場合にfalseを返すことになっている。注意して欲しい。

なお制約を実装するメソッドは、booleanを返す引数なしのものである必要がある。 これに反するとJCUnitは実行時にエラーを報告する。

制約2の場合、このようになる。

制約2: a,b,cのいずれかの絶対値が100より大きい場合、例外が創出される。(問題3:巨大な係数)

Javaで書き下すと以下のようになる。これも非常に直接的だ。

  @Condition
  public boolean coefficientsAreValid() {
    return
        -100 <= a && a <= 100 &&
            -100 <= b && b <= 100 &&
            -100 <= c && c <= 100;
  }

そして最後の制約、判別式の判定だ。

制約3: a,b,cb * b - 4 * c * a >= 0を満たさない場合、例外が送出される。(問題1:虚数)

Javaで書くとこう。

  @Condition
  public boolean discriminantIsNonNegative() {
    int a = this.a;
    int b = this.b;
    int c = this.c;
    return b * b - 4 * c * a >= 0;
  }

これも単純だ。

判定基準の実装

正常の場合と異常の場合、あるいは特定の制約が満たされなかった場合等でテスト対象に対して期待するべき動作は異なる。 無論if文で、現在どの条件でテストを実施しているかを書いてみてもいいが、それでは担当者によるむらも生じるし、その判定文自体のバグも心配になる。

JCUnitでは、上で定義にした制約を参照してどのメソッドを用いてテスト対象の動作を検証するべきかを定義できる。 ここでは正常系の場合と、上で定義した各制約のうち制約1の場合だけを説明する。

まず正常系の場合だが、

出力される解x1またはx2を上述の2次方程式に代入した時、その絶対値は0.01未満になる。(問題2:丸め誤差)

ということになる。 これをJCUnitで表現すると以下のようになる。

  @Test
  @When({ "*" })
  public void solveEquation$thenSolved() {
    QuadraticEquation.Solutions s = new QuadraticEquation(a, b,
        c).solve();
    assertThat(
        String.format("(a,b,c)=(%d,%d,%d)", a, b, c),
        Math.abs(a * s.x1 * s.x1 + b * s.x1 + c),
        closeTo(0, 0.01)
    );
    assertThat(
        String.format("(a,b,c)=(%d,%d,%d)", a, b, c),
        a * s.x2 * s.x2 + b * s.x2 + c,
        closeTo(0, 0.01)
    );
  }

@Whenに指定した({ "*" })は「@Conditionメソッドがすべて成立(trueを返した)時に実行する」という意味である。 したがって、このメソッドの意図するところは

(When)制約をすべて満たした場合、 方程式を解く、 (Then)すると確かに解ける。

ということになる。

次に異常系だが、

  @Test(expected = IllegalArgumentException.class)
  @When({ "!aIsNonZero" })
  public void solveEquation1$thenThrowIllegalArgumentException() {
    new QuadraticEquation(
        a,
        b,
        c).solve();
  }

この部分はaIsNonZeroメソッドfalseになったとき、という意味だ。 @When({ "!aIsNonZero" })

そして、JUnitの機能で送出されるべき例外の型を確認している。

  @Test(expected = IllegalArgumentException.class)

このメソッド宣言部の意味はこうなる。

(When)aIsNonZero制約に違反した場合、 方程式を解く、 (Then)するとIllegalArgumentExceptionを投げる。

これも見たとおりの意味だ。

実行(その1)

さて、ここまで出来上がったテストを実行するとどうなるか?読者のIDEで実行してみて欲しい。 109件のテストを生成し、6件のみが成功する。

f:id:dakusui:20160314074912p:plain

109件にまで増えている理由は、各因子の水準が増えていることによる。実のところ上で「制約」を定義したものの「制約違反を避けるように組み合わせを選ぶ」処理がまだ作られていない。これは次回、解決方法とともに論じたいが「組み合わせテスト」の趣旨から外れるこまった問題だ。 失敗が増えている理由は、例外を送出するべきところ送出する処理がテスト対象にまだ実装されていないからだ。

生成されたテストスイートや実行結果を見てみると、いくつか改善したい点がある。

  1. テストケースが多すぎる
  2. 正常系のテストと異常系のテストが混ざっていてレビューしづらい
  3. 同時に複数の制約に違反するテストケースが多い。この種のテストケースは多くの場合あまり意味がない。3

これらについても次回はもう少し深く議論し、JCUnitでの解決方法を示したいと思う。

余談

今回の記事を書いてみて思ったのだが、@Whenアノテーション@Givenという名前の方がよかったかもしれない。 すると、

  @Test(expected = IllegalArgumentException.class)
  @Given({ "!aIsNonZero" })
  public void whenSolveEquation1$thenThrowIllegalArgumentException() {

と書け、これは

(Given)aIsNonZero制約に違反している時に (When)方程式を解く (Then)するとIllegalArgumentExceptionを投げる。

こういう意味になる。これはBDDっぽくってひょっとしてかっこいいのではなかろうか。 読者諸賢のご意見を賜りたいところである。

参考