payment-testing について
PSPのサンドボックス、3DSチャレンジ、サブスクリプションの時間経過、Webhook処理を扱う決済テストスキルです。ライブキーや実カード番号を使わずに検証し、署名確認、サーバー側での確定、冪等な履行を重視します。
主な機能
- 01PSPサンドボックスの決済結果テスト
- 02ネストした3DSフレームの操作
- 03テストクロックによる課金時刻の進行
- 04Webhook署名の検証
- 05冪等な履行のテスト
- 06サーバー側での決済照合
活用シーン
- 01カード決済の成功・拒否経路のテスト
- 02サブスクリプション更新と失敗のシミュレーション
- 03Webhookの拒否と重複処理の確認
- 04決済確認まで注文が保留されることの検証
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ petrkindlmann/ qa-skills --skill payment-testingインストール後、Claude Code で/payment-testingを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/multi-psp.mdplaywright-3ds.mdstripe-test-cards.mdwebhooks-and-clocks.md詳細
クイックルート
| テストしたい内容… | 移動先 | 参照 |
|---|---|---|
| 成功/拒否/残高不足の結果 | テストカード | references/stripe-test-cards.md |
| モーダルで表示される 3DS/SCA チャレンジ | 3DS チャレンジ | references/playwright-3ds.md |
| 数か月後/数年後のサブスクリプション更新 | テストクロック | references/webhooks-and-clocks.md |
| Webhook の localhost への配信と署名 | Webhook | references/webhooks-and-clocks.md |
| 更新失敗後の返金 | 決済失敗 | references/webhooks-and-clocks.md |
| 実際の決済後にのみ履行する処理 | 照合 | references/webhooks-and-clocks.md |
| Adyen/PayPal/Braintree のサンドボックス | 複数の PSP | references/multi-psp.md |
確認事項
まず、プロジェクトのルートにある .agents/qa-project-context.md を確認し、すでに回答されている項目(PSP、スタック、テストフレームワーク、既存のフィクスチャ)は飛ばしてください。そのうえで、次の点を確認します。
- どの PSP を使っていますか。Stripe ですか。 ここでは Stripe をデフォルトとしています。他の PSP でもパターンは共通ですが、それぞれ専用のサンドボックスカード/アカウントがあります。複数の PSP を参照してください。
- 単発決済、サブスクリプション、またはその両方ですか。 サブスクリプションでは、テストクロック、督促処理、
invoice.*のライフサイクルが関係します。単発決済ではこれらは不要です。 - SCA/3DS が使われていますか。 EU/英国のカード決済フローでは、ほぼ必ずチャレンジが発生します。使われている場合は、通常のロケーターではなく、ネストされた iframe のパターンが必要です。
- 現在、Webhook とリダイレクトのどちらで履行していますか。
return_urlで履行しているなら、それこそテストすべき不具合です。履行は、検証済みの Webhook を待ってから行う必要があります。 - テスト時に Webhook ハンドラーはどこで動作しますか。 ローカル(
stripe listen)かデプロイ済みのプレビュー環境かによって、イベントの配信方法が変わります。
基本原則
-
実際のカードやライブキーには絶対に触れないでください。これは好みの問題ではなく、絶対に譲れないルールです。 どの環境であっても、実際の PAN を使うと Stripe のサービス契約に違反し、リポジトリが PCI の適用範囲に含まれることになります。唯一正しい方法は、テストモード(
pk_test_/sk_test_)で Stripe が公開しているテストカードを使うことです。実際の番号をマスキングまたは暗号化しても解決にはなりません。取り除く必要があります。 -
決済の確定は必ずサーバー側で行い、クライアント側では行わないでください。 リダイレクト、
onApproveコールバック、?status=successクエリパラメーターは、タイミングが早すぎたり、再送されたり、偽造されたりする可能性があります。署名検証済みのpayment_intent.succeededWebhook を受け取り、API のretrieveで再確認してから、注文を履行してください。 -
本文を解析する前に署名を検証してください。 先に JSON を解析すると、
constructEventが必要とする生のバイト列が失われます。Webhook のルートでは生の本文を受け取り、それ以外では JSON を解析できます。 -
請求における時間はサーバー側で決まります。 Stripe の請求エンジンは Stripe のサーバー上で動作します。テストプロセス内で時計を偽装しても何も変わりません。テストクロックを使ってください。
-
Webhook はすべて複数回配信されると想定してください。 Stripe は再試行します。永続ストレージで
event.idをキーにした冪等性を確保することは必須です。メモリー内のセットでは冪等性を保証できません。
Stripe のテストカードと結果
各結果を確実に発生させるカードを使ってテストします。特に重要な 4 枚は次のとおりです。
| PAN | 結果 | コード |
|---|---|---|
4242424242424242 | 成功 | — |
4000000000000002 | 拒否 | card_declined (generic_decline) |
4000000000009995 | 拒否 | insufficient_funds |
4000000000003220 | 常に 3DS 認証を要求 | — |
4000000000000341 | アタッチ後、請求時に失敗 | card_declined |
テスト対象のアプリではテストキー(pk_test_…/sk_test_…)を使い、セットアップ時にそれをアサートしてください。
4111111111111111 は使わないでください。これは Braintree/PayPal 時代の汎用 Luhn 番号であり、
Stripe のテストカードではなく、確実に拒否されるわけでもありません。
カード入力欄はクロスオリジンの Stripe iframe 内にあるため、frameLocator 経由で入力し、
page.locator を直接使わないでください。スモークテストでは UI 上の文言をアサートするか、
より確実な方法として paymentIntents.retrieve からサーバー側の
last_payment_error.decline_code をアサートします。成功/card_declined/insufficient_funds の
Playwright テスト一式については、references/stripe-test-cards.md を参照してください。
3DS/SCA:入れ子になった iframe のチャレンジ
ここが最も正しく実装するのが難しい部分です。Stripe の 3DS チャレンジは
Stripe モーダルフレーム内にネストされたフレームです。単一の frameLocator では到達できません。
外側から内側へ frameLocator を連結し、Complete authentication をクリックしてください。
失敗する方法とその理由:
page.locator('#card')→ 入力欄はクロスオリジンのため、ロケーターに一致するものがありません。page.frames()[1]→ Stripe がフレームを追加/並べ替えると、フレームのインデックスが変わります。インデックスでフレームを選択しないでください。await page.waitForTimeout(5000)→ チャレンジの所要時間を推測しています。要素を待機してください。
正しい形(認証失敗のバリエーションを含むテスト一式は
references/playwright-3ds.md にあります):
// 3DS-required card so the challenge always appears.
await card.getByPlaceholder('Card number').fill('4000000000003220');
await page.getByRole('button', { name: /pay/i }).click();
// Nested: outer Stripe challenge frame → inner ACS frame. One frameLocator is not enough.
const inner = page
.frameLocator('iframe[name^="__privateStripeFrame"]')
.frameLocator('iframe#challengeFrame, iframe[name="acsFrame"]');
await inner.getByRole('button', { name: /complete authentication|complete|authorize/i }).click();
await expect(page).toHaveURL(/\/success/); // assert the succeeded state
await expect(page.getByText(/payment succeeded/i)).toBeVisible();
4000002760003184 は、セットアップインテント/初回利用のフロー向けの代替 SCA カードです。
1 回限りの支払いで 3DS カードが必要な場合、評価とドキュメントではこのカードも受け付けられます。
テストクロック:サーバー側の時間移動
1 年待たずに年次更新をテストするには、Stripe のテストクロックを使います。これは
サーバー側の仕組みです。クライアント側の偽物(jest.useFakeTimers、sinon、Date のモック)は
Stripe の請求エンジンには何の影響もありません。
見落とすと問題になるルール:
frozen_timeを指定してクロックを作成し、その作成時に顧客を関連付けます(test_clock: clock.id)。 既存の顧客を後からクロックに関連付けることはできません。testHelpers.testClocks.advanceで進められるのは未来方向のみです。時間を巻き戻すことはできません。1 回の呼び出しで進められるのは最大 2 回の請求サイクルです。- 時間を進めた後、クロックが
readyになるまでポーリングし、更新請求書と Webhook をアサートします。
const clock = await stripe.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000), name: 'annual-renewal',
});
const customer = await stripe.customers.create({ test_clock: clock.id /* … */ });
// …create subscription, then advance ~12 months forward:
await stripe.testHelpers.testClocks.advance(clock.id, { frozen_time: oneYearLater });
作成/時間を進める/アサートするまでの全手順については、references/webhooks-and-clocks.md のセクション 4 を参照してください。
Webhook:ローカル配信、署名、冪等性
ローカル配信。 エンドポイントを ngrok で公開したり、API をポーリングしてステータスを確認したりしないでください。stripe listen はテストイベントをネイティブに localhost へトンネリングします。stripe trigger を使えば、任意のタイミングでイベントを発生させられます。
stripe listen --forward-to localhost:3000/webhooks # prints whsec_… ONCE at startup
stripe trigger payment_intent.succeeded
この whsec_… を STRIPE_WEBHOOK_SECRET にコピーしてください。これは署名シークレットであり、STRIPE_SECRET_KEY(sk_test_…)とは別の値です。混同しないでください。
署名の検証。 constructEvent が生のボディを受け取れるよう、グローバルな express.json() より前に、Webhook ルートへ express.raw をマウントしてください。偽造または改ざんされたイベントは 400 で拒否してください。=== signature の文字列比較を独自に実装してはいけません。
app.post('/webhooks', express.raw({ type: 'application/json' }), async (req, res, next) => {
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return res.status(400).send(`Webhook Error: ${(err as Error).message}`); // SignatureVerificationError
}
try { return await handleEvent(event, res); }
catch (err) { return next(err); } // processing failures remain retryable 5xx
});
app.use(express.json()); // everything else, after the webhook route
冪等性。 Stripe は配信を再試行するため、同じ event.id が 2 回届きます。リクエスト側の冪等性キー(送信 API 呼び出し用)では、受信 Webhook の重複を排除できません。event.id を UNIQUE 制約付きで保存し、フルフィルメントと同じデータベーストランザクション内で処理してください。コミット後にのみ 200 を返してください。マーカーの挿入後に処理が失敗した場合は、両方の変更をロールバックし、再試行可能な 5xx を返してください。そうしないと、再試行時にマーカーが見つかり、未払い/未フルフィルメントの注文が黙って破棄されます。references/webhooks-and-clocks.md のトランザクションに紐づいたハンドラーを使ってください。インメモリーのセットは再起動後に保持されません。
外部への副作用については、アウトボックスジョブをコミットし、そのコンシューマーを冪等にしてください。また、注文 ID や支払い ID などのビジネス操作キーも適用してください。異なるイベント ID が同じ操作を表すことがあります。exactly-once 配信をうたってはいけません。
署名テスト(有効な署名は受理、偽造された署名は 400)と、「同じイベントを 2 回配信し、フルフィルメントは 1 回のみ」と確認する冪等性テストは、references/webhooks-and-clocks.md(セクション 2~3)にあります。重複排除マーカー挿入後の失敗、再起動後の再試行、同時に発生する重複、1 つの支払いに対する異なるイベント ID、順不同の配信も追加してください。逐次的な重複テストだけでは、クラッシュ発生の隙間を検証できません。
支払い失敗:督促と返金
定期支払いの失敗をエンドツーエンドでテストするには、4000000000000341(SDK トークン pm_card_chargeCustomerFail)でサブスクライブしてください。このカードは顧客に紐づけられますが、後続の請求時に失敗します。更新処理に必要なのはこの挙動です。紐づけ時に拒否されるカードは保存できないため、更新時の失敗を再現できません。
テストクロックでライフサイクルを進めます。
- 顧客を(テストクロック上で)紐づけ時に成功し、その後失敗するカードを使ってサブスクライブします。
- クロックを更新日より先まで
advanceします → Stripe が請求を試みます。 - 請求が失敗します → Stripe は
invoice.payment_failedを発行し、サブスクリプションはpast_dueになります。両方を確認してください。 refunds.createで返金を発行して解決します(charge.refundedが発生します)。サブスクリプションを削除して「修正」してはいけません。
完全なテストドライバーは references/webhooks-and-clocks.md(セクション 5)にあります。
照合:リダイレクトではなくWebhookでフルフィルメントを実行
署名検証済みの payment_intent.succeeded Webhook を API と照合して確認した後にのみ、注文を支払い済みにしてください。return_url へのリダイレクトやクライアント側の成功フラグで支払い済みにしてはいけません。また、待機時間を設けてポーリングしてもいけません。
if (event.type === 'payment_intent.succeeded') {
const verified = await stripe.paymentIntents.retrieve(event.data.object.id);
if (verified.status === 'succeeded' && verified.amount_received === expected) {
await markOrderPaid(verified.metadata.orderId); // fulfillment happens HERE
}
}
照合テストでは、リダイレクト後も注文が pending のままであり、検証済みの Webhook を受け取った後にのみ paid になることを確認します。references/webhooks-and-clocks.md(セクション 6)を参照してください。
複数のPSP:Adyen、PayPal、Braintree
Stripe のテストカードは、他の PSP では動作しません。各 PSP には独自のサンドボックス用カードとサンドボックス購入者アカウントがあります。Stripe のテストの構成を移植し、PSP のサンドボックス用の値に置き換えてください。Stripe の PAN やライブ/本番用キーは絶対に再利用しないでください。
- Adyen — 独自のテストカードを使用します(例:3DS2 用の
4212345678910014)。多くの拒否はカードではなく、取引金額(.13は拒否、.51は照会)によって決まります。イベントは HMAC 署名付き通知として届きます。 - PayPal — カードではなく、サンドボックス購入者アカウント(サンドボックス用の個人メールアドレス/パスワード)でログインします。クライアントの
onApproveではなく、Orders API/ウェブフックを使ってサーバー側で確認します。 - Braintree — Drop-in UI/Hosted Fields から独自のサンドボックス用テストカード番号を使用します。取引結果は金額によって決まり、カード番号は検証に使用されます。
共通する点:テスト/サンドボックス用認証情報を分けること、実際のカードを使わないこと、検証済みのサーバー側イベント/通知に基づいて決済処理を完了することです。詳細は references/multi-psp.md を参照してください。
アンチパターン
1. 4111111111111111 を使う
この Luhn 検証に通る番号は、Braintree/PayPal 時代の汎用 PAN であり、Stripe のテストカードではありません。成功には 4242424242424242 を、拒否テストには専用のカード(4000000000000002、4000000000009995)を使用してください。
2. カード欄を通常の入力欄として扱う
フィールドはクロスオリジン iframe 内にあるため、page.locator('#card-number') は何も見つからず、失敗にも気づけません。frameLocator を使用してください。
3. インデックスで iframe を選択する
Stripe が iframe の順序を変更した瞬間に、page.frames()[1] は動作しなくなります。安定した名前のプレフィックス(iframe[name^="__privateStripeFrame"])でフレームを特定し、ネストされた 3DS チャレンジには frameLocator を連結して使用してください。
4. 「チャレンジを待つ」ために waitForTimeout を使う
遅い CI では不安定になり、速い CI では無駄になります。時計ではなく要素を待機してください(expect(...).toBeVisible()/自動待機するロケーターアクション)。
5. 請求処理にクライアント側の時刻モックを使う
jest.useFakeTimers/sinon/Date のモックでは、Stripe のサーバー側請求処理を進められません。テストクロックを使用してください。
6. ローカルのウェブフックに ngrok を使ったり、ポーリングしたりする
stripe listen --forward-to localhost:3000/webhooks はイベントをネイティブにトンネルし、stripe trigger でイベントを発火できます。公開トンネルも、ステータスのポーリングも不要です。
7. 署名を検証する前に本文を解析する
ウェブフックルートより前にグローバルな express.json() を配置すると、constructEvent が必要とする生の本文が破壊され、検証に成功しなくなります。ウェブフックルートには、まず express.raw をマウントしてください。
8. リクエストの冪等性キーをウェブフックの重複排除と混同する、またはメモリー内のセットを使う
送信リクエストの冪等性キーでは、受信ウェブフックの重複を排除できません。メモリー内の Set は再起動で消えます。event.id を UNIQUE 制約付きで永続化してください。
9. リダイレクト/クライアント側の成功フラグで決済処理を完了する
return_url は早すぎたり、再送されたり、偽造されたりする可能性があります。検証済みの payment_intent.succeeded ウェブフックでのみ決済処理を完了してください。
10. 失敗した更新請求をサブスクリプションの削除で「解決」する
正しい解決方法は、サブスクリプションの督促ライフサイクル(invoice.payment_failed → past_due)を維持してテスト可能にしたまま、refunds.create で返金することです。
11. 実カードを「CI だけなら」と正当化する
ハードコードされた実際の PAN は、環境に関係なく PCI/コンプライアンス違反です。テストモードでテストカードを使い、さらにリポジトリと git 履歴から秘密情報を削除し、漏えいしたキーをローテーションしてください。マスキングや暗号化をしても、許容される状態にはなりません。
検証
stripe listen --forward-to localhost:3000/webhooksを実行するとwhsec_…が表示され、stripe trigger payment_intent.succeededを実行するとイベントが届くことを確認できます。4000000000003220を使って 3DS テストを実行すると、Complete authentication ボタンに到達してクリックします(ネストされたフレームが見つからない場合、テストは黙って失敗せず、明確に失敗します)。- 署名テスト:改ざんされた
Stripe-Signatureは 400 を返し、generateTestHeaderStringによるヘッダーは 200 を返します。 - 冪等性テスト:同じ
event.idを 2 回配信しても、決済処理は 1 回だけ実行されます。 - テストスイートに対して
grep -rE 'pk_live|sk_live|4111111111111111'を実行すると、何も見つかりません。
完了条件
- チェックアウトのテストスイートで、成功(
4242424242424242)、card_declined(4000000000000002)、insufficient_funds(4000000000009995)を網羅し、それぞれ対応する結果を検証します。キーにはpk_test_/sk_test_を使用します。 - 3DS テストでは
4000000000003220を入力し、連鎖したframeLocatorを使ってネストされたチャレンジフレームに到達し、認証を完了をクリックして、成功状態を検証します。frames()[index]もwaitForTimeoutも使用しません。 - サブスクリプション更新テストでは、クライアント側の時刻モックではなく、Stripe のテストクロック(
testHelpers.testClocks.create+advance)を使用します。時刻は前にのみ進め、顧客は作成時に紐付けます。 - ローカルの Webhook は
stripe listen --forward-to/stripe triggerで受信し、whsec_…をSTRIPE_WEBHOOK_SECRETに設定します(STRIPE_SECRET_KEYとは別の値です)。 - Webhook ハンドラーは、解析前に未加工の本文に対して
constructEventで署名を検証し、偽造されたイベントには400を返します。これをテストで実証します。 event.idをUNIQUE制約付きで永続化して冪等性を確保します。重複した配信には200を返し、処理が一度だけ実行されることをテストで実証します。- 更新失敗テストでは、テストクロックと、紐付けてから失敗させるカード
4000000000000341を使用し、invoice.payment_failed→past_due→refunds.createの流れを実行します。 - 照合テストでは、リダイレクト時ではなく、検証済みの
payment_intent.succeededWebhook(paymentIntents.retrieveで再確認)を受信した後にのみ注文がpaidになることを確認します。 grep -rE 'pk_live|sk_live|4111111111111111'を実行しても、テストスイート内に本番用キーや禁止されたPANが見つからないことを確認します。([0-9]{16}だけで検索すると、正規のテストカードがすべて誤検出されます。16桁の文字列すべてではなく、本番用キーのプレフィックスと禁止された4111…の番号を照合してください。)
関連スキル
- api-testing — 決済以外のエンドポイントに対する一般的なREST/GraphQLエンドポイントのテスト、スキーマ検証、認証フロー。対象がPSPのチェックアウト/Webhookでない場合はこちらを参照してください。
- playwright-automation — このスキルの3DSフローの基盤となる、ページオブジェクトモデル、フィクスチャ、一般的なブラウザーE2Eの仕組み。
- compliance-testing — PCI-DSS、GDPR、規制監査に関する作業。このスキルではテストカードを使用してPCIの対象範囲外にとどめます。正式なコンプライアンス監査については、こちらを参照してください。
- test-data-management — 顧客、サブスクリプション、フィクスチャのシード処理、およびこのスキルで作成するテストクロックに紐付いた顧客の管理。
- qa-project-context — まず最初に確認する共通の情報源です。PSP、スタック、フィクスチャの規約など、上記のすべての質問で参照すべき情報が含まれています。
参考ファイル(references/内)
- stripe-test-cards.md — 失敗コードを含むテストカードの完全な一覧と、Playwright による成功/拒否/残高不足のテスト。
- playwright-3ds.md — ネストされたiframeでの3DSチャレンジの処理、成功・失敗のパターン、セレクターに関する注意点。
- webhooks-and-clocks.md —
stripe listen/trigger、未加工の本文に対する署名検証、event.idによる冪等性、テストクロック、更新失敗時の督促と返金、照合。 - multi-psp.md — Adyen、PayPal、BraintreeのサンドボックスのパターンとStripeとの相違点。