Bonaventure OgetoBy Bonaventure Ogeto|

Testing Paystack Integrations: The Complete Guide

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.

Why Testing Payment Code Is Different

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:

  1. External dependency. Your code talks to Paystack's servers, which you do not control. Network failures, API changes, and rate limits are all real. You need to test both the happy path and every way that external call can fail.
  2. Asynchronous confirmation. Payment results arrive through webhooks, sometimes seconds or minutes after the initial request. Your tests need to handle this timing gap without flaking.
  3. Money is involved. You cannot "undo" a real charge easily. Test mode exists for a reason, but even test mode does not cover every production scenario. You need multiple layers of testing to catch problems before they cost real money.

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.

Paystack Test Mode: Your First Line of Defense

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:

  • Test mode supports all API endpoints: transactions, transfers, subscriptions, splits, identity verification, and refunds.
  • Webhooks fire in test mode. You receive charge.success, transfer.success, subscription.create, and every other event type, just as you would in live mode.
  • Test mode has its own dashboard view. Toggle between "Test" and "Live" in the top-left of your Paystack dashboard to see test transactions.
  • Test mode transfers do not actually send money to bank accounts. The API accepts the call and simulates success, but no bank transfer happens.
  • Some edge cases behave differently in test mode. Bank transfer timeouts, mobile money prompts, and settlement timing may not match production exactly.

For the complete walkthrough of test mode features and limitations, see Paystack Test Mode: Complete Guide.

Test Cards and Test Scenarios

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:

  • Successful payment: card accepted, webhook fires, order fulfilled
  • Declined card: user sees an error message, can retry, order stays unfulfilled
  • Abandoned checkout: user closes the Paystack popup without completing payment
  • Timeout: transaction started but never completed (check with the Verify API after a delay)
  • Duplicate reference: try initializing two transactions with the same reference
  • Amount mismatch: verify that your server checks the paid amount against the expected amount

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 Testing: Mocking the Paystack API

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.

Integration Testing Against Paystack Test Mode

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:

  • Always use unique references. Timestamp-based or UUID references prevent collisions. Paystack rejects duplicate references, and a hardcoded reference will fail on the second run.
  • Set longer timeouts. Network calls to Paystack take 200ms to 2 seconds. Your default Jest timeout of 5 seconds might be tight. Set 15 seconds for integration tests.
  • Do not run in parallel by default. Parallel integration tests hitting the same Paystack account can cause rate limiting. Run them sequentially or use separate test accounts.
  • Tag them separately. Integration tests are slower and require network access. Tag them so you can run unit tests quickly during development and integration tests in CI or before deployment.

For the full strategy, see Integration Testing Paystack Flows End to End.

End-to-End Testing with Playwright

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:

  • Paystack's checkout UI changes. The iframe selectors above are illustrative. Paystack can update their checkout form at any time, breaking your E2E selectors. Treat E2E payment tests as smoke tests, not as your primary safety net.
  • Test mode only. Never run E2E tests against live mode. You would charge real money.
  • Flakiness is normal. Network latency, iframe load timing, and Paystack server response times all introduce flakiness. Use generous timeouts and retry logic.
  • Run these less frequently. E2E payment tests are slow (30+ seconds each). Run them in CI on merge to main, not on every commit.

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: Simulating Events and Local Tunnels

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.

Approach 1: Local Tunnels for Real Webhooks

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.

Approach 2: Simulating Webhooks Locally

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 Your Webhook Handler

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:

  1. Your handler breaks when Paystack sends an event type you did not anticipate.
  2. Your handler incorrectly processes one event type (for example, fulfilling an order on charge.failed).
  3. Your handler crashes on malformed payloads instead of logging and moving on.

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.

CI Pipeline Setup for Payment Code

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:

  • Path filtering. The workflow only runs when payment-related files change. No point running payment tests when someone edits a README.
  • Unit tests run first. They are fast (seconds) and catch most bugs. Integration tests only run if unit tests pass.
  • Secrets management. The real test key is stored as a GitHub secret and only used for integration tests. Unit tests and contract tests use a fake key because they mock all HTTP calls.
  • Separate test commands. Configure your test runner to separate 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.

