(第一回)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
のデバッグ、仕様の精緻化を進めていくことになる。
今回の教材はこちら。
(第ニ回)JCUnitで2次方程式を解くプログラムをテストする。
さて、39件も失敗した前回のテストだが、どのテストがどのように失敗しているかつぶさに見ていこう。 失敗するテストは以下の通り。
{ 1, 3, 4, 6, 7, 8, 9, 10,11,12,13,14,15,17,18,19, 24,25,26,27,28, 30,31,32,33,34,35,36,37,38, 40,41,42,43,45,46,48,49,51}
これらが成功していたり、これら以外のものが失敗していたらJCUnitにバグがあることになる。ぜひ知らせて欲しい。
分析
前提として今回のテストで用いたテストメソッドはこうなっている。
@Test public void solveEquation() { QuadraticEquation.Solutions s = new QuadraticEquation(a, b, c).solve(); double v1 = a * s.x1 * s.x1 + b * s.x1 + c, v2 = a * s.x2 * s.x2 + b * s.x2 + c; assertThat(String.format("%d*x1^2+%d*x1+%d=%f {x1=%f}", a, b, c, v1, s.x1), v1, is(0.0)); assertThat(String.format("%d*x2^2+%d*x2+%d=%f {x2=%f}", a, b, c, v2, s.x2), v2, is(0.0)); }
まずテストケース1
java.lang.AssertionError: 1*x1^2+0*x1+100=NaN {x1=NaN} Expected: is <0.0> but: was <NaN>
NaN
は浮動小数で表すことができない数に対して用いる記号であり、この場合は根を方程式で求めようとすると、虚数になってしまう=実根がないのが原因だ。(問題1:虚数解)
テストケース4も同様。
次にテストケース3を見てみる。
java.lang.AssertionError: 1*x1^2+100*x1+-100=0.000000 {x1=0.990195} Expected: is <0.0> but: was <1.4210854715202004E-14>
これは、テストとしては根を代入して式を計算すると0になるのを期待しているのだが、実際には1.42 * 10^-14という幾らかの大きさを持つ値になってしまっていることが原因である。係数が100や−100のように比較的大きいと、実数計算を行った際の誤差も大きくなる。結果として式の計算結果が0にならなくなってしまっている。(問題2:丸め誤差)
テストケース6も似ている。
java.lang.AssertionError: 1*x1^2+-2147483648*x1+1=1.000000 {x1=2147483648.000000} Expected: is <0.0> but: was <1.0>
しかし、b
がInteger.MIN_VALUE
という大きな絶対値の数であるため、誤差も1.0
という大きな値になっている。(問題3:巨大な係数)
次にテストケース9。これもNaN
だ。
java.lang.AssertionError: 0*x1^2+-1*x1+-100=NaN {x1=Infinity} Expected: is <0.0> but: was <NaN>
しかし、問題1:虚数解とは少し事情が違う。a
が0であるので、解の公式の分母が0になっている。浮動小数点数の演算では分母が0の計算を行った場合にもNaN
が生じるのだ。(問題4:a==0)
「バグ」と「仕様」、「ドキュメント」について
上で見てきたいくつかの問題の修正に先立って「バグ」とは何か、「仕様」や「ドキュメント」とはなにかについて少しだけ論じておきたい。
ソフトウェアのバグには2種類あって2種類しか無い。コードが間違っている。つまり通常我々が呼ぶところの「バグ」(単に「バグ」と呼ぶと紛らわしいので今回のケーススタディのなかでは「製品バグ」と呼ぶことにする)。もうひとつはコードがよって立つべき仕様自体が間違っている「仕様バグ」だ。それゆえ仕様やドキュメントがない状況ならば、どんな動きをしようと「仕様です」と言い張ることが一応は可能だ。だが、どこにも書かれていないとしても実際にはプロダクト設計者の意図、関係者に対する約束や信義、暗黙の前提、業界の慣行や平均的技術水準、社会常識、技術者としての矜持等々からあるソフトウェアのある挙動がバグかどうか判断される。言うなれば「こころの中に仕様がある」という場合もある。程度にもよるがそれで良い場合も多々あろう。
さて、仕様のとおりに作られたプログラムがあり、仕様自体に瑕疵が判明した場合、仕様もプログラムも修正することになる。 しかしこれも「製品バグ」と「仕様バグ」の両方が同時に発生したと考えればよい。 所謂「仕様変更」を仕様バグと考えるかどうか。SIer的な伝統では仕様変更はバグではない、区別する必要があるということになる。お金をもらえるかもらえないかに係るから。 しかし、自分たちで作ったソフトウェアを自分たちで運用する世界ではその区別の重要性は低い。 仕様変更と仕様バグの境界はSIer的マネジメントにおいて必要に応じて決めればいいのであって、ひとまず本稿では興味の対象外である。
ところで「仕様バグ」だけが発生することがあるのか? ある。 実装者が仕様の瑕疵に気づき、より適切な実装を行ったものの、仕様書の更新が行われなかった場合や、仕様のある部分を見ずに実装し、仕様と異なっていたが結果的にその実装のほうがより適切と判明した場合、特殊な条件下で一見奇妙な動作に見えるが精密に検討してみると正しいと言わざるを得ない場合、などがこれにあたる。 この場合にはリリースノートの更新や、仕様書の修正が行われることになる。心の中にしか仕様書が無いなら心の中の仕様書を更新することになる。
実際にどのような粒度のドキュメントがどのような抽象度、どのような詳細さで書かれるべきかはこのエントリの範囲では立ち入らない。どういった粒度や抽象度の仕様が適切かはプロダクト、プロジェクト、企業文化、重要なステークホルダー、担当者の好みなど様々な要因で変わる。ある製品の仕様書は通常、その製品が何をでき何ができないかを明らかにする。「あるべき挙動」がチームや製品の状況などに照らして明々白々なときにはドキュメントの量は減ることであろう。
今回の一連のエントリの中で私が「ドキュメント」や「仕様」というとき、それは単に「ソフトウェア製品の言語化されたあるべき動作」という意味である。ものものしく定式化された体系的書類のことだけではない。
問題の整理
では問題を改めて整理してみよう。 「分析」の節で上がった問題は以下の4つ。
これらが昔風にいうならバグ表の項目、今どきならJIRAのチケットに対応する。
これらをどういう形で決着させるか。実に色々な形があり得る。なぜならばここまでのところ、我々のQuadraticEquation
クラスの仕様は以下のものでしかないので様々な側面を明確にする余地があるし、その仕様にしたって正当な理由があれば適切な手順を踏んで仕様を変えることもありえるのがソフトウェア開発というものだ。
QuadraticEquationクラス仕様 version 1
入力:三つの整数
a
,b
,c
出力:二次方程式、
a x^2 + b x + c = 0
を満たす実数x1
とx2
だから当初予定されていた仕様を変更して虚数解をサポートするという判断に至ることだってあるかもしれない。 が、このポストは、JCUnitの使い方のあらましを示すためのものだ。可能な限り当初の仕様を変えない方向で、これらの問題への対応方法を決めていこう。カッコ内には先程述べたバグの分類(製品バグと仕様バグ)に基づいて、それぞれの問題がどちらに当たるかを記した。
- 問題1:虚数解 二次方程式の解の判別式を用い、実根がない組み合わせが与えられた場合例外を送出する。(仕様バグ、製品バグ)
- 問題2:丸め誤差 根を方程式に代入した時、その計算結果の絶対値が0.01を下回ること。(仕様バグ)
- 問題3:巨大な係数
a
,b
,c
いずれも絶対値が100以下とし、その範囲外の値が与えられた場合例外を送出する。(仕様バグ、製品バグ) - 問題4:a==0
a
に0が与えられた場合例外を送出する。(仕様バグ、製品バグ)
問題の修正(1)
これらの問題を修正するにあたっては、まずは仕様書を更新し、しかるのちテスト対象ソフトウェアを修正するという古式ゆかしい手順を踏んでみよう。
仕様書は以下のように修正されることになる。
QuadraticEquationクラス仕様 version 2