(第一回)JCUnitで2次方程式を解くプログラムをテストする。
今回からしばらく、JCUnitの使い方を解説するために簡単なプログラムをテストしてみたいと思う。
例題としてQuadraticEquationSolver
(2次方程式ソルバー)を取り上げる。a
,b
,c
の三つの入力をとり、これらを係数とする2次方程式を解の公式を使って解く他愛もないプログラムだ。
準備
0.6.0リリース。 - jcunit's blog を参考に、JCUnitをDependencyに持つMavenプロジェクトを用意して欲しい。
SUT(テスト対象ソフトウェアについて)
Javaで2次方程式を解くプログラムを作るとしたらどんなものだろうか。大体、こんな感じになるのではないか。
public class QuadraticEquationSolver { public static class Solutions { public final double x1; public final double x2; public Solutions(double x1, double x2) { this.x1 = x1; this.x2 = x2; } } private final int a; private final int b; private final int c; public QuadraticEquationSolver(int a, int b, int c) { this.a = a; this.b = b; this.c = c; } public Solutions solve() { double a = this.a; double b = this.b; double c = this.c; return new Solutions( (-b + Math.sqrt(b * b - 4 * c * a)) / (2 * a), (-b - Math.sqrt(b * b - 4 * c * a)) / (2 * a) ); } }
2次方程式を解の公式を使って解いているだけの単純なプログラムだ。
だが、このプログラムをこのまま使おうとすると、色々と問題に気づくはずだ。
a
、b
、c
の値によっては解が虚数になったり、そもそも2次方程式にならなかったりする。
たったこれだけのプログラムを作るだけでも、プログラマは多くの明示的なあるいは暗黙の仮定を置いている。
こういう場合、ソフトウェアエンジニアはどう振る舞うべきか?こうした数々の仮定の明確化は能力の見せ所であろう。
Solution
クラスを拡張し、複素数を取り扱えるようにするか?a == 0
の場合には一次方程式の解をもとめるか?あるいは例外を送出するか?この例では係数であるa
、b
、c
がint
だが、そもそもこれらは整数で良いのか?等々。
このプログラムがこうした入力に対して示すことであろう「不自然な挙動」はバグと言えるか?バグが定義された仕様と実際の挙動の乖離であるとするならば、仕様がなにも定義されていない以上、どんなへんてこな挙動であろうと「設計通り」と言い張ってみてもよいのだが、あまり生産的な態度でもなかろうし、何より見苦しいのでやめて欲しい。
最初のテストプログラム
が、仕様がないならないなりにでもなにかしらテストっぽいことをしてみないと、仕事は進まない。言語化されていない暗黙の仮定を白日の下に晒す。それは思うにテストの重要な価値ではなかろうか。 JCUnitでこのプログラムをテストするなら、おおよそこういう風になる。
@RunWith(JCUnit.class) public class QuadraticEquationSolverTest1 { @FactorField public int a; @FactorField public int b; @FactorField public int c; @Test public void solveEquation() { QuadraticEquationSolver.Solutions s = new QuadraticEquationSolver(a, b, c).solve(); assertThat(a * s.x1 * s.x1 + b * s.x1 + c, is(0.0)); assertThat(a * s.x2 * s.x2 + b * s.x2 + c, is(0.0)); } }
JCUnitは@FactorField
を付したpublic
メンバa
, b
, c
に様々な値を自動的に代入してからsolveEquation
テストメソッドを実行する。
assertThat
とかis
(CoreMatchers
クラスのメソッドがstatic
インポートされている)とかは、Assert.assertEquals
とおよそ同じ意味だ。
solveEquation
メソッドは、QuadraticEquationSolver#solve
メソッドが出力した値を実際に二次方程式に代入して計算し、その結果が0になるかを確かめる。
JCUnitがデフォルトでa
、b
、c
に代入するのは以下の7つの値のうちの一つだ。
{ 1, 0, -1, 100, -100, Integer.MAX_VALUE, Integer.MIN_VALUE }
これらのすべての値の可能なすべての組み合わせとなると7 * 7 * 7 = 343となる。
JCUnitのデフォルトの挙動では任意の2つの係数(因子)の組み合わせ(aとb、bとc、cとa)に関して可能な値(水準)の組み合わせを網羅するテストスイートを生成する。
つまり{a=100,b=-100}
、{b=-100,c=1}
、{c=1,a=100}
のそれぞれはJCUnitが生成するテストスイートによって必ず網羅される。
しかし、{a=100,b=-100,c=1}
が同時に網羅されるかどうかについては保証しない。
このような網羅性を担保するテストスイートは無数にある。がJCUnitが生成するものは53件のテストケースからなるものだ。7水準の因子が2つあれば、7*7で49通りは必要になる。しかしこのテストは3因子使われているにも拘らず、わずかな増加しか見られない。 ちなみに4因子 * 7水準にしてみると60通り、20因子 * 7水準で125通りになる。この辺になるともはや単純に水準を足し合わせていくやり方(7水準 * 20因子 - 19件=121件)と大差がない。
2水準 * 100因子だとJCUnitは18通りのテストケースしか作らない。それでいて上述した特性を満たすテストスイートになるわけだから、興味深いことではなかろうか。(ただし、JCUnitが生成するテストスイートが一番小さいわけではない。PICTなどは同じ性質を満たしながらもっと小さいテストスイートを生成する)
もう一点、JCUnitの特性で注意を促したいことがある。 それは、同じ因子と同じ水準を使った場合、常に同じテストスイートを生成することだ。
テスト対象製品の挙動自体が不安定で同じ条件のテストが成功したり失敗したりするのはある意味やむを得ない。
例えば、内部的にHashSet
を使えば、その要素の列挙において順番は不定になる。
このような場合には「このバグはこのテストケース107番をおよそ10回に1回失敗させる。修正を行い1,000回同じ条件のテストを行ったところ1度も再現しなくなった」という決着のさせ方をすることになる。
だとしたら「テストケース107番は常に{a=100,b=-1,c=-100}
である」のように言えないと非常に不便だ。
テスト対象製品自体の出力が、なにかしらの理由で固定的に予測できない場合はあり得る。例えば我々がHashSet
クラスを自分自身でテストするときなどがそうだ。
だが、テスティングフレームワーク側の事情でテストスイートが不定になるのは避けるべきだと、私は考えるのだ。
さて、では先程のテストクラスを実行するとどうなるか?
53件のテスケースが生成され、39件も失敗するのだ。 これもなかなか興味深いことではなかろうか。
次回以降、ステップ・バイ・ステップでJCUnitの使用方法を解説していきたい。その過程でテスト対象であるQuadraticEquationSolver
のデバッグ、仕様の精緻化を進めていくことになる。
今回の教材はこちら。
0.6.0リリース。
長い道のりであったが、0.6.0をリリースした。 Maven coordinateは以下のとおり。
<dependency> <groupId>com.github.dakusui</groupId> <artifactId>jcunit</artifactId> <version>[0.6.0,)</version> <scope>test</scope> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.12</version> <scope>test</scope> </dependency>
古いJUnitを使っていると実行時にエラーが出るようなので、JUnitについての依存もバージョンを指定するほうがよいようだ。 今回の新機能は以下の通り。
- レポート機能
- テストスイート再利用機能 - 以前からあったのはテストスイートの再利用というよりは、SUTの出力に対するCapture/replay機能。作者本人が混同してしまっていた。混乱を招いてしまった方には申し訳ありません。
- ネガティブテストの自動生成機能
自分で言うのもなんだが、かなり「使える」水準になってきたと思う。 特にネガティブテストの自動生成は便利だ。
ということで、以前見つけた正規表現逆生成ライブラリxeger
のテストを作ってみた。こちら
@RunWith(JCUnit.class) public class XegerTest { @FactorField(stringLevels = { ".", "A", "[A]", "[AB]", "[A-C]" }) public String characterClass1; @FactorField(stringLevels = { "?", "*", "+", "{1,2}", "{1,3}", "{2,3}" }) public String quantifier1; @FactorField(stringLevels = { "a", "[a]", "[ab]", "[a-c]" }) public String characterClass2; @FactorField(stringLevels = { "?", "*", "+", "{1,2}", "{1,3}", "{2,3}" }) public String quantifier2; /** * 0 ... characterClass1 * 1 ... quantifier1 * 2 ... characterClass2 * 3 ... quantifier2 */ @FactorField(stringLevels = { "{0}{1}{2}{3}", " ({0}{1}{2}){3}", " ({0}{1}|{2}){3}", " (({0}|{2}){1}){3}" }) public String structure; @Test public void shouldGenerateTextsThatMatchOriginalRegex() { String regex = this.composeRegex(); Xeger generator = new Xeger(regex, new Random(1)); for (int i = 0; i < 100; i++) { String text = generator.generate(); assertTrue( String.format("Generated text '%s' didn't match regex '%s", text, regex), Pattern.compile( regex ).matcher(text).matches()); } } @Test public void shouldBeRepeatable() throws InterruptedException { int tries = 100; int seed = 1; String regex = this.composeRegex(); List<String> first = new ArrayList<String>(tries); { Xeger generator1 = new Xeger(regex, new Random(seed)); for (int i = 0; i < tries; i++) { first.add(generator1.generate()); } } TimeUnit.MILLISECONDS.sleep(1); List<String> second = new ArrayList<String>(tries); { Xeger generator2 = new Xeger(regex, new Random(seed)); for (int i = 0; i < tries; i++) { second.add(generator2.generate()); } } assertTrue( String.format("Xeger didn't return the same result in spite that the same pattern and seed were given:(pattern=%s, seed=%s)", regex, seed), first.equals(second)); } String composeRegex() { return MessageFormat.format( this.structure, this.characterClass1, this.quantifier1, this.characterClass2, this.quantifier2 ); } }
こんな風にテストを作るといい感じに2つほどバグが見つかる。
また、今回からドキュメントはWikiにまとめることにした。
機能追加、改善の要望がもしあれば、
Issues · dakusui/jcunit · GitHub
またはこのブログのコメントにでも寄せてほしい。
0.6.0-alpha
JCUnit 0.6.0 alphaをリリースしてみた。 今回の主要な変更は以下の通り。
- カバレッジレポーティング機能
- FSM(有限状態機械)の入力履歴検査機能 (InteractionHistory)
- リファクタリング
- 各種プラグインの構造改善
- リクエストのあった生成されたテストスイートの再利用機能
MavenのCoordinateは以下の通り。
<dependency> <groupId>com.github.dakusui</groupId> <artifactId>jcunit</artifactId> <version>0.6.0-alpha</version> </dependency>
今回から、ドキュメントはgithub wikiで管理・公開することにした。
Home · dakusui/jcunit Wiki · GitHub
まだリンク切れがあるが、正式公開までに直したい。
Theoriesについて
Theoriesランナーについて先日、私自身は使っていないことを書いたのだが、自分で作ってみると興味が出てくるものだ。 Haskellでいうquickcheckが念頭にあると、なんでこういう仕様でこういうものを作ろうとしたのかが分かる。ようだ。
quickcheckというのは、SUTがある特性(property)を満たしていることを総当りでテストしてくれる機能だ。というかそのようだ。
が、Theoriesと同じように組み合わせ爆発の問題は逃れられない。
なんにしてもJCUnitとはだいぶ思想というか方向性が違う。
テスト自動生成ツールを作ろうとすると、次の問題に行き当たる。
- なにを元にテストを作る?(どこから仕様を得る?)
- 組み合わせ爆発にどう対処する?
- テストオラクルはどうする?
FSM/JCUnitは、 1に対してユーザに入力値の組を定義させる、あるいはFSMをJavaのオブジェクトとして定義させることで、2については組み合わせテスト(Pairwise/n-wise)のテクニックを使うことで、3に対してはユーザが不変条件をJavaプログラムとして表現したり、JCUnitがFSMの定義から導出される挙動と照合することで対処している。 また、JCUnit(というか各種の組み合わせテストアルゴリズム)が生成するテストスイートは多くの場合、非常に小さいので(2値のパラメタが100個ある場合、18テストケースが出力される)仕様の定式化が困難な場合、人間がレビューした実行結果を期待値として使用すると言った使い方も可能だろう。
一方Theories的なアプローチだと、1.についてはユーザが定義したパラメタの組に対して総当りであり得べき組み合わせをすべて実施する。したがって2.についてはそういうものだと素朴に受け入れる。3.についてはユーザがプログラムとして定義した不変条件・SUTの特性そのものがテストオラクルと言える。
「素朴に受け入れる」と言ってもquickcheckはもう少し洗練された方法も持っている。ひとつは乱数に基づいてテストを選択すること、もうひとつはshrinkと呼ばれる機構だ。
どうも日本語、英語ともにわかりやすい説明が見つらない。困ったものだ。
Mapの変更
コードのある場所でTreeMap
をLinkedHashMap
に変えてみた。
すると、
benchmark1_3$4 :(testcases, remainders, time(sec))=( 9, 0, 0.0) benchmark2_3$13 :(testcases, remainders, time(sec))=( 24, 0, 0.03) benchmark3_4$15_3$17_2$20 :(testcases, remainders, time(sec))=( 39, 0, 3.053) benchmark4_4$1_3$30_2$35 :(testcases, remainders, time(sec))=( 30, 0, 6.654) benchmark5_2$100 :(testcases, remainders, time(sec))=( 18, 0, 17.904) benchmark6_10$20 :(testcases, remainders, time(sec))=( 244, 0, 2.58)
以前、行った比較は以下で
横に並べてみるとこうなる
# | Task | TreeMap | LinkedHashMap |
---|---|---|---|
1 | 3(4) | 9 | 9 |
2 | 3(13) | 25 | 24 |
3 | 4(15)+3(17)+2(20) | 41 | 39 |
4 | 4(1)+3(30)+2(35) | 31 | 30 |
5 | 2(100) | 18 | 18 |
6 | 10(20) | 273 | 244 |
わずかながらテストスイートのサイズが小さくなっている。 原因はいろいろ考えられるが、興味深いことだ。
問題がないことを確信できたら、LinkedHashMapに変更することにしよう。
Builderのフィールドは全部publicでいいのでは
Builderがクラス化された生成子なら、それは本来メソッドの中で使い捨てにされるものだろう。 ならば、フィールドをprivateにする必要はあるのだろうか?
メソッド間、クラス間で本当に受け渡ししないならばprivateでもpublicでもいいのだが、受け渡しをするとしたらどういう時だろう?
それはあるオブジェクト構築の責任をメソッド間やクラス間で分担する場合だ。 メソッドAとメソッドBの間で構築の責任を分担しているのなら、メソッドAがBuilderに行った結果がメソッドBに影響を及ぼすことはあってもいいのではないか。 publicにすることのデメリットは、Builderはその性質上フィールドにfinal修飾子を与えられないことだ。だからメソッドBの中でBuilderの内部状態が壊されるかもしれない。
しかし壊されて困るようなフィールドならBuilderの生成子でfinalなフィールドに代入しておくべきだろう。
この記事に目を止めてくれた人のコメントをお待ちしたい。