🔖

リファクタリングのための低コストなテスト環境の構築

2024/02/28に公開

目的

リファクタリングを行う際に、動作が変わってないことの保証を取るハードルが高すぎて、リファクタリングがしたくてもできないとなった経験はありませんか?
そういう方のためにリファクタリング用のテストを低コストで構築する方法を記事にしました。

・リファクタリングとは

リファクタリングとは要するにソース整理のことですね。コードを読みやすく、保守性の高いものに書き換えることを言うかと思います。例えば以下のようなコードをリファクタリングしてみましょう。

①リファクタリング前

// 10~39の範囲において、一桁目の値をbに入れる
void hoge(int a)
{
	int b = 0;
	if (10 <= a && a <= 19)
	{
		b = a - 10;
	}
	else if (20 <= a && a <= 29)
	{
		b = a - 20;
	}
	else if (30 <= a && a <= 39)
	{
		b = a - 30;
	}

	return(b);
}

②リファクタリング後

// 10~39の範囲において、一桁目の値をbに入れる
void hoge(int a)
{
	int b = 0;
	if (10 <= a && a <= 39)
	{
		b = a % 10;
	}

	return(b);
}

こちら①のコードは、余りを求める演算子%を使用せずにif文で無理やり書いている感じですね。対して、②のコードではそれを演算子を用いて、一括で行う形に変更しています。
この2つのコードの動作(入力aに対する出力bの値)は全く変わっていません。
「動作が変わっていないのなら何のためにリファクタリングしたの?」と思う方もいらっしゃるかと思いますが、以下のメリットがあります。

可読性がよくなる

①では行数が多くこのコードが何をやっているのかが一見してわかりづらいのに対し、②はやりたいことが簡潔に書かれているため、理解しやすい。

保守性がよくなる

①では例えばaの範囲を49から99に変更したい場合、if文を追加する必要がありますが、②では一部の値を変更するだけでよい。

信頼性が向上する

動作が変わらないことのチェックを行う前提になりますが、他えば①でミスしていて、if(10 <= a && a <= 19)のところをif(10 <= a && a <= 20)と書いてしまったとしましょう。これだと②とaが20の場合に動作が変わってしまいます。この場合、リファクタリングにより①の不具合が発見され、②で解消した言えます。逆に②でb = a % 10;b = a / 10;と誤って書いてしまった場合でも動作が変わってしまっているため、チェックで指摘されることになり、リファクタリングをやり直します。つまり、リファクタリングを行ってチェックが通ったということは 2つの異なった書き方のコードが両方とも会っている可能性が高いため、コードの信頼性が向上した言えます。

このようなメリットがあるため、リファクタリングは積極的に開発途中段階から行っていくべきだと考えています。

リファクタリングのためのテストの作成

リファクタリングのメリットはなんとなくわかったかと思いますが、リファクタリングを行う上で最も重要な「動作が変わっていないことをチェックする」環境の構築が必要になります。
こちらは一般的には以下のようなテストを手動で作成する方法があるかと思います。

void test(void)
{
    assert(hoge(0) == 0);
    assert(hoge(9) == 0);
    assert(hoge(10) == 0);
    assert(hoge(19) == 9);
    assert(hoge(49) == 9);
    assert(hoge(50) == 0);
}

ただし、この方法ですと以下のような結構大きなデメリットがあります。

テストの作成、保守コストが高い

入力が多い場合などは、数百行になることもある。関数の動作仕様が変更になった場合にその膨大なテストも手動で変更する必要がある。

テストケースが漏れやすい

この場合ですとaが20の時のみ動作が変わるケースを発見できない。

このため、手動でリファクタリング用にテストを作成することは何が何でも避けたいです。
そこで、以下のように考えました。

そもそもリファクタリングはテストが通るようにコードを変更を行うのが目的ではなく、動作が変わらないようにコード変更を行うのが目的のはず。
なので、変更前のコードをテスト関数として、テストを行えばよいのではないか。
要は以下のように書きます。

void test(void)
{
    // hoge2はリファクタリング後の関数
    assert(hoge(0) == hoge2(0));
    assert(hoge(9) == hoge2(9));
    assert(hoge(10) == hoge2(10));
    assert(hoge(19) == hoge2(19));
    assert(hoge(49) == hoge2(49));
    assert(hoge(50) == hoge2(50));
}