Staging Environments with Separate Paystack Accounts

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:

  1. Development: Each developer uses the shared team test account (or their own personal Paystack test account).
  2. Staging: A dedicated Paystack account with its own test keys. Register a separate Paystack business or use a sub-account if available.
  3. Production: Your real Paystack account with live keys. Only deployed production code touches this.

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 Payment Endpoints Responsibly

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.

  • Mock the Paystack API for load tests. Stand up a local mock server that returns realistic responses with realistic latencies (200ms to 1 second). Point your application at this mock during load tests. This tests your server's ability to handle concurrent payment requests, database writes, and webhook processing without hammering Paystack.
  • Test webhook processing throughput. Your webhook endpoint is the bottleneck in most payment systems. Generate thousands of simulated webhook payloads and fire them at your handler to find where it breaks. Does your database connection pool run out? Does your queue fill up? Does latency spike?
  • Run a controlled test against Paystack test mode. If you need to verify actual Paystack behavior under load, keep the volume modest (50 to 100 concurrent transactions) and stagger the requests. Contact Paystack support first if you plan anything larger.

Tools that work well for payment load testing:

  • k6 by Grafana: write load test scripts in JavaScript, which makes it easy to construct valid Paystack request payloads.
  • Artillery: YAML-based configuration, good for HTTP endpoint testing with custom payloads.
  • autocannon: lightweight Node.js load testing, useful for quick webhook throughput tests.

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.

Test Coverage Targets for Payment Code

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.

Putting It All Together: The Testing Pyramid for Payments

Here is the full testing strategy, organized as a pyramid from fast/cheap at the bottom to slow/expensive at the top:

  1. Unit tests (base of the pyramid). Fast, no network, mock all external calls. Test your business logic: amount calculations, reference generation, response parsing, error handling. Run on every save during development.
  2. Contract tests. Verify that your webhook handler processes every known event type correctly and survives unknown events. Use fixture files captured from real Paystack payloads. Run on every commit.
  3. Integration tests. Hit Paystack test mode for real. Verify that your request formatting, authentication, and response handling work against the actual API. Run on every pull request.
  4. End-to-end tests. Drive a browser through the full checkout flow. Catch UI bugs, redirect issues, and iframe interaction problems. Run on merge to main or before deployment.
  5. Load tests (top of the pyramid). Verify that your payment infrastructure handles production traffic. Run before major releases or when you change the payment architecture.

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.

Key Takeaways

  • Paystack test mode uses separate API keys (sk_test_*) and never moves real money. Every integration should start here.
  • Paystack provides specific test card numbers that simulate success, failure, bank decline, and timeout scenarios. Use all of them, not just the success card.
  • Unit tests should mock the Paystack API at the HTTP level so your business logic runs without network calls. Jest, pytest, and PHPUnit all support this.
  • End-to-end tests with Playwright or Cypress can drive the full checkout flow using test mode credentials, catching integration bugs that unit tests miss.
  • Webhook testing requires either a local tunnel (ngrok, Cloudflare Tunnel) to receive real Paystack events or fixture payloads with valid HMAC signatures for offline testing.
  • Contract tests verify that your webhook handler correctly processes every event shape Paystack sends, catching breakage before production.
  • Run payment tests in CI on every pull request. Use a dedicated Paystack test account so parallel pipelines do not collide.

Frequently Asked Questions

Can I run Paystack integration tests without internet access?
No. Integration tests by definition hit the real Paystack test mode API, which requires internet access. For offline testing, use unit tests with mocks and contract tests with fixture files. These cover the majority of your payment logic without any network dependency. Save integration tests for CI pipelines that always have internet access.
How do I test Paystack webhooks on localhost?
Two options. First, use a tunnel like ngrok or Cloudflare Tunnel to expose your local server to the internet, then register the tunnel URL in your Paystack dashboard as the webhook URL. Paystack will send real webhook events to your local machine. Second, construct webhook payloads yourself, compute a valid HMAC-SHA512 signature using your test secret key, and POST them to your local endpoint. The second approach works offline and is better for automated tests.
Do Paystack test mode webhooks fire automatically?
Yes. When you complete a transaction in test mode (for example, using the test card with PIN 0000), Paystack sends webhook events to the URL you configured in your test mode dashboard settings. The events are identical in structure to live mode events. Make sure you have registered a webhook URL in the test mode settings, not just the live mode settings.
What test coverage percentage should I target for payment code?
Target 95 to 100% for critical paths: payment verification, webhook handling, and amount validation. Target 90%+ for transaction initialization, refund processing, and error handling. These are higher than typical application targets because bugs in payment code have financial consequences. Measure coverage per payment module, not as a global average across your entire codebase.
Should I use a separate Paystack account for staging?
Yes. Using the same test keys for development and staging causes transaction collisions and dashboard confusion. Create a separate Paystack account for your staging environment with its own test keys and webhook URL. This mirrors your production setup and catches configuration problems that sharing keys would hide.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon