「テストを書きましょう」までは合意している。でも、引き継いだのはテストが1本もない10万行級のレガシーコードだとしたら、どのファイルから手を付ければいいだろうか。

本記事はそうした状況に立つエンジニアと、改修を発注する側の責任者に向けて、「最初の1本をどこに書くか」の手順をまとめたものです。言語は Perl / Mojolicious を例にとりますが、教訓は言語非依存です。前作の記事「EC リプレース前にやるべきこと」ではテスト自動化の必要性を概説し、小規模チームの AI 開発フローでは「ステップ1:テスト自動化を先に整える」と位置づけましたが、いずれも「最初の1本をどう書くか」の手順までは踏み込んでいません。本記事はその空白を埋める、実践的な手順書です。

characterization test(特性テスト)とは何か

特性テスト(characterization test)とは、既存コードの観測された振る舞いをそのままテストとして固定し、変更による意図しない副作用を検出する手法です1。この言葉は Michael Feathers による造語で、レガシーコードの安全な改修に向けた出発点として位置づけられています。「正しい動作」を検証するのではなく、「変わっていないか」を検証する点に特徴があります。

仕様テストとの違い:「正しさ」を追いかけない

レガシーコードに対して最初から「正しい仕様」をテストしようとすると、設計書が散逸している場合にすぐに行き詰まります。コードが唯一の仕様文書になっているのがレガシーの常です。

特性テストと仕様テストの違いは、 「許可リスト方式」か「禁止リスト方式」か に集約されます。仕様テストは個別の値やプロパティが期待値を満たすかをアサーションで確認します(許可リスト)。それに対し、特性テストは「複雑な結果全体が以前のバージョンと同じであるか」を比較します(禁止リスト)1

たとえば、既存コードで f(3.14) の実行結果が 42 だと観測できたら、その入出力の組み合わせをテストとして固定します。変更後に同じ入力で異なる出力が出た場合、そのテストが失敗することで「意図しない変更が起きた可能性」を検出できます。見つかった差分が「意図的な修正」なのか「巻き込みバグ」なのかは、人間が判断します1

特性テスト・仕様テスト・回帰テストの違い

特性テスト仕様テスト回帰テスト
検証対象観測された動作が変わっていないか仕様(期待値)を満たしているか過去に存在したバグが再発していないか
前提条件コードが動いていること仕様が明文化されていること過去のバグとその修正内容が記録されていること
適用フェーズレガシー改修の最初新規開発・リファクタリング後変更後のバグ追跡
判断主体人間が差分を判断テストフレームワークが自動判定テストフレームワークが自動判定

表からわかるように、特性テストは仕様テストや回帰テストと対立するものではありません。レガシーコードの段階では仕様が不明確なため、まず「変化検出器」として特性テストを敷き、徐々に仕様テストへ移行していく流れが自然です。

Note

Q: characterization test と仕様テストの違いは? A: 仕様テストは「正しいか」を問います。特性テストは「変わっていないか」を問います。レガシーコードでは仕様が不明なため、まず後者から始めて、Seam(接合部)を発見したうえで仕様テストへ段階的に移行するのが現実的です。

最初のテストをどこに書くか:「次に触るファイル」から始める

10万行全体をカバーするテストを書こうとすると、決して終わりません。最初の1本は、 明日いじる予定のファイル に書きます。

筆者の経験では、選定基準は次の順序で機能します。

  1. 変更予定のあるファイル — 明日から着手する改修対象が最優先
  2. バグ修正対象ファイル — 現在調査中の不具合の発生箇所
  3. 頻繁に呼ばれるエンドポイント — アクセスログで上位に来る重要経路

この「局所集中戦略」は、Google Testing Blog で提唱された Diff Coverage(変更差分カバレッジ) の考え方と重なります2。レガシーコード全体のカバレッジがたとえ3%でも、「今回の変更」に対してテストが存在するかを測定すれば十分です。全体の低い数値に心理的に圧倒される必要はありません。

前作の記事「EC リプレース前にやるべきこと」でも、「まず全部にテストを書くのではなく、触る箇所からカバレッジを可視化する」という順序を示しました。本記事は、その次の実践ステップです。

現状の振る舞いを固定する書き方

ここからは Perl / Mojolicious の環境を例に、実際のコードを見ながら説明します。他言語の読者の方へ:エンドポイントにリクエストを投げてレスポンスをそのまま検証するというパターンは言語を問わず共通です。

なお、テスト実行時にアプリが本番用の DB や外部サービスに接続しようとして起動しない場合は、後述の Seam(接合部)で環境変数やサブルーチンを差し替えてから、以下のキャプチャに進んでください。

Test::Mojo によるエンドポイント単位のキャプチャ

Mojolicious のテスト用モジュール Test::Mojo を使うと、Web アプリケーションのエンドポイントに対して実際にリクエストを発行し、そのレスポンスをアサーションとして記録できます。メソッドチェーン方式で、同じリクエストに対する複数の検証を一文に束ねられるのが特徴です。

