jcunit's blog

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

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

はじめに

本ブログでご案内しているように、javacのバグによりJDK1.7.0_79以前でコンパイルエラーが生じることがわかった。 この問題を回避するために、jcunit 0.6.4をリリースしたので、pom.xmlを以下のように更新して欲しい。

    <dependency>
      <groupId>com.github.dakusui</groupId>
      <artifactId>jcunit</artifactId>
      <version>[0.6.4,)</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.12</version>
      <scope>test</scope>
    </dependency>

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

前回のエントリでは、FL表を作成し、それにもとづいてJCUnitにテストスイートを生成させたところ、109件ものテストができた。 これについてすでに以下のように述べた。

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

テストスイートを自動で生成し、かつ自動で実行するとしてもテストケースの数は大きな問題になる。 あまりに数が多すぎれば管理ができない。多くのテストが様々な理由で失敗している場合、失敗している理由を捕捉するのに手間がかかる。 また、適切にテストオラクルを作るのが難しい場合、ひとまず現在の挙動を記録しておき、プログラム修正後(リファクタリング等)の次回実行時に前回との差異を点検することでテストとする場合もあろう。 こうした時に、何千何万というテストケースがあったのでは容量を食い過ぎるという場合もある。 また、私としてはテスト対象の外部的な仕様に基づいてテストを設計・実装・実行するために、つまり結合テストシステムテストの段階で、JCUnitを使いたいのだがそうすると一件のテストの実行時間は短くても500msec、ながければ数分ということがあり得る。この場合、如何に自動で生成・実装・実行されるとしても無駄なテストをする余裕は無い。

そもそも、JCUnitは人間が与えたソフトウェアの仕様にそって、テストを生成・実行するものである。テストが意図通りに生成され、実行されているかは人間によるレビューが必要だろう。 何万件、何十万件もテストを実施してもそのテストが正しいかをレビュー・点検する方法を欠いているのでは、いささか中途半端というものではないか。

正常系と異常系のテストが入り組んでいるのも同じような理由で好ましくない。 設計者は正常系の挙動と異常系の挙動を区別してソフトウェアを設計するもので、これらのテストが交互に現れたのではレビューにならない。

「制約」と「条件」の区別

上述諸点を改善するためにどうする必要があるか?「制約」と「条件」を明確に区別してはどうか?というのがJCUnitのアイデアだ。 前回の記事で紹介した@Conditionは実を言えばもともと単にテストを実行する「条件」を記述するためのものだ。

例えばある「OS」という因子があるとして水準が「Windows」のときと「Linux」や「MaxOSX」の時では異なる事項を確認したい。そういう際に、

  @Test
  @When({ "isWindows" })
  public void openInternetExplorerAndGoToWikipedia$thenWelcomPageIsShown() {
      ...
  }

  @Test
  @When({ "isWindows","isLinux" })
  public void openFirefoxAndGoToWikipedia$thenWelcomPageIsShown() {
      ...
  }

  ...

このように使い分けるためのものだ。

「制約」も「条件」の一種と言えるが、こうした普通の条件とは重要な違いがある。まず、通常、アプリケーションやシステムというものは、異常(環境設定、ユーザ入力、内部状態等)を検出したら、その時点で処理を打ち切り、それ以降の処理(それ以外の制約検査も含め)は行わない。

  1. 一つ以上制約が破られたら、そのテストケースがカバーしようとしている値の組み合わせはそれ以降の正常な処理によって使われない可能性がある。(組み合わせ網羅率の低下)
  2. 一つのテストケースで複数の制約が同時に破られる(複数の異常が発生する)と、テスト対象システムは最初に検出した異常に反応して処理を打ち切る。したがってこれら複数の異常のうち、(たまたま)システムが検出した異常処理のみが行われる。
  3. 制約違反を検出して、異常を報告するのはそれ自体システムが備えるべき機能であって、正常機能とは独立して検査する必要がある。

組み合わせテストは、処理が最後まで正常に行われるべき場合に、システムが正しく動作するかを効率的に検証するためのテクニックである。 テスト対象システムの動作条件がWindows 7以降であるときに、以下のようなテストスイートが生成されたとしよう。

OS Browser Site's Language
Windows XP Safari English
Windows 7 Firefox Japanese
Linux Chromium English

対象システムは、稼働OSがWindows XPであることを検出した時点で、異常を報告して処理を終了する。しかしブラウザがSafariで言語がEnglishの組み合わせが別のテストケースによって網羅されている保証はどこにもない。むしろ、良い組み合わせテスト生成ツールほど、テストスイートを小さくまとめるために網羅しなくなる可能性が高いと言える。

