メインコンテンツまでスキップ

レガシーコードへの後付け: 継ぎ目と特性テスト

ここまでは、何も無いところから書いてきました。実際の仕事の多くは違います。既にコードがあり、テストは無く、動いています。

この状況で困るのは、テストを書く場所が無いことです。対象を単独で動かせません。動かそうとするとデータベースが要り、現在時刻に依存し、静的メソッドが別のクラスを直接呼びます。

この章は、その状態から始める手順を扱います。題材はここまでの会議室予約ではなく、意図的に触りにくく書いたコードです。

この章で学ぶこと​

  • 現在の振る舞いを先に固定する特性テストの書き方
  • 継ぎ目の探し方と、無いときの作り方
  • 既存を書き換えずに機能を足す 2 つの形
前提知識

04 テストダブル と 07 境界を差し替える を使います。「React 側でも同じ」の節だけは 09 章と 10 章の書き方を使うので、PHP だけを読む場合は飛ばせます。

この章で扱わないこと​

観点参照先
リファクタリングの個々の手順リファクタリングの実践
継承をコンポジションへ置き換える判断継承とコンポジション

題材: 手を入れたいが触れないコード​

請求額を計算するコードです。

final class InvoiceCalculator
{
public static function calculate(int $customerId): int
{
$customer = Database::query(
"SELECT * FROM customers WHERE id = {$customerId}"
);

$rate = 0.10;
if (date('Y-m-d') >= '2026-10-01') {
$rate = 0.12;
}

$subtotal = 0;
foreach (Database::query("SELECT * FROM orders WHERE customer_id = {$customerId}") as $order) {
$subtotal += $order['amount'];
// ここに 150 行ほどの調整処理が続く
}

return (int) round($subtotal * (1 + $rate));
}
}

触りにくい点が 4 つあります。静的メソッドなので、オブジェクトを渡して差し替えることができません。データベースを直接呼びます。date() で現在時刻を直接読みます。そして長すぎます。

なお、この例は値を文字列で埋め込んで SQL を組み立てています。本題ではありませんが、実務では値を束縛する形に直す対象です。

ここに「特定の顧客は税率を据え置く」という変更を入れたい、という状況を考えます。

まず現状を固定する​

変更の前にテストを書きます。ただし、ここで書くテストは正しさを確かめるものではありません。

このコードが正しいかどうかは分かりません。150 行の調整処理に何が書いてあるかも、それが仕様通りかも分かりません。分かっているのは、いまこう動いていることだけです。

その「いまこう動いている」を固定するテストを特性テスト (characterization test) と呼びます1。目的は、変更の前後で振る舞いが変わっていないことを確かめられる状態を作ることです。

書き方は素直です。実際に動かして、返ってきた値をそのまま期待値にします。

test('顧客 42 の請求額は現状 13200 である', function () {
// 期待値は仕様から導いたものではなく、現在の出力をそのまま置いたもの
expect(InvoiceCalculator::calculate(42))->toBe(13200);
});

期待値が仕様から来ていないことを、コメントで明示します。書かないと、後から読んだ人がこれを仕様だと誤解します。

現在の出力が間違っている可能性もあります。それでも固定します。間違っているなら、直すのは別の作業です。いま必要なのは、自分の変更が何を壊したかを知る手段です。

継ぎ目を探す​

特性テストを書こうとすると、そもそも動かせないことに気づきます。データベースが要ります。

ここで探すのが継ぎ目 (seam) です。Feathers はこれを「その場所を編集せずに振る舞いを変えられる場所」と定義しています1。

上のコードで候補になるのは 3 か所です。

候補差し替えられるか
Database::query()静的呼び出しなので、オブジェクトを渡して差し替える口が無い。クラスの読み込みを横取りする手はあるが、分かりにくい
date()言語の関数。名前空間の工夫で差し替える余地はあるが、分かりにくい
InvoiceCalculator::calculate() 自体静的なので、呼び出し側からオブジェクトとして差し替えられない

オブジェクトを渡して差し替えられる継ぎ目がありません。これがこのコードの本当の問題です。

継ぎ目を作る​

無いなら作ります。最小の変更で、振る舞いを変えずに。

静的メソッドをインスタンスメソッドに変え、外から渡せる形にします。下は、顧客・注文・時刻の継ぎ目を 1 つずつ作り終えた後の形です。

final class InvoiceCalculator
{
public function __construct(
private CustomerQuery $customers,
private OrderQuery $orders,
private ClockInterface $clock,
) {}

public function calculate(int $customerId): int
{
$customer = $this->customers->find($customerId);

$rate = 0.10;
if ($this->clock->now()->format('Y-m-d') >= '2026-10-01') {
$rate = 0.12;
}

$subtotal = 0;
foreach ($this->orders->forCustomer($customerId) as $order) {
$subtotal += $order['amount'];
// ここに 150 行ほどの調整処理が続く (変えない)
}

return (int) round($subtotal * (1 + $rate));
}
}

