知っている、ということと、実践している、ということは、まったく別の話だ。
僕はそれを、誰よりもよく知っているはずだった。
社内では「パターンの先生」と呼ばれている。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個以下 | | ✓ |
治療のステップ
- Builder Role の定義 —
Role::Builder でデフォルト値と build() の契約を宣言 - デフォルト値の集約 —
_defaults() にテストデータの基本形を1箇所に集約 - Fluent API の実装 —
with_*() メソッドで差分だけを指定するチェイン可能なインターフェース - バリデーションの組込み —
build() 時に必須フィールドと整合性を検証、偽グリーンの根絶 - 既存テストの置換 — コピペのハッシュリファレンスをBuilderのメソッドチェインに順次置換
助手より
「知っている」と「実践している」の間にある溝——それは、どんなベテランにも存在するものですよね。
先生は無口ですが、患者さんのGitLabコメントを画面に映したあの瞬間、きっと「自分の正しさを、自分にも適用しろ」と伝えたかったんだと思います。厳しいようですが、それが先生なりの誠実さです。
テストコードも「本番コード」です。これからは、ご自分のコードにも、後輩に向けるのと同じ厳しさで向き合ってくださいね。お大事に。
——ナナコ