決済連携はあらゆる SaaS でもっともクリティカルな部分です。テストが漏れると、本番で発覚します — そして本番では、それは実際のお金、誤って課金された顧客、そしてパンク状態のサポートを意味します。Stripe、Asaas、Mercado Pago を本番システムに連携してきた経験から、決済のテストは CRUD のテストとはまったく別物だと学びました。専用の戦略が必要です。
この記事では、Node.js で決済連携をテストするために私が使っている戦略を共有します — 何をテストし、何をモックし、いつサンドボックスを使い、webhook・冪等性・非同期フロー(PIX、ボレト)が本当に動作することをどう保証するか、です。
問題:決済は CRUD ではない
CRUD のテストは、データが正しくデータベースに入って出てくることを検証します。決済には次のものが関わります:
- 非同期フロー — PIX とボレトは即時に確定しません。ステータスは数分後、あるいは数時間後に webhook 経由で変わります
- 外部システム — ゲートウェイは遅くなったり、エラーを返したり、予告なくレスポンスのフォーマットを変えたりします
- 冪等性 — 同じ webhook が 2 回、3 回、5 回届くことがあります。システムは 1 回だけ処理しなければなりません
- 実際のお金 — バグがあると顧客に二重に課金したり、まったく課金しなかったりします。簡単な「ロールバック」はありません
- 複数のプロバイダー — 各ゲートウェイには独自の API、webhook フォーマット、サンドボックスの挙動があります
うまくいく戦略は、4 つのレイヤーでテストすることです:ユニット、契約、サンドボックスとの結合、レジリエンス。
レイヤー 1:ユニットテスト — 隔離されたビジネスロジック
最初のレイヤーは、どのゲートウェイにも触れずにロジックをテストします。ここでテストするのは、金額の計算、ステータスの検証、ビジネスルール(キャンセルできるか?返金できるか?)、そして冪等性のロジックです。
// ユニットテスト — 冪等性のロジック
describe('PaymentEventProcessor', () => {
it('should process a new event', async () => {
const processor = new PaymentEventProcessor(mockDb)
const event = createPaymentEvent({ externalId: 'evt_123', type: 'payment.confirmed' })
const result = await processor.handle(event)
expect(result.status).toBe('processed')
expect(mockDb.paymentEvent.create).toHaveBeenCalledWith(
expect.objectContaining({ idempotencyKey: 'evt_123:payment.confirmed' })
)
})
it('should skip duplicate events', async () => {
const processor = new PaymentEventProcessor(mockDb)
mockDb.paymentEvent.findUnique.mockResolvedValue({ id: 'existing' })
const event = createPaymentEvent({ externalId: 'evt_123', type: 'payment.confirmed' })
const result = await processor.handle(event)
expect(result.status).toBe('already_processed')
expect(mockDb.order.update).not.toHaveBeenCalled()
})
it('should reject invalid status transitions', async () => {
const processor = new PaymentEventProcessor(mockDb)
mockDb.order.findUnique.mockResolvedValue({ paymentStatus: 'REFUNDED' })
const event = createPaymentEvent({ type: 'payment.confirmed' })
await expect(processor.handle(event)).rejects.toThrow('Invalid transition: REFUNDED → CONFIRMED')
})
})ここでのモックはデータベースであって、ゲートウェイではありません。処理するかどうかの判断、ステータス遷移が妥当かどうか、idempotency key がすでに存在するかどうか — それらはすべて自分のコードであり、カバーされている必要があります。
レイヤー 2:契約テスト — ゲートウェイのアダプター
adapter pattern を使っているなら(複数のゲートウェイと連携するなら使うべきです)、各アダプターには契約テストが必要です。このテストは、アダプターがゲートウェイのペイロードを正しい内部フォーマットに変換することを保証します。
// 契約テスト — 各アダプターは同じ出力フォーマットに従う
describe('AsaasWebhookAdapter', () => {
it('should parse a PIX payment confirmation', () => {
const rawPayload = {
event: 'PAYMENT_CONFIRMED',
payment: {
id: 'pay_abc123',
value: 99.90,
billingType: 'PIX',
status: 'CONFIRMED',
externalReference: 'order_456',
},
}
const adapter = new AsaasWebhookAdapter()
const event = adapter.parse(rawPayload)
expect(event).toEqual({
provider: 'asaas',
externalId: 'pay_abc123',
type: 'payment.confirmed',
amount: 99.90,
method: 'PIX',
orderId: 'order_456',
})
})
})
describe('MercadoPagoWebhookAdapter', () => {
it('should parse a PIX payment confirmation', () => {
const rawPayload = {
action: 'payment.updated',
data: { id: '12345' },
}
// Mercado Pago は ID だけを送る — 詳細を取得する必要がある
const paymentDetails = {
id: 12345,
status: 'approved',
transaction_amount: 99.90,
payment_method_id: 'pix',
external_reference: 'order_456',
}
const adapter = new MercadoPagoWebhookAdapter()
const event = adapter.parse(rawPayload, paymentDetails)
expect(event).toEqual({
provider: 'mercadopago',
externalId: '12345',
type: 'payment.confirmed',
amount: 99.90,
method: 'PIX',
orderId: 'order_456',
})
})
})重要なポイント:どのゲートウェイが送信したかに関係なく、出力は常に同じフォーマットです。明日 4 つ目のゲートウェイを追加しても、契約テストがアダプターは同じ形を生成することを保証します。
レイヤー 3:サンドボックスとの結合テスト
ここではゲートウェイの本物の API を叩きます — ただしサンドボックス環境で。各プロバイダーには独自のものがあります:
- Stripe:
sk_test_*キーを使ったテストモード。特別なカード(成功用の4242...、拒否用の4000000000000002)であらゆるシナリオをシミュレートできます - Asaas:
sandbox.asaas.comのサンドボックス環境。お金を動かさずに PIX、ボレト、カードをシミュレートします - Mercado Pago: テストユーザーを使ったテスト用クレデンシャル。より制約が多く — 一部の PIX フローはサンドボックスで完全には動作しません
サンドボックスとの結合テストはフロー全体を検証します:請求の作成 → webhook の受信 → 決済の処理 → 注文の更新。
// サンドボックスとの結合テスト — フロー全体
describe('Payment Flow (Sandbox)', () => {
it('should create a PIX charge and process the webhook', async () => {
// 1. サンドボックスで請求を作成する
const charge = await paymentService.createCharge({
provider: 'asaas',
amount: 49.90,
method: 'PIX',
orderId: 'test_order_001',
})
expect(charge.externalId).toBeDefined()
expect(charge.pixQrCode).toBeDefined()
expect(charge.status).toBe('PENDING')
// 2. 確認 webhook をシミュレートする(サンドボックスはこれを許可する)
const webhookPayload = buildSandboxWebhook('asaas', {
paymentId: charge.externalId,
event: 'PAYMENT_CONFIRMED',
})
const response = await request(app)
.post('/api/webhooks/asaas')
.set('asaas-access-token', SANDBOX_WEBHOOK_TOKEN)
.send(webhookPayload)
expect(response.status).toBe(200)
// 3. 注文が更新されたことを検証する
const order = await db.order.findUnique({ where: { id: 'test_order_001' } })
expect(order.paymentStatus).toBe('CONFIRMED')
})
})CI でのサンドボックステストには注意してください。 これらはネットワークと外部サービスに依存します。Asaas のサンドボックスが落ちていれば、CI が壊れます。解決策は、これらのテストを別ジョブで、リトライと余裕のあるタイムアウト付きで実行し、それらでマージをブロックしないことです。
レイヤー 4:レジリエンステスト — うまくいかないときに何が起きるか
これはほとんどの人が無視するレイヤーです。ハッピーパスをテストするだけでは足りません — 次のような場合に何が起きるかをテストする必要があります:
- ゲートウェイが請求の途中でタイムアウトを返す
- webhook が請求作成のレスポンスより先に届く(本物のレースコンディション)
- webhook が重複して届く — 2 秒間に 3 回
- webhook が無効な署名で届く(不正アクセスの試み)
- ゲートウェイが予告なくペイロードのフォーマットを変える(Mercado Pago で実際に起きました)
- PIX が期限切れになり、顧客が後から支払おうとする
// レジリエンステスト
describe('Payment Resilience', () => {
it('should handle duplicate webhooks gracefully', async () => {
const webhook = buildWebhook({ externalId: 'pay_dup', type: 'payment.confirmed' })
// 連続して 3 回送信する
const results = await Promise.all([
request(app).post('/api/webhooks/asaas').send(webhook),
request(app).post('/api/webhooks/asaas').send(webhook),
request(app).post('/api/webhooks/asaas').send(webhook),
])
// すべて 200 を返す(ack)
results.forEach(r => expect(r.status).toBe(200))
// しかし注文は 1 回だけ更新された
const events = await db.paymentEvent.findMany({
where: { externalId: 'pay_dup' },
})
expect(events).toHaveLength(1)
})
it('should reject webhooks with invalid signature', async () => {
const webhook = buildWebhook({ externalId: 'pay_fake' })
const response = await request(app)
.post('/api/webhooks/asaas')
.set('asaas-access-token', 'invalid_token')
.send(webhook)
expect(response.status).toBe(401)
})
it('should handle expired PIX without crashing', async () => {
const webhook = buildWebhook({
externalId: 'pay_expired',
type: 'payment.expired',
})
const response = await request(app)
.post('/api/webhooks/asaas')
.send(webhook)
expect(response.status).toBe(200)
const order = await db.order.findUnique({ where: { externalPaymentId: 'pay_expired' } })
expect(order.paymentStatus).toBe('EXPIRED')
})
})署名の検証:絶対に飛ばさない
各ゲートウェイは webhook を異なる方法で署名します。検証しなければ、あなたの webhook URL を見つけた誰もが決済を偽装できます。
// プロバイダーごとの署名検証
function validateWebhookSignature(provider: string, req: Request): boolean {
switch (provider) {
case 'stripe': {
// Stripe は 'stripe-signature' ヘッダーで HMAC-SHA256 を使う
const sig = req.headers['stripe-signature'] as string
try {
stripe.webhooks.constructEvent(req.body, sig, STRIPE_WEBHOOK_SECRET)
return true
} catch {
return false
}
}
case 'asaas': {
// Asaas は 'asaas-access-token' ヘッダーで固定トークンを使う
return req.headers['asaas-access-token'] === ASAAS_WEBHOOK_TOKEN
}
case 'mercadopago': {
// Mercado Pago は 'x-signature' ヘッダーで HMAC-SHA256 を使う
const xSignature = req.headers['x-signature'] as string
const xRequestId = req.headers['x-request-id'] as string
const dataId = req.query['data.id'] as string
const computed = crypto
.createHmac('sha256', MP_WEBHOOK_SECRET)
.update(`id:${dataId};request-id:${xRequestId};ts:${extractTs(xSignature)};`)
.digest('hex')
return extractHash(xSignature) === computed
}
default:
return false
}
}各検証を、正しい署名と誤った署名の両方でテストしてください。それが最初の防衛線です。
モックしてはいけないもの
私が使っている実践的なルール:
- ユニットテストと契約テストではゲートウェイをモックする — テストしているのは自分のロジックです
- 結合テストではゲートウェイをモックしない — サンドボックスを使います。ゲートウェイのモックは誤った安心感を与えます
- 結合テストではデータベースを決してモックしない — 本物のデータベース(テスト用 PostgreSQL)を使います。データベースのモックはトランザクション、制約、レースコンディションのバグを隠します
- 期限切れのテストでは時間をモックする — 30 分待たずに PIX の期限切れをシミュレートするために
jest.useFakeTimers()を使います
本番で使っているテスト構成
tests/
├── unit/
│ ├── payment-event-processor.test.ts # 冪等性のロジック
│ ├── status-machine.test.ts # 状態遷移
│ └── charge-calculator.test.ts # 金額の計算
├── contract/
│ ├── asaas-adapter.test.ts # Asaas の webhook フォーマット
│ ├── mercadopago-adapter.test.ts # MP の webhook フォーマット
│ └── stripe-adapter.test.ts # Stripe の webhook フォーマット
├── integration/
│ ├── payment-flow.test.ts # サンドボックスでのフロー全体
│ └── webhook-endpoint.test.ts # 本物の HTTP エンドポイント
└── resilience/
├── duplicate-webhook.test.ts # 負荷下での冪等性
├── invalid-signature.test.ts # 不正の拒否
└── expired-payment.test.ts # 期限切れの PIX/ボレト本番からの教訓
- サンドボックスは本番ではない。 Mercado Pago は PIX のシナリオでサンドボックスと本番で挙動が異なります。webhook のフォーマットが変わったために、サンドボックスで通っていたテストが本番で失敗した経験があります。解決策:本物のペイロードのスナップショットを使った契約テスト
- webhook の重複は例外ではなくルールである。 Stripe は「at least once delivery」を保証します。Asaas も同様です。重複をテストしなければ、顧客に二重に課金します。実際に起きるのを見ました
- webhook のレースコンディション。 webhook は請求作成のレスポンスが返ってくる前に届くことがあります。コードがまだ存在しないデータベースのレコードに依存していると、エラーになります。解決策:webhook 処理でのバックオフ付きリトライ
- すべてをログに記録する。 本番で決済がうまくいかなかったとき、タイムラインを再構築する必要があります。受信したペイロード、署名の有効/無効、idempotency key、以前のステータス、新しいステータス、トランザクションの結果をログに記録します。これがなければ、デバッグは不可能です
- webhook のレスポンス時間を監視する。 エンドポイントが応答に 5 秒以上かかると、ゲートウェイはリトライします。即座の ack + バックグラウンドでの処理が、うまくいくパターンです
本番に出る前のチェックリスト
- 各プロバイダーに対して署名の検証を実装した
- 重複した webhook で冪等性をテストした
- 状態遷移を検証した(すでに 'refunded' なら 'confirmed' を受け付けない)
- PIX/ボレトのフローをサンドボックスでエンドツーエンドにテストした
- 決済の期限切れを処理した(PIX が期限切れ、ボレトが支払期限超過)
- コンテキスト付きの構造化ログ(orderId、provider、externalId)
- webhook エンドポイントが 1 秒未満で応答する(ack + バックグラウンド処理)
- 定期的なリコンサイル(reconciliation)を実装した(ゲートウェイとすべて一致するか確認する)
