単にテキストを出力して差分を見るというテスト手法のススメ

この記事ではどこでも使えて強力なテスト手法を紹介します

* テストフレームワークを使いません。
* テキストが出力できれば、どの言語でも使えます。

# 手順

1. プログラムを Git 管理する
1. 結果をテキストでファイル出力するプログラム書く（出力結果は `.gitignore` しない）
1. 出力ディレクトリについて `git diff` を表示する

以上

## 実践

テスト対象のコードとして、コサインの近似値の計算を Python で書きました。

```py:my_math.py
import math

def cos(t):
    t %= math.pi * 2
    if (t < math.pi * 0.25):
        return _cos(t)
    elif (t < math.pi * 0.75):
        return _sin(t - math.pi * 0.5)
    elif (t < math.pi * 1.25):
        return -_cos(t - math.pi)
    elif (t < math.pi * 1.75):
        return _sin(t - math.pi * 1.5)
    else:
        return _cos(t - math.pi * 2)

def _sin(t):
    result = current = t;
    # テイラー展開
    for i in range(2, 10, 2):
        current *= - t * t / (i * (i + 1))
        result += current
    return result

def _cos(t):
    result = current = 1;
    # テイラー展開
    for i in range(1, 11, 2):
        current *= - t * t / (i * (i + 1))
        result += current
    return result
```

## 1. Git 管理する

サンプルの Git レポジトリは
https://github.com/shohei909/diff_test
に用意しました

## 2. テキストを標準出力する

これに対して以下のようなテストコードを書きます。単に `-3 * PI` から `3 * PI` までの `my_math.cos()` の計算結果を標準出力しています

```py:test/code/my_cos_test.py
import sys
import math
import os

sys.path.append(os.path.dirname(__file__) + '/../../')
import my_math

print("ver:0.0") # このテストのバージョン
print("my_math.cos()の戻り値に対するテスト") # このテストの説明文

resolution = 8
for i in range(-3 * resolution, 3 * resolution):
    t = i / resolution
    print("cos({:<6} * PI): {}".format(t, my_math.cos(math.pi * t)))
```


## 3. 結果をファイルにリダイレクトして、`git diff` を見る

テストコードの実行結果をファイルに保存していくスクリプトを書きます。具体的には以下の内容です

* `test/code` 以下のすべてのコードを実行する
* その標準出力を `test/out` 下のファイルにリダイレクト

```py:test/run.py
import glob
import subprocess
import os
import sys

test_dir = os.path.dirname(__file__)
out_files = set()

# code 以下の *.py ファイルを検索
for file in glob.glob(test_dir + "/code/**/*.py", recursive=True):
    relpath = os.path.relpath(file, test_dir + "/code")
    file_name = os.path.splitext(relpath)[0]

    extension = ".txt"
    out_file_path = test_dir + "/out/" + file_name + extension
    
    os.makedirs(os.path.dirname(out_file_path), exist_ok=True) # 出力フォルダが無ければ作成
    out_file = open(out_file_path, "w")

    # `python *.py` のプロセスを実行
    subprocess.run([sys.executable, file], stdout=out_file)
    
    # 出力したファイルを記録
    out_files.add(os.path.abspath(out_file_path))

# 削除ずみのテストの出力を削除
for file in glob.glob(test_dir + "/out/**/*", recursive=True):
    if os.path.isfile(file):
        if not os.path.abspath(file) in out_files: # 出力ファイルに含まれてないファイルを削除
            os.remove(file)
```

次に、出力結果の `git diff` を出すスクリプトです

```py:test/diff.py
import subprocess
import os

# Git で差分を表示
test_dir = os.path.dirname(__file__)
subprocess.run(["git", "add", "-N", test_dir + "/out"])
subprocess.run(["git", "--no-pager", "diff", "--relative=test/out", "--ignore-space-change"])
```


上記2つを合わせて実行します

```py:test/test.py
import importlib
importlib.import_module('run')
importlib.import_module('diff')
```

以上の3つのスクリプトのうち `run.py` を少し改変すればテスト対象が Python でない場合でも使用できます。


実行結果は以下のようになります

