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
- Call
Stub<Service>Client.reset()insetUp. - Drive the provider with the stub's controls.
- Act through the controller or the Gateway Payment Request.
- Assert on the outcome, and on what the stub recorded.
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")
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:
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 |
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.
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
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: how a stub is built
- Webhooks and security: the rules your webhook tests check