Test Paystack integrations in layers. Start with test mode and test cards to exercise the API without moving real money. Write unit tests that mock the Paystack API. Run integration tests against test mode endpoints. Use Playwright or Cypress for end-to-end payment flows. Simulate webhooks locally with tunnels or fixture payloads. Add contract tests for webhook handlers. Wire it all into CI and keep a separate Paystack account for staging.
Payment code is not like the rest of your application. A bug in your profile page shows a wrong avatar. A bug in your payment flow charges someone twice, fails to deliver a paid product, or leaks money through a race condition. The consequences are financial, legal, and reputational. That changes how you test.
Three things make payment integrations harder to test than typical application code:
The strategy is defense in depth. No single test layer catches everything. Unit tests verify your logic. Integration tests verify your Paystack calls. End-to-end tests verify the full user flow. Webhook tests verify your event handling. CI enforces that nothing ships without passing all of them.
This guide walks through each layer, with code you can copy. If you want to go deep on any single topic, follow the links to the dedicated spoke articles.
Every Paystack account comes with two sets of API keys: test keys and live keys. Test keys start with sk_test_ and pk_test_. Live keys start with sk_live_ and pk_live_. When you use test keys, no real money moves. Transactions appear in your test dashboard, webhooks fire to your configured URL, and you can exercise the full API surface without financial risk.
Set up your development environment to use test keys by default:
# .env.development
PAYSTACK_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxx
PAYSTACK_PUBLIC_KEY=pk_test_xxxxxxxxxxxxxxxxxxxx
PAYSTACK_WEBHOOK_SECRET=sk_test_xxxxxxxxxxxxxxxxxxxx
Key things to know about test mode:
charge.success, transfer.success, subscription.create, and every other event type, just as you would in live mode.For the complete walkthrough of test mode features and limitations, see Paystack Test Mode: Complete Guide.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Paystack provides specific test card numbers that trigger different outcomes. Most developers only use the success card. That is a mistake. You need to test every failure path your code handles.
Here are the key test cards:
| Card Number | CVV | Expiry | PIN | OTP | Result |
|---|---|---|---|---|---|
4084 0840 8408 4081 |
408 | Any future date | 0000 | 123456 | Success |
4084 0840 8408 4081 |
408 | Any future date | 1111 | 123456 | Declined |
5060 6666 6666 6666 666 |
123 | Any future date | 1234 | 123456 | Verve card success |
4000 0000 0000 0002 |
Any | Any future date | Any | Any | Card declined |
The PIN determines the outcome on the primary test card. PIN 0000 succeeds. PIN 1111 fails. This lets you test both paths with the same card number.
Build a checklist of scenarios to test with these cards:
For the full list of test cards, test bank accounts, and test mobile money numbers, see Paystack Test Cards and Test Scenarios. For testing specific failure modes, see Simulating Failed Payments on Paystack.
Unit tests verify your business logic without hitting Paystack's servers. The idea is simple: replace the HTTP calls to Paystack with controlled responses so you can test what your code does with those responses.
Here is a realistic example. Suppose you have a payment service that initializes a transaction:
// paymentService.js
async function initializePayment(email, amountKobo, reference) {
const response = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, amount: amountKobo, reference }),
});
const data = await response.json();
if (!data.status) {
throw new Error(`Paystack init failed: ${data.message}`);
}
return {
authorizationUrl: data.data.authorization_url,
accessCode: data.data.access_code,
reference: data.data.reference,
};
}
module.exports = { initializePayment };
Now the Jest test that mocks the fetch call:
// paymentService.test.js
const { initializePayment } = require('./paymentService');
// Mock global fetch
global.fetch = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
process.env.PAYSTACK_SECRET_KEY = 'sk_test_fake';
});
test('returns authorization URL on successful init', async () => {
fetch.mockResolvedValueOnce({
json: async () => ({
status: true,
message: 'Authorization URL created',
data: {
authorization_url: 'https://checkout.paystack.com/abc123',
access_code: 'abc123',
reference: 'ref_001',
},
}),
});
const result = await initializePayment('test@example.com', 50000, 'ref_001');
expect(result.authorizationUrl).toBe('https://checkout.paystack.com/abc123');
expect(result.reference).toBe('ref_001');
// Verify the right endpoint was called
expect(fetch).toHaveBeenCalledWith(
'https://api.paystack.co/transaction/initialize',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Authorization': 'Bearer sk_test_fake',
}),
})
);
});
test('throws on Paystack failure response', async () => {
fetch.mockResolvedValueOnce({
json: async () => ({
status: false,
message: 'Duplicate Transaction Reference',
}),
});
await expect(
initializePayment('test@example.com', 50000, 'ref_duplicate')
).rejects.toThrow('Paystack init failed: Duplicate Transaction Reference');
});
test('throws on network failure', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));
await expect(
initializePayment('test@example.com', 50000, 'ref_002')
).rejects.toThrow('Network error');
});
Notice the three test cases: success, API error, and network failure. Payment code needs all three. Many developers only test the happy path and discover the error paths in production, usually at 2 AM when a customer complains.
For detailed patterns in each language, see Mocking the Paystack API in Jest and Mocking the Paystack API in pytest.
Unit tests with mocks verify your logic, but they do not prove your code actually talks to Paystack correctly. Integration tests hit the real Paystack test mode API and confirm that your requests are well-formed and your response handling works with actual API responses.
The key difference: unit tests mock the API, integration tests call it.
// integration/payment.integration.test.js
const { initializePayment } = require('../paymentService');
// Use real test keys for integration tests
beforeAll(() => {
process.env.PAYSTACK_SECRET_KEY = process.env.PAYSTACK_TEST_SECRET_KEY;
});
test('initializes a real transaction in test mode', async () => {
const reference = `test_${Date.now()}`;
const result = await initializePayment('integration@test.com', 10000, reference);
expect(result.authorizationUrl).toMatch(/^https:\/\/checkout\.paystack\.com\//);
expect(result.accessCode).toBeTruthy();
expect(result.reference).toBe(reference);
}, 15000); // Longer timeout for network calls
test('verify transaction returns correct status', async () => {
// Initialize, then verify
const reference = `test_verify_${Date.now()}`;
await initializePayment('integration@test.com', 10000, reference);
const verifyResponse = await fetch(
`https://api.paystack.co/transaction/verify/${reference}`,
{
headers: {
'Authorization': `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
},
}
);
const data = await verifyResponse.json();
// Transaction was initialized but not completed, so status should be 'abandoned' or similar
expect(data.status).toBe(true);
expect(data.data.reference).toBe(reference);
}, 15000);
Practical rules for integration tests against Paystack:
For the full strategy, see Integration Testing Paystack Flows End to End.
End-to-end tests drive a real browser through your entire payment flow: user clicks "Pay", the Paystack checkout popup or redirect appears, the user enters test card details, and your application fulfills the order. This catches bugs that no other layer can find, like a broken redirect URL, a popup that does not close, or a callback page that crashes.
Here is a Playwright test for a Paystack Inline JS checkout flow:
// e2e/payment.spec.ts
import { test, expect } from '@playwright/test';
test('complete payment flow with test card', async ({ page }) => {
// Navigate to your checkout page
await page.goto('http://localhost:3000/checkout');
// Fill in order details
await page.fill('[data-testid="email"]', 'e2e-test@example.com');
await page.fill('[data-testid="amount"]', '500');
// Click pay button, which opens Paystack popup
await page.click('[data-testid="pay-button"]');
// Wait for Paystack checkout iframe to appear
const paystackFrame = page.frameLocator('iframe[name="checkout"]');
// Enter test card details inside the iframe
await paystackFrame.locator('input[data-attr="card-number"]').fill('4084084084084081');
await paystackFrame.locator('input[data-attr="expiry"]').fill('12/30');
await paystackFrame.locator('input[data-attr="cvv"]').fill('408');
// Submit card
await paystackFrame.locator('button:has-text("Pay")').click();
// Enter test PIN
await paystackFrame.locator('input[data-attr="pin"]').fill('0000');
await paystackFrame.locator('button:has-text("Confirm")').click();
// Enter test OTP
await paystackFrame.locator('input[data-attr="otp"]').fill('123456');
await paystackFrame.locator('button:has-text("Confirm")').click();
// Wait for redirect back to your success page
await page.waitForURL('**/payment/success**', { timeout: 30000 });
// Verify success state
await expect(page.locator('[data-testid="payment-status"]')).toHaveText('Payment Successful');
});
Important caveats about E2E payment tests:
Cypress follows the same pattern but uses cy.iframe() or similar plugins to interact with the Paystack checkout. For detailed Playwright patterns, see End-to-End Payment Tests with Playwright.
Webhook testing is where most developers get stuck. The problem: Paystack sends webhooks to a public URL, but your development machine is behind a NAT or firewall. You have two approaches: bring Paystack's webhooks to your machine, or simulate them yourself.
Use ngrok or Cloudflare Tunnel to expose your local server to the internet:
# Start your app
node server.js # listening on port 3000
# In another terminal, start the tunnel
npx ngrok http 3000
# Output: https://abc123.ngrok-free.app
Copy the tunnel URL and paste it into your Paystack dashboard under Settings > API Keys & Webhooks as your webhook URL: https://abc123.ngrok-free.app/paystack/webhook. Now complete a test mode transaction, and the webhook hits your local machine.
This approach gives you real webhook payloads with valid signatures. The downside: it requires internet access, the tunnel URL changes every session (unless you pay for a static subdomain), and it is too slow for automated tests.
For automated tests, construct webhook payloads yourself and POST them to your handler with a valid HMAC signature:
const crypto = require('crypto');
function buildWebhookPayload(event, data) {
return { event, data };
}
function signPayload(payload, secretKey) {
return crypto
.createHmac('sha512', secretKey)
.update(JSON.stringify(payload))
.digest('hex');
}
// In your test
const payload = buildWebhookPayload('charge.success', {
id: 123456789,
domain: 'test',
status: 'success',
reference: 'test_ref_001',
amount: 50000,
currency: 'NGN',
channel: 'card',
customer: {
email: 'test@example.com',
},
paid_at: '2026-07-20T10:00:00.000Z',
});
const signature = signPayload(payload, process.env.PAYSTACK_SECRET_KEY);
// POST to your webhook endpoint
const response = await fetch('http://localhost:3000/paystack/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-paystack-signature': signature,
},
body: JSON.stringify(payload),
});
expect(response.status).toBe(200);
This approach is fast, works offline, and runs reliably in CI. The risk: your simulated payloads might diverge from what Paystack actually sends. Capture real payloads from test mode (via your tunnel) and save them as fixture files. Then use those fixtures in your automated tests.
Store fixtures in a directory like test/fixtures/webhooks/:
test/
fixtures/
webhooks/
charge.success.json
charge.failed.json
transfer.success.json
transfer.failed.json
subscription.create.json
refund.processed.json
For detailed guides on each approach, see the webhook testing spokes in this cluster.
Contract testing sits between unit testing and integration testing. The idea: define a "contract" for every webhook event your handler processes, then verify that your handler meets the contract for every event shape.
Why this matters: Paystack sends many event types (charge.success, transfer.success, transfer.failed, subscription.create, refund.processed, and more). Each event has a different payload shape. Your handler needs to process the ones it cares about and safely ignore the ones it does not. A contract test verifies this for every event type.
// webhook.contract.test.js
const { handleWebhook } = require('../webhookHandler');
const fixtures = require('./fixtures/webhookFixtures');
describe('Webhook contract tests', () => {
test.each([
['charge.success', fixtures.chargeSuccess, { shouldFulfillOrder: true }],
['charge.failed', fixtures.chargeFailed, { shouldFulfillOrder: false }],
['transfer.success', fixtures.transferSuccess, { shouldUpdateTransferStatus: true }],
['transfer.failed', fixtures.transferFailed, { shouldUpdateTransferStatus: true }],
['subscription.create', fixtures.subscriptionCreate, { shouldActivateSubscription: true }],
['refund.processed', fixtures.refundProcessed, { shouldUpdateRefundRecord: true }],
])('handles %s event correctly', async (eventType, payload, expectations) => {
const result = await handleWebhook(payload);
expect(result.acknowledged).toBe(true);
expect(result.eventType).toBe(eventType);
if (expectations.shouldFulfillOrder !== undefined) {
expect(result.orderFulfilled).toBe(expectations.shouldFulfillOrder);
}
});
test('ignores unknown event types without crashing', async () => {
const unknownPayload = {
event: 'some.future.event',
data: { id: 1, status: 'success' },
};
const result = await handleWebhook(unknownPayload);
expect(result.acknowledged).toBe(true);
expect(result.processed).toBe(false);
});
test('rejects payloads with missing required fields', async () => {
const malformedPayload = {
event: 'charge.success',
data: {}, // missing reference, amount, status
};
const result = await handleWebhook(malformedPayload);
expect(result.error).toBeTruthy();
});
});
The contract test above catches three critical classes of bugs:
charge.failed).Update your fixture files whenever Paystack changes their payload format. Check the Paystack changelog periodically. For the full contract testing strategy, see Contract Testing Your Paystack Webhook Handler.
Payment tests should run on every pull request. If a change breaks your payment flow, you want to know before it reaches production, not after a customer reports a failed checkout.
Here is a GitHub Actions workflow that runs unit tests, integration tests, and contract tests:
# .github/workflows/payment-tests.yml
name: Payment Tests
on:
pull_request:
paths:
- 'src/payments/**'
- 'src/webhooks/**'
- 'tests/payments/**'
- 'tests/webhooks/**'
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run test:unit -- --coverage
env:
PAYSTACK_SECRET_KEY: sk_test_fake_for_mocks
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run test:integration
env:
PAYSTACK_SECRET_KEY: ${{ secrets.PAYSTACK_TEST_SECRET_KEY }}
contract-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run test:contract
env:
PAYSTACK_SECRET_KEY: sk_test_fake_for_mocks
Key decisions in this pipeline:
test:unit, test:integration, and test:contract so you can run them independently.Add E2E payment tests to your deployment pipeline (not the PR pipeline) since they are slow:
e2e-payments:
runs-on: ubuntu-latest
needs: [unit-tests, integration-tests]
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run test:e2e:payments
env:
PAYSTACK_SECRET_KEY: ${{ secrets.PAYSTACK_TEST_SECRET_KEY }}
PAYSTACK_PUBLIC_KEY: ${{ secrets.PAYSTACK_TEST_PUBLIC_KEY }}
For the complete CI strategy including caching, parallel runs, and deployment gates, see CI Pipelines for Payment Code.
Your staging environment should have its own Paystack account, not share test keys with development or production. Here is why: if your staging pipeline and a developer's local machine both use the same test account, their transactions mix in the dashboard. A developer testing locally might accidentally verify a transaction that staging created, or vice versa. Separate accounts prevent this confusion.
The setup:
In your deployment configuration:
# .env.staging
PAYSTACK_SECRET_KEY=sk_test_staging_account_key
PAYSTACK_PUBLIC_KEY=pk_test_staging_account_key
PAYSTACK_WEBHOOK_URL=https://staging.yourapp.com/paystack/webhook
# .env.production
PAYSTACK_SECRET_KEY=sk_live_production_account_key
PAYSTACK_PUBLIC_KEY=pk_live_production_account_key
PAYSTACK_WEBHOOK_URL=https://yourapp.com/paystack/webhook
Staging lets you test the full deployment pipeline: database migrations, webhook URL registration, environment variable injection, and SSL certificates. It catches configuration bugs that no amount of local testing will find.
One common mistake: developers set up staging correctly but forget to register the staging webhook URL in the staging Paystack account's dashboard. The transaction initializes fine, but the webhook never arrives, and the team spends hours debugging code when the problem is a missing dashboard setting.
For the full guide, see Staging Environments and Separate Paystack Accounts.
Load testing a payment endpoint is tricky because you are calling an external API that you do not own. You cannot blast 10,000 requests per second at Paystack's test mode and expect them to be happy about it. But you still need to know whether your payment flow handles real traffic.
The responsible approach: test your code, not Paystack's infrastructure.
Tools that work well for payment load testing:
The numbers you care about: requests per second your webhook handler sustains, p95 latency under load, and the point at which errors start appearing. If your webhook handler cannot process events faster than Paystack sends them, you will accumulate a backlog and risk missed or delayed order fulfillment.
How much test coverage is enough for payment code? More than the rest of your application. Here are practical targets:
| Component | Target Coverage | Rationale |
|---|---|---|
| Transaction initialization | 95%+ | This is where money starts moving. Every code path must be verified. |
| Payment verification | 100% | The verify-then-grant flow is your last line of defense against fraud. No exceptions. |
| Webhook handler | 100% | Every event type you handle, every error branch, every edge case. Untested webhook code is a liability. |
| Amount/currency validation | 100% | Off-by-one errors in kobo/pesewa conversion can overcharge or undercharge customers. |
| Refund processing | 95%+ | Refund bugs cost you real money and customer trust. |
| Transfer/payout logic | 95%+ | Sending money to wrong accounts is harder to reverse than a failed charge. |
| Error handling and retries | 90%+ | Network failures, timeouts, and rate limits must be handled gracefully. |
These numbers are higher than the typical "80% coverage" target you see in general application code. That is intentional. Payment code has outsized consequences when it breaks. A bug in your user profile page shows the wrong name. A bug in your payment verification grants free access or double-charges a customer.
Measure coverage per module, not as a global average. A project with 85% overall coverage might have 60% coverage in its webhook handler and 95% in its UI components. The overall number hides the risk.
Configure your test runner to enforce minimums on payment modules:
// jest.config.js
module.exports = {
coverageThreshold: {
'./src/payments/': {
branches: 90,
functions: 95,
lines: 95,
statements: 95,
},
'./src/webhooks/': {
branches: 95,
functions: 100,
lines: 100,
statements: 100,
},
},
};
If coverage drops below these thresholds, CI fails the build. This prevents someone from adding a new payment feature without tests.
Here is the full testing strategy, organized as a pyramid from fast/cheap at the bottom to slow/expensive at the top:
Most teams skip levels 2 through 4 and rely entirely on manual testing in test mode. That works until it does not. The first time a Paystack payload change breaks your webhook handler in production, you will wish you had contract tests. The first time a deployment breaks your checkout redirect, you will wish you had E2E tests.
Start with unit tests and contract tests. They give you the most coverage for the least effort. Add integration tests when your team has CI set up. Add E2E tests when your checkout flow is stable enough that the tests do not break every time the UI changes.
For the pillar overview of every aspect of Paystack development, see Paystack: The Complete Engineering Guide for African Developers.
If you want hands-on training building production payment integrations with proper testing, our Full-Stack Software and AI Engineering programme (KES 120,000) includes real Paystack integration projects with CI pipelines and automated tests. You ship code that moves real money before you graduate.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course