use Test::More;
use Test::Mojo;

my $t = Test::Mojo->new('MyApp');

# GET リクエストのレスポンスをキャプチャ
$t->get_ok('/welcome')
  ->status_is(200)
  ->text_is('div#message' => 'Hello!');

# POST リクエストの JSON レスポンスをキャプチャ
$t->post_ok('/search.json' => form => {q => 'Perl'})
  ->status_is(200)
  ->header_is('Server' => 'Mojolicious (Perl)')
  ->json_is('/results/4/title' => 'Perl rocks!');

done_testing();

Test::MojoMojo::UserAgent をベースとしたテスト用ユーザーエージェントです3。アプリケーションのインスタンス化(Test::Mojo->new('MyApp'))は1行で済み、未使用ポート(例: localhost:32114)で自動的にリッスンを開始して、テスト終了後に自動停止します3。Cookie やセッション情報も Mojo::UserAgent が自動的に保存・送信するため、テストコード側で手動のセッション管理を書く必要はありません3

主要メソッド一覧

メソッド用途
get_ok / post_ok / delete_okHTTP リクエストを発行し、通信成功を検証
status_isHTTP ステータスコードを検証(例: 200, 404)
content_like / content_isレスポンスボディ全体を検証
text_is / text_likeCSS セレクタで要素を絞り込んでテキストを検証
json_is / json_likeJSON Pointer 構文で JSON レスポンスを検証
header_is / header_likeレスポンスヘッダーを検証

これらのメソッドを組み合わせて、既存エンドポイントが「現在どのようなレスポンスを返しているか」をそのままアサーションとして記録します。変更前にテストが通ることを確認し、変更後にもう一度実行して同じ結果が出ることを検証します。これが特性テストの基本的なサイクルです4

テストの実行は prove コマンドで行います。

prove -l -v t/characterization.t

後付け計測のカバレッジ数字の読み方

テストが1本もない状態でカバレッジ計測をすると、当然0%が出ます。最初のテストを書いて計測し直しても、たとえ3%しか出なくても、それは失敗ではありません。

カバレッジは「未テスト部分の発見ツール」である

Martin Fowler は次のように述べています。

「カバレッジは、コードベースのどの部分がテストされていないかを発見するための有用なツールである。テストがどれだけ優秀かを数値で表明するものとしてはほとんど役に立たない。」5

低いカバレッジの数字は「変更時のリスクが高い」という 陰性指標 です。「品質が低い」という陽性指標ではありません。半分以下のカバレッジは問題の兆候ですが、高い数値が必ずしも品質の保証になるわけではありません5

Brian Marick も次のように言っています。

「私は高いカバレッジ水準を期待している。時々、経営者がそれを要求する。微妙な違いがあるのだ。」5

カバレッジ数値を目標に掲げると、人々は数値を達成するための低品質なテストを書きがちになります。本当に重要なのは、本番にバグが漏れにくいこと、そしてコードを変更する際に「本番を壊すのではないか」という恐怖を感じないことです5

カバレッジ基準の比較

カバレッジには複数の基準があります6。Perl の Devel::Cover は以下を計測できます7

基準名前計測内容
C0ステートメントカバレッジ各ステートメントが少なくとも1回実行されたか
C1ブランチカバレッジ各分岐(if/else など)の true/false が少なくとも1回実行されたか
C2条件カバレッジ複合条件(A && B など)の各ブール項が true/false を取ったか

C0 は到達したかどうか、C1 は分岐を網羅したかどうか、C2 はさらに細かい条件まで網羅したかどうか、という粒度の違いです。レガシーコードに最初から C2 を目指す必要はありません。まず C0 で「触った箇所がテストを通過しているか」を確認し、徐々に基準を上げていけばよいです。

Devel::Cover の実行方法

Devel::Cover はテスト実行時にモジュールを読み込むことでカバレッジを収集します。

HARNESS_PERL_SWITCHES=-MDevel::Cover prove -l -v t/characterization.t

テスト実行後に cover コマンドを実行すると、HTML レポートが生成されます7。レポートでどのステートメント、どのブランチがテストを通過したかを確認でき、次にテストを追加すべき箇所の発見に役立ちます6

Note

Q: 最初のテスト後にカバレッジが3%しか出なかった。これは失敗? A: 失敗ではありません。Devel::Cover は「テストがコードをどれだけ実行したか」を計測するツールです。0%から3%に上がったこと自体が、あなたのテストが実際にコードを通過した証拠です。次に触るファイルにもう1本足していけばよいのです。

特性テストから仕様テストへの次のステップ

特性テストで「変化」を検出できるようになったら、次は Seam(接合部)を発見して個別関数の仕様テストへ段階的に移行していきます。

Seam(接合部)の発見