```diff
diff --git a/my_cos_test.txt b/my_cos_test.txt
new file mode 100644
index 0000000..0f305ad
--- /dev/null
+++ b/my_cos_test.txt
@@ -0,0 +1,50 @@
+ver:0.0
+my_math.cos()の戻り値に対するテスト
+cos(-3.0   * PI): -1.0
+cos(-2.875 * PI): -0.9238795325112585
+cos(-2.75  * PI): -0.7071067829368665
+cos(-2.625 * PI): -0.3826834323659474
+cos(-2.5   * PI): 0.0
+cos(-2.375 * PI): 0.3826834323659474
+cos(-2.25  * PI): 0.7071067810719247
+cos(-2.125 * PI): 0.9238795325112585
+cos(-2.0   * PI): 1.0
+cos(-1.875 * PI): 0.9238795325112585
+cos(-1.75  * PI): 0.7071067829368671
+cos(-1.625 * PI): 0.3826834323659474
+cos(-1.5   * PI): -0.0
+cos(-1.375 * PI): -0.3826834323659474
+cos(-1.25  * PI): -0.7071067810719247
+cos(-1.125 * PI): -0.9238795325112586
+cos(-1.0   * PI): -1.0
+cos(-0.875 * PI): -0.9238795325112586
+cos(-0.75  * PI): -0.7071067829368671
+cos(-0.625 * PI): -0.3826834323659474
+cos(-0.5   * PI): 0.0
+cos(-0.375 * PI): 0.3826834323659474
+cos(-0.25  * PI): 0.7071067810719247
+cos(-0.125 * PI): 0.9238795325112585
+cos(0.0    * PI): 1.0
+cos(0.125  * PI): 0.9238795325112586
+cos(0.25   * PI): 0.7071067829368671
+cos(0.375  * PI): 0.38268343236594693
+cos(0.5    * PI): -0.0
+cos(0.625  * PI): -0.38268343236594693
+cos(0.75   * PI): -0.7071067810719247
+cos(0.875  * PI): -0.9238795325112586
+cos(1.0    * PI): -1.0
+cos(1.125  * PI): -0.9238795325112586
+cos(1.25   * PI): -0.7071067829368671
+cos(1.375  * PI): -0.3826834323659474
+cos(1.5    * PI): 0.0
+cos(1.625  * PI): 0.3826834323659474
+cos(1.75   * PI): 0.7071067810719247
+cos(1.875  * PI): 0.9238795325112585
+cos(2.0    * PI): 1.0
+cos(2.125  * PI): 0.9238795325112585
+cos(2.25   * PI): 0.7071067829368671
+cos(2.375  * PI): 0.3826834323659474
+cos(2.5    * PI): -0.0
+cos(2.625  * PI): -0.3826834323659474
+cos(2.75   * PI): -0.7071067829368665
+cos(2.875  * PI): -0.9238795325112585
```

出てきた差分が意図通りならOKです。

## 達成できたこと

これだけではテストとして、不十分に見えますか？

しかし、少なくともテストに求められる重要なことのいくつかは達成できています

* プログラムの挙動の可視化
* プログラムが最後まで動くことのチェック
* **デグレード(退化)を起こしていないことのチェック**

特に重要なのが3つ目です。Git 差分の表示で変更行がないことを確認すれば挙動が変わってないことのチェックができます。

また、不安に思う部分には assert も書いていけば、いわゆるテストファースト的な手法を複合できます。


## テストを書く際のポイント

よく「テストはドキュメント」といった言い回しを耳にしますが、この手法の場合は出力もドキュメントです。

ですから、以下のことを意識して書くといいです

* テストのファイル名に、より良く内容を表現した名前を付ける
* 出力ファイルの冒頭にテスト内容の説明文を書く
* テストのプログラムから出力される各行にはそれが何かわかるようなラベルを書く



### CIを行う

CI(継続的インテグレーション)を行うとより快適に開発できるでしょう。

具体的には、プルリクエストをトリガーとして、自動で以下の2つを行うといいです

1. `test/run.py` を実行して差分をコミットする
1. 出力に意図しない差分があったら failure させる

#### `test/run.py` を実行して差分をコミットする

これを行うことで、テストを実行し忘れた状態でマージされることを防げます。

さらに

* 終了コードが `0` であること
* 標準エラー出力がされていないこと

を見てテストが正常に終了したかを判定するといいです。


#### 出力に意図しない差分があったら failure させる

プルリクエスト内容の差分を見るだけであれば以下のコマンドを実行すればいいです

```sh
git diff --no-pager マージ先ブランチ(base)...リクエストしてるブランチ(head) --relative=test/out
```

<sup>（`--ignore-space-change` をつけるべきかは、ソフトウェアの性質に応じて要検討）</sup>

ただ、これだけでは変更が意図的かどうかの区別ができません。

