コンテンツへスキップ
Topos Uranos

要約

コンパイルが通り、もっともらしいものを出力するプログラムは、それだけでテストされたことにはならない。このレッスンでは方法に基づいてテストすることを学ぶ。仕様から、領域と境界に沿ってテストケースを導くこと。それらを、失敗を数えて報告する自動テストのプログラムにまとめること。プログラムが正しければ起こりえないことを assert で確かめ、constexpr 関数が計算すべき値をコンパイル時に static_assert で確かめること。そして、正しい結果に隠れた未定義動作を見つけ出すサニタイザを有効にしてテストを実行すること。そのすべてが一つの警告の上に成り立っている。テストは誤りの存在を示すことはできても、その不在を示すことは決してできない。

学習目標

  1. 関数の仕様からテストケースを導く。すなわち、入力の分割、分割どうしの境界、特殊な場合を、それぞれについて事後条件が要求する結果とともに導く。
  2. 得られた結果を期待される結果と比較し、止まらずに失敗を一つずつ報告し、最後に要約と、それを反映する終了コードで終わる自動テストのプログラムを書く。
  3. 事前条件と不変条件を確かめるために assert を使い、-DNDEBUG を付けてコンパイルするとなぜ消えるのか、またなぜその条件が副作用をもってはならないのかを説明する。
  4. static_assert を constexpr 関数とともに使ってコンパイル時に結果をテストし、サニタイザを有効にしてテストを実行して、結果には現れない未定義動作を見つけ出す。

テストするとは何か

プログラムをテストするとは、選んだ入力でプログラムを実行し、その振る舞いを本来すべきだった振る舞いと比べることである。各テストケースは二つの部分からなる。事前条件を満たす入力と、その入力に対して事後条件が要求する結果であり、後者はあらかじめ別の道筋で(手計算で、別の公式で、既知の表で)求めておく。期待される結果がなければテストではなく、ただの実行である。出力を眺めて「もっともらしい」と思うのは印象であって、比較ではない。

ところが、int 型の引数を一つとる関数は四十億を超える入力を受け付け、引数を二つとる関数ではおよそ 1.8⋅10191.8 \cdot 10^{19} 組になる。どんなテストの集まりも、そのすべてをたどることはできない。そうであれば、テストは起こりうる場合のごく一部を調べるにすぎず、そのすべてに通ったからといって関数が正しいことは導かれない。エドガー・ダイクストラは 1970 年にこう述べた。テストは誤りの存在を示すことはできても、その不在を示すことは決してできない。これは、コース「数理論理学 I」が個々の場合の確認と証明とのあいだに設ける区別と同じである。ある論理式がいくつかの値について真であると確かめても、すべての値について真であることは証明されない。これに対して証明(このコースでは、レッスン 正しいループの設計 の不変条件)は、すべての場合を一度に覆う。テストはその推論の代わりにはならない。テストが見つけるのは、推論が見落とした誤りである。

ここから、ケースを選ぶ基準が導かれる。テストが正しさを確証できないのなら、その価値は誤りを見つけることにあり、よいテストケースとは失敗する見込みがいちばん高いケースである。うまくいくと分かっていたから作者が選んだケースは、何も教えてくれない。次のプログラムは、空でないベクターの最大要素を返すはずの関数で、このことを示す。

C++ のプログラム
#include <print>#include <vector>​// データの並びの最大要素。// 事前条件:values は空でない。// 事後条件:values の最大要素を返す。int largest(const std::vector<int>& values){    int best{0};    for (const int v : values) {        if (v > best) {            best = v;        }    }    return best;}​int main(){    std::println("{}", largest({3, 9, 4}));    std::println("{}", largest({7}));    std::println("{}", largest({1, 2, 3, 4}));    std::println("{}", largest({-5, -2, -8}));}
出力
9740

最初の三つのケースは誰もがまず書くもので、関数はそれらに通る。9、7、4 はどれも正しい。四つ目は 0 を返す。これはベクターにさえない値であり、−2-2 を返すべきところである。誤りは 9 行目にある。best が最初の要素ではなく 0 から始まるので、関数が正しいのは、どれかの要素が正かゼロであるときに限られる。典型的なケースは、誰も選んだわけではないのにこの性質を共有していた。四つ目はそれを意図して破る。修正は int best{values[0]}; であり、これは事前条件によって正当になる。事前条件が values[0] の存在を保証するからである。