Featured image of post コードドクター【Builder】壊死性筋膜炎〜コピペ壊疽と知識の処方箋〜

コードドクター【Builder】壊死性筋膜炎〜コピペ壊疽と知識の処方箋〜

テックリードが後輩に説いたBuilderパターン。しかし自分のテストコードは50箇所のコピペ壊疽に蝕まれていた。コードドクターが暴いた「知識と実践の乖離」、その処方箋。

知っている、ということと、実践している、ということは、まったく別の話だ。

僕はそれを、誰よりもよく知っているはずだった。

社内では「パターンの先生」と呼ばれている。GoFの23パターンは全て説明できるし、チームのコードレビューでは「ここはBuilderを使うべきだ」「Strategyで分離しろ」と的確に指摘してきた自負がある。OSSにも2つほどコントリビュートしている。エンジニア歴15年。テックリード。42歳。

先週、後輩のPull Requestにこうコメントした。

「テストデータはBuilderパターンを使うべきだ。ハードコードされたハッシュはメンテナンス性を著しく損なう。」

我ながら完璧な指摘だった。

今週、大規模なスキーマ変更が入った。

僕は自分のテストコードを開いた。50箇所のテストファイルに、ほぼ同じハッシュリファレンスがコピペされている。1箇所ずつ、手で、直していく。

3時間かけて全部直した——はずだった。

翌朝、本番で障害が出た。テストは全てグリーンだったのに。調査の結果、テストデータの修正漏れが2箇所あり、テストが「偽グリーン」になっていた。壊れていることに気づけない、最悪のパターンだ。

僕が後輩に指摘した、まさにその問題に、僕自身が殺されかけた。

来院

マスクとサングラス。我ながら不審者のような格好だが、仕方がない。社内で「パターンの先生」と呼ばれている人間が、コード診療所に駆け込んでいるところを見られるわけにはいかない。

雑居ビルの2階。磨かれたリノリウムの廊下を進むと、重厚な鉄の扉に「コード診療所」とだけ書かれたプレートが目に入った。

ドアを引く。O’Reillyの技術書が天井近くまで積み上げられた壁。その隙間から覗く受付カウンター。

「お待ちしておりました。ご予約の方ですね?」

白衣を着た女性——助手のナナコさんが、にこやかに立ち上がった。

「あの、匿名でお願いしたんですが……」

「大丈夫ですよ、ここはコード診療所です。患者さんのお名前より、コードの症状が大事ですから」

ナナコさんの背後のトリプルディスプレイに向かう男の背中。HHKBの打鍵音が途切れた。

「……テスト。何件?」

振り返りもしない。声だけが聞こえた。

「ご、50件くらい、でしょうか……」

ドクターの肩が微かに揺れた。嘲笑——ではなかった。何か別の感情に見えたが、背中からでは読み取れない。

触診

ドクターがゆっくりと椅子を回転させた。鋭い目が僕の手元のノートPCを見ている。

「見せろ」

僕はマスクとサングラスを外し、ノートPCを開いた。テストコードを表示する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
subtest '通常の注文は正常に処理される' => sub {
    my $user = {
        id         => 1001,
        name       => 'テスト太郎',
        email      => 'test@example.com',
        age        => 30,
        address    => '東京都渋谷区',
        phone      => '090-1234-5678',
        created_at => '2025-01-01T00:00:00+09:00',
        status     => 'active',
    };
    my $order = {
        id          => 5001,
        user_id     => 1001,
        total_price => 3000,
        status      => 'pending',
        ordered_at  => '2025-06-15T10:30:00+09:00',
        items       => [
            {
                product_id => 101,
                name       => 'Perlクックブック',
                price      => 3000,
                quantity   => 1,
            },
        ],
        shipping => {
            zipcode    => '150-0001',
            address    => '東京都渋谷区神宮前1-1-1',
            method     => 'standard',
        },
    };

    is $order->{status}, 'pending', '注文ステータスがpending';
    is $order->{user_id}, $user->{id}, 'ユーザーIDが一致';
    ok $order->{total_price} > 0, '合計金額が正';
};

ドクターがスクロールした。次のテスト。また同じ構造のハッシュリファレンス。その次も。その次も。50箇所。ほぼ同じ構造のデータが、微妙に異なる値で繰り返されている。

ドクターの指がピタリと止まった。

