---
title: "Test with stubs"
space: "BWH Payments (Next)"
url: "https://docs.bwh.tech/bwh-payments-next/build/test-with-stubs"
updated: "2026-10-12"
---

Gateway tests in BWH Payments never touch the network and never patch a transport. The controller picks its stub client under tests on its own, so every test runs the real controller code.

## The pattern

1. Call `Stub<Service>Client.reset()` in `setUp`.
2. Drive the provider with the stub's controls.
3. Act through the controller or the Gateway Payment Request.
4. Assert on the outcome, and on what the stub recorded.

```python
from frappe.tests import IntegrationTestCase

from bwh_payments.services.razorpay.stub import StubRazorpayClient


class TestRazorpay(IntegrationTestCase):
	def setUp(self):
		StubRazorpayClient.reset()
		configure_razorpay_gateway()

	def test_a_three_decimal_currency_is_charged_in_minor_units(self):
		make_razorpay_payment_request(12.345, "KWD")
		self.assertEqual(StubRazorpayClient.created_links[-1]["amount"], 12345)

	def test_an_authorised_payment_stays_pending(self):
		link_id = StubRazorpayClient.add_link("created")
		StubRazorpayClient.add_payment(link_id, "authorized")
		settings = frappe.get_single("Razorpay Gateway Settings")
		self.assertEqual(settings.get_payment_status(link_id), "Pending")
```

<picture>
  <source srcset="/files/bwhp-test-with-stubs-dark.svg" media="(prefers-color-scheme: dark)">
  <img src="/files/bwhp-test-with-stubs-light.svg" alt="The test calls pay() on the stub, then sync_status() on the request, and the request moves from Pending to Paid.">
</picture>

## Test the whole webhook path

To test a webhook end to end, build a werkzeug `Request` into `frappe.local.request` and call `webhook.handle()` directly. Sign the body with the stub's helper, so the real signature check runs:

```python
from werkzeug.test import EnvironBuilder
from werkzeug.wrappers import Request

from bwh_payments.bwh_payments import webhook
from bwh_payments.services.razorpay.stub import build_payment_link_event, sign_payload


def post_webhook(payload: bytes, signature: str, event_id: str = "evt_1"):
	builder = EnvironBuilder(
		method="POST", path="/api/method/bwh_payments.bwh_payments.webhook.handle", data=payload
	)
	builder.headers.extend(
		{"X-Razorpay-Signature": signature, "X-Razorpay-Event-Id": event_id}
	)
	environ = builder.get_environ()
	environ["QUERY_STRING"] = "gateway=Razorpay"
	frappe.local.request = Request(environ)
	frappe.local.request_ip = "127.0.0.1"  # the rate limiter counts against it
	frappe.local.response = frappe._dict()
	return webhook.handle()


payload = build_payment_link_event(payment_request.order_ref)
post_webhook(payload, sign_payload(payload, RAZORPAY_WEBHOOK_SECRET))
```

Restore the original `frappe.local.request` in a cleanup.

## What to cover

Every gateway should have tests for:

| Area | Cases |
|---|---|
| Amounts | A 2-, a 3- and a 0-decimal currency, as sent to the provider. An unsupported currency refused before any call. |
| Sessions | The session id is the one webhooks use. Return URLs carry `reference_id`. |
| Status | Each known status. An unknown one stays Pending. Authorised versus captured. |
| Capture | If the gateway captures: done once, for the authorised amount. |
| Webhooks | Valid. Forged. Missing header. Wrong secret, for example the API key in place of the webhook secret. An ignored event type. An unreadable paid event. |
| Refunds | Accepted. Refused. Nothing captured. Unknown session. |
| Cancel | `release_if_unpaid()` on an open session, and on a paid one. |

## Fixtures to reuse

| Fixture | In | Sets up |
|---|---|---|
| `configure_stripe_gateway`, `make_payment_request` | `test_gateway_payment_request.py` | Stripe settings, profile and a request |
| `configure_razorpay_gateway` | `test_razorpay_gateway_settings.py` | Razorpay settings and profile |

:::caution[Clean up what commits]
`IntegrationTestCase` rolls back only at the end of the class. `create_request_log` commits, and so does `create_refund`, which commits the Pending refund before it calls the gateway. Webhook and refund tests therefore leave rows behind. Remove them in `tearDownClass`, as `remove_razorpay_gateway` does. Otherwise your dev site keeps an enabled gateway with a fake key, and offers it at checkout.
:::

## Test an app that takes payments

`bwh_payments.testing` gives your app's tests a gateway that keeps its state in memory. Your tests run the real payment request, refund and webhook code, and never name a provider.

```python
from bwh_payments import testing as payment_testing
from bwh_payments.api import create_payment, refund


class TestOrderPayment(IntegrationTestCase):
	@classmethod
	def setUpClass(cls):
		super().setUpClass()
		cls.commits = payment_testing.without_commits()
		cls.commits.__enter__()
		cls.gateway = payment_testing.configure_test_gateway()

	@classmethod
	def tearDownClass(cls):
		cls.commits.__exit__(None, None, None)  # before the rollback, which is a no-op inside it
		super().tearDownClass()

	def setUp(self):
		payment_testing.reset()

	def test_a_paid_order_is_fulfilled(self):
		payment_request = create_payment("Sales Order", self.order.name, 100, "INR", self.gateway)

		payment_testing.pay(payment_request.name)  # on_payment_authorized runs

		self.assertEqual(frappe.db.get_value("Sales Order", self.order.name, "status"), "Paid")
```

| Helper | Does |
|---|---|
| `configure_test_gateway(name="Test Gateway")` | Enables a Payment Gateway Profile on the in-memory gateway and returns its name |
| `reset()` | Forgets every payment and refund. Call it in `setUp`. |
| `pay(payment_request)` | The shopper pays, and the payment is confirmed |
| `set_next_refund_status(status)` | How the gateway answers the next refund: `Pending`, `Processed` or `Failed` (refused) |
| `set_refund_status(refund, status)` | The gateway now reports a refund as `Processed` or `Failed`, for `sync_refunds` |
| `send_refund_webhook(refund, status)` | Posts a signed refund webhook for a Gateway Refund |
| `send_external_refund_webhook(payment_request, amount, status="Processed")` | Posts a refund raised on the gateway's dashboard |
| `without_commits()` | Turns commits into no-ops, so the class rollback also undoes refunds and webhook logs |
| `remove_test_gateway(name="Test Gateway")` | Deletes the test gateway's payments, refunds and logs, if you do not use `without_commits` |

## Run the tests

```bash
bench --site test_site set-config allow_tests true
bench --site test_site run-tests --app bwh_payments
bench --site test_site run-tests --module bwh_payments.tests.test_razorpay_webhook
```

Tests inside a doctype folder can set `IGNORE_TEST_RECORD_DEPENDENCIES` to avoid loading ERPNext test records. Don't set it outside a doctype folder: Frappe raises `NotImplementedError` there.

## Next steps

- [Service clients](/bwh-payments-next/build/service-clients): how a stub is built
- [Webhooks and security](/bwh-payments-next/build/webhooks-and-security): the rules your webhook tests check