これでテストの結果を考えるコストは削減されそうです。
ただし、やはりテストケースの作成には依然大きなコストが掛かりそうですね。
ここで、乱数を使用した自動テスト(モンテカルロ法(https://ja.wikipedia.org/wiki/モンテカルロ法))を導入したいと思いました。

void test(void)
{
    // hoge2はリファクタリング後の関数
    for(int i = 0; i < 100; i++){
        int j = rand(100);
        assert(hoge(j) == hoge2(j));
    }
}

こうすることにより確率的にではありますが、テストケースを手動で書くコストが大幅に削減されたかと思います。
保守コストも大幅に削減されるかと思います。
例えば、関数の引数が2個になった場合でも以下のように変更するだけです。
テストケースの漏れが懸念される場合は繰り返し回数を多めに調整します。

void test(void)
{
    // hoge2はリファクタリング後の関数
    for(int i = 0; i < 10000; i++){
        int j = rand(100);
        int k = rand(100);
        assert(hoge(j,k) == hoge2(j,k));
    }
}

この手法を使えばリファクタリング専用のテストが非常に低コストで構築できるかと思います。

では、このようなシンプルな関数ではなく、もっと複雑なシステムについて考えてみましょう。
複雑なシステムでは出力は必ずしも関数の戻り値だけとは限りません。
しばしば外部システムへのコマンド送信やポート出力などが考えれます。
そのようなシステムに対してのリファクタリングではシステムの動作にかかわる全ての出力に変更がないかを保証する必要があります。
そこで以下のような方法を考えました。

システムのシミュレータを作成する。

シミュレータはデバッガ部とシステム部で構成され、デバッガ部は乱数を用いて、システムの入力インターフェースに起こりえる入力を高速で与え続ける。このデバッガ部の構築はかなり複雑でシステムの仕様により実現困難な場合もありますが、ボリュームが大きすぎるためここでは省略させていただきます。要はテストシナリオを乱数を用いて無限に作成する機能って感じです。
もちろん、乱数は任意のシート値で固定できるようにしておきます。

// シミュレータのイメージ
void simlater(void)
{
    while(true){
        // デバッガ部:ここでsystemの状況に応じて乱数を用いて様々な入力を行う。
        debugger();
        // システム部:入力に応じて、ビジネスロジックが実行される
        system();
    }
}

システムの動作にかかわる出力について、すべてログとして出力ようにする。

コマンド送信用関数やポート出力関数をフックして、ログとして必要な情報を出せるようにします。

void system(void)
{
    // 本来はプロジェクトのビジネスロジックが丸ごと入る想定ですが省略します。
    if(IsClick()){
        OutTextLog("クリック");
        int cmd = 1000;
        g_cnt = (g_cnt + 1) % 1000;         
        SendCmd(cmd + g_cnt);
    }

    if(g_cnt % 2 == 0){
        SetOutPort(30, 1);
    }
    else{
        SetOutPort(30, 0);
    }
}

void SendCmd(int cmd)
{
    OutTextLog("コマンド送信:%d", cmd); 
    // コマンド送信処理
}

void SetOutPort(int port, int val)
{
    OutTextLog("ポート出力:%d %d", port, val); 
    // ポート出力処理
}

ログは以下のようになります。

ポート出力:30 1
ポート出力:30 1
ポート出力:30 1
ポート出力:30 1
クリック
コマンド送信:1001
ポート出力:30 0
ポート出力:30 0

リファクタリング前とリファクタリング後のシステムにおいてシミュレータを同時に実行して、ログの差分を確認する。

2つのシミュレータを同時に実行して、一文字ずつ同期を取りながらログを比較すると、シミュレートが低速になる懸念があるため、以下の方法でログを比較を行います。
・ログ出力時にログをリングバッファに保存し、同時にハッシュ値を計算する。
・特定の区間ごとにシミュレータ同士の同期を取る。(1000サイクル毎など)
・区間終了毎にログのハッシュ値を比較し、一致しなかった場合はシミュレートを停止する。
・停止時にリングバッファに保存しておいたログを全て出力し、同時にWinMergeでログ同士の差分確認画面を開く。

こうすることで、リファクタリング後にこのシミュレータを実行し、ログの不一致により停止しなければ少なくともその時点までの出力には影響を与えていないという保証が簡単に取れます。
ただし、シミュレータのデバッガ部の作りこみに依存して、そもそもシミュレータで通らないパスがあった場合や、確率的に長時間シミュレートを行わないと差分として出てこないような変更を行った場合はその限りではありませんので、注意が必要です。

出力ログの差分確認によるその他の効果

仕様変更が出力にどのような影響を与えたかを瞬時に確認

こちらの機能では影響を与えていないかだけではなく、どのような影響を与えたかの確認も行えます。
例えば以下のようなコードを追加したとしましょう。

void system(void)
{
    g_cnt = (g_cnt + 1) % 100;
    if(g_cnt=0){
        SendCmd(1);
    }
}

このコードの意図はg_cntが0になるたびに1のコマンドを送信するというものです。
ログの差分的には0になるたびにコマンドが送信されるようになることが期待されますが、実際にはシミュレートは止まらず動作差分なしという結果になってしまいます。
なぜならif文の中がg_cnt==0ではなく、g_cnt=0となってしまっているため、条件式が0(false)と判定されてしまい、if文の中の処理は絶対に行われないからです。
このようなミスはC言語ではしばしばあるのですが、実際に行った場合にそれを発見するためには本来であれば人による外部テストが必須になることがほとんどです。
それがこの仕組みを使えば適切な差分が発生したかがほぼノーコストで高速に行えてしまいます。

ソースレビューなどでコードの意図の確認が容易になる

コードレビューの際に一見不要と思える処理を見つけてしまった場合、その処理を削除して本機能を実行すると、本当に不要だったのか、実は必要な作用があったのか、また、不具合を起こしていたのかがログの差分にて容易に確認できます。

Discussion