この変更自体にはテストがありません。テストを書くための変更だからです。ここが最も危ない瞬間なので、やることを絞ります。

  • 呼び出しの形だけを変える。中の計算には触らない
  • 1 回の変更で 1 つの継ぎ目だけ作る
  • 変更のたびに、呼び出し元をすべて grep で確認する

継ぎ目ができたら、ダブルを渡して特性テストが書けます。07 章の FixedClock と、配列で動く CustomerQuery と OrderQuery の Fake を使います。

ここまで来て初めて、最初に書きたかった変更に着手できます。

新しいコードは別の場所に書く​

「特定の顧客は税率を据え置く」を足します。150 行のメソッドに if を足したくなりますが、別の場所に書きます。

final class TaxRate
{
public function __construct(private ClockInterface $clock) {}

public function forCustomer(Customer $customer): float
{
if ($customer->hasFixedRate()) {
return 0.10;
}

return $this->clock->now()->format('Y-m-d') >= '2026-10-01' ? 0.12 : 0.10;
}
}

新しいクラスには、最初からテストがあります。TDD で書けるからです。既存のコードからは 1 行呼ぶだけにします。

$rate = $this->taxRate->forCustomer($customer);

この形を sprout と呼びます。既存を育てるのではなく、脇に芽を出して、そこだけ新しい規律で作ります。

もう 1 つの形が wrap です。既存の処理をそのまま残し、前後に処理を足したいときに、既存を呼ぶ新しいクラスで包みます。既存には一切触りません。

形使いどころ
sprout既存の処理の途中に、新しい判断を差し込みたい
wrap既存の処理の前後に、別の処理を足したい

どちらも狙いは同じです。触りにくいコードを触る量を最小にし、新しく書く部分はテストがある状態にする。

React 側でも同じ​

巨大なコンポーネント (React で画面の部品を表す関数) でも手順は変わりません。

// 特性テスト: いま何が表示されているかをそのまま固定する
it('現状では合計 3 件の行と「合計: 13200 円」が出ている', () => {
render(<InvoiceScreen customerId={42} />)

expect(screen.getAllByRole('row')).toHaveLength(3)
expect(screen.getByText('合計: 13200 円')).toBeInTheDocument()
})

getAllByRole は同じ役割の要素をまとめて取ります。09 章で使った getByRole は 1 つだけ見つかることを要求するので、行のように複数あるものには使えません。toBeInTheDocument を使うための設定は、09 章の「Testing Library の最小面」 にあります。

継ぎ目は props (コンポーネントが外から受け取る引数) です。コンポーネントが内部で fetch を呼んでいるなら、10 章と同じく取得の手段を props で受け取る形に変えます。それが継ぎ目を作る作業にあたります。

新しい表示を足すときは、既存のコンポーネントを分岐で膨らませず、新しい小さなコンポーネントを作って 1 行差し込みます。sprout と同じ考え方です。

どこで止めるか​

この作業には終わりが見えません。全部きれいにしたくなりますが、止める線を先に決めます。

自分が触る範囲だけを対象にします。今回なら税率の計算です。150 行の調整処理は、触らないなら手を付けません。

特性テストは資産ですが、負債でもあります。現状を固定しているので、仕様を変えるときには書き換えが要ります。書きすぎると、仕様変更のたびに大量の期待値を直すことになります。触る範囲の周りに限って書きます。

継ぎ目を作る変更は、単独で確認します。継ぎ目を作る変更と機能を足す変更を混ぜると、何が原因で壊れたのか分からなくなります。

まとめ​

  • 特性テストは正しさを確かめません。いまの振る舞いを固定し、変更が何を壊したかを見えるようにします
  • 期待値が仕様から来ていないことは、コメントで明示します
  • 継ぎ目は「その場所を編集せずに振る舞いを変えられる場所」です。無ければ最小の変更で作ります
  • 継ぎ目を作る変更にはテストがありません。範囲を絞り、単独で確認します
  • 新しいコードは既存の中に書かず、脇に作って呼びます。そこは TDD で書けます
  • 全部をきれいにしません。触る範囲の周りだけを対象にします

次に読む​

Footnotes​

  1. 継ぎ目 (seam) を「その場所を編集せずに振る舞いを変えられる場所」とする定義は、Michael Feathers の『Working Effectively with Legacy Code』(2004) によります。特性テスト (characterization test)、および sprout と wrap も同書で扱われる形です。この章の説明はいずれも逐語の引用ではありません。 ↩ ↩2