「……コピペ」

「はい。テストデータって、毎回少しだけ違う値が必要で……各テストケースに合わせて値を変えているんですが」

「壊疽(Gangrene)」

診断

ナナコさんが穏やかに、しかし確信を持った声で補足した。

「先生がおっしゃっているのは、壊死性筋膜炎ですね。同じ 組織 ——データ構造のコピーが全身に50箇所転移しています。1箇所の感染——つまりスキーマ変更が入ると、全身で壊死が連鎖するんです」

「でも、テストは全てグリーンだったんです——」

ドクターが黙ってテスト結果の画面を開いた。全テストがグリーン。

「偽陽性(False Positive)」

ナナコさんが真剣な表情で頷いた。

「壊死した組織が 健康に見える ——偽グリーンですね。修正漏れがあっても、テスト自体が古いデータ構造で書かれているので、テストが通ってしまう。本当に恐ろしいのは、ここなんですよ」

僕は青ざめた。まさに先週の本番障害の原因がこれだった。

ドクターが無言でブラウザを開いた。GitLabの画面が表示される。

僕のPRコメントだった。

「テストデータはBuilderパターンを使うべきだ。ハードコードされたハッシュはメンテナンス性を著しく損なう。」 —— 投稿者: 僕

沈黙。

「……先生、それは僕のコメントです」

ドクターは無言でスクロールし、僕自身のテストコードと、僕のレビューコメントを画面に並べて表示した。

ナナコさんが小さく微笑んだ。

「先生は、患者さんの 診断 が正しかったとおっしゃりたいんだと思いますよ。処方箋もご存知なわけですし」

「知っていたんです。知識としては完璧に理解してるんです。ただ、自分のテストコードだけは……特別だと思ってたんです。テストコードは 本番コード じゃないから、って」

ドクターが初めてこちらを見た。

「……全部、本番」

処方箋

ドクターが黙々とキーボードを叩き始めた。

ナナコさんが横で解説してくれた。

「先生は今から、テストデータの 設計図 を作ります。デフォルトの材料が入っていて、テストごとに必要な部分だけ差し替えられる仕組みですよ」

処方の骨格

まず、Builderの共通基盤が定義された。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package Role::Builder;
use v5.36;
use Moo::Role;

requires 'build';
requires '_defaults';

sub _merge_deep($self, $base, $override) {
    my %merged = $base->%*;
    for my $key (keys $override->%*) {
        if (ref $merged{$key} eq 'HASH' && ref $override->{$key} eq 'HASH') {
            $merged{$key} = $self->_merge_deep($merged{$key}, $override->{$key});
        } else {
            $merged{$key} = $override->{$key};
        }
    }
    return \%merged;
}

1;

build_defaults を持つことが契約です。全てのBuilderはこの規約に従います」

患者データの投薬

次に、ユーザーデータ専用のBuilderが作られていく。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package TestDataBuilder::User;
use v5.36;
use Moo;
with 'Role::Builder';

has _overrides => (is => 'ro', default => sub { {} });

sub _defaults($self) {
    return {
        id         => 1001,
        name       => 'テスト太郎',
        email      => 'test@example.com',
        age        => 30,
        address    => '東京都渋谷区',
        phone      => '090-1234-5678',
        created_at => '2025-01-01T00:00:00+09:00',
        status     => 'active',
    };
}

sub with_id($self, $id)         { $self->_with(id => $id) }
sub with_name($self, $name)     { $self->_with(name => $name) }
sub with_email($self, $email)   { $self->_with(email => $email) }
sub with_status($self, $status) { $self->_with(status => $status) }

sub _with($self, $key, $value) {
    return (ref $self)->new(
        _overrides => { $self->_overrides->%*, $key => $value },
    );
}

sub build($self) {
    my $data = $self->_merge_deep($self->_defaults, $self->_overrides);
    for my $required (qw(id name email status)) {
        die "必須フィールド '$required' が未設定です"
            unless defined $data->{$required};
    }
    return $data;
}

「デフォルト値は 1箇所 に集約されました」ナナコさんが指差した。「50箇所のコピペが、たった1つの設計図になりますよ。そして with_* メソッドで、変えたい部分だけを指定するんです」

「メソッドチェイン……」

