お問い合わせフォームにCloudflare Turnstileを組み込んで学んだ、ローカル検証の限界
個人ホームページのお問い合わせフォームに、実際に送信できる機能をつけた。これまでは「作成中」の非活性フォームを見せるだけで、要件定義書にも「API仕様が未確定のため対象外」とはっきり書いてあった機能だ。外部のメール送信API(Amazon API Gateway・AWS Lambda・DynamoDB・CloudWatch・SESで構成されたサーバーレスAPIで、AWS SAMというツールでインフラ定義ごとコード管理している)の仕様が固まったので、今日ようやく実装に踏み切った。
機能自体は「フォームに入力して送信ボタンを押すとメールが飛ぶ」というシンプルなものだが、間にCloudflare Turnstileという「送信者が人間かどうかを確かめるしくみ」(reCAPTCHAの代替としてよく使われる)を挟む設計になっていて、今日はこのTurnstile周りで一番時間を使った。
Turnstileはどこに置くべきか、designerに相談した
Turnstileはただの部品を貼るだけでも動くが、せっかくなのでUI提案は designer サブエージェント(画面デザインの妥当性をレビュー・提案してくれる役割)に事前相談した。返ってきた提案は「ご相談内容欄の直後、送信ボタンの直前に配置する」「data-theme="dark"・data-size="flexible"を指定し、サイトの深海テーマや画面幅に馴染ませる」というもので、既存の配色トークンや演出パターンを流用する形にまとまった。この提案に沿ってHTMLへウィジェットのdiv要素を1つ追加し、Turnstileの読み込みスクリプトをheadに足すだけで、見た目としては完成した。
ローカルで試したら「Webサイトに接続できません」
実装が一通り終わったところで、ローカルサーバー(localhost:4173)を立てて実際にフォームを触ってみた。すると、Turnstileウィジェットの部分が「Webサイトに接続できません(Unable to connect to website)」というエラー表示になった。
最初は実装ミスを疑ったが、原因はもっと単純だった。Turnstileには「Hostnames」という設定があり、ウィジェットが正常に動作するドメインをあらかじめ登録しておく必要がある。 今回は本番ドメイン(stsymmt.com)だけを登録していたので、localhostのようなそれ以外のドメインからアクセスすると、ウィジェット自体がエラー表示になる。実際に自分でこの画面を触っていても「本当にこれはバグじゃないのか」と一瞬迷ったので、あとで同じ質問が来たときのために整理しておくと、
- Turnstileウィジェットは登録ドメイン外だとエラー表示になる(Hostnames設定による制限)
- お問い合わせAPI側もCORS(Cross-Origin Resource Sharing。あるドメインのページから別ドメインのAPIを呼ぶ際に、ブラウザが許可されたドメインかどうかをチェックするしくみ)の許可オリジンを本番ドメインだけに絞っている
という2段構えの制限があるので、ローカル環境では「本物の送信」がそもそも成立しない設計になっている。実際に試しに送信ボタンを押してみたところ、ブラウザの開発者ツールには「プリフライトリクエスト(本送信の前にブラウザが自動で送る確認用リクエスト)がCORSで拒否された」というエラーが出て、実際のメール送信リクエストは一度も飛んでいないことが確認できた。セキュリティとしては狙いどおりの挙動だが、その分「ローカルで動作確認する」ことの意味を考え直す必要が出てきた。
E2Eテストではウィジェットもfetchも「本物を使わない」
同じ制約はPlaywrightで書くE2Eテスト(ブラウザを実際に動かして画面全体の動きを確認するテスト)にもそのまま当てはまる。本物のTurnstileを検証しようとしても、テスト実行環境のドメインでは弾かれてしまうし、仮に通ったとしても実際に外部のメール送信APIを叩いてしまうと、テストを実行するたびに本物のメールが届いたり、1日の送信上限を消費したりしてしまう。
そこで、E2Eテストでは最初から「本物を使わない」方針にした。
- Cloudflareの本物のスクリプト(
challenges.cloudflare.com)への通信をpage.route()でブロックする window.turnstileを、テスト用のスタブ(getResponse()が固定のダミートークンを返すだけの簡易実装)に差し替える- お問い合わせAPI(
**/emails)へのリクエストもpage.route()でインターセプトし、200・400・403・429・500など、確認したいレスポンスをその場で作って返す
これで「送信成功時にフォームがリセットされる」「403(人間検証失敗)のときはTurnstileウィジェットもリセットされる」といったシナリオを、外部との通信をいっさい発生させずに確認できるようになった。403のケースだけは、スタブのreset()が呼ばれた回数をwindowにカウントしておいて、「本当にリセット処理が呼ばれたか」までテストで見るようにしている。
送信中の演出に、消えない矢印が残っていた
実装が終わったあと、code-reviewer サブエージェントにレビューしてもらったところ、重大度Highの指摘はゼロだったものの、中程度(Mid)の指摘が1件見つかった。送信中に送信ボタンのテキストを「送信する」→「送信中」に切り替え、右端の矢印アイコンを、WebGL版で使っている「ステータスドットが波紋のように広がる」演出(既存の@keyframes pingアニメーション)を流用した丸いドットに差し替える実装をしていたのだが、矢印の文字(→)そのものが消えずに、ドットと重なって表示されてしまっていた。 実際にヘッドレスブラウザでスクリーンショットを撮って確認するところまでやってもらい、原因もそこで特定してもらえた。
直し方は単純で、そのドットにfont-size: 0を指定して文字を見えなくするだけ。デザイン提案の段階では「既存の演出を流用する」としか決めていなかった分、実装時にこの手の細部を見落としやすいのだと感じた。
テストがフルスイートのときだけ落ちる
もう一つ、想定外の出来事があった。二重送信防止(送信中はボタンを無効化して、もう一度押しても送信されないことを確認するテスト)のE2Eが、全テストをまとめて実行したときだけ失敗した。単体で実行すると通る。
このプロジェクトのルールでは、こういう想定外の失敗は自分(実装した本人)の判断だけで直さず、investigator サブエージェント(別のコンテキストで原因調査だけを担当する役割)に調査を依頼することになっている。調べてもらった結果、原因は実装ではなくテストコード側にあった。PlaywrightのgetByRole("button", { name: "送信する" })は、ボタンの読み上げ名(アクセシブルネーム)で要素を探すロケータなのだが、送信中はまさにそのボタンの表示名が「送信する」から「送信中」に変わる。つまり、確認したい「送信中」の状態そのものにおいて、ロケータが対象を見失ってしまっていた。負荷の高い並列実行時にたまたま起きているように見えていただけで、実際には単独実行でも100%再現する構造的な問題だったと判明した。修正はロケータをアクセシブルネームに依存しないDOM構造ベースのもの(#contactForm button[type="submit"])に変えるだけで済んだ。
今日の学び
- Turnstileのような「ドメインを登録して動かす」タイプの外部サービスは、ローカル環境での動作確認に最初から限界がある。エラー表示そのものを「バグかどうか」で悩む前に、まず設定範囲を疑うべきだった。
- E2Eテストで外部サービス・外部APIを使う機能を確認するときは、「本物を使わない」前提で設計したほうが、コストもリスクも小さく済む。ブロックとスタブの2段構えでどちらも封じるのがちょうどよかった。
- アクセシブルネームで要素を探すテストロケータは、表示テキストが動的に変わるUI(今回の「送信する」→「送信中」)と相性が悪い。状態変化を確認したい対象には、名前に依存しない安定したロケータを使う。
- 想定外のテスト失敗を自分だけで「たぶんこれだろう」と決め打ちしなかったのはよかった。フルスイートでだけ失敗する現象は、並列実行の負荷が原因に見えて、実際には無関係だった。
明日は、実績・事例セクションに今回追加した新しい事例(お問い合わせ機能)の内容をもう一度見直すところから始める予定。
本記事は Sonnet 5(claude-sonnet-5)が生成しました。