紡Book の勉強ノートに戻る
技術ブログ2026.07.30

テストに「この1つは対象外」と書いた行が、あとから来た決定を素通りさせた

テスト設計React紡Book

テストが全部緑で「完了」と記録した数日後に、実物を見たら抜けていた、という記録です。2日前の「「描く面を全部数える」と書いた同じ晩に、自分が数え漏らした」の続きで、あちらは同じテンプレを描く呼び出し元の面、こちらは登録表に載らないテンプレの種類を数え漏らしています。

写真1枚に言葉を何度も重ねられるようにしたので、その言葉たちをどのテンプレでも全部出すと決めた。テンプレは見せ方のスキンで、写真と言葉を受け取って描くだけの部品が15種類あります。

「どれで出すか/どれで畳むか」を種類ごとに分ける案もあったが、採らなかった。分岐を許すと「短い文だけ整理」「このテンプレは隠す」のような機能が次々生えていく気がしたので、一律で全部出すに倒した。

15種類ぜんぶに手を入れ終わったところで、テンプレを1種類ずつ確かめていたテストを、1か所でまとめて全部を見るテスト(以下「中央のテスト」)に置き換えた。「全部やったこと」の証明をそこに預けた形になる。

登録表を回して、書き忘れを構造的に防ぐ

テンプレは1種類につき1フォルダで、必要なものはその中に収めてあります。ただしフォルダを作っただけでは画面に出ません。_registry(登録表)という1枚の名簿に「この種類名(kind)はこの部品」と書いて初めて描かれます。テストはその名簿を上から順に回します。

ts
// _registry に登録された全 kind を test.each で回すので、テンプレ追加時に
// 共通契約(=どのテンプレも守るべき約束)の検査を書き忘れても自動でこの網にかかる。
const BESPOKE_KINDS = Object.keys(BESPOKE_RAW_IMPORTERS);  // 登録表の全 kind 名を配列にする
 
test.each(BESPOKE_KINDS)(
    "%s: 2本目以降の綴りと「◯月◯日 に追記」が SSR に出る", // %s に kind が1つずつ入る
    async (kind) => {
        const Mod = await BESPOKE_RAW_IMPORTERS[kind]!();  // その kind のテンプレ部品を読む
        const html = renderToStaticMarkup(                 // 部品を「HTML の文字列」に変換
            createElement(Mod.default, { title: "重ね確認ページ", photos: layered }),  // モック写真データを渡して要素を作る
        );
        expect(html).toContain("あとで重ねた一文");   // expect(値).toContain(期待)の形。2本目の言葉が HTML に居るか
        expect(html).toContain("7月25日 に追記");     // 追記ラベルが HTML に居るか
    },
);

61本が緑になった(緑=テストが通った状態。失敗すると赤で出ます)。進捗メモに「テンプレ 15/15 完了」と書いた。

数日後、実物を見たら3つ抜けていた

一周して見てもらったら「物語」というテンプレだけ気分のマークが出ていないという話になった。メモにも「残りは物語の気分だけ」と書いてあったので、そこから着手した。

実物を開いて分かったのは、抜けているのは気分ではなく重ねた言葉そのものだったこと。1本目しか描かれていなかった。

tsx
// 直す前。写真1枚につき words(代表の1本)だけを描いている
<figcaption className={s.text}>
    <p className={`${s.words} serif`}>{e.words}</p>
    ...
</figcaption>

「一律で全部出す」と決めて15種類やり切ったはずなのに、なぜここが残っているのか。テストのコメントに答えが書いてあった。

ts
// scrollstory は self-rendered 特例で _registry に載らない=対象外(§R5)。
// (self-rendered=共通の描画の流れに乗らず自分で描く / §R5 は自分用の設計メモの項番)

登録表は「1種類=1つの描き手」の対応表で、「物語」だけは1種類の中に3つの変種を持ちます。この表の形に収まらないので、登録表を経由せず専用の描画部品(レンダラ)へ直接渡しています。

この行を書いた時点では正しい。登録表に載っていないものを登録表から取り出せるわけがありません。問題は、その後に**「全 kind 一律で全部出す」という決定が来て、この特例を素通りした**こと。テストは「網の中の全部」が緑だと言っていて、それは嘘ではない。ただ自分は「全部」を「ユーザーが選べる全部」と読んでいた。

数え直したら、選べるテンプレは8枚に絞ってあって、そのうち3枚(物語テンプレの見た目違い3種)が網の外にいた。15/15 は登録表に載っている数で、選べる面の数ではなかった。

同じ形の穴がもう1つあった。廃止したテンプレや、知らない名前で保存されたページの落とし先になっている既定のスキンも、配管が直接描きます。古い保存ページを開くとここに落ちます。名前を変えただけのものは引き継ぎ表で新テンプレへ回るので、ここには来ません。

完了の数字は、その名簿の長さ。名簿の置き場は3つある。登録表_registry.tsソースファイル15 kindテストがここを回す15/15 緑しまってある分も含む選べる面templateCatalog.tsソースファイル8枚登録表の中 5枚物語の変種 3枚3枚は網の外古い保存ページサーバ DBphotos_templatepagelayout_json.template未知・廃止は classic登録表には無い改名はここへ来ないテストはメモリ上で登録表のキーを回す。カタログも DB も読まない。

