-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathfeed.rss
More file actions
1578 lines (1118 loc) · 201 KB
/
feed.rss
File metadata and controls
1578 lines (1118 loc) · 201 KB
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
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Pepabo Tech Portal</title>
<link>https://tech.pepabo.com/</link>
<description>GMOペパボのエンジニア・デザイナーによる技術情報のポータルサイト</description>
<atom:link href="https://tech.pepabo.com/feed.rss" rel="self" type="application/rss+xml"/>
<pubDate>Fri, 24 Apr 2026 00:00:00 +0900</pubDate>
<item>
<title>後付け可能な認証を NextAuth で設計する — ログイン機構が決まらないまま、ログイン前提のプロダクトを作った話</title>
<link>https://tech.pepabo.com/2026/04/24/nextauth-mock-auth-design/</link>
<guid>https://tech.pepabo.com/2026/04/24/nextauth-mock-auth-design/</guid>
<pubDate>Fri, 24 Apr 2026 00:00:00 +0900</pubDate>
<description><h2 id="はじめに">はじめに</h2>
<p>こんにちは。ロリポップ・ムームードメイン事業部でエンジニアリングリードをしています <a href="https://x.com/kinosuke01">kinosuke01</a> といいます。</p>
<p>「この機能はログインしたユーザーのものとして扱いたい」というのは、ほとんどのプロダクトで当たり前の要件となります。ところが、プロダクト本体の開発を進めたいタイミングで、<strong>ログインの仕組みがまだ決まっていない</strong>という状況に直面することがあります。</p>
<p>後から差し替え可能にしておくというのは一つの手です。しかし「あとで差し替え」を甘く見ていると、いざ差し替えるときに思いのほか大がかりな書き換えが発生してしまう場合もあるのではないでしょうか。</p>
<p>この記事では、<a href="https://lolipop.jp/ai/site-agent/">AIサイトエージェント</a> というプロダクトの開発で実際に直面したこの状況と、そこで取った方針について紹介していきます。</p>
<p>要点を先にまとめると、以下の一点になります。</p>
<blockquote>
<p>決まっていない領域を、差し替え可能なレイヤーに封じ込める。そのレイヤーだけを「本物と同じ形の偽物」で置き、他のコードからは本物と区別できない状態で先に作り切る。</p>
</blockquote>
<p>具体的には、<a href="https://authjs.dev/">NextAuth.js</a> を土台にした「本物と同じ形の偽物ログイン」を用意することで、後から本番のログイン機構(OIDC)に NextAuth インスタンスの差し替えだけで移行できるようになりました。以降の節で、この構造を順に分解していきます。</p>
<h2 id="前提aiサイトエージェントとは">前提:AIサイトエージェントとは</h2>
<p>本題に入る前に、舞台となるプロダクトの輪郭を簡単に共有しておきます。</p>
<p><a href="https://lolipop.jp/ai/site-agent/">AIサイトエージェント</a>は、「カフェのサイトを作りたい」「フリーランスのポートフォリオが欲しい」といった自然言語の指示を投げると、ページ構成・デザインテーマ・コンテンツまでを一括で生成してくれる Web サイト制作サービスです。生成したあとも、チャット越しに「トップのキャッチを変えて」「このセクションの写真を差し替えて」と伝えれば、AI が編集を代行してくれます。</p>
<p><img src="/blog/2026/04/24/nextauth-mock-auth-design/img01.png" alt="img01" />
<img src="/blog/2026/04/24/nextauth-mock-auth-design/img02.png" alt="img02" /></p>
<p>このサービスは、<a href="https://lolipop.jp/">ロリポップ!レンタルサーバー</a> と <a href="https://muumuu-domain.com/">ムームードメイン</a> のどちらからも利用できるようになっています。ロリポップのユーザーとムームードメインのユーザー、それぞれが AIサイトエージェントのコンパネに入ってサイトを作れる、というのが本番のユースケースとなります。</p>
<p><img src="/blog/2026/04/24/nextauth-mock-auth-design/oidc.png" alt="ロリポップ/ムームードメインのアカウントでOIDCログインする構成" /></p>
<h3 id="技術スタック">技術スタック</h3>
<p>この記事のコード例を読む前提として、プロダクトの技術スタックにも軽く触れておきます。</p>
<ul>
<li><strong>フレームワーク</strong>: <a href="https://nextjs.org/">Next.js</a>(App Router)</li>
<li><strong>認証</strong>: <a href="https://authjs.dev/">NextAuth.js(Auth.js v5)</a></li>
<li><strong>言語</strong>: TypeScript</li>
</ul>
<p>以降の本文では NextAuth の <code>authorize</code> / <code>jwt</code> / <code>session</code> といったコールバック関数がコード例に登場します。NextAuth に馴染みのない方は、「ログイン処理の各ステップで呼ばれるフック関数」程度のざっくりした理解で読み進めていただければ大丈夫です。</p>
<h2 id="課題の整理ログインが決まっていない中で何を作るか">課題の整理:ログインが決まっていない中で何を作るか</h2>
<p>AIサイトエージェントは、最終的にはロリポップとムームードメインの2つのサービスのアカウントでログインできる形に落ち着きました。しかし、はじめからそう決まっていたわけではなく、アカウント基盤をどうするか・どう認証するかは、ビジネス・技術の両面から議論が続いている状況でした。</p>
<p>一方で、サイトを生成・編集するというプロダクトの中核機能の開発を止めて待つ、という選択肢はありませんでした。Webサイトの生成、ユーザーによる編集、自分のサイトにだけアクセスできる仕組みといった機能は、どれも「ログイン済みユーザーがいる」ことを前提にしています。つまり、<strong>ログイン機構が決まっていないまま、ログイン前提の機能を作り進める必要がある</strong>という状況になっていました。</p>
<p>この状況から、解くべき課題は次の2つに分解できます。</p>
<ol>
<li><strong>今、ログイン前提の機能をどう作るか</strong></li>
<li><strong>本番のログイン機構が決まったとき、どうシームレスに繋ぎ込むか</strong></li>
</ol>
<p>1.だけなら、サーバー側関数で固定の userId を返すような簡易なモックで十分です。しかし 2. を同時に成立させようとすると、それだけでは足りません。本物のログインが乗ったときに、セッションの持ち方もユーザーのテーブル構造もごっそり変わるとなると、結局そのタイミングで広範囲の書き換えが発生してしまいます。</p>
<p>加えて、設計上もうひとつ大きな制約がありました。それが次の <strong>独立開発の要件</strong> です。</p>
<h3 id="開発環境を外部サービスから独立させたい">開発環境を外部サービスから独立させたい</h3>
<p>この時点では本番のログイン機構がまだ決まっていない、というのは先に述べたとおりです。ただし、アカウント基盤の候補として議論されていたロリポップやムームードメインといった既存サービスは、どちらも長く運用されている巨大なコードベースを持つサービスとなります。<strong>仮にこうした既存サービスのアカウント基盤と繋ぎ込むことが決まった場合</strong>、それら本番と同等のログイン基盤をまるごとローカル開発に繋ぎ込むというのは現実的ではありません。セットアップの手間もさることながら、外部依存が増えるほど開発体験は悪くなっていきます。</p>
<p>そのため、本番のログイン機構が最終的に何になっても困らないよう、<strong>認証の外部依存ゼロでプロダクト本体を動かせる</strong> ことも考慮したいと考えていました。</p>
<h3 id="方針">方針</h3>
<p>ここまでを踏まえて、方針は次のように定めました。</p>
<blockquote>
<p>「本物が来たときに差し替えるレイヤー」だけを偽物にする。それ以外は、本番運用で使うものと同じ構造で作り込む。</p>
</blockquote>
<p>具体的には、以下の3点を本物と同じ形で作り込むことにしました。</p>
<ul>
<li><strong>セッション管理の仕組み</strong>: JWT ベースのセッション、コールバックの流れ</li>
<li><strong>ユーザーのテーブル構造</strong>: 外部認証プロバイダーとの紐付けを前提にしたスキーマ</li>
<li><strong>JIT プロビジョニング</strong>: 初回ログイン時にユーザーと所属組織を作成する流れ</li>
</ul>
<p>偽物にするのは「認証のやり方そのもの」だけとなります(以降、この偽物の認証を <strong>モック認証</strong> と呼びます)。これなら、認証のやり方が決まったときに、そこだけ差し替えれば済むようになります。</p>
<h2 id="モック認証に求める振る舞い">モック認証に求める振る舞い</h2>
<p>実装の話に入る前に、このモック認証にどんな挙動をさせたいのかを具体化しておきます。「本物と同じ形」と言っても曖昧ですので、期待する振る舞いをあらかじめ言語化しておくと、以降の実装がなぜそうなっているのかが見えやすくなります。</p>
<p>ここで <code>puid</code>(provider user id の略。プロバイダー側のユーザー識別子に相当する値)という言葉が出てきます。本番では OIDC プロバイダーから渡ってくる <code>sub</code> クレームですが、モックでは開発者が自由に指定できる文字列として扱います。以降の節でも繰り返し登場する語となります。</p>
<p>求める振る舞いは、次のように整理できます。</p>
<p><strong>モック特有の部分</strong>(偽物としての振る舞い)</p>
<ol>
<li><strong>任意のIDでログインできる</strong>: ログイン画面で puid を自由に入力でき、未指定の場合は固定のデフォルトユーザーとしてログインできる</li>
<li><strong>外部サービスへの問い合わせは発生しない</strong>: OIDC の認可エンドポイントや userinfo を呼ばない。入力された puid を信頼してログイン完了とする(前節の独立開発要件に対応)</li>
<li><strong>初回ログイン時にユーザーを自動生成する</strong>: その puid に対応する DB レコードが無ければ、ユーザー・組織・<code>ExternalIdentity</code> を自動で作る(JIT プロビジョニング)</li>
</ol>
<p><strong>本番と揃えたい部分</strong>(アプリから見て本物と同じ形)</p>
<ol>
<li><strong>セッションから得られる情報は本番と同じ</strong>: <code>appUserId</code>, <code>providerUserId</code>, <code>provider</code> がセッションに揃い、<strong>アプリケーションコードから見ると</strong> 本番認証と区別がつかない状態となる</li>
</ol>
<p><strong>開発体験の要件</strong></p>
<ol>
<li><strong>puid を変えればユーザーを切り替えられる</strong>: 権限・所有権など複数ユーザーが絡む検証を、開発中も素直に試せる</li>
</ol>
<p><img src="/blog/2026/04/24/nextauth-mock-auth-design/login-form.png" alt="モック認証のログインフォーム" /></p>
<p class="pager__caption">任意のユーザー名を入力すると、そのユーザーでログインできる</p>
<p>1〜3 が偽物として置く部分、4 がアプリに向けてそろえる部分、5 が開発者が使うときの体験です。では、この3つの層をどう実装していくか、順に見ていきましょう。</p>
<h2 id="土台として-nextauth-を選ぶ">土台として NextAuth を選ぶ</h2>
<p>この設計を支える土台として NextAuth を採用しました。NextAuth は Provider という概念を中心に、Credentials(任意の独自認証)、OAuth、OIDC など複数の認証方式を同じ抽象のもとに扱えるフレームワークです。</p>
<p>「モック認証も一つの Provider として扱い、本番の OIDC 認証も同じく Provider として差し替える」という構造が、そのまま課題にフィットしました。セッション管理やコールバックの組み立て方は Provider に依存しないので、モック時に書いたコールバック処理は OIDC 導入後もそのまま使い回せます。このことが、後述する差し替え時の手数の少なさに直結しました。</p>
<h2 id="テーブル構造は外部連携前提にしておく">テーブル構造は「外部連携前提」にしておく</h2>
<p>ログインがモックであっても、テーブル構造はあとで使う本物のスキーマを想定して設計しました。ポイントは、ユーザー本体(<code>User</code>)と外部認証プロバイダーとの結び付け情報(<code>ExternalIdentity</code>)をテーブル分離し、プロバイダー種別をユニーク制約に含めたところとなります。</p>
<div class="highlight"><pre class="highlight plaintext"><code>model ExternalIdentity {
userId String @unique
provider AuthProvider
providerUserId String
// ... 他のカラム省略
@@unique([provider, providerUserId])
}
enum AuthProvider {
MUUMUU
LOLIPOP
MOCK
}
</code></pre></div>
<p>モック認証もれっきとした「プロバイダーの一つ」として <code>AuthProvider.MOCK</code> を割り当てることで、本番のプロバイダーと同じ経路でユーザーを特定できるようになります。本番のプロバイダーが後から増えても、enum に値を足して <code>ExternalIdentity</code> を作るだけで対応可能です。モックと本番を同じ構造で受け止めるスキーマとなっています。</p>
<h2 id="モック認証を-nextauth-の上に載せる">モック認証を NextAuth の上に載せる</h2>
<p>モック認証は NextAuth の <strong>Credentials Provider</strong> で実装しました。本物の認証ではなく、画面で入力された puid をそのまま通すだけのダミーです。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="c1">// 入力された puid を簡易バリデーションするための正規表現</span>
<span class="kd">const</span> <span class="nx">PUID_PATTERN</span> <span class="o">=</span> <span class="sr">/^</span><span class="se">[</span><span class="sr">a-zA-Z0-9-</span><span class="se">]</span><span class="sr">+$/</span><span class="p">;</span>
<span class="kd">function</span> <span class="nx">createMockAuth</span><span class="p">()</span> <span class="p">{</span>
<span class="k">return</span> <span class="nx">NextAuth</span><span class="p">({</span>
<span class="na">providers</span><span class="p">:</span> <span class="p">[</span>
<span class="nx">Credentials</span><span class="p">({</span>
<span class="na">id</span><span class="p">:</span> <span class="dl">"</span><span class="s2">credentials</span><span class="dl">"</span><span class="p">,</span>
<span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Mock Login</span><span class="dl">"</span><span class="p">,</span>
<span class="na">credentials</span><span class="p">:</span> <span class="p">{</span>
<span class="na">puid</span><span class="p">:</span> <span class="p">{</span> <span class="na">label</span><span class="p">:</span> <span class="dl">"</span><span class="s2">ProviderUserId</span><span class="dl">"</span><span class="p">,</span> <span class="na">type</span><span class="p">:</span> <span class="dl">"</span><span class="s2">text</span><span class="dl">"</span> <span class="p">},</span>
<span class="p">},</span>
<span class="k">async</span> <span class="nx">authorize</span><span class="p">(</span><span class="nx">credentials</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">puid</span> <span class="o">=</span>
<span class="k">typeof</span> <span class="nx">credentials</span><span class="p">?.</span><span class="nx">puid</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">string</span><span class="dl">"</span> <span class="o">&amp;&amp;</span>
<span class="nx">credentials</span><span class="p">.</span><span class="nx">puid</span><span class="p">.</span><span class="nx">trim</span><span class="p">()</span> <span class="o">!==</span> <span class="dl">""</span>
<span class="p">?</span> <span class="nx">credentials</span><span class="p">.</span><span class="nx">puid</span><span class="p">.</span><span class="nx">trim</span><span class="p">()</span>
<span class="p">:</span> <span class="dl">"</span><span class="s2">mock-user</span><span class="dl">"</span><span class="p">;</span>
<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">PUID_PATTERN</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">puid</span><span class="p">))</span> <span class="p">{</span>
<span class="k">return</span> <span class="kc">null</span><span class="p">;</span>
<span class="p">}</span>
<span class="c1">// jwt コールバックで user として受け取る値</span>
<span class="k">return</span> <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="nx">puid</span> <span class="p">};</span>
<span class="p">},</span>
<span class="p">}),</span>
<span class="p">],</span>
<span class="na">trustHost</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="na">callbacks</span><span class="p">:</span> <span class="p">{</span>
<span class="k">async</span> <span class="nx">jwt</span><span class="p">({</span> <span class="nx">token</span><span class="p">,</span> <span class="nx">account</span> <span class="p">})</span> <span class="p">{</span>
<span class="k">return</span> <span class="nx">mockJwtCallback</span><span class="p">({</span> <span class="nx">token</span><span class="p">,</span> <span class="nx">account</span> <span class="p">});</span>
<span class="p">},</span>
<span class="k">async</span> <span class="nx">session</span><span class="p">({</span> <span class="nx">session</span><span class="p">,</span> <span class="nx">token</span> <span class="p">})</span> <span class="p">{</span>
<span class="k">return</span> <span class="nx">commonSessionCallback</span><span class="p">({</span> <span class="nx">session</span><span class="p">,</span> <span class="nx">token</span> <span class="p">});</span> <span class="c1">// 本番と共通</span>
<span class="p">},</span>
<span class="p">},</span>
<span class="na">pages</span><span class="p">:</span> <span class="p">{</span> <span class="na">signIn</span><span class="p">:</span> <span class="dl">"</span><span class="s2">/login</span><span class="dl">"</span> <span class="p">},</span>
<span class="p">});</span>
<span class="p">}</span>
</code></pre></div>
<p>コードの中に <code>authorize</code> / <code>jwt</code> / <code>session</code> という3つのコールバックが出てきます。NextAuth に馴染みのない方向けに、それぞれの役割と、<code>mock-user</code> でログインボタンを押したときの呼び出し順を軽く整理しておきます。</p>
<ul>
<li><strong><code>authorize</code></strong>: ログインフォームから送られた値を受け取り、認証の可否を判断するコールバックです(Credentials Provider 特有のもの)。OK ならユーザーを表すオブジェクトを返し、NG なら <code>null</code> を返します。</li>
<li><strong><code>jwt</code></strong>: セッションの元となる JWT を組み立てるコールバックです。<code>authorize</code> が返した情報をベースに、JWT へ積みたい情報(ここでは <code>appUserId</code> や <code>providerUserId</code> など)を追加できます。</li>
<li><strong><code>session</code></strong>: アプリケーション側で <code>auth()</code> から取り出すセッションオブジェクトを組み立てるコールバックです。JWT の中身を、アプリに見せたい形へ整えるのが役割です。</li>
</ul>
<p>ログイン画面で puid に <code>mock-user</code> を入力してログインボタンを押した場合、これらは次の順で呼ばれます。</p>
<ol>
<li><strong><code>authorize({ puid: "mock-user" })</code></strong>: puid を検証し、<code>{ id: "mock-user" }</code> を返す</li>
<li><strong><code>jwt</code></strong>: <code>authorize</code> が返した情報をもとに、JIT プロビジョニングで DB からユーザーを取得(なければ作成)し、<code>appUserId</code> などを JWT に積む</li>
<li><strong><code>session</code></strong>: JWT の値をセッションに詰め替え、アプリから参照できる形に整える</li>
</ol>
<p>この流れを頭に入れておくと、以降のコールバックの中身が読みやすくなります。では、<code>authorize</code> は任意の puid を受け取ってそのまま通すだけなので、キモとなるのは残りの2つです。<code>session</code> コールバックは本番と共通のものを使っており、<code>jwt</code> コールバックもモックと本番で中の処理(後述する JIT プロビジョニングの有無)こそ違うものの、JWT に積む情報の形(<code>providerUserId</code>, <code>appUserId</code>, <code>provider</code>)は揃えてあります。</p>
<h3 id="jit-プロビジョニングで複数ユーザーに対応する">JIT プロビジョニングで複数ユーザーに対応する</h3>
<p>開発中は「ユーザーAとしてログインして確認」「ユーザーBとしてログインして権限を確認」といったシナリオが頻繁に発生します。モックだからといって単一ユーザー固定にしてしまうと、そうした検証がやりにくくなってしまいます。</p>
<p>そこで、JWT コールバックで <strong>JIT(Just-In-Time)プロビジョニング</strong> を行うようにしました。ログイン時に指定された puid がまだ DB に存在しなければ、そのタイミングでユーザーと所属組織を作成します。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="k">async</span> <span class="kd">function</span> <span class="nx">mockJwtCallback</span><span class="p">({</span> <span class="nx">token</span><span class="p">,</span> <span class="nx">account</span> <span class="p">})</span> <span class="p">{</span>
<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">account</span><span class="p">)</span> <span class="p">{</span>
<span class="k">return</span> <span class="nx">token</span><span class="p">;</span>
<span class="p">}</span>
<span class="kd">const</span> <span class="nx">providerUserId</span> <span class="o">=</span> <span class="nx">token</span><span class="p">.</span><span class="nx">sub</span> <span class="k">as</span> <span class="kr">string</span><span class="p">;</span>
<span class="c1">// (provider, providerUserId) で既存ユーザーを探し、なければ</span>
<span class="c1">// ユーザー・組織・組織メンバー・ExternalIdentity をトランザクション内で一括作成する</span>
<span class="kd">const</span> <span class="nx">appUser</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">userService</span><span class="p">.</span><span class="nx">getOrCreateUserByExternalIdentity</span><span class="p">(</span>
<span class="nx">AuthProvider</span><span class="p">.</span><span class="nx">MOCK</span><span class="p">,</span>
<span class="nx">providerUserId</span><span class="p">,</span>
<span class="p">);</span>
<span class="k">return</span> <span class="p">{</span>
<span class="p">...</span><span class="nx">token</span><span class="p">,</span>
<span class="nx">providerUserId</span><span class="p">,</span>
<span class="na">appUserId</span><span class="p">:</span> <span class="nx">appUser</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span>
<span class="na">provider</span><span class="p">:</span> <span class="nx">AuthProvider</span><span class="p">.</span><span class="nx">MOCK</span><span class="p">,</span>
<span class="p">};</span>
<span class="p">}</span>
</code></pre></div>
<h3 id="セッションコールバックを本番と共通化する">セッションコールバックを本番と共通化する</h3>
<p>さて、この記事のキモとなるのが次の <code>commonSessionCallback</code> です。モックと本番で <strong>完全に同じ関数</strong> を使い回すことで、「セッションから取り出せる値の形」がモードに依らず一定です。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="k">async</span> <span class="kd">function</span> <span class="nx">commonSessionCallback</span><span class="p">({</span> <span class="nx">session</span><span class="p">,</span> <span class="nx">token</span> <span class="p">})</span> <span class="p">{</span>
<span class="c1">// jwt コールバックで積んだ error をそのまま伝搬</span>
<span class="k">if</span> <span class="p">(</span><span class="nx">token</span><span class="p">.</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
<span class="nx">session</span><span class="p">.</span><span class="nx">error</span> <span class="o">=</span> <span class="nx">token</span><span class="p">.</span><span class="nx">error</span> <span class="k">as</span> <span class="kr">string</span><span class="p">;</span>
<span class="k">return</span> <span class="nx">session</span><span class="p">;</span>
<span class="p">}</span>
<span class="c1">// DB 側でユーザーが削除・無効化されていないかを確認</span>
<span class="kd">const</span> <span class="nx">validation</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">validateUserExists</span><span class="p">(</span>
<span class="nx">token</span><span class="p">.</span><span class="nx">appUserId</span> <span class="k">as</span> <span class="kr">string</span> <span class="o">|</span> <span class="kc">undefined</span><span class="p">,</span>
<span class="p">);</span>
<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">validation</span><span class="p">.</span><span class="nx">isValid</span><span class="p">)</span> <span class="p">{</span>
<span class="nx">session</span><span class="p">.</span><span class="nx">error</span> <span class="o">=</span> <span class="nx">validation</span><span class="p">.</span><span class="nx">error</span><span class="p">;</span>
<span class="k">if</span> <span class="p">(</span><span class="nx">validation</span><span class="p">.</span><span class="nx">error</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">ActiveUserNotFound</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
<span class="nx">session</span><span class="p">.</span><span class="nx">appUserId</span> <span class="o">=</span> <span class="kc">undefined</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">return</span> <span class="nx">session</span><span class="p">;</span>
<span class="p">}</span>
<span class="nx">session</span><span class="p">.</span><span class="nx">providerUserId</span> <span class="o">=</span> <span class="nx">token</span><span class="p">.</span><span class="nx">providerUserId</span> <span class="k">as</span> <span class="kr">string</span> <span class="o">|</span> <span class="kc">undefined</span><span class="p">;</span>
<span class="nx">session</span><span class="p">.</span><span class="nx">appUserId</span> <span class="o">=</span> <span class="nx">token</span><span class="p">.</span><span class="nx">appUserId</span> <span class="k">as</span> <span class="kr">string</span> <span class="o">|</span> <span class="kc">undefined</span><span class="p">;</span>
<span class="nx">session</span><span class="p">.</span><span class="nx">provider</span> <span class="o">=</span> <span class="nx">token</span><span class="p">.</span><span class="nx">provider</span> <span class="k">as</span> <span class="nx">AuthProvider</span> <span class="o">|</span> <span class="kc">undefined</span><span class="p">;</span>
<span class="k">return</span> <span class="nx">session</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>
<p>中身はほぼ JWT からセッションへの値の詰め替えだけで、モック・本番の差は一切ありません。「ログイン済みユーザーを DB で再確認する」というプロダクト側の要件だけを淡々と満たす形となっています。この関数がモードに依存しない形で書けていることが、後の差し替えコストを最小にしてくれます。</p>
<p>セッション型も同じ形で固定しておきます。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="kr">declare</span> <span class="kr">module</span> <span class="dl">"</span><span class="s2">next-auth</span><span class="dl">"</span> <span class="p">{</span>
<span class="kr">interface</span> <span class="nx">Session</span> <span class="p">{</span>
<span class="nl">providerUserId</span><span class="p">?:</span> <span class="kr">string</span><span class="p">;</span>
<span class="nl">appUserId</span><span class="p">?:</span> <span class="kr">string</span><span class="p">;</span>
<span class="nl">provider</span><span class="p">?:</span> <span class="nx">AuthProvider</span><span class="p">;</span>
<span class="nl">error</span><span class="p">?:</span> <span class="kr">string</span><span class="p">;</span>
<span class="p">}</span>
<span class="p">}</span>
</code></pre></div>
<p>アプリケーションのコードは、このセッションから <code>session.appUserId</code> を取り出して使うだけです。<strong>モックか本番かを意識する必要がありません</strong>。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nx">verifyWebsiteOwnership</span><span class="p">(</span><span class="nx">websiteId</span><span class="p">:</span> <span class="kr">string</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">session</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">auth</span><span class="p">();</span>
<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">session</span><span class="p">?.</span><span class="nx">appUserId</span><span class="p">)</span> <span class="p">{</span>
<span class="k">throw</span> <span class="k">new</span> <span class="nx">UnauthorizedError</span><span class="p">();</span>
<span class="p">}</span>
<span class="kd">const</span> <span class="nx">website</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">websiteService</span><span class="p">.</span><span class="nx">findByIdForUser</span><span class="p">(</span>
<span class="nx">websiteId</span><span class="p">,</span>
<span class="nx">session</span><span class="p">.</span><span class="nx">appUserId</span><span class="p">,</span>
<span class="p">);</span>
<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">website</span><span class="p">)</span> <span class="p">{</span>
<span class="k">throw</span> <span class="k">new</span> <span class="nx">NotFoundError</span><span class="p">(</span><span class="dl">"</span><span class="s2">Website not found</span><span class="dl">"</span><span class="p">);</span>
<span class="p">}</span>
<span class="p">}</span>
</code></pre></div>
<p>このガード関数が、モック時代も本番移行後もそのまま動いているというのが、この設計のポイントとなります。</p>
<h2 id="本番ログインが決まった後の差し替え">本番ログインが決まった後の差し替え</h2>
<p>その後、「ロリポップとムームードメインのアカウントで OIDC ログインする」という方針が確定しました。OIDC は NextAuth が標準でサポートしているので、Provider 設定を書くだけで基本的には動くようになっています。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="kd">function</span> <span class="nx">createOidcAuth</span><span class="p">()</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">providers</span> <span class="o">=</span> <span class="p">[</span>
<span class="nx">createOidcProviderConfig</span><span class="p">(</span><span class="dl">"</span><span class="s2">muumuu</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">Muumuu Domain</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">MUUMUU</span><span class="dl">"</span><span class="p">),</span>
<span class="nx">createOidcProviderConfig</span><span class="p">(</span><span class="dl">"</span><span class="s2">lolipop</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">Lolipop</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">LOLIPOP</span><span class="dl">"</span><span class="p">),</span>
<span class="p">].</span><span class="nx">filter</span><span class="p">((</span><span class="nx">p</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">p</span> <span class="o">!==</span> <span class="kc">null</span><span class="p">);</span>
<span class="k">return</span> <span class="nx">NextAuth</span><span class="p">({</span>
<span class="nx">providers</span><span class="p">,</span>
<span class="na">trustHost</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="na">session</span><span class="p">:</span> <span class="p">{</span>
<span class="na">strategy</span><span class="p">:</span> <span class="dl">"</span><span class="s2">jwt</span><span class="dl">"</span><span class="p">,</span>
<span class="na">maxAge</span><span class="p">:</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">60</span><span class="p">,</span>
<span class="na">updateAge</span><span class="p">:</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">60</span><span class="p">,</span>
<span class="p">},</span>
<span class="na">callbacks</span><span class="p">:</span> <span class="p">{</span>
<span class="na">signIn</span><span class="p">:</span> <span class="nx">oidcSignInCallback</span><span class="p">,</span>
<span class="na">jwt</span><span class="p">:</span> <span class="nx">oidcJwtCallbackWithProviderGuard</span><span class="p">,</span>
<span class="na">session</span><span class="p">:</span> <span class="nx">commonSessionCallback</span><span class="p">,</span> <span class="c1">// モック時代と同じもの</span>
<span class="p">},</span>
<span class="na">pages</span><span class="p">:</span> <span class="p">{</span> <span class="na">signIn</span><span class="p">:</span> <span class="dl">"</span><span class="s2">/login</span><span class="dl">"</span> <span class="p">},</span>
<span class="p">});</span>
<span class="p">}</span>
</code></pre></div>
<p>重要なのは、<strong><code>session</code> コールバックはモックと共通のものを使っている</strong>ところです。セッションから取り出せる値(<code>appUserId</code>, <code>provider</code>, <code>providerUserId</code>)の形は変わっていないので、アプリ本体のコードはほぼそのままで動きました。OIDC 導入に伴う変更は、認証レイヤー内のファイル(新設した OIDC 用のコールバック群と起動時の分岐)にとどまり、ガード関数や Server Action などアプリ側の呼び出しコードは変更不要となっています。</p>
<p>JWT コールバックだけは OIDC 用に差し替わります。ムームー/ロリポップの OIDC では「契約 API 側で事前に作成済みのユーザーに対してログインを許可する」というルールとなっているため、<code>getOrCreateUserByExternalIdentity</code>(なければ作る)ではなく <code>getUserByExternalIdentity</code>(見つからなければログイン拒否)を使う点がモックとの違いとなります。</p>
<p>モードの切り替えは、どちらのファクトリ関数を呼ぶかを差し替えるだけとなっています。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="c1">// OIDC モードで起動する場合</span>
<span class="kd">const</span> <span class="nx">nextAuth</span> <span class="o">=</span> <span class="nx">createOidcAuth</span><span class="p">();</span>
<span class="c1">// モック認証モードで起動する場合</span>
<span class="kd">const</span> <span class="nx">nextAuth</span> <span class="o">=</span> <span class="nx">createMockAuth</span><span class="p">();</span>
<span class="k">export</span> <span class="kd">const</span> <span class="p">{</span> <span class="nx">handlers</span><span class="p">,</span> <span class="nx">auth</span><span class="p">,</span> <span class="nx">signIn</span><span class="p">,</span> <span class="nx">signOut</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">nextAuth</span><span class="p">;</span>
</code></pre></div>
<p>本番コードの書き換えはほぼこの一カ所で済み、シームレスな差し替えが実現できました。</p>
<h2 id="副産物モック認証が開発環境に残り続けている">副産物:モック認証が開発環境に残り続けている</h2>
<p>最初から狙っていたこととはいえ、本番ログインが決まったあとも開発環境向けにモック認証モードを残せているというのは、実際に開発を回すうえで効いています。ローカル開発で puid を切り替えながら複数ユーザーのシナリオを試せるので、「差し替えのためのモック」がそのまま「開発体験のためのモック」として現役で動き続けている状態となります。</p>
<h2 id="まとめ">まとめ</h2>
<p>決まっていない領域があるとき、「あとで差し替える」を本当にシームレスにやるには、<strong>本物と同じ形の偽物</strong>を用意するというのが有効なアプローチになります。ポイントを振り返ります。</p>
<ul>
<li><strong>差し替えるレイヤーを明確にする</strong>: 認証のやり方だけを偽物にし、セッション・テーブル構造・JIT プロビジョニングは本番と同じ形で作り込む</li>
<li><strong>抽象の提供者を借りる</strong>: NextAuth のように「複数の認証方式を同じ抽象で扱う」ことが前提のフレームワークを土台にすると、差し替えが自然な操作となる</li>
<li><strong>モックも本番と同列に扱う</strong>: <code>AuthProvider.MOCK</code> のように、モックを本番プロバイダーと同じ型・経路の上に載せることで、設計がぶれない</li>
</ul>
<p>ログイン機構に限らず、要件が決まりきっていない依存を抱えたままプロダクトを前に進めたい場面は、開発の現場には少なくありません。そういうときは、決まっていない部分を特定のレイヤーに封じ込めつつ、それ以外は本番の設計で作り込む、という方針が有効です。どこまでを偽物で受け、どこから先は本番と同じ形で作るか、その線引きの設計こそが、不確実性を抱えたまま前に進むための要となります。</p>
</description>
</item>
<item>
<title>プロンプトのtypoをCIで弾く ── TypeScriptの型でAIへの指示を守る</title>
<link>https://tech.pepabo.com/2026/04/17/typescript-type-safe-prompt/</link>
<guid>https://tech.pepabo.com/2026/04/17/typescript-type-safe-prompt/</guid>
<pubDate>Fri, 17 Apr 2026 00:00:00 +0900</pubDate>
<description><h2 id="はじめに">はじめに</h2>
<p>こんにちは。ロリポップ・ムームードメイン事業部でエンジニアリングリードをしています <a href="https://x.com/kinosuke01">kinosuke01</a> といいます。先日ゴジラ-0.0のティザー映像が解禁されましたね。公開が楽しみです。</p>
<p>さて、GMOペパボでは、ユーザーとの対話をもとにWebサイトをまるごと自動生成する<a href="https://lolipop.jp/ai/site-agent/">AIサイトエージェント</a>を提供しています。ユーザーが「カフェのサイトを作りたい」「コーポレートサイトがほしい」と伝えるだけで、ページ構成からデザインテーマ、コンテンツまでを一括で生成し、すぐに公開できるWebサイトを作り上げます。</p>
<p><img src="/blog/2026/04/17/typescript-type-safe-prompt/img01.png" alt="img01" />
<img src="/blog/2026/04/17/typescript-type-safe-prompt/img02.png" alt="img02" /></p>
<p>この仕組みの裏側では、Webサイトの構造をすべて<strong>JSON</strong>で表現しています。生成AIに対して「このJSONを埋めてください」と指示し、Structured Outputでフォーマットを固定することで、安定した出力を得ています。</p>
<p>しかし、JSONの構造を固定するだけでは不十分でした。各プロパティに<strong>何を入れるべきか</strong>を正しく伝えるために、システムプロンプトでフィールドごとの説明を与えています。ここで問題になったのが、<strong>プロンプトに書いたプロパティ名と実際のコードの型定義がズレるリスク</strong>です。</p>
<p>この記事では、TypeScriptの型システムを活用してプロンプトの正しさをコンパイル時に保証する仕組みを紹介します。</p>
<h2 id="サイト生成の仕組み">サイト生成の仕組み</h2>
<h3 id="jsonでwebサイトを表現する">JSONでWebサイトを表現する</h3>
<p>私たちのシステムでは、Webサイトを「セクション」の組み合わせで表現しています。ヒーローセクション、特徴紹介セクション、料金表セクションなど、複数のセクションを用意しており、それぞれがJSON構造を持ちます。</p>
<p>たとえば、ヒーローセクションはこのような構造です。</p>
<div class="highlight"><pre class="highlight json"><code><span class="p">{</span><span class="w">
</span><span class="nl">"component"</span><span class="p">:</span><span class="w"> </span><span class="s2">"HeroSection"</span><span class="p">,</span><span class="w">
</span><span class="nl">"props"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
</span><span class="nl">"headline"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"text"</span><span class="p">:</span><span class="w"> </span><span class="s2">"想いを、かたちに。"</span><span class="w"> </span><span class="p">},</span><span class="w">
</span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"text"</span><span class="p">:</span><span class="w"> </span><span class="s2">"あなたの「やりたい」を実現するために"</span><span class="w"> </span><span class="p">},</span><span class="w">
</span><span class="nl">"primaryButton"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"label"</span><span class="p">:</span><span class="w"> </span><span class="s2">"お問い合わせ"</span><span class="p">,</span><span class="w"> </span><span class="nl">"href"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/contact"</span><span class="w"> </span><span class="p">},</span><span class="w">
</span><span class="nl">"layout"</span><span class="p">:</span><span class="w"> </span><span class="s2">"split-content-image"</span><span class="p">,</span><span class="w">
</span><span class="nl">"image"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"imageId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hero-1"</span><span class="p">,</span><span class="w"> </span><span class="nl">"alt"</span><span class="p">:</span><span class="w"> </span><span class="s2">"メインビジュアル"</span><span class="w"> </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div>
<h3 id="3つのエージェントによる段階的な生成">3つのエージェントによる段階的な生成</h3>
<p>サイト生成は、1回のAPI呼び出しで完結するわけではありません。大きく3つの生成ステップに分けて処理を進めます。</p>
<div class="highlight"><pre class="highlight plaintext"><code>ユーザーのヒアリング情報
│
▼
┌──────────────────────┐
│ PageAndSectionPlanner│ ← ページ構成とセクション選定
└─────────┬────────────┘
▼
┌─────────────────────┐
│ ThemeDeterminer │ ← カラー・フォントの決定
└─────────┬───────────┘
▼
┌──────────────────────┐
│ SectionValueGenerator│ ← 各セクションのコンテンツ生成
└─────────┬────────────┘
▼
Webサイト完成
</code></pre></div>
<h3 id="structured-outputでフォーマットを固定する">Structured Outputでフォーマットを固定する</h3>
<p>各ステップの出力は、Zodスキーマで定義した構造に従います。ZodスキーマをJSON Schemaに変換し、Gemini APIの<code>responseSchema</code>に渡すことで、AIの出力フォーマットを固定しています。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="c1">// Zodスキーマを定義</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">themeConfigSchema</span> <span class="o">=</span> <span class="nx">themeValuesSchema</span><span class="p">.</span><span class="nx">extend</span><span class="p">({</span>
<span class="na">themeId</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="kr">string</span><span class="p">().</span><span class="nx">max</span><span class="p">(</span><span class="nx">MAX_ID</span><span class="p">),</span>
<span class="na">fontId</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="kr">enum</span><span class="p">(</span><span class="nx">FONT_IDS</span><span class="p">).</span><span class="nx">optional</span><span class="p">(),</span>
<span class="p">});</span>
<span class="c1">// JSON Schemaに変換してStructured Outputに渡す</span>
<span class="kd">const</span> <span class="nx">themeConfigResponseSchema</span> <span class="o">=</span> <span class="nx">zodToResponseSchema</span><span class="p">(</span><span class="nx">themeConfigSchema</span><span class="p">);</span>
<span class="c1">// AIにリクエスト</span>
<span class="kd">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="k">await</span> <span class="k">this</span><span class="p">.</span><span class="nx">model</span><span class="p">.</span><span class="nx">generateContent</span><span class="p">(</span><span class="nx">prompt</span><span class="p">,</span> <span class="p">{</span>
<span class="na">responseSchema</span><span class="p">:</span> <span class="nx">themeConfigResponseSchema</span><span class="p">,</span>
<span class="p">});</span>
</code></pre></div>
<p>これにより、AIが自由なフォーマットでJSONを返してしまう問題は解消されます。</p>
<h3 id="システムプロンプトで意味を伝える">システムプロンプトで「意味」を伝える</h3>
<p>構造を固定しただけでは、AIは各フィールドに何を入れるべきか判断できません。そこで、システムプロンプトでフィールドごとの説明を与えています。</p>
<p>たとえばセクションのコンテンツを生成する際、こんなプロンプトを組み立てます。</p>
<div class="highlight"><pre class="highlight plaintext"><code>## セクション1: HeroSection
### フィールド説明
- layout: レイアウトパターン。split-content-image: コンテンツ左・画像右(全幅)、...
- headline.text: ページ全体のキャッチコピー。事業の価値を一言で伝える短いフレーズ(10〜20文字)
- title.text: キャッチコピーを補足するサブメッセージ(15〜30文字)
- primaryButton.label: メインのCTAボタンのラベル(2〜8文字)。例: 「お問い合わせ」「詳しく見る」
- primaryButton.href: ボタンのリンク先。サイト内ページへの相対パス(例: /about, /contact)
</code></pre></div>
<p><code>headline.text</code> や <code>primaryButton.label</code> といったドットパスでプロパティの位置を指定し、AIに「ここには何文字くらいの、どんなテキストを入れてほしい」と具体的に伝えています。</p>
<h2 id="課題プロンプトとコードの乖離リスク">課題:プロンプトとコードの乖離リスク</h2>
<p>ここまでの仕組みはうまく動いていました。しかし、開発を進める中で不安が生まれます。</p>
<h3 id="プロパティ名が変わったらどうなる">プロパティ名が変わったらどうなる?</h3>
<p>フィールド説明のドットパス(<code>headline.text</code> や <code>primaryButton.label</code>)は、セクションのProps型と対応しています。もしリファクタリングで <code>headline</code> を <code>mainHeading</code> に変えたとき、フィールド説明のパスも更新しなければなりません。</p>
<p>しかし、フィールド説明がただの文字列であれば、Props型の変更に追従できているかはコードを目視で確認するしかありません。</p>
<h3 id="typoは静かに品質を劣化させる">typoは静かに品質を劣化させる</h3>
<p><code>headline.text</code> を <code>headling.text</code> とtypoしたらどうなるでしょうか。プログラムはエラーを出しません。プロンプトの中に誤ったパスが紛れ込むだけです。AIはそのパスに対応するフィールドを見つけられず、適切なコンテンツを生成できなくなります。</p>
<p>結果として、ある日突然「ヒーローセクションのキャッチコピーがなぜか空っぽのサイトが生成された」といった不具合が起きる可能性があります。Structured Outputで構造は正しいのに、中身の品質が落ちる。原因の特定が難しい厄介な問題です。</p>
<h2 id="解決策typescriptの型でプロンプトを縛る">解決策:TypeScriptの型でプロンプトを縛る</h2>
<h3 id="アプローチの全体像">アプローチの全体像</h3>
<p>まず、私たちが取ったアプローチの全体像を示します。</p>
<p>セクションごとに「フィールド説明オブジェクト」を定義し、それをプロンプト組み立て時に埋め込む構成にしました。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="kd">const</span> <span class="nx">fieldDescs</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">layout</span><span class="p">:</span> <span class="dl">"</span><span class="s2">レイアウトパターン。split-content-image: コンテンツ左・画像右...</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">headline.text</span><span class="dl">"</span><span class="p">:</span>
<span class="dl">"</span><span class="s2">ページ全体のキャッチコピー。事業の価値を一言で伝える短いフレーズ(10〜20文字)</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">title.text</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">キャッチコピーを補足するサブメッセージ(15〜30文字)</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">primaryButton.label</span><span class="dl">"</span><span class="p">:</span>
<span class="dl">"</span><span class="s2">メインのCTAボタンのラベル(2〜8文字)。例: 「お問い合わせ」「詳しく見る」</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">primaryButton.href</span><span class="dl">"</span><span class="p">:</span>
<span class="dl">"</span><span class="s2">ボタンのリンク先。サイト内ページへの相対パス(例: /about, /contact)</span><span class="dl">"</span><span class="p">,</span>
<span class="p">};</span>
</code></pre></div>
<p>ドットパスをキー、AIへの説明を値とするシンプルなオブジェクトです。プロンプト組み立て時にこのオブジェクトを展開し、箇条書き形式でプロンプトに埋め込みます。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="kd">function</span> <span class="nx">formatSectionFieldDescs</span><span class="p">(</span><span class="nx">descs</span><span class="p">:</span> <span class="nb">Record</span><span class="o">&lt;</span><span class="kr">string</span><span class="p">,</span> <span class="kr">string</span><span class="o">&gt;</span><span class="p">):</span> <span class="kr">string</span> <span class="p">{</span>
<span class="k">return</span> <span class="nb">Object</span><span class="p">.</span><span class="nx">entries</span><span class="p">(</span><span class="nx">descs</span><span class="p">)</span>
<span class="p">.</span><span class="nx">map</span><span class="p">(([</span><span class="nx">path</span><span class="p">,</span> <span class="nx">desc</span><span class="p">])</span> <span class="o">=&gt;</span> <span class="s2">` - </span><span class="p">${</span><span class="nx">path</span><span class="p">}</span><span class="s2">: </span><span class="p">${</span><span class="nx">desc</span><span class="p">}</span><span class="s2">`</span><span class="p">)</span>
<span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="dl">"</span><span class="se">\n</span><span class="dl">"</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div>
<p>ここまでは特別なことはしていません。ポイントはこの次です。</p>
<p>このオブジェクトのキーに<strong>TypeScriptの型制約</strong>を加えることで、typoやプロパティ名の変更に対してコンパイルエラーで気づけるようにしました。</p>
<h3 id="satisfiesで定義を検証する">satisfiesで定義を検証する</h3>
<p>具体的に見ていきましょう。各セクションのメタデータ定義で <code>satisfies</code> を使って型制約をかけます。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">heroSectionMeta</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">HeroSection</span><span class="dl">"</span><span class="p">,</span>
<span class="c1">// ...</span>
<span class="na">fieldDescs</span><span class="p">:</span> <span class="p">{</span>
<span class="na">layout</span><span class="p">:</span> <span class="nx">buildLayoutFieldDesc</span><span class="p">(</span><span class="nx">layoutDescs</span><span class="p">),</span>
<span class="dl">"</span><span class="s2">headline.text</span><span class="dl">"</span><span class="p">:</span>
<span class="dl">"</span><span class="s2">ページ全体のキャッチコピー。事業の価値を一言で伝える短いフレーズ(10〜20文字)</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">title.text</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">キャッチコピーを補足するサブメッセージ(15〜30文字)</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">primaryButton.label</span><span class="dl">"</span><span class="p">:</span>
<span class="dl">"</span><span class="s2">メインのCTAボタンのラベル(2〜8文字)。例: 「お問い合わせ」「詳しく見る」</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">primaryButton.href</span><span class="dl">"</span><span class="p">:</span>
<span class="dl">"</span><span class="s2">ボタンのリンク先。サイト内ページへの相対パス(例: /about, /contact)</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">secondaryButton.label</span><span class="dl">"</span><span class="p">:</span>
<span class="dl">"</span><span class="s2">サブのCTAボタンのラベル(2〜8文字)。例: 「詳しく見る」「サービス一覧」</span><span class="dl">"</span><span class="p">,</span>
<span class="p">}</span> <span class="nx">satisfies</span> <span class="nx">ValidFieldDescs</span><span class="o">&lt;</span><span class="nx">HeroSectionProps</span><span class="o">&gt;</span><span class="p">,</span> <span class="c1">// ← ここ</span>
<span class="p">}</span> <span class="k">as</span> <span class="kd">const</span><span class="p">;</span>
</code></pre></div>
<p>末尾の <code>satisfies ValidFieldDescs&lt;HeroSectionProps&gt;</code> が効いています。もし <code>"headline.text"</code> を <code>"headling.text"</code> とtypoしたら、<strong>コンパイルエラー</strong>になります。</p>
<div class="highlight"><pre class="highlight plaintext"><code>// typoするとコンパイルエラー
"headling.text": "ページ全体のキャッチコピー..."
// ~~~~~~~~~~~~~~
// Type '"headling.text"' is not assignable to type 'FieldDescPaths&lt;HeroSectionProps&gt;'
</code></pre></div>
<p>Props型のプロパティ名を変更した場合も同様です。<code>headline</code> を <code>mainHeading</code> にリネームすれば、<code>"headline.text"</code> は無効なパスになり、コンパイラが教えてくれます。</p>
<h3 id="validfielddescsの仕組み">ValidFieldDescsの仕組み</h3>
<p>では <code>ValidFieldDescs</code> がどのように機能しているかを見てみましょう。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="k">export</span> <span class="kd">type</span> <span class="nx">ValidFieldDescs</span><span class="o">&lt;</span><span class="nx">Props</span><span class="o">&gt;</span> <span class="o">=</span> <span class="nb">Partial</span><span class="o">&lt;</span>
<span class="nb">Record</span><span class="o">&lt;</span><span class="nx">FieldDescPaths</span><span class="o">&lt;</span><span class="nx">Props</span><span class="o">&gt;</span><span class="p">,</span> <span class="kr">string</span><span class="o">&gt;</span>
<span class="o">&gt;</span><span class="p">;</span>
</code></pre></div>
<p><code>FieldDescPaths&lt;Props&gt;</code> がProps型から「有効なドットパス」のunion型を自動生成する部分です。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="k">export</span> <span class="kd">type</span> <span class="nx">FieldDescPaths</span><span class="o">&lt;</span><span class="nx">T</span><span class="p">,</span> <span class="nx">D</span> <span class="kd">extends</span> <span class="kr">number</span> <span class="o">=</span> <span class="mi">4</span><span class="o">&gt;</span> <span class="o">=</span>
<span class="nx">D</span> <span class="kd">extends</span> <span class="mi">0</span>
<span class="p">?</span> <span class="nx">never</span>
<span class="p">:</span> <span class="nx">T</span> <span class="kd">extends</span> <span class="nx">object</span>
<span class="p">?</span> <span class="nx">IsIndexSignature</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span> <span class="kd">extends</span> <span class="kc">true</span>
<span class="p">?</span> <span class="nx">never</span>
<span class="p">:</span> <span class="p">{</span>
<span class="p">[</span><span class="nx">K</span> <span class="k">in</span> <span class="kr">keyof</span> <span class="nx">NonNullish</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span> <span class="o">&amp;</span> <span class="kr">string</span><span class="p">]:</span>
<span class="o">|</span> <span class="nx">K</span>
<span class="o">|</span> <span class="p">(</span><span class="nx">NonNullish</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">[</span><span class="nx">K</span><span class="p">]</span> <span class="kd">extends</span> <span class="nb">Array</span><span class="o">&lt;</span><span class="nx">infer</span> <span class="nx">E</span><span class="o">&gt;</span>
<span class="p">?</span> <span class="nx">E</span> <span class="kd">extends</span> <span class="nx">object</span>
<span class="p">?</span> <span class="s2">`</span><span class="p">${</span><span class="nx">K</span><span class="p">}</span><span class="s2">[]`</span> <span class="o">|</span> <span class="s2">`</span><span class="p">${</span><span class="nx">K</span><span class="p">}</span><span class="s2">[].</span><span class="p">${</span><span class="nx">FieldDescPaths</span><span class="o">&lt;</span><span class="nx">E</span><span class="p">,</span> <span class="nx">Prev</span><span class="p">[</span><span class="nx">D</span><span class="p">]</span><span class="o">&gt;</span><span class="p">}</span><span class="s2">`</span>
<span class="p">:</span> <span class="s2">`</span><span class="p">${</span><span class="nx">K</span><span class="p">}</span><span class="s2">[]`</span>
<span class="p">:</span> <span class="nx">NonNullish</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">[</span><span class="nx">K</span><span class="p">]</span> <span class="kd">extends</span> <span class="nx">object</span>
<span class="p">?</span> <span class="s2">`</span><span class="p">${</span><span class="nx">K</span><span class="p">}</span><span class="s2">.</span><span class="p">${</span><span class="nx">FieldDescPaths</span><span class="o">&lt;</span><span class="nx">NonNullish</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">[</span><span class="nx">K</span><span class="p">],</span> <span class="nx">Prev</span><span class="p">[</span><span class="nx">D</span><span class="p">]</span><span class="o">&gt;</span><span class="p">}</span><span class="s2">`</span>
<span class="p">:</span> <span class="nx">never</span><span class="p">);</span>
<span class="p">}[</span><span class="kr">keyof</span> <span class="nx">NonNullish</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span> <span class="o">&amp;</span> <span class="kr">string</span><span class="p">]</span>
<span class="p">:</span> <span class="nx">never</span><span class="p">;</span>
</code></pre></div>
<p>一見複雑ですが、やっていることはシンプルです。Props型を再帰的に走査し、有効なドットパスをすべてunion型として列挙します。</p>
<ul>
<li><strong>トップレベル</strong>: <code>"layout"</code></li>
<li><strong>ネストしたオブジェクト</strong>: <code>"headline.text"</code>, <code>"primaryButton.label"</code></li>
<li><strong>配列の要素</strong>: <code>"orderList.items[].title.text"</code></li>
</ul>
<p>たとえば <code>HeroSectionProps</code> に適用すると、<code>"layout" | "headline" | "headline.text" | "title" | "title.text" | "primaryButton" | "primaryButton.label" | "primaryButton.href" | ...</code> のようなunion型が得られます。このunion型に含まれないキーを <code>fieldDescs</code> に書くと、<code>satisfies</code> によりコンパイルエラーになるというわけです。</p>
<h3 id="テーマプロンプトでも同じアプローチ">テーマプロンプトでも同じアプローチ</h3>
<p>テーマ決定のプロンプトでも、カラーパスの定数を型で縛っています。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="kd">type</span> <span class="nx">ColorsInferred</span> <span class="o">=</span> <span class="nx">z</span><span class="p">.</span><span class="nx">infer</span><span class="o">&lt;</span><span class="k">typeof</span> <span class="nx">colorsSchema</span><span class="o">&gt;</span><span class="p">;</span>
<span class="cm">/** "group.field" 形式のパス型。colorsSchemaと一致しない場合コンパイルエラー */</span>
<span class="kd">type</span> <span class="nx">ColorPath</span><span class="o">&lt;</span><span class="nx">G</span> <span class="kd">extends</span> <span class="kr">keyof</span> <span class="nx">ColorsInferred</span><span class="o">&gt;</span> <span class="o">=</span>
<span class="s2">`</span><span class="p">${</span><span class="nx">G</span><span class="p">}</span><span class="s2">.</span><span class="p">${</span><span class="kr">string</span> <span class="o">&amp;</span> <span class="kr">keyof</span> <span class="nx">ColorsInferred</span><span class="p">[</span><span class="nx">G</span><span class="p">]}</span><span class="s2">`</span><span class="p">;</span>
<span class="cm">/** スキーマと連動した型安全なパス定数 */</span>
<span class="kd">const</span> <span class="nx">BG_PRIMARY_PATH</span><span class="p">:</span> <span class="nx">ColorPath</span><span class="o">&lt;</span><span class="dl">"</span><span class="s2">background</span><span class="dl">"</span><span class="o">&gt;</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">background.primary</span><span class="dl">"</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">TYPOGRAPHY_CONTRAST_PATH</span><span class="p">:</span> <span class="nx">ColorPath</span><span class="o">&lt;</span><span class="dl">"</span><span class="s2">typography</span><span class="dl">"</span><span class="o">&gt;</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">typography.contrastText</span><span class="dl">"</span><span class="p">;</span>
</code></pre></div>
<p>これらの定数はシステムプロンプトに埋め込まれます。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="kd">const</span> <span class="nx">themePrompt</span> <span class="o">=</span> <span class="s2">`
...
- {TYPOGRAPHY_CONTRAST_KEY} と {BG_PRIMARY_KEY} は必ず高コントラストを保ってください
...
`</span><span class="p">;</span>
<span class="c1">// プロンプト組み立て時に定数を埋め込む</span>
<span class="k">return</span> <span class="nx">themePrompt</span>
<span class="p">.</span><span class="nx">replace</span><span class="p">(</span><span class="sr">/{TYPOGRAPHY_CONTRAST_KEY}/g</span><span class="p">,</span> <span class="nx">TYPOGRAPHY_CONTRAST_PATH</span><span class="p">)</span>
<span class="p">.</span><span class="nx">replace</span><span class="p">(</span><span class="sr">/{BG_PRIMARY_KEY}/g</span><span class="p">,</span> <span class="nx">BG_PRIMARY_PATH</span><span class="p">);</span>
</code></pre></div>
<p>もし <code>colorsSchema</code> から <code>contrastText</code> が削除されたり名前が変わったりすれば、<code>TYPOGRAPHY_CONTRAST_PATH</code> の定義でコンパイルエラーが発生します。プロンプトに存在しないプロパティ名が紛れ込む余地がありません。</p>
<h3 id="フィールド説明の型安全性も">フィールド説明の型安全性も</h3>
<p>テーマのカラーフィールド説明にも、同じ考え方を適用しています。</p>
<div class="highlight"><pre class="highlight typescript"><code><span class="cm">/** palette 各フィールドの説明(AI プロンプト用) */</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">paletteFieldDescs</span><span class="p">:</span> <span class="nb">Record</span><span class="o">&lt;</span>
<span class="kr">keyof</span> <span class="nx">z</span><span class="p">.</span><span class="nx">infer</span><span class="o">&lt;</span><span class="k">typeof</span> <span class="nx">paletteSchema</span><span class="o">&gt;</span><span class="p">,</span>
<span class="kr">string</span>
<span class="o">&gt;</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">primary</span><span class="p">:</span> <span class="dl">"</span><span class="s2">パレットの主役となる色(全体の起点)</span><span class="dl">"</span><span class="p">,</span>
<span class="na">light</span><span class="p">:</span> <span class="dl">"</span><span class="s2">背景とprimaryを繋ぐ低彩度カラー</span><span class="dl">"</span><span class="p">,</span>
<span class="na">deep</span><span class="p">:</span> <span class="dl">"</span><span class="s2">primary のダークトーン(引き締め)</span><span class="dl">"</span><span class="p">,</span>
<span class="p">};</span>
</code></pre></div>
<p><code>Record&lt;keyof z.infer&lt;typeof paletteSchema&gt;, string&gt;</code> により、<code>paletteSchema</code> のすべてのフィールドに対して説明を書くことが強制されます。フィールドを追加したのに説明を書き忘れたらコンパイルエラー、存在しないフィールドの説明を書いてもコンパイルエラーです。</p>
<h2 id="まとめ">まとめ</h2>
<p>生成AIを活用したシステムでは、プロンプトの品質がそのままサービスの品質に直結します。しかしプロンプトは往々にしてただの文字列として扱われ、コードとの整合性は開発者の注意力に依存しがちです。</p>
<p>私たちはTypeScriptの型システムを使って、<strong>プロンプトに埋め込むフィールド名やパスをコードの型定義と連動させる</strong>ことで、この問題を解決しました。特別なツールやライブラリは必要ありません。<code>satisfies</code>、Template Literal Types、<code>Record&lt;keyof T, string&gt;</code> といったTypeScriptの標準的な機能だけで実現できます。</p>
<p>この仕組みの良いところは、<strong>CIの型チェック(<code>tsc --noEmit</code>)で自然にカバーされる</strong>点です。プロンプト専用のテストを書く必要はなく、普段の開発フローの中で、プロパティ名の変更やtypoが自動的に検出されます。</p>
<p>プロンプトもコードの一部です。型で守れるものは、型で守りましょう。</p>
</description>
</item>
<item>
<title>flaky testの原因は、無関係なファイルの1行にあった</title>
<link>https://tech.pepabo.com/2026/04/15/flaky-test-investigation/</link>
<guid>https://tech.pepabo.com/2026/04/15/flaky-test-investigation/</guid>
<pubDate>Wed, 15 Apr 2026 00:00:00 +0900</pubDate>
<description><p>SUZURI Webアプリケーションエンジニアのarumaです。<a href="/2026/04/14/flaky-tests/">昨日の記事</a>では、SUZURIで遭遇したflaky testの事例をいくつかご紹介しました。本記事では、その中でも特に原因の特定が難しかった1件を深掘りします。</p>
<ol id="markdown-toc">
<li><a href="#発生していた現象" id="markdown-toc-発生していた現象">発生していた現象</a></li>
<li><a href="#手がかり1-自前のメソッドが呼ばれていない" id="markdown-toc-手がかり1-自前のメソッドが呼ばれていない">手がかり1: 自前のメソッドが呼ばれていない</a></li>
<li><a href="#手がかり2-フレームワークに同名メソッドが追加されていた" id="markdown-toc-手がかり2-フレームワークに同名メソッドが追加されていた">手がかり2: フレームワークに同名メソッドが追加されていた</a></li>
<li><a href="#手がかり3-ancestorsチェーンの順序が変わっている" id="markdown-toc-手がかり3-ancestorsチェーンの順序が変わっている">手がかり3: ancestorsチェーンの順序が変わっている</a></li>
<li><a href="#手がかり4-トップレベルでのinclude" id="markdown-toc-手がかり4-トップレベルでのinclude">手がかり4: トップレベルでのinclude</a></li>
<li><a href="#真相" id="markdown-toc-真相">真相</a></li>
<li><a href="#おわりに" id="markdown-toc-おわりに">おわりに</a></li>
</ol>
<h2 id="発生していた現象">発生していた現象</h2>
<p><code>ApplicationHelper</code> に定義された画像表示用のヘルパーメソッド <code>picture_tag</code> のテストが、時々failしていました。</p>
<p>fail時のログを確認すると、<code>picture_tag</code> が期待と異なるHTMLを生成していました。</p>
<p>CIの実行ログからpass時とfail時それぞれのseed値を確認し、ローカルで同じseedを指定して実行してみると、seed値に応じてpassとfailが再現しました。</p>
<h2 id="手がかり1-自前のメソッドが呼ばれていない">手がかり1: 自前のメソッドが呼ばれていない</h2>
<p>試しに <code>ApplicationHelper#picture_tag</code> の中身に <code>raise</code> を加えてそれぞれのseedで実行してみると、</p>
<ul>
<li>passしていたseedで実行 → 例外が発生</li>
<li>failしていたseedで実行 → 例外は発生せず、同じようにfail</li>
</ul>
<p>という結果になりました。つまり、failするseedでは、テスト対象 <code>ApplicationHelper#picture_tag</code> がそもそも呼ばれていないということです。では、代わりに何が呼ばれているのでしょうか?</p>
<h2 id="手がかり2-フレームワークに同名メソッドが追加されていた">手がかり2: フレームワークに同名メソッドが追加されていた</h2>
<p><code>picture_tag</code> で検索してみると、Rails 7.1で <code>ActionView::Helpers::AssetTagHelper</code> に同名の <code>picture_tag</code> メソッドが<a href="https://guides.rubyonrails.org/7_1_release_notes.html#action-view-notable-changes">新規追加されていた</a>ことがわかりました。<code>ApplicationHelper#picture_tag</code> とは異なる動作をするメソッドです。</p>
<p>fail時の出力は、この <code>AssetTagHelper#picture_tag</code> の動作と一致していました。failするseedでは、<code>ApplicationHelper#picture_tag</code> ではなく <code>AssetTagHelper#picture_tag</code> が呼ばれていたようです。</p>
<p>Rubyのメソッド解決は、ancestorsチェーンを前から順にたどり、最初に見つかったメソッドを呼び出す仕組みです。通常、<code>ApplicationHelper</code> は <code>ActionView::Helpers::AssetTagHelper</code> よりもancestorsチェーンの前方に位置するため、フレームワーク側に同名のメソッドが追加されても <code>ApplicationHelper</code> の方が優先されるはずです。</p>
<p>ところが今回は、seed値によって呼ばれるメソッドが変わっています。ancestorsチェーンを実際に確認してみることにしました。</p>
<h2 id="手がかり3-ancestorsチェーンの順序が変わっている">手がかり3: ancestorsチェーンの順序が変わっている</h2>
<p>pass時とfail時、それぞれのseedでテスト対象のオブジェクトのancestorsチェーンを出力して比較してみると、次のようになっていました。</p>
<p><strong>pass時</strong></p>
<div class="highlight"><pre class="highlight plaintext"><code>...
29: ApplicationHelper
...
77: ActionView::Helpers::AssetTagHelper
...
</code></pre></div>
<p><strong>fail時</strong></p>
<div class="highlight"><pre class="highlight plaintext"><code>...
76: ActionView::Helpers::AssetTagHelper
...
95: ApplicationHelper
...
</code></pre></div>
<p>pass時には <code>ApplicationHelper</code> が <code>AssetTagHelper</code> より前にありますが、fail時には順序が逆転していました。つまり、failするseedでは <code>AssetTagHelper#picture_tag</code> が先に見つかり、そちらが呼ばれていたのです。</p>
<h2 id="手がかり4-トップレベルでのinclude">手がかり4: トップレベルでのinclude</h2>
<p>ancestorsチェーンが変わる原因を突き止めるため、<code>ApplicationHelper</code> に <code>included</code> フックを仕込みました。モジュールがどこかに <code>include</code> されるたびに、include先とそのコールスタックを出力するものです。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="k">module</span> <span class="nn">ApplicationHelper</span>
<span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">included</span><span class="p">(</span><span class="n">base</span><span class="p">)</span>
<span class="nb">puts</span> <span class="s2">"ApplicationHelper included into: </span><span class="si">#{</span><span class="n">base</span><span class="si">}</span><span class="s2">"</span>
<span class="nb">puts</span> <span class="nb">caller</span><span class="p">.</span><span class="nf">first</span><span class="p">(</span><span class="mi">3</span><span class="p">).</span><span class="nf">join</span><span class="p">(</span><span class="s2">"</span><span class="se">\n</span><span class="s2">"</span><span class="p">)</span>
<span class="k">end</span>
<span class="k">end</span>
</code></pre></div>
<p>failするseedで実行してみると、次の出力が得られました。</p>
<div class="highlight"><pre class="highlight plaintext"><code>ApplicationHelper included into: Object
spec/graphql/queries/some_query_spec.rb:2:in 'include'
spec/graphql/queries/some_query_spec.rb:2:in '&lt;main&gt;'
</code></pre></div>
<p><code>ApplicationHelper</code> が <strong><code>Object</code> クラスに</strong> includeされています。該当のspecファイルを見てみると、以下のようなコードでした。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="nb">require</span> <span class="s1">'spec_helper'</span>
<span class="kp">include</span> <span class="no">ApplicationHelper</span>
<span class="n">describe</span> <span class="s1">'SomeQuery'</span> <span class="k">do</span>
<span class="c1"># ...</span>
<span class="k">end</span>
</code></pre></div>
<p><code>include</code> が <code>describe</code> ブロックの外、つまりトップレベルで実行されています。トップレベルで <code>include</code> を実行すると、<code>Object</code> クラスに対しての <code>include</code> となります。<code>Object</code> はほぼ全てのRubyクラスの祖先なので、ここにモジュールが追加されると全クラスのancestorsチェーンに影響します。</p>
<h2 id="真相">真相</h2>
<p>今回の事象は、3つの要因が重なって発生していました。</p>
<ol>
<li>Rails 7.1で追加された <code>AssetTagHelper#picture_tag</code> と、自前の <code>ApplicationHelper#picture_tag</code> が名前衝突していた。どちらが呼ばれるかはancestorsチェーンの順序で決まる(通常は自前のメソッドが優先される)</li>
<li>あるspecファイルがトップレベルで(つまり <code>Object</code> へ) <code>include ApplicationHelper</code> を実行していた</li>
<li>RSpecのテスト実行順序はseed値によって毎回変わる</li>
</ol>
<p>つまり、seed値次第で、テスト実行の流れは次のどちらかになります。</p>
<p>問題のspecが<strong>先に</strong>実行される場合:</p>
<ol>
<li>テスト実行開始</li>
<li>問題のspecが読み込まれ、<code>ApplicationHelper</code> が <code>Object</code> にincludeされる</li>
<li>ヘルパーのspecが実行される。<code>ApplicationHelper</code> はすでにinclude済みのため、テストコンテキストへのincludeが無視され、<code>AssetTagHelper#picture_tag</code> が優先される → <strong>fail</strong></li>
</ol>
<p>問題のspecが<strong>後に</strong>実行される場合:</p>
<ol>
<li>テスト実行開始</li>
<li>ヘルパーのspecが実行される。<code>ApplicationHelper</code> が通常通りテストコンテキストにincludeされ、<code>ApplicationHelper#picture_tag</code> が優先される → <strong>pass</strong></li>
<li>問題のspecが読み込まれ、<code>ApplicationHelper</code> が <code>Object</code> にincludeされる(しかし結果に影響しない)</li>
</ol>
<p>なお、”先か後か” の二択ならfailの確率は50%となりますが、SUZURIのCIではテストを複数プロセスに分散して実行しているため、実際の発生頻度は数%程度となっていたのでした。</p>
<p>原因さえわかれば対策はシンプルで、トップレベルでの <code>include</code> をやめるだけでした。<br />
同種の問題を防ぐには、トップレベルでの <code>include</code> を禁止するlint規則が有効です。</p>
<h2 id="おわりに">おわりに</h2>
<p>この問題は、今回のflaky test対処の中で原因特定に最も時間がかかったケースでした。どの要因も単体では問題になりませんが、3つが重なることで原因の見えにくいflaky testになっていました。</p>
<p>他のflaky testの事例は<a href="/2026/04/14/flaky-tests/">前回の記事</a>で紹介しています。</p>
</description>
</item>
<item>
<title>シニアエンジニア所信表明 — レバレッジと横断性で事業やサービスが向かうべき先に未来を作る</title>
<link>https://tech.pepabo.com/2026/04/15/senior-engineer-haruotsu/</link>
<guid>https://tech.pepabo.com/2026/04/15/senior-engineer-haruotsu/</guid>
<pubDate>Wed, 15 Apr 2026 00:00:00 +0900</pubDate>
<description><p>こんにちは!ロリポップ・ムームードメイン事業部ムームードメイングループのはるおつ(<a href="https://x.com/haruotsu_hy">@haruotsu_hy</a>)です。2026年4月1日にシニアエンジニアになりました。このエントリーでは、シニアエンジニアとしてこれから何をしていくのかを、ここに至るまでの経緯とあわせて書きます。</p>
<h2 id="所信表明">所信表明</h2>
<p>シニアエンジニアとして、<strong>レバレッジの効く開発と横断的な行動を武器に、目の前の課題を技術構造ごと解き、事業やサービスが向かうべき先に未来を作る。その成果を全社・業界を動かすレベルまで広げていきます。</strong></p>
<p>レバレッジの効く開発とは、一つの技術投資が時間軸・組織軸・技術軸で複数の価値を生む開発です。個別の問題を個別に解くのではなく、構造的に解く。目の前のタスクの背後にある構造を変えることで、まだ見えていない未来の課題まで同時に解決する。</p>
<p>横断的な行動とは、価値を届けるために自分から境界を越えに行くということです。技術領域も、組織も、職域も関係ない。価値があるなら自分のチームに閉じさせず、どこまでも持っていく。境界を一つ越えるたびに、同じ投資の効果が倍になっていく。</p>
<p>レバレッジで一つの投資の価値を最大化し、横断性でその価値の届く範囲を最大化する。この掛け算で、事業が向かうべき方向に対して技術の力で道を作る。それが私のエンジニアリングです。</p>
<h2 id="ペパボのエンジニア職位制度">ペパボのエンジニア職位制度</h2>
<p>GMOペパボのエンジニア職位制度は立候補制です。「作り上げる力」「先を見通す力」「影響を広げる力」の3軸で評価され、基準を超えた人が立候補資料を提出して昇格の判断を受けます(<a href="https://tech.pepabo.com/engineers/">詳細</a>)。</p>
<p>判断は「<strong>追認制</strong>」、つまりすでに実質その等級として振る舞えている人だけが昇格します。<br />
私の立候補資料も「以上で、私がシニアエンジニアに相応しい人材であるということは十分であろう。」という結論とともに締めくくりました。</p>
<p><img src="/blog/2026/04/15/senior-engineer-haruotsu/images/position-map.png" alt="エンジニア職位制度" /></p>
<h2 id="これまで">これまで</h2>
<p>私は2024年4月、<a href="https://recruit.group.gmo/710program/">新卒年収710万プログラム</a>のエンジニアとしてペパボに入社しました。<a href="https://www.onecareer.jp/articles/2754">プログラムの導入背景</a>にあるように、尖った技術力や専門性を幹にしながら複数の枝を生やし、すべてを自分ごと化しながら高いモチベーションと覚悟でチャレンジしていくことが求められる採用枠です。</p>
<p>その期待に応えるべく、ペパボの「<a href="https://speakerdeck.com/kentaro/the-secret-of-leadership-and-followership">やっていき・のっていき</a>」文化の「いいじゃん!」に背中を押されながら、同期や先輩パートナーとともに、速度感をもってありとあらゆることに挑戦し時には職種を超えて社内外で注目される活動を重ねてきたように思います。</p>
<p>しかし、2025年8月に行った1度目の立候補では、シニアエンジニアになることは叶いませんでした。</p>
<blockquote>
<p>「キャッチアップ力と行動量によって仕事が速いことは素晴らしい」「課題の粒度が小さい」「専門性が浅い」「レバレッジの影響範囲が狭い」「ペパボやサービスが haruotsu の専門性によってどう変わっていくのかという視点で課題に取り組み、上長やチームと同期し、意思決定を行なっていくことを期待」(抜粋)</p>
</blockquote>
<p>与えられたタスクを速を出しながらやっていき・のっていきを発揮しながら薙ぎ倒していくと同時に、シニアエンジニアに求められていたのは、部門の目標達成に関わる構造的課題を自分で定義し、自分の専門性で解き、その成果を事業部や会社レベルで動かしていく。この一連のサイクルを自分の手で回し切ることでした。</p>
<p>それ以降降ってくるタスクひとつひとつに対して、事業のボトルネックや構造的な課題はどこにあるか、当たり前を常に疑い、開発にレバレッジを組み込める余地はないか。</p>
<p>そしてそれを個人の思いつきで突き進めるのではなく、事業が向かうべき方向、ムームードメインでいえば「ドメインを入り口としたサービス展開」を軸に、未解決の専門領域の課題を自分で定義し、最速最高で実行し続けました。
その過程では、エンジニアチームが描きたい未来、自分が目指す「最高」が他メンバーの「最高」と干渉しないか、チーム・設計・拡張性の観点で最適かを常にすり合わせました。
それに加えて「なぜ今これをやるのか」「どのような価値を出しながら進めていくか」を言葉にし、エンジニアが目指す姿とマネージャを含めたビジネス側が求める価値のキャリブレーションを続けました。</p>
<p>結果として、私が引いた設計や事業価値の整理は、以降の施策を進める際の判断軸の一つとしてチームに残り、他メンバーの意思決定の土台にもなっていきました。
自分の動きが、自分一人の成果にとどまらず、組織の共通資産として機能し始めた手応えがあります。</p>
<p>これをひとつのサイクルとして回し続けることで、言葉として持っていた「レバレッジ」と「横断性」を実践の規模で体現していきました。</p>
<p>そうして臨んだ2度目の立候補で、以下のようなフィードバックをいただきました。</p>
<blockquote>
<p>長らく手がつけられなかった大きな事業課題に対し、臆することなく解決可能なサイズへと切り分け、<strong>圧倒的なコミット力で薙ぎ倒していく姿勢</strong>を高く評価します。アウトプットの量のみならず、その質においても <strong>「最高・最速」を両立</strong>させており、シニアエンジニアに相応しい模範を示しました。特に、歴史的経緯から困難であったシステムに対し、<strong>長期的な拡張性を担保した基盤を短期間で組み込んだ功績</strong>は大きいです。<strong>「負の遺産の解消と未来の創出」が同時に実現</strong>されたことは、4等級に求められる<strong>専門性と推進力の証左</strong>です。(抜粋)</p>
</blockquote>
<p>過去の負債を片付けるために作ったものが、そのまま未来の武器になる。一つの投資で過去と未来の両方を動かす。これがレバレッジと横断性の掛け算であり、大切にしていることそのものを認めていただけたと受け止めています。</p>
<h2 id="これから">これから</h2>
<p>てこの支点が深いところにあるほど、動かせるものは大きくなります。フィードバックでは、以下のような期待もいただいています。</p>
<blockquote>
<p>事業の中核をなす専門技術への理解をさらに深め、社内での実践に留まらず、該当領域の技術コミュニティへの積極的な参加や発信にも取り組んでほしいです。一般的なWebエンジニアの枠を超え、ペパボの事業ドメインにおける第一人者としての専門性を身につけることで、会社に、そして業界にさらなる大きなインパクトを与えるリーダーへと成長していく姿を期待しています。 (抜粋)</p>
</blockquote>
<p>今いる場所でその中核技術となるのはドメインやDNSです。インターネットの根幹をなすこの領域にまずは深く潜ります。<br />
また、ドメイン領域に限らず、どこにいても、その事業の中核をなす技術に深く潜っていきます。</p>
<p>それがレバレッジの支点を深くすることになると考えています。</p>
<p>加えて、AIによって開発の時間軸が圧縮され続けているいま、この変化にただ適応するのではなく、自分自身、そしてペパボが業界において先んじてインパクトを起こしていきたいと考えています。</p>
<p>事業ドメインの深化も新しい時代への対応も、レバレッジと横断性を武器に最高・最速を両立させていきます。</p>
</description>
</item>
<item>
<title>SUZURIで遭遇したflaky testの事例集</title>
<link>https://tech.pepabo.com/2026/04/14/flaky-tests/</link>
<guid>https://tech.pepabo.com/2026/04/14/flaky-tests/</guid>
<pubDate>Tue, 14 Apr 2026 00:00:00 +0900</pubDate>
<description><p>SUZURI Webアプリケーションエンジニアのarumaです。SUZURIにはRSpecによるテストコードが多数ありますが、一時期flaky testが増えてCIが不安定になってしまったことがありました。まとめて調査・対処した際の記録から、いくつかの事例をピックアップしてご紹介します。</p>
<p>なお、記事中のコードは説明のために簡略化したものです。</p>
<ol id="markdown-toc">
<li><a href="#事例1-2026年になると失敗するテスト" id="markdown-toc-事例1-2026年になると失敗するテスト">事例1: 2026年になると失敗するテスト</a></li>
<li><a href="#事例2-深夜0時ちょうどにのみ失敗するテスト" id="markdown-toc-事例2-深夜0時ちょうどにのみ失敗するテスト">事例2: 深夜0時ちょうどにのみ失敗するテスト</a></li>
<li><a href="#事例3-保証のない順序に依存していたテスト" id="markdown-toc-事例3-保証のない順序に依存していたテスト">事例3: 保証のない順序に依存していたテスト</a></li>
<li><a href="#事例4-不正なテストデータが作られていたテスト" id="markdown-toc-事例4-不正なテストデータが作られていたテスト">事例4: 不正なテストデータが作られていたテスト</a></li>
<li><a href="#事例5-偶然の一致で失敗するテスト" id="markdown-toc-事例5-偶然の一致で失敗するテスト">事例5: 偶然の一致で失敗するテスト</a></li>
<li><a href="#番外編-そもそも実行されていなかったテスト" id="markdown-toc-番外編-そもそも実行されていなかったテスト">番外編: そもそも実行されていなかったテスト</a></li>
<li><a href="#おわりに" id="markdown-toc-おわりに">おわりに</a></li>
</ol>
<h2 id="事例1-2026年になると失敗するテスト">事例1: 2026年になると失敗するテスト</h2>
<p>これは厳密にはflaky testというより「ある時点から必ず失敗するようになったテスト」ですが、テストの書き方に共通する教訓があるので紹介します。</p>
<p>クレジットカード決済に関するテストのセットアップ内で、カードの有効期限がハードコードされていました。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">let</span><span class="p">(</span><span class="ss">:credit_card</span><span class="p">)</span> <span class="k">do</span>
<span class="n">create</span><span class="p">(</span>
<span class="ss">:credit_card</span><span class="p">,</span>
<span class="ss">expire_year: </span><span class="mi">2025</span><span class="p">,</span>
<span class="ss">expire_month: </span><span class="mi">12</span>
<span class="p">)</span>
<span class="k">end</span>
</code></pre></div>
<p>このコードが書かれたのは2020年。5年間は問題なく動いていましたが、2026年を迎えた途端に有効期限切れの無効なカードとなり、テストの検証対象とは無関係な理由でエラーが発生するようになってしまいました。</p>
<p>対処として、有効期限が常に未来の日付となるよう、現在の日付から算出する形に変更しました。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">let</span><span class="p">(</span><span class="ss">:credit_card</span><span class="p">)</span> <span class="k">do</span>
<span class="n">create</span><span class="p">(</span>
<span class="ss">:credit_card</span><span class="p">,</span>
<span class="ss">expire_year: </span><span class="no">Time</span><span class="p">.</span><span class="nf">zone</span><span class="p">.</span><span class="nf">today</span><span class="p">.</span><span class="nf">year</span> <span class="o">+</span> <span class="mi">1</span><span class="p">,</span>
<span class="ss">expire_month: </span><span class="mi">12</span>
<span class="p">)</span>
<span class="k">end</span>
</code></pre></div>
<p>似た話として、<code>travel_to</code> で固定した時刻がSQLの <code>now()</code> には反映されないために、テストとDBで時刻がずれていたケースもありました。<code>travel_to</code> はRubyプロセス内の時刻を差し替えるだけなので、DBの <code>now()</code> には効きません。これも月をまたいで初めて顕在化しました。</p>
<p>時間の経過でテストの前提が崩れないようにすることが大切です。</p>
<h2 id="事例2-深夜0時ちょうどにのみ失敗するテスト">事例2: 深夜0時ちょうどにのみ失敗するテスト</h2>
<p>夜間に自動作成されるPRだけなぜか時々CIが落ちる、ということがありました。落ちていたのはセールの適用判定に関するテストで、調査するとテストデータの時刻指定方法が原因でした。</p>
<p>セールには開始時刻と終了時刻があります。factoryのデフォルトでは、開始時刻が「今日の00:00:00」に設定されていました。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">factory</span> <span class="ss">:time_discount</span> <span class="k">do</span>
<span class="n">start_time</span> <span class="p">{</span> <span class="no">Time</span><span class="p">.</span><span class="nf">current</span><span class="p">.</span><span class="nf">beginning_of_day</span> <span class="p">}</span>
<span class="n">end_time</span> <span class="p">{</span> <span class="mi">1</span><span class="p">.</span><span class="nf">hour</span><span class="p">.</span><span class="nf">from_now</span> <span class="p">}</span>
<span class="k">end</span>
</code></pre></div>
<p>そして、終了済みセールのテストでは、終了時刻のみを過去日時に差し替えていました。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">let</span><span class="p">(</span><span class="ss">:time_discount</span><span class="p">)</span> <span class="k">do</span>
<span class="n">create</span><span class="p">(</span>
<span class="ss">:time_discount</span><span class="p">,</span>
<span class="ss">end_time: </span><span class="mi">2</span><span class="p">.</span><span class="nf">minutes</span><span class="p">.</span><span class="nf">ago</span>
<span class="p">)</span>
<span class="k">end</span>
</code></pre></div>
<p>ほとんどの時間帯では問題になりませんが、深夜00:00〜00:02の間にテストが実行されると</p>
<ul>
<li>開始時刻: <code>Time.current.beginning_of_day</code> → 今日の00:00:00</li>
<li>終了時刻: <code>2.minutes.ago</code> → 昨日の23:58頃</li>
</ul>
<p>と、終了時刻が開始時刻より前になり、セットアップ段階でバリデーションエラーが発生する状態になっていました。</p>
<p>対処として、時刻の逆転が起きないよう、開始時刻も明示的に指定しました。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">let</span><span class="p">(</span><span class="ss">:time_discount</span><span class="p">)</span> <span class="k">do</span>
<span class="n">create</span><span class="p">(</span>
<span class="ss">:time_discount</span><span class="p">,</span>
<span class="ss">start_time: </span><span class="mi">1</span><span class="p">.</span><span class="nf">hour</span><span class="p">.</span><span class="nf">ago</span><span class="p">,</span>
<span class="ss">end_time: </span><span class="mi">2</span><span class="p">.</span><span class="nf">minutes</span><span class="p">.</span><span class="nf">ago</span>
<span class="p">)</span>
<span class="k">end</span>
</code></pre></div>
<p>パラメータの一部を上書きする際、他のパラメータとの整合性は見落としがちなポイントです。</p>
<h2 id="事例3-保証のない順序に依存していたテスト">事例3: 保証のない順序に依存していたテスト</h2>
<p>コレクションを返すAPIのテストで、特定の要素が含まれることを検証する際に <code>.first</code> で先頭要素だけをチェックしていました。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">result</span> <span class="o">=</span> <span class="n">api_response</span><span class="p">.</span><span class="nf">items</span>
<span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="nf">first</span><span class="p">).</span><span class="nf">to</span> <span class="n">have_attributes</span><span class="p">(</span><span class="ss">color: </span><span class="s2">"white"</span><span class="p">)</span>
</code></pre></div>
<p>しかし、このAPIは返却順序を保証しておらず、<code>first</code> で取得される要素が実行ごとに変わりうる状態になっていました。</p>
<p>対処として、「先頭が一致すること」ではなく「目的の要素が含まれること」に修正しました。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">result</span> <span class="o">=</span> <span class="n">api_response</span><span class="p">.</span><span class="nf">items</span>
<span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">).</span><span class="nf">to</span> <span class="kp">include</span><span class="p">(</span><span class="n">an_object_having_attributes</span><span class="p">(</span><span class="ss">color: </span><span class="s2">"white"</span><span class="p">))</span>
</code></pre></div>
<p>順序保証のないコレクションに対して <code>first</code> で検証するのは、flaky testの典型的な原因です。</p>
<h2 id="事例4-不正なテストデータが作られていたテスト">事例4: 不正なテストデータが作られていたテスト</h2>
<p>あるモデルが1対多の関連を持ち、そのうち1件を <code>primary: true</code> で代表データとして扱う構造がありました。代表データは <code>has_one</code> で取得できるようになっています。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">has_one</span> <span class="ss">:primary_record</span><span class="p">,</span> <span class="o">-&gt;</span> <span class="p">{</span> <span class="n">where</span><span class="p">(</span><span class="ss">primary: </span><span class="kp">true</span><span class="p">)</span> <span class="p">},</span> <span class="ss">class_name: </span><span class="s2">"Record"</span>
</code></pre></div>
<p>factoryのデフォルトが <code>primary: true</code> であり、2件作成すると両方がprimaryで作成されていました。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">let</span><span class="p">(</span><span class="ss">:parent</span><span class="p">)</span> <span class="p">{</span> <span class="n">create</span><span class="p">(</span><span class="ss">:parent</span><span class="p">)</span> <span class="p">}</span>
<span class="n">let!</span><span class="p">(</span><span class="ss">:record_a</span><span class="p">)</span> <span class="p">{</span> <span class="n">create</span><span class="p">(</span><span class="ss">:record</span><span class="p">,</span> <span class="ss">parent: </span><span class="n">parent</span><span class="p">)</span> <span class="p">}</span> <span class="c1"># primary: true</span>
<span class="n">let!</span><span class="p">(</span><span class="ss">:record_b</span><span class="p">)</span> <span class="p">{</span> <span class="n">create</span><span class="p">(</span><span class="ss">:record</span><span class="p">,</span> <span class="ss">parent: </span><span class="n">parent</span><span class="p">)</span> <span class="p">}</span> <span class="c1"># primary: true</span>
<span class="n">it</span> <span class="s2">"正しいURLが組み立てられること"</span> <span class="k">do</span>
<span class="n">expect</span><span class="p">(</span><span class="n">parent</span><span class="p">.</span><span class="nf">page_url</span><span class="p">).</span><span class="nf">to</span> <span class="n">eq</span> <span class="s2">"https://example.com/records/</span><span class="si">#{</span><span class="n">record_a</span><span class="p">.</span><span class="nf">id</span><span class="si">}</span><span class="s2">"</span>
<span class="k">end</span>
</code></pre></div>
<p>その結果、内部で <code>primary_record</code> を参照していた、URLを組み立てるメソッドのテストがflakyになっていました。<code>has_one</code> は該当レコードが複数あってもエラーにはならず、いずれか1件を返すため、<code>record_b</code> が返された場合にテストが落ちていました。</p>
<p>2件目の作成時に <code>primary: false</code> を指定することで解消しました。</p>
<p>テストデータのセットアップでは、実環境では起こりえないようなデータを作ってしまわないよう注意が必要です。</p>
<h2 id="事例5-偶然の一致で失敗するテスト">事例5: 偶然の一致で失敗するテスト</h2>
<p>SUZURIでは、Tシャツなどの物理商品に加えて、スマホ用壁紙画像などのデジタルコンテンツを販売することもできます。これらを横断的に取得するAPIがあり、そのテストで問題が発生しました。</p>
<p>APIのレスポンスには順序保証がなく、かつ各エントリの属性を個別に検証する必要があったため、IDで目的のレコードを探していました。イメージとしては以下のようなコードです。</p>
<div class="highlight"><pre class="highlight ruby"><code><span class="n">let</span><span class="p">(</span><span class="ss">:product</span><span class="p">)</span> <span class="p">{</span> <span class="n">create</span><span class="p">(</span><span class="ss">:product</span><span class="p">)</span> <span class="p">}</span>
<span class="n">let</span><span class="p">(</span><span class="ss">:digital_product</span><span class="p">)</span> <span class="p">{</span> <span class="n">create</span><span class="p">(</span><span class="ss">:digital_product</span><span class="p">)</span> <span class="p">}</span>
<span class="n">before</span> <span class="k">do</span>
<span class="n">register</span><span class="p">(</span><span class="n">product</span><span class="p">)</span>
<span class="n">register</span><span class="p">(</span><span class="n">digital_product</span><span class="p">)</span>
<span class="k">end</span>
<span class="n">it</span> <span class="k">do</span>
<span class="n">entries</span> <span class="o">=</span> <span class="n">api_response</span><span class="p">[</span><span class="s2">"entries"</span><span class="p">]</span>
<span class="n">product_entry</span> <span class="o">=</span> <span class="n">entries</span><span class="p">.</span><span class="nf">find</span> <span class="p">{</span> <span class="o">|</span><span class="n">entry</span><span class="o">|</span> <span class="n">entry</span><span class="p">[</span><span class="s2">"id"</span><span class="p">]</span> <span class="o">==</span> <span class="n">product</span><span class="p">.</span><span class="nf">id</span><span class="p">.</span><span class="nf">to_s</span> <span class="p">}</span>
<span class="n">digital_product_entry</span> <span class="o">=</span> <span class="n">entries</span><span class="p">.</span><span class="nf">find</span> <span class="p">{</span> <span class="o">|</span><span class="n">entry</span><span class="o">|</span> <span class="n">entry</span><span class="p">[</span><span class="s2">"id"</span><span class="p">]</span> <span class="o">==</span> <span class="n">digital_product</span><span class="p">.</span><span class="nf">id</span><span class="p">.</span><span class="nf">to_s</span> <span class="p">}</span>