「ええ。そして最後の build() で、デフォルト値と指定値をマージしつつ、必須フィールドのバリデーション を行います。ハッシュのキー名をタイプミスしても、ここで弾けますよ」

注文データの投薬

続いて、注文データ用のBuilderも組み上がっていく。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package TestDataBuilder::Order;
use v5.36;
use Moo;
with 'Role::Builder';

has _overrides => (is => 'ro', default => sub { {} });
has _items     => (is => 'ro', default => sub { [] });

sub _defaults($self) {
    return {
        id          => 5001,
        user_id     => 1001,
        total_price => 3000,
        status      => 'pending',
        ordered_at  => '2025-06-15T10:30:00+09:00',
        items       => [
            {
                product_id => 101,
                name       => 'Perlクックブック',
                price      => 3000,
                quantity   => 1,
            },
        ],
        shipping => {
            zipcode => '150-0001',
            address => '東京都渋谷区神宮前1-1-1',
            method  => 'standard',
        },
    };
}

sub with_id($self, $id)           { $self->_with(id => $id) }
sub with_user_id($self, $user_id) { $self->_with(user_id => $user_id) }
sub with_total($self, $total)     { $self->_with(total_price => $total) }
sub with_status($self, $status)   { $self->_with(status => $status) }

sub with_shipping($self, %args) {
    my $current = $self->_overrides->{shipping} // {};
    return (ref $self)->new(
        _overrides => {
            $self->_overrides->%*,
            shipping => { $current->%*, %args },
        },
        _items => [ $self->_items->@* ],
    );
}

sub with_items($self, @items) {
    return (ref $self)->new(
        _overrides => { $self->_overrides->%* },
        _items     => [ @items ],
    );
}

sub build($self) {
    my $data = $self->_merge_deep($self->_defaults, $self->_overrides);
    if ($self->_items->@*) {
        $data->{items} = [ $self->_items->@* ];
    }
    for my $required (qw(id user_id status)) {
        die "必須フィールド '$required' が未設定です"
            unless defined $data->{$required};
    }
    die "total_price が0以下です"
        unless $data->{total_price} && $data->{total_price} > 0;
    return $data;
}

ドクターのキーボードが止まった。

「……DRY」

ナナコさんが微笑んだ。

「Don’t Repeat Yourself。同じことを繰り返さない原則ですね。患者さんが後輩に教えていたことですよ」

外科手術

「さて」ナナコさんがテストコードの画面に戻った。「それでは、あの50箇所のテストを書き換えましょう」

ドクターが黙々とキーボードを叩く。50行のハッシュリファレンスが、数行のメソッドチェインに次々と置き換わっていく。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# === テスト1: 通常の注文 ===
subtest '通常の注文は正常に処理される' => sub {
    my $user  = TestDataBuilder::User->new->build;
    my $order = TestDataBuilder::Order->new
        ->with_user_id($user->{id})
        ->build;

    is $order->{status}, 'pending', '注文ステータスがpending';
    is $order->{user_id}, $user->{id}, 'ユーザーIDが一致';
    ok $order->{total_price} > 0, '合計金額が正';
};

# === テスト2: 高額注文 ===
subtest '高額注文は承認待ちになる' => sub {
    my $user = TestDataBuilder::User->new
        ->with_id(1002)
        ->with_name('高額太郎')
        ->with_email('rich@example.com')
        ->build;

    my $order = TestDataBuilder::Order->new
        ->with_id(5002)
        ->with_user_id($user->{id})
        ->with_total(500_000)
        ->with_status('pending_approval')
        ->with_items({
            product_id => 201,
            name       => '高級ウイスキー',
            price      => 500_000,
            quantity   => 1,
        })
        ->with_shipping(method => 'express')
        ->build;

    is $order->{status}, 'pending_approval', '高額注文は承認待ち';
    cmp_ok $order->{total_price}, '>=', 100_000, '10万円以上';
};

# === テスト3: 退会済みユーザー ===
subtest '退会済みユーザーは注文できない' => sub {
    my $user = TestDataBuilder::User->new
        ->with_id(1003)
        ->with_status('inactive')
        ->build;

    is $user->{status}, 'inactive', 'ユーザーが退会済み';
};

僕は画面を見つめた。

「……50行が、3行に?」

「テストごとに 違う部分だけ を指定しています。名前もメールアドレスも住所も、デフォルトで十分なら書く必要がないんですよ」

