あらためて「バグとは何か」を考える
「理想的な状態」と「実際の状態」のギャップ(差)のことを「欠陥」といいます。欠陥は、ソフトウェア開発のいろいろな場面にみられるものです。プログラムに限らず仕様書や設計書などのドキュメント、あるいは開発計画などに欠陥が見つかることもあるでしょう。
「バグ」というのは、おもにプログラムの欠陥のことを指す比喩的な表現です。「欠陥」という言葉には少しキツイ印象があるので、「仕様のバグだね」なんていう言い方をすることもありますが。もともとは、コンピューターに「虫(bug)」が入り込んで悪さをしているせいで、プログラムの動作がおかしくなってしまったという意味です。
テストは正常に動作していることを確認する作業
テストとは、作ったプログラムを実際に動作させてみて、正常に振る舞うかどうかをチェックする作業のことです。これは、プログラムが「理想的な状態」であることを確認しているのだといえます。
そのためには、「どんなプログラムを作ろうとしていたか」を認識することが重要です(バグを直すときも、そうでしたね)。この認識がなければ、そもそも何をテストすればいいのか分かりません。
例として、整数の配列を昇順(小さい順)にソートする関数があるものとしましょう。
/* 整数の配列を小さい順にソートする。
* - pNumbers: ソートする配列の先頭アドレス
* - numberCount: ソートする要素数
* pNumbersが0(NULL)の場合の動作は未定義。
*/
void SortNumbers(int *pNumbers, size_t numberCount) {
/* ここに、ソートの実装 */
}
この関数が何をするためのものなのかは、コメントに書かれています。具体的には、次のようなことを理解できるでしょう。
- 整数の配列
pNumbers
と要素数numberCount
を渡すと、配列の要素がソートされる pNumbers
に0(NULL
)を指定する呼び出し方は禁止
この関数をテストするためのテストコード、つまり単体テスト(ユニットテスト)は、どのように書けるでしょうか。
では、具体的なテストコードについて考えていきましょう。
まず、実際にプログラムを動かしてソートの動作を確認するために、テストデータとして「ソート前の配列」を用意する必要があります。例えば、次のようなものです。
int numbers[] = {56, 0, -34, 2, 976, 2, 5, 55, -20, 48, 16};
これをソートした結果が正しいかどうかをチェックするには、「ソート後の配列」も必要です。次のような、ソートの「正解」を表すデータです。
const int expectedNumbers[] = {-34, -20, 0, 2, 2, 5, 16, 48, 55, 56, 976};
numbers
を実際にソートして、結果がexpectedNumbers
と等しくなっていることをチェックすれば、テストに合格したといえるでしょう。そのためのテストコードは、ざっと次のような形になります。
int numbers[] = {56, 0, -34, 2, 976, 2, 5, 55, -20, 48, 16};
size_t count = sizeof(numbers) / sizeof(int);
SortNumbers(numbers, count);
const int expectedNumbers[] = {-34, -20, 0, 2, 2, 5, 16, 48, 55, 56, 976};
for (size_t i=0; i<count; i++) {
if (numbers[i] == expectedNumbers[i]) {
; /* OK */
} else {
; /* NG(期待どおりにソートできていない) */
}
}
このようなテストを実施すれば、関数がだいたい期待どおりに動作していることを確認できます。
関数が受け付けられる配列のパターンは無数にあるにもかかわらず、上記のテストでは1通りのテストデータでしか動作を確認していません。かといって、テストデータをいくら増やしても、あらゆるパターンをテストするのは不可能です。テストには、限界があるのです。
そこで、どのようなパターンでテストすれば、自信をもって「正しく動作している」といえそうか考える必要があります。今回のテスト対象はソートする関数ですが、ここではデータ数が1のパターンと2のパターンが要注意だと分かるでしょうか。
- データ数が2以上のときは、配列の中身がソートされる
- データ数が1以下のときは、ソートしても配列の中身は変化しない
このように、データ数1と2の間に、関数の動作が変化する境目があるのです。こうした境目の値のことを「境界値」といいます。境界値の直前と直後の値で動作を確認すれば、より信頼性の高いテストになるでしょう。
また、さらに極端なケースとして、配列が空のパターンも考えられます。以下の動作についても、併せてチェックしておくとベターです。
- たとえデータ数が0でも、問題なくソートできる(配列の中身は変化しない)
/* 整数の配列を小さい順にソートする。
* - pNumbers: ソートする配列の先頭アドレス
* - numberCount: ソートする要素数
* pNumbersが0(NULL)の場合の動作は未定義。
*/
void SortNumbers(int *pNumbers, size_t numberCount) {
/* ここに、ソートの実装 */
}
pNumbers
に0(NULL
)を指定したときの動作をテストしてないです!
pNumbers
を0(NULL
)にして呼び出すことは禁止されているわけだから、正常な動作ではないわね。
繰り返しになりますが、テストとはプログラムが正常だと確認することです。「この場合は、このように振舞うべき」というような「正解」が決まっているパターンだけがテスト可能だといえます。
これに対して、バグはそもそも正常ではない動作です。どのような場合に、どう振舞うかを決めておけないため、テストのパターンとして表現することができません。この点も、テストの限界だといえるでしょう。
例えば今回の関数では、pNumbers
に0(NULL
)を指定して呼び出したとすると、それは呼び出し側のバグということになります。これをテストのパターンとするには、誤った呼び出し方をテストコードに加えるしかありません。また、その場合の正常な動作も不明です。結果として、ただテストにバグを埋め込んだだけになってしまうのです。
デバッグコードはバグを検出するための仕組み
デバッグコードとは、バグを捕まえるためにプログラム中に仕込む「罠」のことでした。デバッグコードを書く動機は、テストとは反対のものだといえます。
- テストを実施する動機:動作が正常であることを確認したい
- デバッグコードを書く動機:バグを積極的に検出したい
動機が異なる以上、「テストとデバッグコードはどちらが優れているか」という話ではありません。これら2つを組み合わせることで、最終的にバグのないプログラムにできる可能性が高まると考えましょう。
では、先ほどのソートする関数に、デバッグコードを追加してみましょう。ここでのポイントは、次の2つです。
- 関数が予想外の使われ方をしていたら、呼び出し側のバグ
- 関数の実行結果が期待と違ったら、この関数自体のバグ
関数の使われ方をチェック
関数が予想外の使われ方をしていないかどうか確認するには、引数のチェックが基本です。次のように、関数の入り口にチェックを入れるのがいいでしょう。
/* 整数の配列を小さい順にソートする。
* - pNumbers: ソートする配列の先頭アドレス
* - numberCount: ソートする要素数
* pNumbersが0(NULL)の場合の動作は未定義。
*/
void SortNumbers(int *pNumbers, size_t numberCount) {
/* 引数をチェック */
if (pNumbers) {
; /* OK */
} else {
; /* NG(pNumbersが0になっている) */
}
……
}
ここではpNumbers
に0(NULL
)を指定する使い方はNGなので、それをif
文でチェックしています。これで、この関数が呼び出されるときには常に、デバッグコードがはたらきます。
ここで、if
文には「正常ならこう」という条件式が書かれているのが分かるでしょうか。バグを検出するといっても、実際にできるのは正常な動作の確認だけだという点は、テストと変わりがありません。
では、テストと何が違うのかといえば、チェックが関数の内部ではたらくという点です。これにより、呼び出し側の正常ではない振る舞いも、いとも簡単に検出できるのです。
関数の実行結果をチェック
次は実行結果をチェックしましょう。今回は次のように、配列をソートしたあと、関数を抜ける前にチェックを入れればいいでしょう。
/* 整数の配列を小さい順にソートする。
* - pNumbers: ソートする配列の先頭アドレス
* - numberCount: ソートする要素数
* pNumbersが0(NULL)の場合の動作は未定義。
*/
void SortNumbers(int *pNumbers, size_t numberCount) {
……
/* 結果をチェック(配列の要素が小さい順に並んでいるか確認する) */
for (size_t i=1; i<numberCount; i++) {
if (pNumbers[i - 1] <= pNumbers[i]) {
; /* OK */
} else {
; /* NG(期待どおりにソートできていない) */
}
}
}
ここでは、ソート後の配列が、本当に昇順に並んでいるかどうかをチェックしています。
関数が受け付けられる配列のパターンは無数にあるため、テストだけではテストデータを用意するのも限界があったことを思い出しましょう。デバッグコードも、すべてのパターンをチェックできるわけではないのは同じです。しかし、デバッグコードは実際に関数が呼び出された際のデータすべてに対してはたらくので、より多くのパターンをチェックできます。
上の例は、隣り合う要素の大小関係をチェックするだけの簡易的なチェックになっているのが分かるでしょうか。これでは、ソート後のデータを厳密に検証したことにはなりません。例えば、すべてのデータが同じ値に書き換えられてしまった場合でも、正しくソートできたとみなしてしまいます。
とはいえ、このチェックをすり抜けてしまうような「でたらめ」な状況には、デバッグコードがなくても気付けるでしょう。とくに、テストと併用しているなら確実です。
qsort
と比較するのがいいわ。
ここで補足。もしかすると、「標準関数でソートできるのなら、ソートを自作する意味はないのでは」と思った人もいるかもしれません。
でも、ソートのアルゴリズムには、さまざまな種類があります。また、それぞれ特徴が異なります。
- アルゴリズムがシンプルか
- データの個数が増えたとき、ソートにかかる時間はどう変わるか
- ソートするとき、どれくらいのメモリーを使用するか
- などなど……
目的や状況に合わせて、アルゴリズムを選びたい場面もあるでしょう。そのため、標準関数を採用せずに、ソートする関数を自作するというのは実際にもあることです。
qsort
を採用せずに自作するケースになってたんですね。
qsort
と同じになるのが正しいわけ。
qsort
と比較する」っていうのは、両方の結果が同じかどうかチェックするっていうことなんですか?
バグの発生箇所は、プログラム内の特定の範囲に集中しやすい傾向があるといわれています。「バグが集中しそう」と感じたときは、より厳密なチェックをするデバッグコードを書いておくと安心です。
そのためには、別のアルゴリズムを用いたときの結果と照合する方法が考えられます。今回はソートする関数なので、標準関数のqsort
が使えます。自分でソートした結果が、qsort
でソートした結果と完全に一致することを確認すればいいでしょう。