制約違反を適切に報告できるかもシステムの機能としてテストする場合、それはまとめてではなく一個一個行う必要があることは上に述べたとおりである。10個独立な制約があったら、これらのすべてについて、1個、そして1個だけに違反するようなテストケースをそれぞれ作成する必要があることになる。

経験を積んだソフトウェアエンジニアであれば、このあたりは本能的に知っている。例えば「処理対象にして良い文字が入力できるか」をテストする場合には、テストデータに「際どいけど正常な文字」を全部いっぺんに使うように試みる。

    AZaz09ー。、.,能あをんアヲン?

こうすることによって、「処理できるべき文字がすべて処理できるか」については一度にテストが行える。 しかし、「処理を中断するべき文字がすべて拒否されるか」については一個一個テストを行う必要がある。例えば、\が拒否されるかと、"が拒否されるかは別のことなので、両方を一つのテストデータに含めてしまうとどちらによってシステムが処理を拒否したのかがわからなくなってしまう。拒否された場合のメッセージが適正かなどはそれでも検査できるにしても、不当な文字を適正に拒絶できているかは別のことである。

JCUnitでの解決方法

JCUnitでは以下のようにしてこうした問題に対処することになる。 まず、前回のコード例は以下の場所にある。

github.com

@RunWith(JCUnit.class)
public class QuadraticEquationTest {
  @FactorField(intLevels = {1, 0, -1, 100, 101, -100, -101, Integer.MAX_VALUE, Integer.MIN_VALUE})
  ...

  /**
   * 制約1: ```a```が0の場合、例外が送出される。(**問題4:a==0**)
   */
  @Condition
  public boolean aIsNonZero() {
    return this.a != 0;
  }
  ...

行うべき変更は

  1. 「制約」を前節で述べた方針にそって特別扱いするようJCUnitに指示する。
  2. @Conditionで定義していた条件が単なる条件ではなく、特別な取り扱いであることを明示する。

このニつである。 以下が変更後のファイルである。

github.com

@RunWith(JCUnit.class)
@GenerateCoveringArrayWith(checker = @Checker(value = SmartConstraintChecker.class)) // <-- 追加:1. のための変更
public class QuadraticEquationTest {
  @FactorField(intLevels = {1, 0, -1, 100, 101, -100, -101, Integer.MAX_VALUE, Integer.MIN_VALUE})
  public int   a;
  ...


  /**
   * 制約1: ```a```が0の場合、例外が送出される。(**問題4:a==0**)
   */
  @Condition(constraint = true) // <-- 修正:2. のための変更
  public boolean aIsNonZero() {
    return this.a != 0;
  }    ...

これを実行してみよう。

f:id:dakusui:20160410171812p:plain

お気づきのように、今回は34件のテストしか生成されていない。 また、前半(19まで)はすべて成功しているが、後半(20以降)はすべて失敗している。

サイズの減少はa,b,cのそれぞれに制約を設定したため、事実上水準の数が減ったためである。 しかし、複数の因子に関係する制約を加えると、その制約を回避しながら可能な組み合わせをすべて生成するようJCUnitは試みる。これが生じるとテストスイートの大きさはむしろ増すことになる。 今回、二次方程式の判別式に関する制約を(3つの因子すべてに関連する)加えている。これはテストケースの数を増す方向に作用する。が、それ以上に、100よりも大きい水準や-100よりも小さい水準が正常処理に関するテストから取り除いた効果の方が大きかったため全体としてテストケースの数が減ったと見受けられる。

前半と後半で成功・失敗がはっきり別れたのは、上述のコード変更でJCUnitが以下の手順でテストスイートを生成するようになったからである。

  1. 正常テスト(与えられた制約に一つも違反しない)の生成を行う
  2. 異常テスト(与えられた制約の一個だけに違反しているテスト)を生成する。
  3. 水準網羅テスト(正常テストのうち一件を取り出し、1., 2.,でまだ網羅されていない因子の水準を一つずつ試す)を生成する。

今回の実行結果では、20件が成功しているが第三回の例では6件のみ成功していた。正常系についてテストが不足していた可能性がある。

余談

この部分をデフォルトにしてしまえばよいような気がしてきた。

@GenerateCoveringArrayWith(checker = @Checker(value = SmartConstraintChecker.class)) 

読者のご意見をこう次第である。