変更が意図的なものかどうかを区別するためにつけてるのが、出力ファイルの先頭の `ver:0.0` のバージョン情報です。

このバージョン番号は以下のルールに従って書き換えをします

* 出力ファイルの変更が、行の追加のみであることを意図しているなら、マイナーバージョンを上げる。(`0.0` -> `0.1`)
* 出力ファイルの変更が、行の削除を含むことを意図しているなら、メジャーバージョンを上げる。(`0.1` -> `1.0`)

diff を読んで上記のルールを満たしているか判定するプログラムを書けば、マージの可否を判定する CI を組むことができます。

この判定を行うプログラムは以下に公開しています

https://github.com/shohei909/diff_test/blob/master/test/check.py


### テスト対象を広げる

実践例で紹介したのは、いわゆる単体テスト的なものですが、より結合テスト的なものに対しても効果的です。

例えば、以下のようなものです

* JSON返すWebサーバーなら、実際にアクセスしたレスポンスを記録する (JSONは `jq . -sort-keys` などで内容のソートをしておくといい)
* コマンドラインツールなら、実際にそのツールを実行した結果を記録する
* Webサイトなら、 Headless Chrome でそのページにアクセスした結果のスクリーンショット画像などを記録する


## 差分を見るテストのメリット

### デグレードに対する耐性が高い

この手法はデグレードを発見する力高いです。この点については、あらかじめ期待される結果を手で書く手法と比べても強力です。

というのも、期待される結果を手で書く場合、「手で書ける程度の個数」のケースしか用意できません。網羅的にデグレードをチェックできるほどのケースを書いて用意するのは時間がかかります。

しかし、単に結果を出力しておくのであれば、「目を通せる程度の個数」にまで広がります。



### プロトタイピングや仕様変更の速度を落とさない

テストを書く大きなメリットは、リファクタリングが容易になることです。リファクタリング時のデグレードのリスクが抑えられます。

逆に、テストを大量に書くデメリットもあります

* ソフトウェアのプロトタイプが実際に動くまでに時間がかかる
* 仕様変更が発生したときのテストの書き直しが大変になる

単にテキストを出力しておく手法はテストコードは比較的小さく、これらのデメリットが小さいです。

仕様の変更内容によっては、テストコードに対する変更は単に `ver:*.*` を書き換えるだけで済みます。それでいて、プログラムの挙動がどう変わったかは出力の差分を見れば明確です。

このテスト手法は、すばやくプロトタイプを作って、その挙動や反省を踏まえてリファクタリングや仕様の改善を繰り返していくような開発フローとの相性がいいです。



### 挙動が可視化される & 挙動の履歴をたどれるようになる

この手法でテストを書くということは、サンプルコードとその実行結果を残しておくということそのものです。これらがあれば、レビュアーも、新規メンバーも、実装者自身もコードがどういう動きをするのか理解しやすくなります。

また、 Git 上に挙動ベースでの変更履歴が残ることにもメリットがあります。この履歴は、例えば、後からバグが発見された場合に、その原因になった挙動がいつから発生していたか、どの修正によって発生かを調べる手助けになります。



### テストフレームワークにロックインされない

テストコードはコード量が多くなりがちな部分なので、例えばテストフレームワークが言語のアップデートに追随してないといった事態には対処が大変です。

この手法では、テストフレームワークのへの依存が無いのでそのリスクがありません。



## 発展：SVGを出力して差分を見る

テキストの出力はより挙動をわかりやすく表現する形で行うのが理想です。より視覚的に表現する方法として SVG を出力するというのがあります。

先ほどの `my_math.cos()` でそれをやってみたのが以下です。

https://github.com/shohei909/diff_test/pull/1/files#diff-859f44ef4608ed7f4557a2bfcb2ce991

![cos.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2685/e14132c3-aa30-7d26-65a0-30cfd150f554.png)

ちゃんとコサインの近似ができていることが視覚的にわかります。

SVG はテキストと画像の両面の性質があるので、両面のメリット受けられます。例えば、値が変わったかを厳密に見たければテキストで見ればいいですし、目視でわかる変化があったかを見たければ画像としてみればいいです。

試しに、 `my_math.cos()` の精度を落とす変更を加えてみます

https://github.com/shohei909/diff_test/pull/2/files?short_path=859f44e#diff-859f44ef4608ed7f4557a2bfcb2ce991

![page6_15.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2685/9e1512dc-b524-2806-0e8a-030c85e68154.gif)

誤差が目視ではわからないレベルであることが確認できました。