テストファーストプログラミング - 情報処理IIレジュメ(2021-8)

1. はじめに

後期からの授業では複数人で一つのソースコードを共有して編集します。 複数人で共同してソフトウェアを書く場合には、皆で共通のコーディング規約を制定することが多くなります。 私の授業の範囲内では、moodle で配布したコーディング規約にしたがって,コードを書いてもらう予定です。 もし、この規約に意見があるなら指摘してください
(授業中ではここで規約の細かい説明を行う)。 ただし、vscode の Google 書式設定をしていると保存時に自動的にこの規約通りに設定されていると思います。

2. テストファーストプログラミングとは

テストファーストプログラミングは、常にまずテストを書き、それを満足する関数を作成していくことを繰り返して、目的のプログラムを作成していく方法です。 ステップごとに完成品ができ上がっていくために、プログラムを作る上で達成感があり、実装を変更した時のエンバグも少なくすることができます。

JAVA や Ruby などのオブジェクト思考言語では、テストファーストプログラムを簡単に実現するためのフレームワーク(Junit, TestUnit) などがあります。 今回の授業では、手続き型言語である C 言語を使用するため、フレームワーク部分までも実装する必要があります。

3. テストファーストプログラミングの例

ここでは、階乗の計算をする関数をテストファーストプログラミングを用いて作成する例を示します。 プログラムの仕様は以下の通りです。

  • キーボードから整数 x を入力する
  • の階乗を計算する。x が 0 未満の場合にはエラーを表示する。
  • エラーでない場合には、計算した値を表示する

作成するプログラムは以下の通りです。 たった一個の関数にしては、大がかりな仕掛けであるが、来週以降の時間関数群を作るときと同じ構造にしているためです。

  • factMain.c: 階乗を計算するプログラム本体。fact 関数を呼び出して結果を表示するだけのプログラム
  • fact.c: 階乗を計算する関数本体 fact 関数を記述する
  • fact.h: fact 関数のプロトタイプ宣言を記述する
  • testFact.c: fact.c をテストするための関数 testFact 関数を記述する

以下にプログラム作成の手順を示す。今回課題は以下の手順で示されるいくつか結果を記録し、各部分で起こっていることを考察することです。

3.1 必要なファイルのダウンロード

  1. moodle から「fact.zip」をダウンロードします
  2. このフォルダで vscode を開きます。

3.2 リポジトリを作成

SourceTree を開き、このフォルダをリポジトリとして設定します。 昨年度までは、Bitbucket のテストもしていましたが、今回はローカルのみにします。

3.3 エディタの起動とリポジトリの更新

  1. すでにコンパイルに必要なファイルは全部作成済で、Makefile に記入済です。今後、別のプロジェクトを実施する時には、ファイル名の修正や Makefile の修正をここで実施します。
  2. SourceTree に戻ると 上記9つのファイルが現れるのでコミットしておきます。今後は、作業がひと段落する度にコミットしてください。

この中で、fact.md という見慣れないファイルがあります。これは、今回の実行結果を記入するための MarkDown というファイルです。 このファイルを開き、macOS では Command-k、Windows は Ctrl-k の後に v とすると右側にプレビューが開きます。MarkDown 自体は Slack や Notion などでも使える便利な記法なので、知っておくとよいです。

3.4 コンパイル自動実行環境を起動

コンパイルに必要なものは全て揃っているので、gnuplot の場合と同様に chokidar で自動実行環境を用意しておく。

chokidar "*.c" "*.h" Makefile -c "make test"

ここで適当なファイルを保存した時にコンパイルが動作するかを確認する。 Windows の場合だとこんな感じの画面が出てくる。必要なファイルがコンパイルされ、testFact.exe が実行される。今回一つだけ簡易テストを記述しておいたので、テストが100%成功していると表示されている。