「スキーマが変わったら?」

_defaults を1箇所直すだけです。50箇所の手修正は、もう必要ありません」

そして、ドクターがテストを実行した。

全テストグリーン。

——いや、違う。

「偽グリーンが2件。検出」

ドクターが呟いた。

ナナコさんが画面を指差した。

「Builderのバリデーションが、古いデータ構造のまま残っていた不整合 を検出しました。偽グリーンだった壊死部分が、正しく炎症反応を示したんです」

僕は震えた。これが先週の本番障害の原因だった。コピペのテストデータでは見えなかった不整合が、Builderの build() バリデーションで白日の下に晒された。

術後経過

修正は簡単だった。Builderのデフォルト値を1箇所直すだけで、全テストが正しい値で動作するようになった。偽グリーンも消え、実際のバグ2件が検出され、修正された。

「壊疽の再発防止ですね」ナナコさんが微笑んだ。「Builderでデータを構築すると、build() 時にバリデーションが走りますから。不整合が黙って通り抜ける恐怖は、もうありませんよ」

ドクターが帰り支度を始めた。鞄から1冊の本を取り出す。GoFの Design Patterns ——かなり年季が入っている。付箋だらけだ。

ドクターはそれを僕の前に置いた。

(まさか……先生が、僕に、この愛読書を贈ってくれるのか? 同じ道を歩む者への、餞として?)

ドクターがページを開いた。Builderパターンのページ。黄色い付箋。

「ここ。読み返せ」

(……ん?)

ナナコさんが苦笑した。

「先生、それはご自分の本ですよね。患者さんに差し上げるわけではなく?」

ドクターは無言で本を鞄に戻した。

「……すみません、先生は 知識は十分だから、あとは実践するだけだ とおっしゃりたかったんだと思います。見せびらかしたわけではないと思うんですが……先生、紛らわしいですよ」

「あ、いえ。はい。……実践します」

顔が熱い。なぜ一瞬でも、ドクターが僕に本を贈ってくれるのかと期待してしまったのだろう。

ドクターが鞄を持った。

「感謝は、このコードに」

ナナコさんが振り返った。

「先生が求めるのはお金ではなく、コード品質へのコミットメントです。テストコードのリファクタリングを続けてくださいね。お大事に」

背後から、HHKBの打鍵音が再び始まっていた。ドクターはもう、次の患者のコードと向き合っているのだろう。

重厚な鉄の扉が閉まり、その音を遮断した。

僕はスマホを開き、GitLabの自分のコメントを見つめた。

「テストデータはBuilderパターンを使うべきだ。」

正しかった。知識としては、完璧に正しかった。

ただ、知っているだけでは、何も治せない。処方箋を書ける医者が、自分の薬を飲まなかったのだ。

僕もオフィスに戻ろう。今度は、自分のコードにも、同じ厳しさで向き合うために。


処方箋まとめ

症状適用すべき経過観察
テストデータのハッシュリファレンスが複数箇所にコピペされている
スキーマ変更のたびに全テストの手修正が必要
コンストラクタの引数が10個以上に膨張している
テストデータの不整合によるテストの偽グリーンが発生
テストデータが1〜2箇所で、変更頻度も低い
オブジェクトの構造がシンプルで、引数が3個以下

治療のステップ

  1. Builder Role の定義Role::Builder でデフォルト値と build() の契約を宣言
  2. デフォルト値の集約_defaults() にテストデータの基本形を1箇所に集約
  3. Fluent API の実装with_*() メソッドで差分だけを指定するチェイン可能なインターフェース
  4. バリデーションの組込みbuild() 時に必須フィールドと整合性を検証、偽グリーンの根絶
  5. 既存テストの置換 — コピペのハッシュリファレンスをBuilderのメソッドチェインに順次置換

助手より

「知っている」と「実践している」の間にある溝——それは、どんなベテランにも存在するものですよね。

先生は無口ですが、患者さんのGitLabコメントを画面に映したあの瞬間、きっと「自分の正しさを、自分にも適用しろ」と伝えたかったんだと思います。厳しいようですが、それが先生なりの誠実さです。

テストコードも「本番コード」です。これからは、ご自分のコードにも、後輩に向けるのと同じ厳しさで向き合ってくださいね。お大事に。

——ナナコ

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。