{"id":2701,"date":"2026-08-19T12:12:00","date_gmt":"2026-08-19T06:42:00","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=2701"},"modified":"2026-08-19T07:15:14","modified_gmt":"2026-08-19T01:45:14","slug":"testing-code-that-sends-messages","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/testing-code-that-sends-messages\/","title":{"rendered":"Testing Code That Sends Messages: Mocks, Contract Tests and Safe Smoke Tests"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">A test suite that really sends is a test suite that really bills. Here is how to test SMS and WhatsApp integrations properly: pure-function tests for segment counting and DLT rendering, transport-boundary mocks in Python, Node.js and PHP, contract tests against the documented response shapes, a deterministic clock for rate limiters and backoff, an ambiguous-timeout test that proves your state machine is correct, and a small number of real calls using the documented <code>testMessage=true<\/code> flag.<\/p>\n\n\n\n<h1 class=\"wp-block-heading\">Table of Contents<\/h1>\n\n\n\n<ol class=\"wp-block-list\">\n<li><a href=\"#executive-summary\" data-type=\"internal\" data-id=\"#executive-summary\">Executive summary<\/a><\/li>\n\n\n\n<li><a href=\"#tldr\">TL;DR<\/a><\/li>\n\n\n\n<li><a href=\"#why-messaging-code-is-harder\" data-type=\"internal\" data-id=\"#why-messaging-code-is-harder\">Why messaging code is harder to test than ordinary API code<\/a><\/li>\n\n\n\n<li><a href=\"#what-the-platform-actually-gives-you-for-testing\" data-type=\"internal\" data-id=\"#what-the-platform-actually-gives-you-for-testing\">What the platform actually gives you for testing<\/a><\/li>\n\n\n\n<li><a href=\"#the-four-layers\" data-type=\"internal\" data-id=\"#the-four-layers\">The four layers, and what each one is actually for<\/a><\/li>\n\n\n\n<li><a href=\"#layer-one\" data-type=\"internal\" data-id=\"#layer-one\">Layer one: pure functions, tested exhaustively<\/a><\/li>\n\n\n\n<li><a href=\"#layer-two\" data-type=\"internal\" data-id=\"#layer-two\">Layer two: mock at the transport boundary, never at your own wrapper<\/a><\/li>\n\n\n\n<li><a href=\"#layer-three\" data-type=\"internal\" data-id=\"#layer-three\">Layer three: contract tests against the documented response shapes<\/a><\/li>\n\n\n\n<li><a href=\"#the-single-most-valuable-test\" data-type=\"internal\" data-id=\"#the-single-most-valuable-test\">The single most valuable test you are not writing<\/a><\/li>\n\n\n\n<li><a href=\"#testing-time-dependent-code\" data-type=\"internal\" data-id=\"#testing-time-dependent-code\">Testing time dependent code with an injectable clock<\/a><\/li>\n\n\n\n<li><a href=\"#testing-the-delivery-report-receiver\" data-type=\"internal\" data-id=\"#testing-the-delivery-report-receiver\">Testing the delivery report receiver<\/a><\/li>\n\n\n\n<li><a href=\"#testing-idempotency-under-concurrency\" data-type=\"internal\" data-id=\"#testing-idempotency-under-concurrency\">Testing idempotency under concurrency<\/a><\/li>\n\n\n\n<li><a href=\"#layer-four\" data-type=\"internal\" data-id=\"#layer-four\">Layer four: live smoke tests, and the rails that keep them safe<\/a><\/li>\n\n\n\n<li><a href=\"#wiring-template-validation-into-ci\" data-type=\"internal\" data-id=\"#wiring-template-validation-into-ci\">Wiring template validation into CI<\/a><\/li>\n\n\n\n<li><a href=\"#what-not-to-test\" data-type=\"internal\" data-id=\"#what-not-to-test\">What not to test<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\" data-type=\"internal\" data-id=\"#decision-matrix\">Decision matrix: which layer catches which bug<\/a><\/li>\n\n\n\n<li><a href=\"#how-to-build-a-messaging-test-suite\" data-type=\"internal\" data-id=\"#how-to-build-a-messaging-test-suite\">How to build a messaging test suite from nothing<\/a><\/li>\n\n\n\n<li><a href=\"#pre-merge-checklist\" data-type=\"internal\" data-id=\"#pre-merge-checklist\">Pre-merge checklist<\/a><\/li>\n\n\n\n<li><a href=\"#ten-mistakes\" data-type=\"internal\" data-id=\"#ten-mistakes\">Ten mistakes that make messaging test suites useless<\/a><\/li>\n\n\n\n<li><a href=\"#faqs\" data-type=\"internal\" data-id=\"#faqs\">FAQs<\/a><\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 id=\"executive-summary\" class=\"wp-block-heading\">Executive summary<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">Test messaging code in four layers, and put almost none of your test count in the layer that actually sends. Pure functions such as segment counting and DLT variable rendering get exhaustive unit tests because they are where silent billing and silent rejection bugs live. Everything that touches HTTP gets mocked at the transport boundary, not at your own wrapper class, so that a change to your request construction actually fails a test. A small set of contract tests asserts the response shapes the documentation actually publishes, including the inconsistencies, so that defensive parsing is proven rather than hoped for. Then, and only then, a handful of real calls run against the live endpoint with <code>testMessage=true<\/code>, which the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/send-sms-batch\/\">Send SMS documentation<\/a> describes verbatim as &#8220;Enable true to test your message and messages wont be delivered when enabled true.&#8221;<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/testing-code-that-sends-messages-transport-boundary.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/testing-code-that-sends-messages-transport-boundary-1024x584.webp\" alt=\"Diagram of a messaging integration test intercepting an outbound API call at the transport boundary so no message reaches the handset\" class=\"wp-image-2702\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/testing-code-that-sends-messages-transport-boundary-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/testing-code-that-sends-messages-transport-boundary-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/testing-code-that-sends-messages-transport-boundary-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/testing-code-that-sends-messages-transport-boundary.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The reason to be this disciplined is financial rather than aesthetic. In this platform&#8217;s billing model, credits are deducted at submission, not at delivery. A test suite that really sends is a test suite that really bills, and a badly written loop in a test file can spend real money faster than any production bug. That single fact reorders the entire test pyramid.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The highest value test in a messaging codebase is not the one that proves a successful send works. It is the one that proves an ambiguous send does not get retried. Force a read timeout in your mock, assert that the message record lands in an <code>unknown<\/code> state and never returns to <code>pending<\/code>, and you have tested the failure mode that causes duplicate customer messages, duplicate charges and support tickets. Almost nobody writes that test.<\/p>\n\n\n\n<h1 id=\"tldr\" class=\"wp-block-heading\">TL;DR<\/h1>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Billing fires at submission. Tests that send are tests that cost money. Structure the suite so the sending layer is tiny.<\/li>\n\n\n\n<li><code>testMessage=true<\/code> on <code>https:\/\/unify.smsgateway.center\/SMSApi\/send<\/code> is the documented way to exercise the real endpoint without delivery. The documentation states messages will not be delivered. It does not state anything about credit consumption, so verify your own wallet before and after rather than assuming either way.<\/li>\n\n\n\n<li>There is also an account level Sandbox mode. The <a href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">demo page<\/a> states that &#8220;The demo environment is designed for testing. Some features use a Sandbox mode, where delivery is simulated for security. For live SMS delivery, you need to upgrade to a paid account.&#8221;<\/li>\n\n\n\n<li>Mock at the transport boundary: <code>respx<\/code> for httpx and <code>responses<\/code> for requests in Python, <code>nock<\/code> in Node.js, Guzzle <code>MockHandler<\/code> plus <code>Middleware::history<\/code> in PHP. Mocking your own <code>SmsClient<\/code> class tests nothing about the request you actually send.<\/li>\n\n\n\n<li>Contract test the documented shapes. <code>SMSApi\/send<\/code> returns <code>statusCode<\/code> as a quoted string. <code>WAApi\/send<\/code> returns it unquoted, and calls the identifier <code>messageId<\/code> rather than <code>transactionId<\/code>. A partial rejection can come back with <code>status<\/code> still reading <code>success<\/code> and the rejected numbers listed in <code>invalidMobile<\/code>.<\/li>\n\n\n\n<li>Inject the clock. A token bucket and a full jitter backoff schedule are untestable with <code>time.time()<\/code> baked in, and become trivially testable with a clock parameter.<\/li>\n\n\n\n<li>Test the ambiguous timeout path explicitly. Connect timeout means nothing was submitted. Read timeout means you do not know.<\/li>\n\n\n\n<li>Test the DLR receiver with a form encoded body, not a JSON body, and assert you persist the raw payload before parsing it.<\/li>\n\n\n\n<li>Gate CI on <code>SMSApi\/validateTemplate<\/code> so a template edit cannot merge and then fail in production with status code 188.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 id=\"why-messaging-code-is-harder\" class=\"wp-block-heading\">Why messaging code is harder to test than ordinary API code<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">Most testing advice assumes the system under test is idempotent, free and reversible. Messaging code is none of those things, and every difficulty in this article traces back to one of five properties.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Every call costs money at submission.<\/strong> The <a href=\"https:\/\/www.smsgatewaycenter.com\/pricing\/\">pricing pages<\/a> state the billing rule plainly: the applicable per SMS rate is deducted from your wallet while sending SMS, and credits are non refundable once the SMS is successfully submitted to the operator. Submission, not delivery. A test that submits and then discovers the number was invalid has still spent the credit. The cost mechanics are covered in depth in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/bulk-sms-pricing-in-india-what-actually-drives-cost\/\">what actually drives bulk SMS cost<\/a>, and the consequence for testing is direct: your test suite has a budget line.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The side effect is irreversible and lands on a human.<\/strong> You cannot roll back an SMS. A test that accidentally targets a real customer number does not fail quietly, it arrives on someone&#8217;s phone at three in the morning. Databases have transactions and rollbacks. Handsets do not.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The unit of cost is not the unit of the call.<\/strong> One request to <code>SMSApi\/send<\/code> can carry thousands of comma separated recipients, and a Unicode message consumes roughly one credit per seventy characters rather than one per hundred and sixty. So a single test making a single HTTP call can consume thousands of credits. Request count tells you nothing about spend. The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/text-messages-chart\/\">text message chart<\/a> documents the mapping: English is 160 characters per segment and 153 when concatenated, Unicode is 70 and 67.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Failures are asymmetric across message classes.<\/strong> A dropped marketing message is a rounding error. A dropped OTP is a locked out user and, worse, a guaranteed resend that charges you a second time. A test suite that treats all messages as interchangeable is testing the wrong risk.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Half your logic is not yours.<\/strong> DLT template approval, operator scrubbing, handset rendering and carrier retries all sit outside your process. You cannot unit test whether an Indian operator will scrub your message. You can only test that you sent exactly what was approved, which is why the pure function layer matters so much.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Generic API testing guides such as <a href=\"https:\/\/redocly.com\/learn\/testing\/contract-testing-101\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">Redocly&#8217;s contract testing primer<\/a> are correct as far as they go, and prior art like <a href=\"https:\/\/thoughtbot.com\/blog\/testing-sms-interactions\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">thoughtbot&#8217;s post on testing SMS interactions<\/a> is a decade of useful thinking about test doubles. None of it accounts for the five properties above, which is the gap this article fills.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 id=\"what-the-platform-actually-gives-you-for-testing\" class=\"wp-block-heading\">What the platform actually gives you for testing<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">Before designing a suite, be precise about which testing affordances are documented and which are not. Guessing here is how teams end up with a suite that silently bills.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The <code>testMessage<\/code> parameter<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The optional parameters table on the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/send-sms-batch\/\">Send SMS endpoint documentation<\/a> lists:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Parameter<\/th><th>Values<\/th><th>Documented description<\/th><\/tr><\/thead><tbody><tr><td><code>testMessage<\/code><\/td><td><code>true<\/code> or <code>false<\/code><\/td><td>&#8220;Enable true to test your message and messages wont be delivered when enabled true.&#8221;<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">That is the whole of it. Read what it says and, more importantly, read what it does not say. It says the message will not be delivered. It does not say the request is free, it does not say the response differs from a real send, and it does not say whether a delivery report is generated. Treat those three as unknowns for your account and settle them empirically once, in a controlled way, rather than assuming. The empirical check is simple and takes one minute:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Note your wallet balance in the dashboard.<\/li>\n\n\n\n<li>Send exactly one message with <code>testMessage=true<\/code> to a number you control.<\/li>\n\n\n\n<li>Note the balance again.<\/li>\n\n\n\n<li>Poll <code>SMSApi\/reports\/status<\/code> for the same window and see whether a record appears.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Write the result down in your repository README. It is an account level fact your entire team will otherwise re-derive one confused engineer at a time.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Sandbox mode on the demo environment<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The free demo page documents an account level sandbox. Verbatim from that page:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\">The demo environment is designed for testing. Some features use a Sandbox mode, where delivery is simulated for security. For live SMS delivery, you need to upgrade to a paid account.<\/p>\n<\/blockquote>\n\n\n\n<p class=\"wp-block-paragraph\">and, on activation:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\">Sign up for a Customer account, then log in and activate the SANDBOX.<\/p>\n<\/blockquote>\n\n\n\n<p class=\"wp-block-paragraph\">Note the careful wording of &#8220;some features&#8221;. The page does not enumerate which features are simulated, whether sandbox credentials differ from live credentials, or whether sandbox responses are byte identical to live ones. So sandbox is genuinely useful as a place to hold a second set of credentials for an automated integration job, but do not build assertions that depend on undocumented sandbox behaviour. If your CI needs to know whether the sandbox returns a real <code>transactionId<\/code>, find out and record it rather than encoding a guess in an assertion.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Postman collection as a contract source<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/download-sms-postman\/\">SMS API Postman collection<\/a> is the fastest way to capture real response bodies for your fixtures. Run each request once by hand, save the actual response, and commit those bodies as fixture files. Fixtures derived from a real response are worth far more than fixtures you typed from memory, because typed fixtures encode your misunderstanding rather than the API&#8217;s behaviour. There is also an <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/sms-messaging-api\/\">interactive API documentation index<\/a> that links every endpoint.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The two error code list endpoints<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">These are underused and they are the best contract testing tool the platform offers, because they are machine readable and they live on the server rather than in your repository.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>POST https:\/\/unify.smsgateway.center\/SMSApi\/info\/responsecodes<\/code> returns a <code>responsecodesList<\/code> array of <code>{errorcode, httpCode, status, description}<\/code>. Documented at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-api-response-error-code-list\/\">API response error code list<\/a>.<\/li>\n\n\n\n<li><code>POST https:\/\/unify.smsgateway.center\/SMSApi\/info\/deliverycodes<\/code> returns a <code>deliverycodesList<\/code> array of <code>{peId, identifier, status, cause}<\/code>. Documented at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-delivery-error-code-list\/\">delivery error code list<\/a>.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">A nightly job that fetches both and diffs them against the codes your application handles will tell you about a new failure mode before a customer does. That is covered in full later under CI wiring.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What is not documented, and therefore not testable by assertion<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Be honest about the gaps rather than papering over them with plausible looking test fixtures:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The outbound webhook push payload schema is not published. You can test your receiver against a body shape you have observed in your own account, but do not commit a fixture invented from imagination and call it a contract test. Label observed fixtures as observed.<\/li>\n\n\n\n<li>No numeric throughput ceiling for the HTTP API is published, so a test asserting &#8220;we stay under N requests per second&#8221; is asserting your own policy, not the provider&#8217;s limit. That is still a worthwhile test, just name it accurately.<\/li>\n\n\n\n<li>Read, update and delete paths for webhooks and templates are not confirmed publicly, so build your suite around the create and validate endpoints that are.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 id=\"the-four-layers\" class=\"wp-block-heading\">The four layers, and what each one is actually for<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">Most teams have exactly two kinds of messaging test: a unit test that mocks their own sender class and proves nothing, and a manual &#8220;send one to my phone and see&#8221; ritual that proves one thing once. The useful structure has four layers, and the count of tests should fall sharply as you go up.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/four-layers-test-message.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1000\" height=\"570\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/four-layers-test-message.webp\" alt=\"Four layers diagram\" class=\"wp-image-2703\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/four-layers-test-message.webp 1000w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/four-layers-test-message-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/four-layers-test-message-768x438.webp 768w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/a><\/figure>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Layer<\/th><th>What it tests<\/th><th>Network<\/th><th>Cost<\/th><th>Typical count<\/th><th>Runs where<\/th><\/tr><\/thead><tbody><tr><td>1. Pure functions<\/td><td>Segment counting, template rendering, phone normalisation, cost estimation, key derivation<\/td><td>None<\/td><td>Free<\/td><td>100 plus<\/td><td>Every commit, milliseconds<\/td><\/tr><tr><td>2. Transport boundary mocks<\/td><td>Request construction, response parsing, error classification, retry decisions, state transitions<\/td><td>Intercepted<\/td><td>Free<\/td><td>30 to 80<\/td><td>Every commit, under 10 seconds<\/td><\/tr><tr><td>3. Contract tests<\/td><td>That the documented shapes still hold and your fixtures have not drifted<\/td><td>Real, read only<\/td><td>Free<\/td><td>5 to 15<\/td><td>Nightly, and on release<\/td><\/tr><tr><td>4. Live smoke tests<\/td><td>End to end wiring, credentials, sender ID, template binding<\/td><td>Real, write<\/td><td>Charged or <code>testMessage=true<\/code><\/td><td>1 to 3<\/td><td>Pre release only, gated<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Two rules make this structure work.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Layer three exists because layer two rots.<\/strong> The most dangerous object in any integration test suite is a mock that was accurate eighteen months ago. Your mocked response says <code>statusCode<\/code> is a string, the API starts returning a number, every test still passes and production breaks. Contract tests are the smoke alarm on that specific fire.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Layer four is a wiring check, not a behaviour check.<\/strong> Do not use live calls to test logic. Use them to answer three questions only: are my credentials valid, is my sender ID active, and does my template binding resolve. Everything else belongs lower down.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 id=\"layer-one\" class=\"wp-block-heading\">Layer one: pure functions, tested exhaustively<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">If a function does not perform I\/O, there is no excuse for not testing it thoroughly, and in messaging these functions carry disproportionate financial risk. Two of them deserve obsessive coverage.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Segment counting<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Segment counting decides what you pay. Get it wrong by one character and a bulk campaign costs double. The classic failure is not arithmetic, it is the GSM-7 extension table: ten characters, including the caret, curly braces, square brackets, tilde, backslash and the euro sign, occupy two positions each rather than one. The second classic failure is index based iteration in languages where an emoji or an astral plane character is two units, which produces a count that is right for ASCII and wrong for the exact messages that cost the most.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># segments.py  -  no I\/O, therefore exhaustively testable\nGSM7_BASIC = set(\n    \"@\u00a3$\u00a5\u00e8\u00e9\u00f9\u00ec\u00f2\u00c7\\n\u00d8\u00f8\\r\u00c5\u00e5\u0394_\u03a6\u0393\u039b\u03a9\u03a0\u03a8\u03a3\u0398\u039e\u00c6\u00e6\u00df\u00c9 !\\\"#\u00a4%&amp;'()*+,-.\/0123456789:;&lt;=&gt;?\"\n    \"\u00a1ABCDEFGHIJKLMNOPQRSTUVWXYZ\u00c4\u00d6\u00d1\u00dc\u00a7\u00bfabcdefghijklmnopqrstuvwxyz\u00e4\u00f6\u00f1\u00fc\u00e0\"\n)\nGSM7_EXTENDED = set(\"^{}\\\\&#91;~]|\u20ac\")  # each costs two septets\n\n\ndef is_gsm7(text: str) -&gt; bool:\n    return all(ch in GSM7_BASIC or ch in GSM7_EXTENDED for ch in text)\n\n\ndef septet_length(text: str) -&gt; int:\n    return sum(2 if ch in GSM7_EXTENDED else 1 for ch in text)\n\n\ndef count_segments(text: str) -&gt; tuple&#91;str, int, int]:\n    \"\"\"Return (encoding, units, segments).\"\"\"\n    if is_gsm7(text):\n        units = septet_length(text)\n        if units &lt;= 160:\n            return (\"TEXT\", units, 1)\n        return (\"TEXT\", units, -(-units \/\/ 153))\n    # Unicode branch: UCS-2 code units, so astral characters count as two.\n    units = len(text.encode(\"utf-16-be\")) \/\/ 2\n    if units &lt;= 70:\n        return (\"UNICODE\", units, 1)\n    return (\"UNICODE\", units, -(-units \/\/ 67))\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The tests write themselves, and every one of them is a real bug someone has shipped:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import pytest\nfrom segments import count_segments\n\n\n@pytest.mark.parametrize(\n    \"text, encoding, segments\",\n    &#91;\n        (\"A\" * 160, \"TEXT\", 1),\n        (\"A\" * 161, \"TEXT\", 2),\n        (\"A\" * 306, \"TEXT\", 2),\n        (\"A\" * 307, \"TEXT\", 3),\n        # The extension table trap: 80 euro signs are 160 septets, still one segment.\n        (\"\u20ac\" * 80, \"TEXT\", 1),\n        (\"\u20ac\" * 81, \"TEXT\", 2),\n        # The autocorrect trap: a curly apostrophe is not GSM-7 and flips the whole\n        # message to Unicode, cutting capacity from 160 to 70.\n        (\"It\" + \"\u2019\" + \"s here\", \"UNICODE\", 1),\n        (\"A\" * 70, \"TEXT\", 1),\n        (\"\u4f60\" * 70, \"UNICODE\", 1),\n        (\"\u4f60\" * 71, \"UNICODE\", 2),\n    ],\n)\ndef test_segment_counting(text, encoding, segments):\n    enc, _units, segs = count_segments(text)\n    assert enc == encoding\n    assert segs == segments\n\n\ndef test_emoji_counts_as_two_ucs2_units():\n    # A single astral plane emoji is two UCS-2 units, so 35 of them fill one segment.\n    _enc, units, segs = count_segments(\"\\U0001F600\" * 35)\n    assert units == 70\n    assert segs == 1\n    _enc, _units, segs = count_segments(\"\\U0001F600\" * 36)\n    assert segs == 2\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Cross check your implementation once against the platform&#8217;s own calculation using <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/info\/msg<\/code>, documented at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-sms-length-cost\/\">get SMS length and cost<\/a>. That endpoint returns <code>{\"encoding\": \"TEXT\", \"length\": 11, \"remaining\": 149, \"credit\": 1}<\/code> inside a <code>msgList<\/code> object. Treat it as the oracle for a handful of tricky strings, record the answers as fixtures, and then test your local function against the fixtures so your unit tests stay offline and free.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">DLT variable rendering<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">For Indian traffic, the message body you send must match an approved template character for character, with only the <code>{#var#}<\/code> positions substituted. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/dlt-template-rejected-reasons-and-fixes\/\">DLT template rejection guide<\/a> covers why templates get rejected in the first place. What concerns us here is the rendering function, and there is one specific, language independent rule.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Render by position, never by search and replace.<\/strong> Every naive approach is unsafe in a way that only shows up with real customer data:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Chained <code>str.replace<\/code> or <code>String.replace<\/code> replaces the first occurrence repeatedly, so if a substituted value itself contains the literal <code>{#var#}<\/code> you corrupt the remaining slots.<\/li>\n\n\n\n<li>Regex substitution in JavaScript expands <code>$&amp;<\/code> and <code>$1<\/code> inside the replacement string, so a customer whose name or order reference contains a dollar sign silently produces a different message than intended, and that different message no longer matches the approved template.<\/li>\n\n\n\n<li>Any approach that walks the string more than once can substitute into text it just inserted.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The safe implementation finds all placeholder positions first, then rebuilds the string in one pass:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import re\n\nTOKEN = \"{#var#}\"\n_TOKEN_RE = re.compile(re.escape(TOKEN))\n\n\nclass TemplateArityError(ValueError):\n    pass\n\n\ndef render(template: str, values: list&#91;str]) -&gt; str:\n    spans = &#91;m.span() for m in _TOKEN_RE.finditer(template)]\n    if len(spans) != len(values):\n        raise TemplateArityError(\n            f\"template has {len(spans)} variables, got {len(values)} values\"\n        )\n    out, cursor = &#91;], 0\n    for (start, end), value in zip(spans, values):\n        out.append(template&#91;cursor:start])\n        out.append(value)\n        cursor = end\n    out.append(template&#91;cursor:])\n    return \"\".join(out)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">And the tests that matter:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def test_value_containing_a_token_is_not_re_substituted():\n    tpl = \"Hi {#var#}, your code is {#var#}.\"\n    out = render(tpl, &#91;\"{#var#}\", \"4821\"])\n    assert out == \"Hi {#var#}, your code is 4821.\"\n\n\ndef test_dollar_sign_in_value_is_literal():\n    tpl = \"Order {#var#} confirmed.\"\n    assert render(tpl, &#91;\"A$&amp;B\"]) == \"Order A$&amp;B confirmed.\"\n\n\ndef test_arity_mismatch_raises_before_any_network_call():\n    with pytest.raises(TemplateArityError):\n        render(\"Hi {#var#} and {#var#}\", &#91;\"only-one\"])\n\n\ndef test_static_text_is_byte_identical():\n    tpl = \"Dear {#var#}, your OTP is {#var#}. Valid 10 min.\"\n    out = render(tpl, &#91;\"Asha\", \"918273\"])\n    assert out.replace(\"Asha\", \"{#var#}\").replace(\"918273\", \"{#var#}\") == tpl\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That last test is the useful one and almost nobody writes it. It asserts that rendering changed nothing except the variable slots, which is precisely the condition that keeps you out of status code 188.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Property based testing is a natural fit here. Generate random templates with between one and five tokens and random values drawn from a nasty alphabet including <code>$<\/code>, <code>{<\/code>, <code>}<\/code>, <code>#<\/code>, backslashes and emoji, then assert the invariant that removing the substituted values restores the original template. A property test with a hundred generated cases finds the bug that eight hand written examples miss.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Phone number normalisation<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Third pure function, quickly. Recipients arrive from CRMs in every shape imaginable: <code>+91 98765 43210<\/code>, <code>098765-43210<\/code>, <code>9876543210<\/code>, <code>91 98765 43210<\/code>. Your normaliser must be deterministic and tested against a table of real world garbage, because the recipient string is part of the idempotency key described in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-idempotency-preventing-duplicate-sends\/\">message idempotency guide<\/a>, and two spellings of the same number defeat deduplication entirely. Test that normalisation is idempotent: <code>normalise(normalise(x)) == normalise(x)<\/code>. That one assertion catches an entire class of bug.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 id=\"layer-two\" class=\"wp-block-heading\">Layer two: mock at the transport boundary, never at your own wrapper<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">Here is the most common testing mistake in messaging codebases, and it is so widespread that many teams do not recognise it as a mistake.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># WRONG. This test passes forever and proves nothing.\ndef test_order_confirmation_sends_sms(mocker):\n    fake_client = mocker.patch(\"app.notifications.sms_client\")\n    send_order_confirmation(order_id=42)\n    fake_client.send.assert_called_once()\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That test asserts that your code called your own method. It will keep passing if you send the wrong <code>senderid<\/code>, omit <code>dltTemplateId<\/code>, post JSON to a form encoded endpoint, forget <code>output=json<\/code>, or point at the wrong host. Every single one of those is a real production incident, and the test is blind to all of them.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Mock one level lower, at the HTTP transport. Then the assertion is about the bytes that would actually have left your process.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Python: respx for httpx<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>import httpx, respx, pytest\nfrom app.sms import SmsClient  # your real client, unmocked\n\nSEND_URL = \"https:\/\/unify.smsgateway.center\/SMSApi\/send\"\n\n\n@respx.mock\ndef test_send_posts_form_encoded_fields_in_the_documented_shape():\n    route = respx.post(SEND_URL).mock(\n        return_value=httpx.Response(\n            200,\n            json={\n                \"status\": \"success\",\n                \"mobile\": \"919999999999\",\n                \"invalidMobile\": \"\",\n                \"transactionId\": \"6305583318236810379\",\n                \"statusCode\": \"200\",\n                \"reason\": \"success\",\n            },\n        )\n    )\n\n    client = SmsClient(userid=\"u\", password=\"p\", senderid=\"SGCTST\")\n    result = client.send(mobile=\"919999999999\", msg=\"Hello World\")\n\n    assert route.called\n    request = route.calls.last.request\n\n    # The endpoint is form encoded. Posting JSON here presents as an auth error,\n    # which is the single most confusing failure mode in Python integrations.\n    assert request.headers&#91;\"content-type\"].startswith(\n        \"application\/x-www-form-urlencoded\"\n    )\n\n    body = dict(httpx.QueryParams(request.content.decode()))\n    assert body&#91;\"sendMethod\"] == \"quick\"\n    assert body&#91;\"msgType\"] == \"text\"\n    assert body&#91;\"output\"] == \"json\"\n    assert body&#91;\"senderid\"] == \"SGCTST\"\n    assert body&#91;\"mobile\"] == \"919999999999\"\n    assert \"password\" not in request.url.query.decode()  # never in the query string\n\n    assert result.transaction_id == \"6305583318236810379\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Four assertions there catch four separate real incidents: wrong content type, missing <code>sendMethod<\/code>, missing <code>output<\/code> so you get plain text back and your JSON parser explodes, and credentials leaking into a URL that ends up in an access log. None of them are reachable from a wrapper level mock.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>requests<\/code> equivalent uses the <code>responses<\/code> library with the same structure. Whichever you choose, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-python-integration-tutorial\/\">Python integration tutorial<\/a> covers the client construction this test exercises, including the trap that <code>requests<\/code> has no default timeout while <code>httpx<\/code> defaults to five seconds.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Node.js: nock<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>import nock from 'nock';\nimport { strict as assert } from 'node:assert';\nimport { test } from 'node:test';\nimport { SmsClient } from '..\/src\/sms.js';\n\nconst HOST = 'https:\/\/unify.smsgateway.center';\n\ntest('send posts the documented form fields and parses the response', async () =&gt; {\n  let captured;\n  const scope = nock(HOST)\n    .post('\/SMSApi\/send', (body) =&gt; {\n      captured = body;\n      return true;\n    })\n    .matchHeader('content-type', \/application\\\/x-www-form-urlencoded\/)\n    .reply(200, {\n      status: 'success',\n      mobile: '919999999999',\n      invalidMobile: '',\n      transactionId: '6305583318236810379',\n      statusCode: '200',\n      reason: 'success',\n    });\n\n  const client = new SmsClient({ userid: 'u', password: 'p', senderid: 'SGCTST' });\n  const res = await client.send({ mobile: '919999999999', msg: 'Hello World' });\n\n  assert.equal(captured.sendMethod, 'quick');\n  assert.equal(captured.output, 'json');\n  assert.equal(res.transactionId, '6305583318236810379');\n  scope.done(); \/\/ fails the test if the interceptor was never consumed\n});\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>scope.done()<\/code> is the part people forget. Without it, a test where your client never made the call at all still passes, because nothing asserted that the interceptor fired. Always assert that the request happened, not merely that nothing threw.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Add <code>nock.disableNetConnect()<\/code> in a global test setup file. That single line converts &#8220;my CI accidentally sent real messages&#8221; from a possibility into an immediate, loud test failure. It is the cheapest safety rail in this entire article.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ test\/setup.js\nimport nock from 'nock';\nnock.disableNetConnect();\nnock.enableNetConnect('127.0.0.1'); \/\/ allow your own test server\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-nodejs-integration-tutorial\/\">Node.js integration tutorial<\/a> covers the client this exercises, including why <code>fetch<\/code> in Node has no default timeout and why <code>AbortSignal.timeout<\/code> is mandatory.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">PHP: Guzzle MockHandler with a history middleware<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;?php\nuse GuzzleHttp\\Client;\nuse GuzzleHttp\\Handler\\MockHandler;\nuse GuzzleHttp\\HandlerStack;\nuse GuzzleHttp\\Middleware;\nuse GuzzleHttp\\Psr7\\Response;\nuse PHPUnit\\Framework\\TestCase;\n\nfinal class SmsClientTest extends TestCase\n{\n    public function testSendPostsDocumentedFormFields(): void\n    {\n        $mock = new MockHandler(&#91;\n            new Response(200, &#91;'Content-Type' =&gt; 'application\/json'], json_encode(&#91;\n                'status'         =&gt; 'success',\n                'mobile'         =&gt; '919999999999',\n                'invalidMobile'  =&gt; '',\n                'transactionId'  =&gt; '6305583318236810379',\n                'statusCode'     =&gt; '200',\n                'reason'         =&gt; 'success',\n            ])),\n        ]);\n\n        $history = &#91;];\n        $stack = HandlerStack::create($mock);\n        $stack-&gt;push(Middleware::history($history));\n\n        $http = new Client(&#91;'handler' =&gt; $stack, 'http_errors' =&gt; false]);\n        $client = new SmsClient($http, 'u', 'p', 'SGCTST');\n\n        $result = $client-&gt;send('919999999999', 'Hello World');\n\n        $this-&gt;assertCount(1, $history);\n        $request = $history&#91;0]&#91;'request'];\n        parse_str((string) $request-&gt;getBody(), $body);\n\n        $this-&gt;assertSame('quick', $body&#91;'sendMethod']);\n        $this-&gt;assertSame('json', $body&#91;'output']);\n        $this-&gt;assertSame('SGCTST', $body&#91;'senderid']);\n        $this-&gt;assertSame('6305583318236810379', $result-&gt;transactionId);\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>Middleware::history<\/code> is what turns a Guzzle mock from a stub into an assertion surface. Without it you can only assert on the response, which means you can only test half the interaction. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-php-integration-tutorial\/\">PHP integration tutorial<\/a> covers the client and the Laravel wiring around it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The rule, stated once<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Mock the lowest layer you do not own. You own your <code>SmsClient<\/code>, so do not mock it. You do not own the socket, so mock there. Everything between the two is code you wrote and code you are paid to have tested.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 id=\"layer-three\" class=\"wp-block-heading\">Layer three: contract tests against the documented response shapes<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">Layer two proves your parser handles the fixtures you wrote. Layer three proves the fixtures still resemble reality. This layer is small, it runs nightly rather than per commit, and it is entirely read only so it costs nothing and can safely be retried.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The inconsistencies you must actually handle<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">These are documented, observable and easy to get wrong. Encode each one as a test.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Field<\/th><th>On <code>SMSApi\/send<\/code><\/th><th>On <code>WAApi\/send<\/code><\/th><th>Consequence if you assume<\/th><\/tr><\/thead><tbody><tr><td>Success identifier<\/td><td><code>transactionId<\/code><\/td><td><code>messageId<\/code><\/td><td>A shared parser returns null for one channel<\/td><\/tr><tr><td>Numeric status<\/td><td><code>statusCode<\/code> quoted as a string, <code>\"200\"<\/code><\/td><td><code>statusCode<\/code> unquoted, <code>200<\/code><\/td><td>A strict equality check against <code>200<\/code> fails on one channel<\/td><\/tr><tr><td>Human readable text<\/td><td><code>reason<\/code><\/td><td><code>description<\/code><\/td><td>Your logs lose the failure text on one channel<\/td><\/tr><tr><td>Partial rejection<\/td><td><code>invalidMobile<\/code> populated, <code>status<\/code> may still read <code>success<\/code><\/td><td>Not applicable<\/td><td>You report success while some recipients were dropped<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The correct response is a normalisation function with its own tests, rather than conditionals scattered through your codebase:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from dataclasses import dataclass\n\n\n@dataclass(frozen=True)\nclass SendResult:\n    ok: bool\n    identifier: str | None\n    status_code: str\n    text: str\n    invalid_recipients: list&#91;str]\n\n\ndef normalise(payload: dict) -&gt; SendResult:\n    identifier = payload.get(\"transactionId\") or payload.get(\"messageId\")\n    invalid_raw = payload.get(\"invalidMobile\") or \"\"\n    invalid = &#91;n.strip() for n in invalid_raw.split(\",\") if n.strip()]\n    return SendResult(\n        ok=str(payload.get(\"status\", \"\")).lower() == \"success\",\n        identifier=str(identifier) if identifier is not None else None,\n        # Coerce, because one channel quotes it and the other does not.\n        status_code=str(payload.get(\"statusCode\", \"\")),\n        text=payload.get(\"reason\") or payload.get(\"description\") or \"\",\n        invalid_recipients=invalid,\n    )\n<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>def test_sms_success_shape():\n    r = normalise({\n        \"status\": \"success\", \"mobile\": \"919999999999\", \"invalidMobile\": \"\",\n        \"transactionId\": \"6305583318236810379\", \"statusCode\": \"200\",\n        \"reason\": \"success\",\n    })\n    assert r.ok and r.identifier == \"6305583318236810379\"\n    assert r.status_code == \"200\" and r.invalid_recipients == &#91;]\n\n\ndef test_whatsapp_success_shape_uses_different_field_names_and_types():\n    r = normalise({\n        \"status\": \"success\", \"messageId\": \"1234567890\", \"mobile\": \"919xxxxxxxxx6\",\n        \"statusCode\": 200, \"description\": \"Message sent successfully\",\n    })\n    assert r.ok and r.identifier == \"1234567890\"\n    assert r.status_code == \"200\"          # coerced, not compared as an int\n    assert r.text == \"Message sent successfully\"\n\n\ndef test_partial_rejection_is_not_treated_as_total_success():\n    r = normalise({\n        \"status\": \"success\", \"mobile\": \"919999999999,919999999998\",\n        \"invalidMobile\": \"919999999998\", \"transactionId\": \"63055833182368\",\n        \"statusCode\": \"200\", \"reason\": \"success\",\n    })\n    assert r.ok is True                     # the request succeeded\n    assert r.invalid_recipients == &#91;\"919999999998\"]   # but not for everyone\n\n\ndef test_template_mismatch_is_a_permanent_failure():\n    r = normalise({\n        \"status\": \"error\", \"statusCode\": \"188\",\n        \"reason\": \"message template mismatch\",\n    })\n    assert r.ok is False and r.status_code == \"188\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That third test is the one worth arguing about in code review. A response with <code>status: success<\/code> and a populated <code>invalidMobile<\/code> is a partial success, and treating it as a clean success means some recipients silently never receive anything while your dashboard shows green. The rule to encode: <code>ok<\/code> means the request was accepted, and per recipient truth comes only from <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/sms-delivery-report\/\">the delivery report endpoint<\/a> via <code>mobileNo<\/code>, <code>msgId<\/code> and <code>uuId<\/code>. There is exactly one top level <code>transactionId<\/code> per request, not one per recipient, so per recipient correlation cannot come from the send response at all.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Using the error code endpoints as a live contract<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is the highest leverage contract test available, and it is read only:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import httpx\n\n\ndef fetch_api_codes(userid: str, password: str) -&gt; set&#91;str]:\n    r = httpx.post(\n        \"https:\/\/unify.smsgateway.center\/SMSApi\/info\/responsecodes\",\n        data={\"userid\": userid, \"password\": password, \"output\": \"json\"},\n        timeout=15.0,\n    )\n    r.raise_for_status()\n    body = r.json()\n    codes = body&#91;\"response\"]&#91;\"responsecodesList\"]\n    return {str(c&#91;\"errorcode\"]) for c in codes}\n\n\ndef test_we_have_a_handler_for_every_documented_api_error_code(creds):\n    from app.errors import HANDLED_API_CODES, DEFAULT_HANDLER\n    live = fetch_api_codes(*creds)\n    unknown = live - HANDLED_API_CODES\n    # Not a hard failure. Unknown codes must fall through to a safe default.\n    assert DEFAULT_HANDLER.classification == \"permanent_unknown\"\n    if unknown:\n        import warnings\n        warnings.warn(f\"New API error codes to triage: {sorted(unknown)}\")\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Do the same against <code>SMSApi\/info\/deliverycodes<\/code> for delivery causes. Run it nightly, route the warning to your team channel, and you will learn about a new failure mode as a scheduled chore rather than as an incident. Pair it with the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-retry-strategy-handling-failed-messages\/\">SMS API retry strategy guide<\/a> so each newly discovered code gets classified as permanent, transient or ambiguous the day it appears.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Design note: assert that unknown codes fall through to a <strong>permanent<\/strong> default rather than a transient one. An unknown code treated as transient becomes an infinite retry loop that bills on every attempt. An unknown code treated as permanent costs you one undelivered message and a log line. The asymmetry is not close.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 id=\"the-single-most-valuable-test\" class=\"wp-block-heading\">The single most valuable test you are not writing<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">Ask an engineer to test their SMS integration and they will write a test for a successful send. Ask them to test a failure and they will write one for a 4xx. Almost nobody writes the test for the case that actually causes duplicate messages and duplicate charges: the request that timed out after the server received it.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/single-most-valuable-test.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1000\" height=\"570\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/single-most-valuable-test.webp\" alt=\"valuable test diagram\" class=\"wp-image-2704\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/single-most-valuable-test.webp 1000w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/single-most-valuable-test-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/single-most-valuable-test-768x438.webp 768w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/a><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">There are two timeouts and they mean opposite things.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Failure<\/th><th>What it means<\/th><th>Safe to retry<\/th><th>Correct state<\/th><\/tr><\/thead><tbody><tr><td>Connect timeout<\/td><td>The connection was never established, nothing was submitted<\/td><td>Yes<\/td><td><code>pending<\/code>, retry freely<\/td><\/tr><tr><td>Read timeout<\/td><td>The request was sent, the response never arrived<\/td><td><strong>No<\/strong><\/td><td><code>unknown<\/code>, reconcile later<\/td><\/tr><tr><td>DNS failure<\/td><td>Nothing was submitted<\/td><td>Yes<\/td><td><code>pending<\/code><\/td><\/tr><tr><td>Connection reset before request body sent<\/td><td>Nothing was submitted<\/td><td>Yes<\/td><td><code>pending<\/code><\/td><\/tr><tr><td>Connection reset after request body sent<\/td><td>Unknown<\/td><td><strong>No<\/strong><\/td><td><code>unknown<\/code><\/td><\/tr><tr><td>HTTP 5xx with a body<\/td><td>The server processed and failed, usually<\/td><td>Depends on code<\/td><td>Classify by code<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Languages differ in whether they let you tell these apart. <code>httpx<\/code> gives you <code>ConnectTimeout<\/code> and <code>ReadTimeout<\/code> as distinct exception classes. <code>requests<\/code> collapses both under <code>requests.exceptions.Timeout<\/code>, which means a <code>requests<\/code> based client cannot distinguish them and must treat every timeout as ambiguous. In Node, <code>AbortSignal.timeout<\/code> produces a <code>TimeoutError<\/code> that does not tell you which phase you were in, so the same conservative rule applies. That is not a defect you can code around. It is a constraint that dictates your state machine.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The test:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import httpx, respx, pytest\nfrom app.sms import SmsClient\nfrom app.models import MessageState\n\nSEND_URL = \"https:\/\/unify.smsgateway.center\/SMSApi\/send\"\n\n\n@respx.mock\ndef test_read_timeout_goes_to_unknown_and_is_never_retried(db):\n    respx.post(SEND_URL).mock(side_effect=httpx.ReadTimeout(\"timed out\"))\n\n    client = SmsClient(userid=\"u\", password=\"p\", senderid=\"SGCTST\")\n    record = db.create_message(business_key=\"order-42-confirmation\")\n\n    with pytest.raises(AmbiguousSendError):\n        client.send_tracked(record, mobile=\"919999999999\", msg=\"Hi\")\n\n    db.refresh(record)\n    assert record.state is MessageState.UNKNOWN\n    assert record.attempts == 1                    # exactly one, no retry\n    assert record.needs_reconciliation is True\n\n\n@respx.mock\ndef test_connect_timeout_stays_pending_and_is_retried(db):\n    respx.post(SEND_URL).mock(side_effect=httpx.ConnectTimeout(\"no route\"))\n\n    client = SmsClient(userid=\"u\", password=\"p\", senderid=\"SGCTST\")\n    record = db.create_message(business_key=\"order-43-confirmation\")\n\n    with pytest.raises(TransientSendError):\n        client.send_tracked(record, mobile=\"919999999999\", msg=\"Hi\")\n\n    db.refresh(record)\n    assert record.state is MessageState.PENDING     # safe to pick up again\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">And the transition that must be impossible:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def test_unknown_can_never_transition_back_to_pending(db):\n    record = db.create_message(business_key=\"k\")\n    record.transition_to(MessageState.UNKNOWN)\n    with pytest.raises(IllegalTransition):\n        record.transition_to(MessageState.PENDING)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That last test is three lines and it is the guard rail on the most expensive bug in messaging. An <code>unknown<\/code> record that drifts back to <code>pending<\/code> gets picked up by the next worker sweep and sent again, and now your customer has two messages and you have two charges. Encode the transition table as data and test it exhaustively, rather than relying on every future contributor to remember the rule. The full state machine, with a Postgres schema and the reasoning behind each transition, is in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-idempotency-preventing-duplicate-sends\/\">message idempotency guide<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Resolution of <code>unknown<\/code> records comes from reconciliation, not from retrying. Test that too: seed an <code>unknown<\/code> record, mock <code>SMSApi\/reports\/status<\/code> returning a matching <code>reports_dlrList<\/code> entry keyed on <code>mobileNo<\/code> and <code>submitTime<\/code> within your window, and assert the sweep resolves the record to sent rather than resending it.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 id=\"testing-time-dependent-code\" class=\"wp-block-heading\">Testing time dependent code with an injectable clock<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">Rate limiters and backoff schedules are pure logic wrapped around a clock, and they are untestable if the clock is a hard coded call to <code>time.time()<\/code> or <code>Date.now()<\/code>. Making the clock a parameter converts a category of &#8220;we cannot test this&#8221; into ordinary unit tests.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The wrong shape:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>class TokenBucket:\n    def take(self, cost: int) -&gt; bool:\n        now = time.time()   # untestable without sleeping or monkeypatching globals\n        ...\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The right shape:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from typing import Callable\n\n\nclass TokenBucket:\n    def __init__(self, capacity: float, refill_per_second: float,\n                 clock: Callable&#91;&#91;], float]):\n        self.capacity = capacity\n        self.refill = refill_per_second\n        self.clock = clock\n        self.tokens = capacity\n        self.updated = clock()\n\n    def take(self, cost: float) -&gt; tuple&#91;bool, float]:\n        \"\"\"Return (allowed, retry_after_seconds).\"\"\"\n        now = self.clock()\n        elapsed = now - self.updated\n        self.tokens = min(self.capacity, self.tokens + elapsed * self.refill)\n        self.updated = now\n        if self.tokens &gt;= cost:\n            self.tokens -= cost\n            return True, 0.0\n        deficit = cost - self.tokens\n        return False, deficit \/ self.refill\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Now the tests are deterministic, instant and complete:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>class FakeClock:\n    def __init__(self, start: float = 1_000.0):\n        self.now = start\n\n    def __call__(self) -&gt; float:\n        return self.now\n\n    def advance(self, seconds: float) -&gt; None:\n        self.now += seconds\n\n\ndef test_bucket_refills_exactly_and_no_faster():\n    clock = FakeClock()\n    b = TokenBucket(capacity=10, refill_per_second=5, clock=clock)\n    assert b.take(10) == (True, 0.0)\n    allowed, retry_after = b.take(5)\n    assert allowed is False\n    assert retry_after == pytest.approx(1.0)     # 5 tokens at 5 per second\n    clock.advance(1.0)\n    assert b.take(5)&#91;0] is True\n\n\ndef test_bucket_never_exceeds_capacity_after_a_long_idle_period():\n    clock = FakeClock()\n    b = TokenBucket(capacity=10, refill_per_second=5, clock=clock)\n    clock.advance(86_400)                        # idle for a day\n    assert b.take(10)&#91;0] is True\n    assert b.take(1)&#91;0] is False                 # no burst credit accrued\n\n\ndef test_cost_is_measured_in_units_not_requests():\n    \"\"\"One request carrying 400 Unicode recipients is not one unit of cost.\"\"\"\n    clock = FakeClock()\n    b = TokenBucket(capacity=100, refill_per_second=10, clock=clock)\n    recipients, segments = 40, 3\n    allowed, _ = b.take(recipients * segments)   # 120 units\n    assert allowed is False                      # correctly refused\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That third test encodes the point that trips up most implementations: the scarce resource is recipients multiplied by segments, not HTTP requests. A limiter that counts requests reports a flat graph while your real submitted volume grows by orders of magnitude, because batching moves load out of the metric you are watching. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/api-rate-limiting-throttling-sms-services-best-practices\/\">existing guide on API rate limiting and throttling<\/a> covers the provider side view of throttling, and for SMPP bind level throughput the mechanics are different again and are covered in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/smpp-rate-limits-throughput-tuning\/\">SMPP rate limits and throughput tuning<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Testing backoff without sleeping<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The same injection trick applies to sleeping. Do not let your retry loop call <code>time.sleep<\/code> directly. Give it a sleeper and assert on the schedule:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def test_full_jitter_schedule_is_bounded_and_capped():\n    sleeps = &#91;]\n    rng = random.Random(1234)               # seeded, therefore reproducible\n    policy = BackoffPolicy(base=1.0, cap=60.0, rng=rng, sleeper=sleeps.append)\n\n    for attempt in range(8):\n        policy.wait(attempt)\n\n    assert len(sleeps) == 8\n    for attempt, slept in enumerate(sleeps):\n        upper = min(60.0, 1.0 * (2 ** attempt))\n        assert 0.0 &lt;= slept &lt;= upper        # full jitter: uniform in &#91;0, upper]\n    assert max(sleeps) &lt;= 60.0              # cap respected\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A test suite that sleeps for real is a test suite people skip. Seven attempts of exponential backoff is over two minutes of wall clock time that buys you nothing. Inject the sleeper, assert the schedule, and the whole thing runs in under a millisecond.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Seed your random number generator explicitly. A jitter test with an unseeded RNG is a flaky test waiting to happen, and flaky tests get deleted.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 id=\"testing-the-delivery-report-receiver\" class=\"wp-block-heading\">Testing the delivery report receiver<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">Your webhook receiver is a public HTTP endpoint that a third party calls with a payload you did not design. It is the least tested component in most messaging systems and one of the easiest to get wrong. Register it with <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/webhook\/create<\/code>, documented at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/create-webhook\/\">create webhook<\/a>, whose required parameters include <code>smswebhook<\/code> for the URL and <code>smswebhookrate<\/code>, described verbatim as &#8220;DLR TPS. AT what TPS rate DLR should be forwarded to. Default is 10.&#8221; The dashboard walkthrough is in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/setup-webhook-sms-whatsapp-dlr\/\">webhook setup knowledge base entry<\/a>, and the conceptual overview is in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/real-time-dlr-webhooks\/\">real time DLR webhooks<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Four properties are worth testing, and one of them catches a bug that has bitten many teams.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Test with a form encoded body, not JSON<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The bug: your framework&#8217;s JSON body parser is registered globally, the incoming payload arrives as <code>application\/x-www-form-urlencoded<\/code>, the JSON parser consumes the request stream, and your handler receives an empty body. It returns 200 because nothing threw. Delivery reports vanish silently, and because you are returning 200 nothing is retried. This is invisible in a test suite that only ever posts JSON.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from fastapi.testclient import TestClient\nfrom app.main import app\n\nclient = TestClient(app)\n\n\ndef test_receiver_accepts_form_encoded_body():\n    resp = client.post(\n        \"\/webhooks\/dlr\",\n        data={\"msgId\": \"abc123\", \"mobileNo\": \"919999999999\", \"status\": \"DELIVERED\"},\n        headers={\"Content-Type\": \"application\/x-www-form-urlencoded\"},\n    )\n    assert resp.status_code == 200\n\n\ndef test_receiver_accepts_json_body_too():\n    resp = client.post(\"\/webhooks\/dlr\", json={\"msgId\": \"abc123\"})\n    assert resp.status_code == 200\n\n\ndef test_receiver_does_not_500_on_a_body_it_cannot_parse():\n    resp = client.post(\"\/webhooks\/dlr\", content=b\"\\x00\\x01garbage\",\n                       headers={\"Content-Type\": \"application\/octet-stream\"})\n    assert resp.status_code == 200          # accepted, parked, investigated later\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Because the push payload schema is not published, your receiver should read the raw body first and parse second. That is good engineering and it happens to make the third test above trivially satisfiable.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Test that you persist before you parse<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>def test_raw_payload_is_persisted_even_when_parsing_fails(db):\n    client.post(\"\/webhooks\/dlr\", content=b\"\\x00\\x01garbage\",\n                headers={\"Content-Type\": \"application\/octet-stream\"})\n    rows = db.fetch(\"select raw_body, parse_error from dlr_inbox\")\n    assert len(rows) == 1\n    assert rows&#91;0]&#91;\"parse_error\"] is not None\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Persist the bytes, acknowledge fast, process asynchronously. A receiver that parses inline and returns 500 on an unexpected field turns a schema change into data loss. A receiver that stores first turns the same schema change into a backlog you can reprocess once you fix the parser. Test the difference explicitly.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Test the signature comparison, including that it is constant time<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>def test_receiver_rejects_a_bad_signature():\n    resp = client.post(\"\/webhooks\/dlr\", data={\"msgId\": \"x\"},\n                       headers={\"X-Signature\": \"wrong\"})\n    assert resp.status_code in (401, 403)\n\n\ndef test_signature_check_uses_a_constant_time_comparison():\n    import inspect\n    from app.webhooks import verify_signature\n    source = inspect.getsource(verify_signature)\n    assert \"compare_digest\" in source     # crude, but it fails loudly on a regression\n    assert \" == \" not in source.split(\"return\")&#91;-1]\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The second test is deliberately unusual, and reasonable people disagree about asserting on source text. The argument for it: timing safety is invisible to behavioural tests, so a well meaning refactor to <code>==<\/code> passes every functional test and silently reintroduces a timing oracle. A crude structural assertion is better than no assertion at all. If your team objects, put the check in a lint rule instead. Do not put it nowhere.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Test out of order and duplicate delivery<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Delivery reports are not ordered and can repeat. Your handler must be commutative and idempotent with respect to terminal states.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def test_a_late_intermediate_report_does_not_overwrite_a_terminal_state(db):\n    post_dlr(msgId=\"m1\", status=\"DELIVERED\", ts=\"2026-08-19T10:00:05Z\")\n    post_dlr(msgId=\"m1\", status=\"SENT\",      ts=\"2026-08-19T10:00:01Z\")  # arrives late\n    assert db.status_of(\"m1\") == \"DELIVERED\"\n\n\ndef test_duplicate_terminal_reports_are_idempotent(db):\n    post_dlr(msgId=\"m1\", status=\"DELIVERED\")\n    post_dlr(msgId=\"m1\", status=\"DELIVERED\")\n    assert db.transition_count(\"m1\") == 1\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">For the polling alternative, the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/sms-delivery-report\/\">delivery report endpoint<\/a> is a read only <code>getDlr<\/code> call with a cursor, which means transport level retries against it are safe. Test the cursor advance and, importantly, test that a partial page failure does not advance the cursor past unprocessed records. Cursor bugs lose delivery data quietly and are only discovered during a billing dispute. The meaning of individual delivery statuses is catalogued in the knowledge base under <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/understanding-delivery-reports-dlr\/\">understanding delivery reports<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 id=\"testing-idempotency-under-concurrency\" class=\"wp-block-heading\">Testing idempotency under concurrency<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">An idempotency guard that works in a single threaded test and fails under concurrency is worse than no guard, because it produces false confidence. The test that matters runs two workers against the same business key at the same time and asserts exactly one send.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import threading\nimport respx, httpx\n\nSEND_URL = \"https:\/\/unify.smsgateway.center\/SMSApi\/send\"\n\n\n@respx.mock\ndef test_concurrent_workers_send_exactly_once(db):\n    route = respx.post(SEND_URL).mock(\n        return_value=httpx.Response(200, json={\n            \"status\": \"success\", \"mobile\": \"919999999999\", \"invalidMobile\": \"\",\n            \"transactionId\": \"1\", \"statusCode\": \"200\", \"reason\": \"success\"})\n    )\n    barrier = threading.Barrier(2)\n    errors = &#91;]\n\n    def worker():\n        try:\n            barrier.wait()                      # maximise the race window\n            dispatch(business_key=\"order-42-confirmation\", mobile=\"919999999999\",\n                     msg=\"Hi\")\n        except AlreadyClaimed:\n            pass\n        except Exception as exc:                # noqa: BLE001\n            errors.append(exc)\n\n    threads = &#91;threading.Thread(target=worker) for _ in range(2)]\n    for t in threads:\n        t.start()\n    for t in threads:\n        t.join()\n\n    assert errors == &#91;]\n    assert route.call_count == 1                # the whole point\n    assert db.count(\"messages\", business_key=\"order-42-confirmation\") == 1\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Run that test against a real Postgres or Redis in CI, not against an in memory fake. The guarantee you depend on is the unique index or the atomic <code>SET NX<\/code>, and an in memory dictionary in a fake will happily provide a guarantee the real store does not. This is the one place where the general advice to mock external systems is wrong: you are testing the external system&#8217;s concurrency semantics, so the external system has to be real.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two more tests belong here.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def test_claim_happens_before_the_request_leaves(db):\n    \"\"\"If the row is not claimed first, a crash mid-request loses the audit trail.\"\"\"\n    order = &#91;]\n    with patch_db_insert(lambda *_: order.append(\"claim\")), \\\n         respx.mock as mock:\n        mock.post(SEND_URL).mock(side_effect=lambda r: order.append(\"http\") or ok())\n        dispatch(business_key=\"k\", mobile=\"919999999999\", msg=\"Hi\")\n    assert order == &#91;\"claim\", \"http\"]\n\n\ndef test_shed_message_may_safely_return_to_pending(db):\n    \"\"\"Shedding is a definite non-submission, unlike an ambiguous timeout.\"\"\"\n    record = db.create_message(business_key=\"k\")\n    record.transition_to(MessageState.SHED)\n    record.transition_to(MessageState.PENDING)   # legal, unlike from UNKNOWN\n    assert record.state is MessageState.PENDING\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That contrast is subtle and worth stating: a message you deliberately dropped before sending is definitely unsent and can be requeued freely, while a message whose outcome you do not know cannot. Two paths that look similar in a queue dashboard have opposite safety properties, and only a test makes the distinction durable. The <code>duplicatecheck<\/code> parameter, incidentally, is not a retry guard. The documentation describes it as &#8220;Enable to remove duplicate mobile numbers. Default is true&#8221;, which is within request recipient deduplication. It removes repeated numbers inside one call and says nothing about two separate calls, so do not write a test that expects it to protect you across requests.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 id=\"layer-four\" class=\"wp-block-heading\">Layer four: live smoke tests, and the rails that keep them safe<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">Everything above runs offline and free. This layer does not, so it gets the smallest test count and the strictest guard rails in your entire repository.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What live tests are for<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Exactly three questions, none of which can be answered by a mock:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Are these credentials valid right now.<\/li>\n\n\n\n<li>Is this sender ID active and bound correctly.<\/li>\n\n\n\n<li>Does this template ID resolve against this message body.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Question three has a dedicated read only endpoint and should never be answered by sending. <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/validateTemplate<\/code>, documented at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/validateTemplate\/\">validate template<\/a>, takes <code>senderid<\/code>, <code>msg<\/code> and <code>dltTemplateId<\/code> and returns a mismatch as <code>{\"status\":\"error\",\"statusCode\":\"188\",\"reason\":\"message template mismatch\"}<\/code>. It costs nothing, it sends nothing, and it answers the question that causes the most production failures for Indian traffic.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The kill switch, the allowlist and the budget<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Three rails, all of which should exist before your first live test runs.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import os\nimport pytest\n\nALLOWED_TEST_RECIPIENTS = {\n    n.strip() for n in os.environ.get(\"SMS_TEST_RECIPIENTS\", \"\").split(\",\") if n.strip()\n}\n\n\ndef guard_recipient(mobile: str) -&gt; None:\n    if mobile not in ALLOWED_TEST_RECIPIENTS:\n        raise RuntimeError(\n            f\"Refusing to send to {mobile}: not in SMS_TEST_RECIPIENTS allowlist\"\n        )\n\n\nlive = pytest.mark.skipif(\n    os.environ.get(\"RUN_LIVE_SMS_TESTS\") != \"yes\",\n    reason=\"live SMS tests are opt in\",\n)\n\n\n@live\ndef test_credentials_and_sender_id_are_valid():\n    mobile = next(iter(ALLOWED_TEST_RECIPIENTS))\n    guard_recipient(mobile)\n    client = SmsClient.from_env()\n    result = client.send(\n        mobile=mobile,\n        msg=\"Smoke test from CI build \" + os.environ.get(\"BUILD_ID\", \"local\"),\n        test_message=True,          # documented: message will not be delivered\n    )\n    assert result.ok\n    assert result.status_code == \"200\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The allowlist is the important one. It is a hard failure, not a warning, and it means a copy pasted production recipient list in a test file cannot reach a real customer. Put the allowlist in an environment variable rather than in code so that a fork of your repository does not inherit a live number.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The opt in marker matters too. Live tests should not run on every pull request. Run them on a release branch, on a nightly schedule, or manually before a deploy. A live test on every commit multiplies your build frequency by your per message cost and produces a bill nobody budgeted for.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Add a budget assertion if your account exposes balance programmatically. Even a crude one, comparing balance before and after the whole live suite and failing the build if the delta exceeds a small threshold, converts &#8220;our tests spent forty thousand rupees&#8221; from a discovery into a build failure.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Load testing pacing without paying for it<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">If you want to verify that your limiter and worker pool behave under volume, <code>testMessage=true<\/code> is the correct instrument. Drive real traffic through the real endpoint at the real shape and observe your own pacing, latency and queue behaviour, without messages reaching handsets.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Verify pacing behaviour against the real endpoint without delivering anything.\ncurl -s -X POST https:\/\/unify.smsgateway.center\/SMSApi\/send \\\n  -H \"Content-Type: application\/x-www-form-urlencoded\" \\\n  --data-urlencode \"userid=$SGC_USER\" \\\n  --data-urlencode \"password=$SGC_PASS\" \\\n  --data-urlencode \"sendMethod=quick\" \\\n  --data-urlencode \"msgType=text\" \\\n  --data-urlencode \"senderid=SGCTST\" \\\n  --data-urlencode \"mobile=919999999999\" \\\n  --data-urlencode \"msg=pacing probe\" \\\n  --data-urlencode \"testMessage=true\" \\\n  --data-urlencode \"output=json\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two caveats, both honest. First, the documentation states only that messages will not be delivered, so confirm the credit behaviour on your own account before running this at volume. Second, a <code>testMessage<\/code> request may not exercise the same downstream path as a real one, so treat the observed latency as indicative rather than authoritative. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-getting-started-guide\/\">SMS API getting started guide<\/a> covers the base request shape if you need the full parameter set.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For OTP flows, note that the generate and verify endpoints at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/generate-otp\/\">generate OTP<\/a> and <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/verify-otp\/\">verify OTP<\/a> are stateful. The generate response carries <code>createTime<\/code>, <code>expiryTime<\/code> and <code>retryAfter<\/code> as millisecond epoch values, which means expiry logic is exactly the kind of time dependent code that needs an injectable clock. Test expiry by feeding your fake clock past <code>expiryTime<\/code> rather than by sleeping for five minutes in CI.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 id=\"wiring-template-validation-into-ci\" class=\"wp-block-heading\">Wiring template validation into CI<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">For Indian traffic, the highest value CI job in a messaging repository is not a unit test run. It is a validation gate that refuses to merge a template edit that no longer matches its registered DLT template.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The mechanism: whenever a template string changes in your repository, call <code>SMSApi\/validateTemplate<\/code> with the rendered message and the registered <code>dltTemplateId<\/code>. A <code>statusCode<\/code> of <code>188<\/code> means mismatch and the build fails with a precise message. A <code>200<\/code> means you are safe to merge.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#!\/usr\/bin\/env python3\n\"\"\"ci\/validate_templates.py  -  fail the build on a DLT template mismatch.\"\"\"\nimport os\nimport sys\nimport httpx\nfrom app.templates import REGISTRY          # {key: (dlt_template_id, template_text)}\nfrom app.render import render\n\nVALIDATE_URL = \"https:\/\/unify.smsgateway.center\/SMSApi\/validateTemplate\"\nSENDER = os.environ&#91;\"SGC_SENDER_ID\"]\n\n\ndef sample_values(template: str) -&gt; list&#91;str]:\n    \"\"\"Deterministic filler so CI results are reproducible.\"\"\"\n    return &#91;\"SAMPLE\"] * template.count(\"{#var#}\")\n\n\ndef main() -&gt; int:\n    failures = &#91;]\n    with httpx.Client(timeout=20.0) as http:\n        for key, (dlt_id, text) in REGISTRY.items():\n            body = render(text, sample_values(text))\n            r = http.post(VALIDATE_URL, data={\n                \"userid\": os.environ&#91;\"SGC_USER\"],\n                \"password\": os.environ&#91;\"SGC_PASS\"],\n                \"senderid\": SENDER,\n                \"msg\": body,\n                \"dltTemplateId\": dlt_id,\n                \"output\": \"json\",\n            })\n            payload = r.json()\n            if str(payload.get(\"statusCode\")) != \"200\":\n                failures.append(\n                    f\"{key}: statusCode={payload.get('statusCode')} \"\n                    f\"reason={payload.get('reason')}\"\n                )\n    for f in failures:\n        print(f\"TEMPLATE VALIDATION FAILED  {f}\", file=sys.stderr)\n    return 1 if failures else 0\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Wire it as a required check:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># .github\/workflows\/messaging.yml\nname: messaging\non: &#91;pull_request]\n\njobs:\n  unit:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\/checkout@v4\n      - run: pip install -r requirements-dev.txt\n      - run: pytest -q                      # layers 1 and 2, offline, no secrets\n\n  templates:\n    runs-on: ubuntu-latest\n    if: github.event.pull_request.head.repo.full_name == github.repository\n    steps:\n      - uses: actions\/checkout@v4\n      - run: pip install -r requirements-dev.txt\n      - run: python ci\/validate_templates.py\n        env:\n          SGC_USER: ${{ secrets.SGC_USER }}\n          SGC_PASS: ${{ secrets.SGC_PASS }}\n          SGC_SENDER_ID: ${{ secrets.SGC_SENDER_ID }}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>if<\/code> condition on the second job matters. Fork pull requests must not receive your credentials, and without that guard a fork can exfiltrate them. Note also that the unit job needs no secrets at all, which is a property worth preserving: if your layer one and layer two tests require credentials, you have mocked at the wrong level.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two additional scheduled jobs are worth adding:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Nightly code list diff.<\/strong> Fetch <code>SMSApi\/info\/responsecodes<\/code> and <code>SMSApi\/info\/deliverycodes<\/code> and diff against the codes your application classifies. New codes become a triage ticket rather than an outage.<\/li>\n\n\n\n<li><strong>Nightly fixture drift check.<\/strong> Replay your committed fixtures through your parser and, separately, make one real read only call whose shape you assert. If the two diverge, your fixtures have rotted.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The registration side of DLT, including entity and header setup and the mechanics of getting a template approved in the first place, is covered in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/dlt-registration-step-by-step-guide-india\/\">DLT registration step by step guide<\/a>, and the regulation itself sits under <a href=\"https:\/\/trai.gov.in\/tcccpr\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">TRAI&#8217;s TCCCPR framework<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 id=\"what-not-to-test\" class=\"wp-block-heading\">What not to test<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">Test suites die of bloat as often as of neglect. Some things belong in your suite and some do not.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do not test that the provider delivers.<\/strong> You cannot control operator routing, handset state or carrier scrubbing. A test asserting that a message arrives is a test that fails for reasons unrelated to your code, and a test that fails for unrelated reasons gets ignored and then deleted.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do not assert an exact throughput number the provider has not published.<\/strong> You can assert your own configured policy. Naming it <code>test_we_stay_under_our_configured_rate<\/code> rather than <code>test_we_respect_the_provider_limit<\/code> is not pedantry, it is the difference between a test that stays true and one that quietly becomes a lie.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do not snapshot entire response bodies.<\/strong> Snapshot tests over third party payloads fail on every irrelevant field addition and train your team to accept new snapshots without reading them. Assert the fields you actually consume.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do not test that your ORM saves a row.<\/strong> That is a framework test, not a messaging test.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do not test the provider&#8217;s retry behaviour.<\/strong> Operator level SMSC retransmission happens outside your process and cannot be prevented or observed from your code. Design around it, do not assert on it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do not build a full fake of the provider&#8217;s API.<\/strong> A hand written fake server drifts, and the drift is invisible because your tests pass against your own fiction. Fixtures captured from real responses plus a nightly contract check give you more truth for less maintenance.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 id=\"decision-matrix\" class=\"wp-block-heading\">Decision matrix: which layer catches which bug<\/h1>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Bug<\/th><th>Layer that catches it<\/th><th>Test to write<\/th><th>Cost of missing it<\/th><\/tr><\/thead><tbody><tr><td>Curly apostrophe flips the message to Unicode<\/td><td>1, pure function<\/td><td>Segment count parametrised cases<\/td><td>Double or triple billing on every campaign<\/td><\/tr><tr><td>Substituted value corrupts the next variable slot<\/td><td>1, pure function<\/td><td>Rendering with a token inside a value<\/td><td>Status code 188 and undelivered traffic<\/td><\/tr><tr><td>Two spellings of one number defeat deduplication<\/td><td>1, pure function<\/td><td>Normalisation idempotence<\/td><td>Duplicate sends and duplicate charges<\/td><\/tr><tr><td>Posting JSON to a form encoded endpoint<\/td><td>2, transport mock<\/td><td>Assert the content type header<\/td><td>Confusing auth errors, hours lost<\/td><\/tr><tr><td>Missing <code>output=json<\/code> so a plain text body is returned<\/td><td>2, transport mock<\/td><td>Assert the request body fields<\/td><td>Parser crash in production only<\/td><\/tr><tr><td>Credentials in the query string<\/td><td>2, transport mock<\/td><td>Assert the URL carries no password<\/td><td>Credential leak via access logs<\/td><\/tr><tr><td>Read timeout retried as if it were safe<\/td><td>2, transport mock<\/td><td>Force <code>ReadTimeout<\/code>, assert <code>unknown<\/code><\/td><td>Duplicate customer messages and charges<\/td><\/tr><tr><td><code>unknown<\/code> drifting back to <code>pending<\/code><\/td><td>2, state machine<\/td><td>Illegal transition test<\/td><td>Silent duplicate sends on every sweep<\/td><\/tr><tr><td>Quoted versus unquoted <code>statusCode<\/code> across channels<\/td><td>3, contract<\/td><td>Normalisation tests for both shapes<\/td><td>One channel appears to fail entirely<\/td><\/tr><tr><td><code>invalidMobile<\/code> populated while <code>status<\/code> reads success<\/td><td>3, contract<\/td><td>Partial rejection test<\/td><td>Silently dropped recipients, green dashboard<\/td><\/tr><tr><td>A new provider error code with no handler<\/td><td>3, contract<\/td><td>Nightly code list diff<\/td><td>Infinite retry loop if defaulted to transient<\/td><\/tr><tr><td>Limiter counting requests instead of recipient segments<\/td><td>1 and 2<\/td><td>Cost unit test on the bucket<\/td><td>Flat graphs while real volume grows 500x<\/td><\/tr><tr><td>Backoff schedule uncapped or unjittered<\/td><td>1, injected clock<\/td><td>Schedule assertion with a seeded RNG<\/td><td>Synchronised retry storms<\/td><\/tr><tr><td>Webhook receiver silently eating form encoded bodies<\/td><td>2, receiver test<\/td><td>Post a form encoded body<\/td><td>Delivery reports vanish, no alarm<\/td><\/tr><tr><td>Late intermediate DLR overwriting a terminal state<\/td><td>2, receiver test<\/td><td>Out of order report test<\/td><td>Wrong delivery reporting to customers<\/td><\/tr><tr><td>Idempotency guard that fails under concurrency<\/td><td>2, with a real store<\/td><td>Two threads on one business key<\/td><td>Duplicate sends under load only<\/td><\/tr><tr><td>Template edited but not revalidated<\/td><td>CI gate<\/td><td><code>validateTemplate<\/code> in the pipeline<\/td><td>188 in production, campaign dead on arrival<\/td><\/tr><tr><td>Expired credentials or a deactivated sender ID<\/td><td>4, live smoke<\/td><td>One gated real call<\/td><td>Everything fails at deploy time<\/td><\/tr><tr><td>SMPP bind level window saturation<\/td><td>Out of scope here<\/td><td>See the SMPP throughput guide<\/td><td>Throttling errors under load<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 id=\"how-to-build-a-messaging-test-suite\" class=\"wp-block-heading\">How to build a messaging test suite from nothing<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">Ten steps, in order. Each one is shippable on its own, so you can stop after any of them and be better off than before.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Capture real fixtures.<\/strong> Run each endpoint once from the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/download-sms-postman\/\">Postman collection<\/a> and commit the actual response bodies. Do not type them from memory.<\/li>\n\n\n\n<li><strong>Extract the pure functions.<\/strong> Pull segment counting, template rendering and phone normalisation out of any class that performs I\/O. If they are entangled with HTTP, untangle them first.<\/li>\n\n\n\n<li><strong>Write the pure function tests.<\/strong> Aim for the traps, not for coverage percentage: extension table characters, curly apostrophes, emoji, tokens inside values, dollar signs, arity mismatches.<\/li>\n\n\n\n<li><strong>Turn off the network in tests.<\/strong> Add <code>nock.disableNetConnect()<\/code> or the equivalent so an accidental real call is a loud failure rather than a silent charge.<\/li>\n\n\n\n<li><strong>Move your mocks down to the transport.<\/strong> Delete every test that patches your own client class and rewrite it against <code>respx<\/code>, <code>nock<\/code> or Guzzle <code>MockHandler<\/code>, asserting on the outgoing request.<\/li>\n\n\n\n<li><strong>Write the ambiguous timeout test.<\/strong> Force a read timeout, assert the record lands in <code>unknown<\/code>, assert exactly one attempt, and assert that <code>unknown<\/code> cannot transition back to <code>pending<\/code>.<\/li>\n\n\n\n<li><strong>Inject the clock.<\/strong> Refactor your limiter and backoff to take a clock and a sleeper, then assert the schedule instead of sleeping through it.<\/li>\n\n\n\n<li><strong>Add contract tests.<\/strong> Normalisation tests for both channel shapes, plus a nightly diff against the two documented error code endpoints.<\/li>\n\n\n\n<li><strong>Gate CI on template validation.<\/strong> Run <code>validateTemplate<\/code> for every template in your registry on every pull request, with fork protection on the secrets.<\/li>\n\n\n\n<li><strong>Add three live smoke tests, behind an allowlist and an opt in flag.<\/strong> Credentials, sender ID and template binding. Nothing else. Run them before release, not on every commit.<\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 id=\"pre-merge-checklist\" class=\"wp-block-heading\">Pre-merge checklist<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\">Twenty six items. Print it, argue about it, adapt it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Safety rails<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] The test process cannot make an unmocked outbound HTTP call.<\/li>\n\n\n\n<li>[ ] Live tests are behind an explicit opt in environment variable.<\/li>\n\n\n\n<li>[ ] A recipient allowlist raises a hard error, not a warning.<\/li>\n\n\n\n<li>[ ] CI secrets are unavailable to pull requests from forks.<\/li>\n\n\n\n<li>[ ] No real customer phone number exists anywhere in the repository or fixtures.<\/li>\n\n\n\n<li>[ ] The layer one and layer two suites need zero credentials to run.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Pure functions<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] Segment counting is tested at every boundary: 160, 161, 306, 307, 70, 71.<\/li>\n\n\n\n<li>[ ] The GSM-7 extension table two septet characters are covered.<\/li>\n\n\n\n<li>[ ] A curly apostrophe is proven to flip the encoding to Unicode.<\/li>\n\n\n\n<li>[ ] Astral plane characters are proven to count as two UCS-2 units.<\/li>\n\n\n\n<li>[ ] Template rendering is proven safe when a value contains the placeholder token.<\/li>\n\n\n\n<li>[ ] Template rendering is proven safe when a value contains a dollar sign.<\/li>\n\n\n\n<li>[ ] Arity mismatch raises before any network call is attempted.<\/li>\n\n\n\n<li>[ ] Phone normalisation is proven idempotent.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Transport layer<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] The content type is asserted as form encoded.<\/li>\n\n\n\n<li>[ ] <code>sendMethod<\/code>, <code>msgType<\/code>, <code>senderid<\/code> and <code>output<\/code> are asserted in the request body.<\/li>\n\n\n\n<li>[ ] Credentials are proven absent from the request URL.<\/li>\n\n\n\n<li>[ ] Every mock interceptor is asserted to have been consumed.<\/li>\n\n\n\n<li>[ ] A read timeout produces <code>unknown<\/code> and exactly one attempt.<\/li>\n\n\n\n<li>[ ] A connect timeout produces <code>pending<\/code> and is retryable.<\/li>\n\n\n\n<li>[ ] The transition from <code>unknown<\/code> to <code>pending<\/code> is proven impossible.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Contract<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] Both the quoted and unquoted <code>statusCode<\/code> shapes parse correctly.<\/li>\n\n\n\n<li>[ ] Both <code>transactionId<\/code> and <code>messageId<\/code> resolve through one normaliser.<\/li>\n\n\n\n<li>[ ] A populated <code>invalidMobile<\/code> is not reported as unqualified success.<\/li>\n\n\n\n<li>[ ] Unknown error codes default to permanent, never to transient.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Receiver and concurrency<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] The webhook receiver is tested with a form encoded body.<\/li>\n\n\n\n<li>[ ] The raw payload is persisted before parsing is attempted.<\/li>\n\n\n\n<li>[ ] Duplicate and out of order delivery reports are proven idempotent.<\/li>\n\n\n\n<li>[ ] The idempotency test runs two concurrent workers against a real store.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 id=\"ten-mistakes\" class=\"wp-block-heading\">Ten mistakes that make messaging test suites useless<\/h1>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Mocking your own client class.<\/strong> The test then asserts that your code called your code. Every request construction bug walks straight past it.<\/li>\n\n\n\n<li><strong>Only testing the happy path.<\/strong> Success is the case that already works. The value is in timeouts, partial rejections and template mismatches.<\/li>\n\n\n\n<li><strong>Letting tests hit the network.<\/strong> Without a global net connect block, one forgotten mock becomes a real send and a real charge, and CI is exactly where nobody is watching.<\/li>\n\n\n\n<li><strong>Sleeping in tests.<\/strong> Real sleeps make suites slow, slow suites get skipped, and skipped suites protect nothing. Inject the clock and the sleeper.<\/li>\n\n\n\n<li><strong>Unseeded jitter.<\/strong> A randomised backoff test without a seeded RNG is flaky, and flaky tests get deleted rather than fixed.<\/li>\n\n\n\n<li><strong>Snapshotting whole third party payloads.<\/strong> Every irrelevant field addition breaks the build, and the team learns to approve snapshot updates without reading them.<\/li>\n\n\n\n<li><strong>Treating a populated <code>invalidMobile<\/code> as success.<\/strong> Recipients are dropped, dashboards stay green, and the discrepancy surfaces weeks later as a support ticket.<\/li>\n\n\n\n<li><strong>Defaulting unknown error codes to transient.<\/strong> That is an infinite retry loop that bills on every attempt. Default to permanent and triage.<\/li>\n\n\n\n<li><strong>Testing idempotency against an in memory fake.<\/strong> The guarantee you rely on lives in the unique index or the atomic set. A fake will grant you a guarantee the real store does not.<\/li>\n\n\n\n<li><strong>Running live tests on every commit.<\/strong> Your build frequency multiplied by your per message rate is a real number, and it is larger than anyone expects.<\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 id=\"faqs\" class=\"wp-block-heading\">FAQs<\/h1>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does <code>testMessage=true<\/code> cost credits?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The documentation states only that &#8220;Enable true to test your message and messages wont be delivered when enabled true.&#8221; It does not state anything about credit consumption. Verify it once on your own account by noting your wallet balance, sending one message with the flag set, and checking the balance again. Record the answer in your repository so nobody re-derives it. Do not assume either way, and be especially careful before running a volume test.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is there a sandbox environment?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, at the account level. The demo page states that &#8220;The demo environment is designed for testing. Some features use a Sandbox mode, where delivery is simulated for security. For live SMS delivery, you need to upgrade to a paid account&#8221;, and that you sign up for a Customer or Reseller account and then activate the SANDBOX after logging in. What the page does not enumerate is which features are simulated or whether sandbox responses are byte identical to live ones, so use the sandbox for wiring checks and keep your behavioural assertions in mocked tests.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should I use the sandbox or <code>testMessage=true<\/code> for CI?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Use neither for the bulk of CI. Use mocks. If you want one gated real call in a release pipeline, <code>testMessage=true<\/code> against your normal account is simpler because it needs no second credential set, while the sandbox is better if you want a hard separation between test and production credentials. Many teams end up with both: sandbox credentials for a nightly job and the flag for a pre release smoke check.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I test that I do not send duplicates?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Run two workers concurrently against the same business key with a real Postgres or Redis behind them, and assert the mock interceptor was called exactly once. Then separately assert that a read timeout leaves the record in <code>unknown<\/code> and that <code>unknown<\/code> cannot transition back to <code>pending<\/code>. Those two tests together cover the overwhelming majority of duplicate send incidents. Note that <code>duplicatecheck<\/code> is not the mechanism here: it removes repeated numbers within a single request and says nothing about two separate requests.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What is the difference between a connect timeout and a read timeout for testing purposes?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A connect timeout means nothing was submitted, so the message is safely retryable and stays <code>pending<\/code>. A read timeout means the request left your process and you never heard back, so the message may or may not have been submitted and must go to <code>unknown<\/code> for reconciliation. Write one test for each. If your HTTP library collapses both into a single exception type, as <code>requests<\/code> does, then every timeout must be treated as ambiguous, and your test should assert that conservative behaviour explicitly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I test a rate limiter without waiting in real time?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Pass the clock in as a dependency. A fake clock with an <code>advance<\/code> method lets you test refill rates, burst capacity, idle behaviour and retry after values in microseconds. The same applies to backoff: inject a sleeper that records durations instead of sleeping, then assert the schedule shape and the cap.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I test the webhook receiver without a public URL?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, for everything that matters. Post directly to your route with a test client and assert on status codes, persistence and idempotency. A public URL is only needed for the one end to end wiring check that the provider can reach you, which is a deployment verification rather than a test. Do that once per environment with a tunnel or a staging deployment.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What should my webhook fixtures contain, given the payload schema is not published?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Capture real payloads from your own account by pointing a webhook at a request logging endpoint you control, then commit those observed bodies as fixtures and label them clearly as observed rather than documented. Do not invent field names. If your parser reads the raw body first and treats missing fields as absent rather than fatal, an unexpected schema becomes a backlog you can reprocess rather than data you lost.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How many live tests should I have?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One to three. Credentials valid, sender ID active, template binding resolves. Template binding is better answered by the read only <code>validateTemplate<\/code> endpoint than by sending anything at all, so in practice many teams end up with a single live send in the whole suite.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I stop a test from reaching a real customer?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">An environment driven recipient allowlist that raises a hard error on any number not in it, plus a global block on unmocked outbound connections in the test process. Those two rails together make an accidental real send fail loudly instead of arriving on a stranger&#8217;s phone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should I use contract testing tools like Pact?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Pact is designed for services where you control both sides and can run a provider verification step. You cannot run provider verification against a third party API, so the consumer driven contract model does not fully apply. The practical substitute is what this article describes: fixtures captured from real responses, a normaliser with tests for every documented shape, and a scheduled job that diffs the live error code endpoints against the codes you handle.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I test OTP expiry without waiting five minutes?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Inject the clock. The generate response carries <code>createTime<\/code>, <code>expiryTime<\/code> and <code>retryAfter<\/code> as millisecond epoch values, so your expiry check should compare against an injected clock rather than calling the system time directly. Then advance the fake clock past <code>expiryTime<\/code> and assert the verification is refused. Also test the boundary at exactly <code>expiryTime<\/code>, because off by one on an inclusive comparison is a real and common bug.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do I need different tests for WhatsApp and SMS?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You need one normaliser with tests for both response shapes, because the field names and types differ: <code>messageId<\/code> versus <code>transactionId<\/code>, unquoted versus quoted <code>statusCode<\/code>, <code>description<\/code> versus <code>reason<\/code>. Beyond parsing, WhatsApp adds session window state that SMS does not have, so if you implement any session aware logic it needs its own tests with an injected clock. The channel selection reasoning is covered in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-vs-whatsapp-business-api-when-to-use-each\/\">SMS API versus WhatsApp Business API comparison<\/a>, and the endpoint details are at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/send-whatsapp-business-message\/\">send WhatsApp Business message<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What is the single test to write if I only have time for one?<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Force a read timeout in your mock and assert that the message record ends in <code>unknown<\/code> with exactly one attempt and is never retried. That one test guards the failure mode that produces duplicate customer messages, duplicate charges and the support tickets that follow.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Build against the real API, safely.<\/strong> Grab the Postman collection to capture accurate fixtures, use <code>testMessage=true<\/code> for smoke checks that do not deliver, and gate your pipeline on <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/validateTemplate\/\">validateTemplate<\/a> so a template edit can never reach production broken. If you do not have credentials yet, start with a <a href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">free demo account<\/a> and activate the sandbox.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"excerpt":{"rendered":"<p>A test suite that really sends is a test suite that really bills. Here is how to test SMS and WhatsApp integrations properly: pure-function tests for segment counting and DLT rendering, transport-boundary mocks in Python, Node.js and PHP, contract tests against the documented response shapes, a deterministic clock for rate limiters and backoff, an ambiguous-timeout test that proves your state machine is correct, and a small number of real calls using the documented testMessage=true flag.<\/p>\n","protected":false},"author":118,"featured_media":2702,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[268],"tags":[2126,2121,2124,2119,2127,2118,2122,2130,2129,2128,481,2120,2125,2123,632],"class_list":["post-2701","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-tutorials","tag-ci-cd-messaging","tag-contract-testing","tag-delivery-report-webhook-testing","tag-dlt-template-validation","tag-idempotency-testing","tag-integration-testing","tag-mock-sms-api","tag-nodejs-nock","tag-php-guzzle-mockhandler","tag-python-respx","tag-sms-api","tag-sms-api-testing","tag-testmessage","tag-unit-testing-sms","tag-whatsapp-business-api"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2701","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/users\/118"}],"replies":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/comments?post=2701"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2701\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/2702"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=2701"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=2701"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=2701"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}