Watching "*.c", "*.h", "Makefile" ..
change:Makefile
gcc -Wall -g   -c -o testFact.o testFact.c
gcc -Wall -g   -c -o fact.o fact.c
gcc -Wall -g   -c -o testCommon.o testCommon.c
gcc -o testFact.exe -Wall -g testFact.o fact.o  testCommon.o -lm
chcp 65001
./testFact.exe
== Test abs ==
All tests are Ok. [# of Tests = 1, # of pass = 1 (100%)]

macOS の場合には、以下のようになる。

Watching "fact.c", "factMain.c", "testCommon.c", "testFact.c", "fact.h", "testCommon.h", "Makefile" ..
change:Makefile
cc -Wall -g   -c -o testFact.o testFact.c
cc -Wall -g   -c -o fact.o fact.c
cc -Wall -g   -c -o testCommon.o testCommon.c
cc -o testFact -Wall -g testFact.o fact.o  testCommon.o -lm
./testFact
== Test abs ==
All tests are Ok. [# of Tests = 1, # of pass = 1 (100%)]

ここで、SourceTree を開くと macOS の場合には、testFact という実行ファイルが候補に現れます。このファイルはリポジトリに登録すべきではないので、右ボタンで「無視...」で「このリポジトリのみ」で登録します。 .gitginore が変更になるので、これをコミットしておく。 Windows の場合には、「*.exe」が無視リストに入っているので特に何もやる必要はありません。

このコンパイルおよびテスト実行結果をfact.md に「結果1」として保存して、何が起こっているかを記録してください。

3.5 最初のテスト

Visual Studio Code で testFact.c を開いてみます。以下の修正をしてください。

  • 二箇所の testAbs を testFact に変更
  • 二箇所の abs を fact に変更
  • 関数の引数を -1 を 1 に変更
  • 「// 1 の階乗は 1 かどうか?」というコメントを追加しておく。
  • `#include "fact.h" を stdio.h の include の次の行に追加しておく。

変更後のファイルは以下のようになる。

#include <stdio.h>
#include "fact.h"
#include "testCommon.h"

void testFact() {
    testStart("fact");
    assertEqualsInt(fact(1), 1); // 1 の階乗は 1 かどうか?
}

int main() {
    testFact();
    testErrorCheck();
}

保存をすると、chokidar が make test を自動実行しまう。この時の結果を結果2として保存して、起こっていることを考察してください。

このなかで implicit declaration とは暗黙の定義という意味です。 「知らないからとりあえず int をもらって int を返す関数だと勝手に判断したよ」ということになります。 今回はこの判断は正しいが、気持ち悪いので fact 関数のプロトタイプ宣言を fact.h の中に作ります。 この授業では関数のプロトタイプ宣言を考える時に、以下のような図を描くことにします。 関数の戻り値(int)、関数名(fact)、引数(int x)がこの図で表現できていることを理解してください。

f:id:hkob:20200531102639p:plain
fact関数

この図を翻訳してプロトタイプ宣言として「fact.h」に記載します。

/* fact.h by hkob */
int fact(int x); /* 一つの値を受けて、その値の階乗を返す */

この時の結果を結果3として保存して、起こっていることを考察してください(特に、結果2との違いを確認してください)。

結果3 では「Undefined symbol」(macOS)や「Undefined reference」のような表示が出ます。 これは、プログラムのコンパイルは成功したが、fact という関数が見つからなかったというエラーになります。 これも作っていないから当たり前です。 そこで、次に fact 関数の本体である fact 関数を fact.c の中に記述します。 ただし、中身はまだ書かず、インターフェース部分である関数の引数の部分(と必要であればreturn文)のみを記述します。 今回は、fact.c の中に以下のプログラムを記述します。

/* fact.c by hkob */

/* x の階乗を計算して返す */
int fact(int x) {
    return 0;
}

ここでは、整数型変数 x を入力し、整数型を返す関数 fact を作成しました。 基本的には関数の雛形はプロトタイプ宣言の「;」を「{}」に変えるだけです。 また、この関数は int の値を返す必要があるので、とりあえず return 0; を記述しています。 void 関数の場合には、return は書かなくていいです。 テストファーストプログラミングでは、関数の呼び出しだけが成功するかを最初に確認します。 最初は決して中身を書かないでください。

この時の結果を結果4として保存して、起こっていることを考察してください。

この時、testFact ... Error: a != b (0 != 1)という出力が確認できれば成功です。 これは、1 の階乗を計算する関数が 0 を返していてテストに失敗しているためです。このテストプログラムが動作し、最初にテストに失敗することがテストファーストプログラミングで必要なこととなります。

3.6 実装の記述(つじつま合わせの成功)

とりあえず結果を合わせるために、fact 関数の return 0; を return 1; に直してコンパイルし、実行してみます。 この時の結果を結果5として保存して、起こっていることを考察してください。

この時、テストが通ったのが確認できる。 このようにひとまずはテストが通ることを目標に簡単なプログラムを作り、それを修正していくことで関数を拡張していきます。

3.7 テストを追加(つじつま合わせではうまくいかない)

気をよくしてどんどんテストを追加します(2 の階乗、3の階乗、6の階乗を追加します)。先程の fact(1) の下に以下のように3行を追加してください。

    assertEqualsInt(fact(1), 1); // 1 の階乗は 1 かどうか?
    assertEqualsInt(fact(2), 2); // この行を追加
    assertEqualsInt(fact(3), 6); // この行を追加 
    assertEqualsInt(fact(6), 720); // この行を追加

この時の結果を結果6として保存して、起こっていることを考察してください。

これらのテストが通過するように fact.c を直してみます(ここは自分で頑張ってください)。 成功した時の結果を記録しておいてください。 成功した時の結果を結果7として保存して、起こっていることを考察してください。

3.8 0の階乗のテストの追加、負の階乗のテストの追加

以下のテストを追加して、テストが通るように fact.c を直してみます。 0 の階乗は 1 であるというテストである。また、負の数を入れた場合も同様にテストしてみる。負の値は定義されていないので、とりあえず -1 を返すなど自分で工夫すること。

    assertEqualsInt(fact(0), 1);
    assertEqualsInt(fact(-1), 自分で決めて);
    assertEqualsInt(fact(-3), 自分で決めて);

この時の結果を結果8として保存して、起こっていることを考察してください。

テストが通るように実装を記述します。成功した時の結果も記録しておいてください。 この時の結果を結果9として保存して、起こっていることを考察してください。

3.9 関数を信用し、他のプログラムで使用する

ここまでテストすれば fact.c は完成したと思われます。 最後にメインプログラムを書いてみます。

/* factMain.c by hkob */
#include <stdio.h>
#include "fact.h"

int main() {
    int x, y;
    fprintf(stderr, "整数を一つ入力して下さい");
    scanf("%d", &x);
    y = fact(x);
    if (自分で定めた条件) {
        printf("0 未満の階乗は計算できません¥n");
    } else {
        printf("%d の階乗は %d です¥n", x, y);
    }
    return 0;
}

4. 授業のまとめ

以上の手順をまとめると以下のようになります。

  1. 関数のテスト関数を作成します
  2. コンパイルする。→ コンパイルエラー(関数の正体をしらないから)
  3. 関数のプロトタイプ宣言を記述します
  4. コンパイルする。→ リンクエラー (関数の正体はわかったが、関数自体がない)
  5. 関数のインターフェースを記述します
  6. コンパイルする。→ コンパイル成功。実行時にテストでエラー(何もしない関数だから、結果も合わないはず)
  7. とりあえずつじつま合わせの実装でもいいから記述して、テストを通過させます
  8. コンパイル・実行する → テスト成功。ただし、つじつま合わせなのですべてでうまく行くとは限らない
  9. 偶然うまくいっているだけかもしれないので、テストを追加してみます。
  10. コンパイル・実行する → さまざまな組み合わせでうまく言ったのなら、この関数は信用できるから使い回します。

hkob.hatenablog.com