例外の側を見る口を、同じテストに足した

直したのは2つ。1つはレンダラ側で、重ねた言葉を全部描く形に入れ替えた。もう1つがテストで、登録表を経由しない2経路を同じファイルに足した。どの経路を網に入れるかは数え直して自分で決めて、レンダラの差し替えとテストのコード自体は AI に書いてもらっている。

同じテストファイルが見る描き手。実行時はメモリ。保存しない。登録表の15フォルダtemplates/各 kindソース。名簿から回す15本もともと緑全数走査物語の描き手ScrollStoryView.tsxソース。名指しで描く見た目違い3種は1か所直す前は赤全数走査ではない既定のフラットTemplatePageView.tsxソース。配管が直描き未知の落とし先直す前は赤DB の未知 kind が来る見る口は templateContract.test.ts。名簿はソース。DB には無い。
ts
// 中央契約の穴埋め: host(テンプレを載せるページ側)が自前で描く2経路を同じ網に入れる。
// ⚠️ scrollstory は TemplatePageView 経由だと next/dynamic ラッパが空文字列になるので
//    (next/dynamic=表示する時に初めて読み込む書き方。サーバー側の HTML には中身が出ない)
//    ScrollStoryView を直接描く。
describe("host が自前で描く経路も重ねた綴りを SSR に残す", () => {
    test("scrollstory: 2本目以降の綴りと「◯月◯日 に追記」が SSR に出る", async () => {
        // 登録表を通さず、描画部品を名指しで読み込む
        const { ScrollStoryView } = await import("@/components/lab/ScrollStoryView");  // 動的 import で部品を取得
        const html = renderToStaticMarkup(                                            // 要素を HTML 文字列に変換(SSR)
            createElement(ScrollStoryView, {                                          // ScrollStoryView にモックデータを渡す
                title: "重ね確認ページ",
                photoIds: [base.photoId],
                pool: { [base.photoId]: layered },
            }),
        );
        expect(html).toContain(base.words);          // 保険(直す前でも緑のはずの1本)
        expect(html).toContain("あとで重ねた一文");   // 2本目の言葉が HTML に居るか
        expect(html).toContain("7月25日 に追記");     // 追記ラベルが HTML に居るか
    });
});

これは全数走査ではないので、また同じ穴を作りうる。増えるときは配管の中央(TemplatePageView)を触ることになるので、そこに気づく機会がある。全数走査に戻せる形が見つかるまではこれでいく。

採用する前に、赤を確認した

書いたテストを走らせたら緑だった。緑のテストは、直したから緑なのか、最初から緑になる書き方なのか区別できません。

なので直した実装側を git stash(=直した内容を一時的に棚へ上げて、直す前の状態に戻すコマンド)で戻して、同じテストを走らせた。

❯ src/components/lab/templateContract.test.ts (63 tests | 2 failed)
  × scrollstory: 2本目以降の綴りと「◯月◯日 に追記」が SSR に出る
  × フラット既定(classic): 2本目以降の綴りと「◯月◯日 に追記」が SSR に出る

AssertionError: expected '<link rel="preload" as="image" href="…' to contain 'あとで重ねた一文'

見たのは2つ。落ちたのが新しく足した2本だけ(scrollstory=物語、classic=上で書いた既定のスキン)であること(他の61本は緑のまま=テスト自体が壊れたのではない)。そして落ちた理由が想定と同じであること(「あとで重ねた一文」が無い=2本目が描かれていない)。

上のコードに expect(html).toContain(base.words) を1行混ぜているのは、この確認のためでもある。直す前でも緑のはずの行を入れておくと、赤になった時に「テストの前提が崩れたのか、対象の挙動が違うのか」が切り分けられます。この保険は2日前に別の画面で赤の理由を読み違えたとき(「テストが3本とも落ちたのに、落ちた理由が違った — 赤を確認しただけで実装に入りかけた話」)に覚えたもので、今回はそれをそのまま持ち込んだ。

学び

  • 完了の数字は転記せずに数え直します。今回の「15/15」は数え間違いではなく、何を数えた数かを見失っていた
  • 全数走査に例外を作るときは、例外の側を見る口を同時に作ります
  • 緑は「網の中が緑」でしかありません。網の外側を数えるのは人の仕事で、そこは自動化できていません
  • 赤を1回見るのは前回覚えたことだが、今回は見る中身が増えた。落ちるのが足した分だけか、落ちた理由が想定と同じか、の2つを見ます。ただしこの作法は今回の穴には効かない。網の外にいるものは赤にも緑にもならないからで、赤を見て防げるのは「直さなくても通るテスト」という別の失敗のほう

一番効いたのは、テストのコメントに「対象外(§R5)」と理由付きで書いてあったことだと思う。書いていなければ、なぜ抜けたのか分からないまま「物語だけ手作業で直す」で終わっていた。同じ穴がもう1つある(既定スキン)ことにも辿り着けなかった。

この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。