Michael Feathers は Seam を「コードをその場所で編集せずに振る舞いを変更できる場所」と定義しています8。Seam の発見は、リファクタリングの出発点であり、特性テストから仕様テストへの橋渡しとなります。

Perl での Seam の典型的な実践パターンは、環境変数の差し替えやサブルーチンのモンキーパッチです。

# 環境変数の差し替え
{
    local $ENV{DATABASE_DSN} = 'dbi:SQLite:dbname=:memory:';
    # テスト対象のコードを実行
}
# スコープを抜けると元の値に復元される

# サブルーチンのモンキーパッチ
{
    local *Some::Package::expensive_function = sub {
        return 'mocked_result';
    };
    # テスト対象のコードを実行
}

local によるモンキーパッチは動的スコープなので、呼び出し先のサブルーチン内での呼び出しもモック化されます。ただしスレッドセーフでないケースもあり、Test::MockModule などのモジュールを使うと内部構造を破壊的に変更せずに安全に差し替え可能です。

Test2::Suite への段階的移行

特性テストから仕様テストへ移行する際、Test2::Suite が提供する Test2::V0 バンドルを使うと便利です。use Test2::V0; 1つで Compare、Subtest、Exception 等を一括 import でき、Test::More との互換性もあります9。サブテスト機能が豊富なため、エンドポイント単位の特性テストから個別関数単位の仕様テストへ移行する際の構造化に有利です。

移行のフローは次のようになります。

  1. エンドポイント単位の特性テストで「変化検出器」を敷く
  2. Seam を発見して外部依存(DB、HTTP、ファイル入出力)を差し替え可能にする
  3. 個別関数単位の仕様テストに徐々に置き換える
  4. カバレッジが局所的に向上していくことを確認する

結論:明日いじるファイルに、最初の1本を書こう

10万行を一気に覆う必要はありません。「次に触るファイル」に1本、characterization test を書くことから始めればよいのです。

特性テストは「正しさ」を追いかけるのではなく「変化」を検出する装置です。Diff Coverage の考え方を取り入れて、今回の変更に対してテストが存在することを基準にすれば、全体の低いカバレッジに囚われる必要はありません。Devel::Cover は「どこがまだテストされていないか」を示す羅針盤として使い、1本ずつテストを増やしていきましょう。

特性テストのサイクルが回るようになったら、Seam を発見して仕様テストへ段階的に移行していく。そうした段階的アプローチが、レガシーコードを安全に改修するための実践的な道のりです。

レガシーコードの改修計画や、テスト自動化の導入について相談したい場合は、コンタクトフォーム からお気軽にお問い合わせください。

Footnotes

  1. Characterization test (Wikipedia) Michael Feathers による造語および「a means to describe (characterize) the actual behavior of an existing piece of software」の定義は本文冒頭に記載。change detectors の表現も本文中に言及(原典未確認のため要約レベルで引用)。 2 3

  2. Code Coverage Best Practices Adam Bender、Carlos Arguelles、Marko Ivanković 著、Google Testing Blog、2020年8月7日。Diff Coverage の概念と、変更した行に対してのみカバレッジを計測するアプローチについて。

  3. Mojolicious::Guides::Testing Mojolicious 公式ガイド。Test::Mojo の概要、アプリケーションの起動・停止、Cookie/セッション管理について記載。 2 3

  4. Test::Mojo - Testing Mojo Mojolicious 公式 POD。Test::Mojo->new による自動インスタンス化、メソッドチェーン方式の基本アサーションについて SYNOPSIS および各メソッド節に記載。

  5. Test Coverage (Martin Fowler) Martin Fowler、2012年4月17日。カバレッジの位置づけについて「Test coverage is a useful tool for finding untested parts of a codebase. Test coverage is of little use as a numeric statement of how good your tests are.」の見解は冒頭に記載。「半分以下のカバレッジは問題の兆候(low coverage numbers, say below half, are a sign of trouble)」の記述、Brian Marick の引用も同ページから。 2 3 4

  6. Code coverage (Wikipedia) C0(ステートメントカバレッジ)/ C1(ブランチカバレッジ)/ C2(条件カバレッジ)の定義について。各基準は個別の節に分かれて記載。 2

  7. Devel::Cover 公式ドキュメント(CPAN)。C0/C1/C2 に対応する statement/branch/condition の計測基準、実行コマンド、HTML レポート出力について記載。 2

  8. Michael Feathers 著『Working Effectively with Legacy Code』(ISBN 0-13-117705-2、Addison-Wesley、2004年)。Seam の定義「A seam is a place where you can alter behavior in your program without editing in that place」、および characterization test の原典。書籍所有のため verbatim 引用は二次情報(Wikipedia 等)を経由。

  9. Test2::Suite / Test2::V0 公式ドキュメント(CPAN)。Test2::V0 バンドルの使用方法、Test::More との互換性について記載。