Table of Contents
- Executive summary
- TL;DR
- Why messaging code is harder to test than ordinary API code
- What the platform actually gives you for testing
- The four layers, and what each one is actually for
- Layer one: pure functions, tested exhaustively
- Layer two: mock at the transport boundary, never at your own wrapper
- Layer three: contract tests against the documented response shapes
- The single most valuable test you are not writing
- Testing time dependent code with an injectable clock
- Testing the delivery report receiver
- Testing idempotency under concurrency
- Layer four: live smoke tests, and the rails that keep them safe
- Wiring template validation into CI
- What not to test
- Decision matrix: which layer catches which bug
- How to build a messaging test suite from nothing
- Pre-merge checklist
- Ten mistakes that make messaging test suites useless
- FAQs
Executive summary
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 testMessage=true, which the Send SMS documentation describes verbatim as “Enable true to test your message and messages wont be delivered when enabled true.”

The reason to be this disciplined is financial rather than aesthetic. In this platform’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.
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 unknown state and never returns to pending, and you have tested the failure mode that causes duplicate customer messages, duplicate charges and support tickets. Almost nobody writes that test.
TL;DR
- Billing fires at submission. Tests that send are tests that cost money. Structure the suite so the sending layer is tiny.
testMessage=trueonhttps://unify.smsgateway.center/SMSApi/sendis 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.- There is also an account level Sandbox mode. The demo page states that “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.”
- Mock at the transport boundary:
respxfor httpx andresponsesfor requests in Python,nockin Node.js, GuzzleMockHandlerplusMiddleware::historyin PHP. Mocking your ownSmsClientclass tests nothing about the request you actually send. - Contract test the documented shapes.
SMSApi/sendreturnsstatusCodeas a quoted string.WAApi/sendreturns it unquoted, and calls the identifiermessageIdrather thantransactionId. A partial rejection can come back withstatusstill readingsuccessand the rejected numbers listed ininvalidMobile. - Inject the clock. A token bucket and a full jitter backoff schedule are untestable with
time.time()baked in, and become trivially testable with a clock parameter. - Test the ambiguous timeout path explicitly. Connect timeout means nothing was submitted. Read timeout means you do not know.
- Test the DLR receiver with a form encoded body, not a JSON body, and assert you persist the raw payload before parsing it.
- Gate CI on
SMSApi/validateTemplateso a template edit cannot merge and then fail in production with status code 188.
Why messaging code is harder to test than ordinary API code
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.
Every call costs money at submission. The pricing pages 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 what actually drives bulk SMS cost, and the consequence for testing is direct: your test suite has a budget line.
The side effect is irreversible and lands on a human. You cannot roll back an SMS. A test that accidentally targets a real customer number does not fail quietly, it arrives on someone’s phone at three in the morning. Databases have transactions and rollbacks. Handsets do not.
The unit of cost is not the unit of the call. One request to SMSApi/send 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 text message chart documents the mapping: English is 160 characters per segment and 153 when concatenated, Unicode is 70 and 67.
Failures are asymmetric across message classes. 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.
Half your logic is not yours. 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.
Generic API testing guides such as Redocly’s contract testing primer are correct as far as they go, and prior art like thoughtbot’s post on testing SMS interactions 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.
What the platform actually gives you for testing
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.
The testMessage parameter
The optional parameters table on the Send SMS endpoint documentation lists:
| Parameter | Values | Documented description |
|---|---|---|
testMessage | true or false | “Enable true to test your message and messages wont be delivered when enabled true.” |
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:
- Note your wallet balance in the dashboard.
- Send exactly one message with
testMessage=trueto a number you control. - Note the balance again.
- Poll
SMSApi/reports/statusfor the same window and see whether a record appears.
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.
Sandbox mode on the demo environment
The free demo page documents an account level sandbox. Verbatim from that page:
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.
and, on activation:
Sign up for a Customer account, then log in and activate the SANDBOX.
Note the careful wording of “some features”. 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 transactionId, find out and record it rather than encoding a guess in an assertion.
The Postman collection as a contract source
The SMS API Postman collection 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’s behaviour. There is also an interactive API documentation index that links every endpoint.
The two error code list endpoints
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.
POST https://unify.smsgateway.center/SMSApi/info/responsecodesreturns aresponsecodesListarray of{errorcode, httpCode, status, description}. Documented at API response error code list.POST https://unify.smsgateway.center/SMSApi/info/deliverycodesreturns adeliverycodesListarray of{peId, identifier, status, cause}. Documented at delivery error code list.
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.
What is not documented, and therefore not testable by assertion
Be honest about the gaps rather than papering over them with plausible looking test fixtures:
- 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.
- No numeric throughput ceiling for the HTTP API is published, so a test asserting “we stay under N requests per second” is asserting your own policy, not the provider’s limit. That is still a worthwhile test, just name it accurately.
- 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.
The four layers, and what each one is actually for
Most teams have exactly two kinds of messaging test: a unit test that mocks their own sender class and proves nothing, and a manual “send one to my phone and see” ritual that proves one thing once. The useful structure has four layers, and the count of tests should fall sharply as you go up.

| Layer | What it tests | Network | Cost | Typical count | Runs where |
|---|---|---|---|---|---|
| 1. Pure functions | Segment counting, template rendering, phone normalisation, cost estimation, key derivation | None | Free | 100 plus | Every commit, milliseconds |
| 2. Transport boundary mocks | Request construction, response parsing, error classification, retry decisions, state transitions | Intercepted | Free | 30 to 80 | Every commit, under 10 seconds |
| 3. Contract tests | That the documented shapes still hold and your fixtures have not drifted | Real, read only | Free | 5 to 15 | Nightly, and on release |
| 4. Live smoke tests | End to end wiring, credentials, sender ID, template binding | Real, write | Charged or testMessage=true | 1 to 3 | Pre release only, gated |
Two rules make this structure work.
Layer three exists because layer two rots. The most dangerous object in any integration test suite is a mock that was accurate eighteen months ago. Your mocked response says statusCode 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.
Layer four is a wiring check, not a behaviour check. 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.
Layer one: pure functions, tested exhaustively
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.
Segment counting
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.
# segments.py - no I/O, therefore exhaustively testable
GSM7_BASIC = set(
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?"
"¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà"
)
GSM7_EXTENDED = set("^{}\\[~]|€") # each costs two septets
def is_gsm7(text: str) -> bool:
return all(ch in GSM7_BASIC or ch in GSM7_EXTENDED for ch in text)
def septet_length(text: str) -> int:
return sum(2 if ch in GSM7_EXTENDED else 1 for ch in text)
def count_segments(text: str) -> tuple[str, int, int]:
"""Return (encoding, units, segments)."""
if is_gsm7(text):
units = septet_length(text)
if units <= 160:
return ("TEXT", units, 1)
return ("TEXT", units, -(-units // 153))
# Unicode branch: UCS-2 code units, so astral characters count as two.
units = len(text.encode("utf-16-be")) // 2
if units <= 70:
return ("UNICODE", units, 1)
return ("UNICODE", units, -(-units // 67))
The tests write themselves, and every one of them is a real bug someone has shipped:
import pytest
from segments import count_segments
@pytest.mark.parametrize(
"text, encoding, segments",
[
("A" * 160, "TEXT", 1),
("A" * 161, "TEXT", 2),
("A" * 306, "TEXT", 2),
("A" * 307, "TEXT", 3),
# The extension table trap: 80 euro signs are 160 septets, still one segment.
("€" * 80, "TEXT", 1),
("€" * 81, "TEXT", 2),
# The autocorrect trap: a curly apostrophe is not GSM-7 and flips the whole
# message to Unicode, cutting capacity from 160 to 70.
("It" + "’" + "s here", "UNICODE", 1),
("A" * 70, "TEXT", 1),
("你" * 70, "UNICODE", 1),
("你" * 71, "UNICODE", 2),
],
)
def test_segment_counting(text, encoding, segments):
enc, _units, segs = count_segments(text)
assert enc == encoding
assert segs == segments
def test_emoji_counts_as_two_ucs2_units():
# A single astral plane emoji is two UCS-2 units, so 35 of them fill one segment.
_enc, units, segs = count_segments("\U0001F600" * 35)
assert units == 70
assert segs == 1
_enc, _units, segs = count_segments("\U0001F600" * 36)
assert segs == 2
Cross check your implementation once against the platform’s own calculation using POST https://unify.smsgateway.center/SMSApi/info/msg, documented at get SMS length and cost. That endpoint returns {"encoding": "TEXT", "length": 11, "remaining": 149, "credit": 1} inside a msgList 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.
DLT variable rendering
For Indian traffic, the message body you send must match an approved template character for character, with only the {#var#} positions substituted. The DLT template rejection guide 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.
Render by position, never by search and replace. Every naive approach is unsafe in a way that only shows up with real customer data:
- Chained
str.replaceorString.replacereplaces the first occurrence repeatedly, so if a substituted value itself contains the literal{#var#}you corrupt the remaining slots. - Regex substitution in JavaScript expands
$&and$1inside 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. - Any approach that walks the string more than once can substitute into text it just inserted.
The safe implementation finds all placeholder positions first, then rebuilds the string in one pass:
import re
TOKEN = "{#var#}"
_TOKEN_RE = re.compile(re.escape(TOKEN))
class TemplateArityError(ValueError):
pass
def render(template: str, values: list[str]) -> str:
spans = [m.span() for m in _TOKEN_RE.finditer(template)]
if len(spans) != len(values):
raise TemplateArityError(
f"template has {len(spans)} variables, got {len(values)} values"
)
out, cursor = [], 0
for (start, end), value in zip(spans, values):
out.append(template[cursor:start])
out.append(value)
cursor = end
out.append(template[cursor:])
return "".join(out)
And the tests that matter:
def test_value_containing_a_token_is_not_re_substituted():
tpl = "Hi {#var#}, your code is {#var#}."
out = render(tpl, ["{#var#}", "4821"])
assert out == "Hi {#var#}, your code is 4821."
def test_dollar_sign_in_value_is_literal():
tpl = "Order {#var#} confirmed."
assert render(tpl, ["A$&B"]) == "Order A$&B confirmed."
def test_arity_mismatch_raises_before_any_network_call():
with pytest.raises(TemplateArityError):
render("Hi {#var#} and {#var#}", ["only-one"])
def test_static_text_is_byte_identical():
tpl = "Dear {#var#}, your OTP is {#var#}. Valid 10 min."
out = render(tpl, ["Asha", "918273"])
assert out.replace("Asha", "{#var#}").replace("918273", "{#var#}") == tpl
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.
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 $, {, }, #, 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.
Phone number normalisation
Third pure function, quickly. Recipients arrive from CRMs in every shape imaginable: +91 98765 43210, 098765-43210, 9876543210, 91 98765 43210. 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 message idempotency guide, and two spellings of the same number defeat deduplication entirely. Test that normalisation is idempotent: normalise(normalise(x)) == normalise(x). That one assertion catches an entire class of bug.
Layer two: mock at the transport boundary, never at your own wrapper
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.
# WRONG. This test passes forever and proves nothing.
def test_order_confirmation_sends_sms(mocker):
fake_client = mocker.patch("app.notifications.sms_client")
send_order_confirmation(order_id=42)
fake_client.send.assert_called_once()
That test asserts that your code called your own method. It will keep passing if you send the wrong senderid, omit dltTemplateId, post JSON to a form encoded endpoint, forget output=json, 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.
Mock one level lower, at the HTTP transport. Then the assertion is about the bytes that would actually have left your process.
Python: respx for httpx
import httpx, respx, pytest
from app.sms import SmsClient # your real client, unmocked
SEND_URL = "https://unify.smsgateway.center/SMSApi/send"
@respx.mock
def test_send_posts_form_encoded_fields_in_the_documented_shape():
route = respx.post(SEND_URL).mock(
return_value=httpx.Response(
200,
json={
"status": "success",
"mobile": "919999999999",
"invalidMobile": "",
"transactionId": "6305583318236810379",
"statusCode": "200",
"reason": "success",
},
)
)
client = SmsClient(userid="u", password="p", senderid="SGCTST")
result = client.send(mobile="919999999999", msg="Hello World")
assert route.called
request = route.calls.last.request
# The endpoint is form encoded. Posting JSON here presents as an auth error,
# which is the single most confusing failure mode in Python integrations.
assert request.headers["content-type"].startswith(
"application/x-www-form-urlencoded"
)
body = dict(httpx.QueryParams(request.content.decode()))
assert body["sendMethod"] == "quick"
assert body["msgType"] == "text"
assert body["output"] == "json"
assert body["senderid"] == "SGCTST"
assert body["mobile"] == "919999999999"
assert "password" not in request.url.query.decode() # never in the query string
assert result.transaction_id == "6305583318236810379"
Four assertions there catch four separate real incidents: wrong content type, missing sendMethod, missing output 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.
The requests equivalent uses the responses library with the same structure. Whichever you choose, the Python integration tutorial covers the client construction this test exercises, including the trap that requests has no default timeout while httpx defaults to five seconds.
Node.js: nock
import nock from 'nock';
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import { SmsClient } from '../src/sms.js';
const HOST = 'https://unify.smsgateway.center';
test('send posts the documented form fields and parses the response', async () => {
let captured;
const scope = nock(HOST)
.post('/SMSApi/send', (body) => {
captured = body;
return true;
})
.matchHeader('content-type', /application\/x-www-form-urlencoded/)
.reply(200, {
status: 'success',
mobile: '919999999999',
invalidMobile: '',
transactionId: '6305583318236810379',
statusCode: '200',
reason: 'success',
});
const client = new SmsClient({ userid: 'u', password: 'p', senderid: 'SGCTST' });
const res = await client.send({ mobile: '919999999999', msg: 'Hello World' });
assert.equal(captured.sendMethod, 'quick');
assert.equal(captured.output, 'json');
assert.equal(res.transactionId, '6305583318236810379');
scope.done(); // fails the test if the interceptor was never consumed
});
scope.done() 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.
Add nock.disableNetConnect() in a global test setup file. That single line converts “my CI accidentally sent real messages” from a possibility into an immediate, loud test failure. It is the cheapest safety rail in this entire article.
// test/setup.js
import nock from 'nock';
nock.disableNetConnect();
nock.enableNetConnect('127.0.0.1'); // allow your own test server
The Node.js integration tutorial covers the client this exercises, including why fetch in Node has no default timeout and why AbortSignal.timeout is mandatory.
PHP: Guzzle MockHandler with a history middleware
<?php
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
final class SmsClientTest extends TestCase
{
public function testSendPostsDocumentedFormFields(): void
{
$mock = new MockHandler([
new Response(200, ['Content-Type' => 'application/json'], json_encode([
'status' => 'success',
'mobile' => '919999999999',
'invalidMobile' => '',
'transactionId' => '6305583318236810379',
'statusCode' => '200',
'reason' => 'success',
])),
]);
$history = [];
$stack = HandlerStack::create($mock);
$stack->push(Middleware::history($history));
$http = new Client(['handler' => $stack, 'http_errors' => false]);
$client = new SmsClient($http, 'u', 'p', 'SGCTST');
$result = $client->send('919999999999', 'Hello World');
$this->assertCount(1, $history);
$request = $history[0]['request'];
parse_str((string) $request->getBody(), $body);
$this->assertSame('quick', $body['sendMethod']);
$this->assertSame('json', $body['output']);
$this->assertSame('SGCTST', $body['senderid']);
$this->assertSame('6305583318236810379', $result->transactionId);
}
}
Middleware::history 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 PHP integration tutorial covers the client and the Laravel wiring around it.
The rule, stated once
Mock the lowest layer you do not own. You own your SmsClient, 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.
Layer three: contract tests against the documented response shapes
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.
The inconsistencies you must actually handle
These are documented, observable and easy to get wrong. Encode each one as a test.
| Field | On SMSApi/send | On WAApi/send | Consequence if you assume |
|---|---|---|---|
| Success identifier | transactionId | messageId | A shared parser returns null for one channel |
| Numeric status | statusCode quoted as a string, "200" | statusCode unquoted, 200 | A strict equality check against 200 fails on one channel |
| Human readable text | reason | description | Your logs lose the failure text on one channel |
| Partial rejection | invalidMobile populated, status may still read success | Not applicable | You report success while some recipients were dropped |
The correct response is a normalisation function with its own tests, rather than conditionals scattered through your codebase:
from dataclasses import dataclass
@dataclass(frozen=True)
class SendResult:
ok: bool
identifier: str | None
status_code: str
text: str
invalid_recipients: list[str]
def normalise(payload: dict) -> SendResult:
identifier = payload.get("transactionId") or payload.get("messageId")
invalid_raw = payload.get("invalidMobile") or ""
invalid = [n.strip() for n in invalid_raw.split(",") if n.strip()]
return SendResult(
ok=str(payload.get("status", "")).lower() == "success",
identifier=str(identifier) if identifier is not None else None,
# Coerce, because one channel quotes it and the other does not.
status_code=str(payload.get("statusCode", "")),
text=payload.get("reason") or payload.get("description") or "",
invalid_recipients=invalid,
)
def test_sms_success_shape():
r = normalise({
"status": "success", "mobile": "919999999999", "invalidMobile": "",
"transactionId": "6305583318236810379", "statusCode": "200",
"reason": "success",
})
assert r.ok and r.identifier == "6305583318236810379"
assert r.status_code == "200" and r.invalid_recipients == []
def test_whatsapp_success_shape_uses_different_field_names_and_types():
r = normalise({
"status": "success", "messageId": "1234567890", "mobile": "919xxxxxxxxx6",
"statusCode": 200, "description": "Message sent successfully",
})
assert r.ok and r.identifier == "1234567890"
assert r.status_code == "200" # coerced, not compared as an int
assert r.text == "Message sent successfully"
def test_partial_rejection_is_not_treated_as_total_success():
r = normalise({
"status": "success", "mobile": "919999999999,919999999998",
"invalidMobile": "919999999998", "transactionId": "63055833182368",
"statusCode": "200", "reason": "success",
})
assert r.ok is True # the request succeeded
assert r.invalid_recipients == ["919999999998"] # but not for everyone
def test_template_mismatch_is_a_permanent_failure():
r = normalise({
"status": "error", "statusCode": "188",
"reason": "message template mismatch",
})
assert r.ok is False and r.status_code == "188"
That third test is the one worth arguing about in code review. A response with status: success and a populated invalidMobile 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: ok means the request was accepted, and per recipient truth comes only from the delivery report endpoint via mobileNo, msgId and uuId. There is exactly one top level transactionId per request, not one per recipient, so per recipient correlation cannot come from the send response at all.
Using the error code endpoints as a live contract
This is the highest leverage contract test available, and it is read only:
import httpx
def fetch_api_codes(userid: str, password: str) -> set[str]:
r = httpx.post(
"https://unify.smsgateway.center/SMSApi/info/responsecodes",
data={"userid": userid, "password": password, "output": "json"},
timeout=15.0,
)
r.raise_for_status()
body = r.json()
codes = body["response"]["responsecodesList"]
return {str(c["errorcode"]) for c in codes}
def test_we_have_a_handler_for_every_documented_api_error_code(creds):
from app.errors import HANDLED_API_CODES, DEFAULT_HANDLER
live = fetch_api_codes(*creds)
unknown = live - HANDLED_API_CODES
# Not a hard failure. Unknown codes must fall through to a safe default.
assert DEFAULT_HANDLER.classification == "permanent_unknown"
if unknown:
import warnings
warnings.warn(f"New API error codes to triage: {sorted(unknown)}")
Do the same against SMSApi/info/deliverycodes 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 SMS API retry strategy guide so each newly discovered code gets classified as permanent, transient or ambiguous the day it appears.
Design note: assert that unknown codes fall through to a permanent 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.
The single most valuable test you are not writing
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.

There are two timeouts and they mean opposite things.
| Failure | What it means | Safe to retry | Correct state |
|---|---|---|---|
| Connect timeout | The connection was never established, nothing was submitted | Yes | pending, retry freely |
| Read timeout | The request was sent, the response never arrived | No | unknown, reconcile later |
| DNS failure | Nothing was submitted | Yes | pending |
| Connection reset before request body sent | Nothing was submitted | Yes | pending |
| Connection reset after request body sent | Unknown | No | unknown |
| HTTP 5xx with a body | The server processed and failed, usually | Depends on code | Classify by code |
Languages differ in whether they let you tell these apart. httpx gives you ConnectTimeout and ReadTimeout as distinct exception classes. requests collapses both under requests.exceptions.Timeout, which means a requests based client cannot distinguish them and must treat every timeout as ambiguous. In Node, AbortSignal.timeout produces a TimeoutError 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.
The test:
import httpx, respx, pytest
from app.sms import SmsClient
from app.models import MessageState
SEND_URL = "https://unify.smsgateway.center/SMSApi/send"
@respx.mock
def test_read_timeout_goes_to_unknown_and_is_never_retried(db):
respx.post(SEND_URL).mock(side_effect=httpx.ReadTimeout("timed out"))
client = SmsClient(userid="u", password="p", senderid="SGCTST")
record = db.create_message(business_key="order-42-confirmation")
with pytest.raises(AmbiguousSendError):
client.send_tracked(record, mobile="919999999999", msg="Hi")
db.refresh(record)
assert record.state is MessageState.UNKNOWN
assert record.attempts == 1 # exactly one, no retry
assert record.needs_reconciliation is True
@respx.mock
def test_connect_timeout_stays_pending_and_is_retried(db):
respx.post(SEND_URL).mock(side_effect=httpx.ConnectTimeout("no route"))
client = SmsClient(userid="u", password="p", senderid="SGCTST")
record = db.create_message(business_key="order-43-confirmation")
with pytest.raises(TransientSendError):
client.send_tracked(record, mobile="919999999999", msg="Hi")
db.refresh(record)
assert record.state is MessageState.PENDING # safe to pick up again
And the transition that must be impossible:
def test_unknown_can_never_transition_back_to_pending(db):
record = db.create_message(business_key="k")
record.transition_to(MessageState.UNKNOWN)
with pytest.raises(IllegalTransition):
record.transition_to(MessageState.PENDING)
That last test is three lines and it is the guard rail on the most expensive bug in messaging. An unknown record that drifts back to pending 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 message idempotency guide.
Resolution of unknown records comes from reconciliation, not from retrying. Test that too: seed an unknown record, mock SMSApi/reports/status returning a matching reports_dlrList entry keyed on mobileNo and submitTime within your window, and assert the sweep resolves the record to sent rather than resending it.
Testing time dependent code with an injectable clock
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 time.time() or Date.now(). Making the clock a parameter converts a category of “we cannot test this” into ordinary unit tests.
The wrong shape:
class TokenBucket:
def take(self, cost: int) -> bool:
now = time.time() # untestable without sleeping or monkeypatching globals
...
The right shape:
from typing import Callable
class TokenBucket:
def __init__(self, capacity: float, refill_per_second: float,
clock: Callable[[], float]):
self.capacity = capacity
self.refill = refill_per_second
self.clock = clock
self.tokens = capacity
self.updated = clock()
def take(self, cost: float) -> tuple[bool, float]:
"""Return (allowed, retry_after_seconds)."""
now = self.clock()
elapsed = now - self.updated
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill)
self.updated = now
if self.tokens >= cost:
self.tokens -= cost
return True, 0.0
deficit = cost - self.tokens
return False, deficit / self.refill
Now the tests are deterministic, instant and complete:
class FakeClock:
def __init__(self, start: float = 1_000.0):
self.now = start
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
def test_bucket_refills_exactly_and_no_faster():
clock = FakeClock()
b = TokenBucket(capacity=10, refill_per_second=5, clock=clock)
assert b.take(10) == (True, 0.0)
allowed, retry_after = b.take(5)
assert allowed is False
assert retry_after == pytest.approx(1.0) # 5 tokens at 5 per second
clock.advance(1.0)
assert b.take(5)[0] is True
def test_bucket_never_exceeds_capacity_after_a_long_idle_period():
clock = FakeClock()
b = TokenBucket(capacity=10, refill_per_second=5, clock=clock)
clock.advance(86_400) # idle for a day
assert b.take(10)[0] is True
assert b.take(1)[0] is False # no burst credit accrued
def test_cost_is_measured_in_units_not_requests():
"""One request carrying 400 Unicode recipients is not one unit of cost."""
clock = FakeClock()
b = TokenBucket(capacity=100, refill_per_second=10, clock=clock)
recipients, segments = 40, 3
allowed, _ = b.take(recipients * segments) # 120 units
assert allowed is False # correctly refused
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 existing guide on API rate limiting and throttling covers the provider side view of throttling, and for SMPP bind level throughput the mechanics are different again and are covered in SMPP rate limits and throughput tuning.
Testing backoff without sleeping
The same injection trick applies to sleeping. Do not let your retry loop call time.sleep directly. Give it a sleeper and assert on the schedule:
def test_full_jitter_schedule_is_bounded_and_capped():
sleeps = []
rng = random.Random(1234) # seeded, therefore reproducible
policy = BackoffPolicy(base=1.0, cap=60.0, rng=rng, sleeper=sleeps.append)
for attempt in range(8):
policy.wait(attempt)
assert len(sleeps) == 8
for attempt, slept in enumerate(sleeps):
upper = min(60.0, 1.0 * (2 ** attempt))
assert 0.0 <= slept <= upper # full jitter: uniform in [0, upper]
assert max(sleeps) <= 60.0 # cap respected
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.
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.
Testing the delivery report receiver
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 POST https://unify.smsgateway.center/SMSApi/webhook/create, documented at create webhook, whose required parameters include smswebhook for the URL and smswebhookrate, described verbatim as “DLR TPS. AT what TPS rate DLR should be forwarded to. Default is 10.” The dashboard walkthrough is in the webhook setup knowledge base entry, and the conceptual overview is in real time DLR webhooks.
Four properties are worth testing, and one of them catches a bug that has bitten many teams.
Test with a form encoded body, not JSON
The bug: your framework’s JSON body parser is registered globally, the incoming payload arrives as application/x-www-form-urlencoded, 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.
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_receiver_accepts_form_encoded_body():
resp = client.post(
"/webhooks/dlr",
data={"msgId": "abc123", "mobileNo": "919999999999", "status": "DELIVERED"},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert resp.status_code == 200
def test_receiver_accepts_json_body_too():
resp = client.post("/webhooks/dlr", json={"msgId": "abc123"})
assert resp.status_code == 200
def test_receiver_does_not_500_on_a_body_it_cannot_parse():
resp = client.post("/webhooks/dlr", content=b"\x00\x01garbage",
headers={"Content-Type": "application/octet-stream"})
assert resp.status_code == 200 # accepted, parked, investigated later
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.
Test that you persist before you parse
def test_raw_payload_is_persisted_even_when_parsing_fails(db):
client.post("/webhooks/dlr", content=b"\x00\x01garbage",
headers={"Content-Type": "application/octet-stream"})
rows = db.fetch("select raw_body, parse_error from dlr_inbox")
assert len(rows) == 1
assert rows[0]["parse_error"] is not None
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.
Test the signature comparison, including that it is constant time
def test_receiver_rejects_a_bad_signature():
resp = client.post("/webhooks/dlr", data={"msgId": "x"},
headers={"X-Signature": "wrong"})
assert resp.status_code in (401, 403)
def test_signature_check_uses_a_constant_time_comparison():
import inspect
from app.webhooks import verify_signature
source = inspect.getsource(verify_signature)
assert "compare_digest" in source # crude, but it fails loudly on a regression
assert " == " not in source.split("return")[-1]
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 == 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.
Test out of order and duplicate delivery
Delivery reports are not ordered and can repeat. Your handler must be commutative and idempotent with respect to terminal states.
def test_a_late_intermediate_report_does_not_overwrite_a_terminal_state(db):
post_dlr(msgId="m1", status="DELIVERED", ts="2026-08-19T10:00:05Z")
post_dlr(msgId="m1", status="SENT", ts="2026-08-19T10:00:01Z") # arrives late
assert db.status_of("m1") == "DELIVERED"
def test_duplicate_terminal_reports_are_idempotent(db):
post_dlr(msgId="m1", status="DELIVERED")
post_dlr(msgId="m1", status="DELIVERED")
assert db.transition_count("m1") == 1
For the polling alternative, the delivery report endpoint is a read only getDlr 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 understanding delivery reports.
Testing idempotency under concurrency
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.
import threading
import respx, httpx
SEND_URL = "https://unify.smsgateway.center/SMSApi/send"
@respx.mock
def test_concurrent_workers_send_exactly_once(db):
route = respx.post(SEND_URL).mock(
return_value=httpx.Response(200, json={
"status": "success", "mobile": "919999999999", "invalidMobile": "",
"transactionId": "1", "statusCode": "200", "reason": "success"})
)
barrier = threading.Barrier(2)
errors = []
def worker():
try:
barrier.wait() # maximise the race window
dispatch(business_key="order-42-confirmation", mobile="919999999999",
msg="Hi")
except AlreadyClaimed:
pass
except Exception as exc: # noqa: BLE001
errors.append(exc)
threads = [threading.Thread(target=worker) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == []
assert route.call_count == 1 # the whole point
assert db.count("messages", business_key="order-42-confirmation") == 1
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 SET NX, 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’s concurrency semantics, so the external system has to be real.
Two more tests belong here.
def test_claim_happens_before_the_request_leaves(db):
"""If the row is not claimed first, a crash mid-request loses the audit trail."""
order = []
with patch_db_insert(lambda *_: order.append("claim")), \
respx.mock as mock:
mock.post(SEND_URL).mock(side_effect=lambda r: order.append("http") or ok())
dispatch(business_key="k", mobile="919999999999", msg="Hi")
assert order == ["claim", "http"]
def test_shed_message_may_safely_return_to_pending(db):
"""Shedding is a definite non-submission, unlike an ambiguous timeout."""
record = db.create_message(business_key="k")
record.transition_to(MessageState.SHED)
record.transition_to(MessageState.PENDING) # legal, unlike from UNKNOWN
assert record.state is MessageState.PENDING
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 duplicatecheck parameter, incidentally, is not a retry guard. The documentation describes it as “Enable to remove duplicate mobile numbers. Default is true”, 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.
Layer four: live smoke tests, and the rails that keep them safe
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.
What live tests are for
Exactly three questions, none of which can be answered by a mock:
- Are these credentials valid right now.
- Is this sender ID active and bound correctly.
- Does this template ID resolve against this message body.
Question three has a dedicated read only endpoint and should never be answered by sending. POST https://unify.smsgateway.center/SMSApi/validateTemplate, documented at validate template, takes senderid, msg and dltTemplateId and returns a mismatch as {"status":"error","statusCode":"188","reason":"message template mismatch"}. It costs nothing, it sends nothing, and it answers the question that causes the most production failures for Indian traffic.
The kill switch, the allowlist and the budget
Three rails, all of which should exist before your first live test runs.
import os
import pytest
ALLOWED_TEST_RECIPIENTS = {
n.strip() for n in os.environ.get("SMS_TEST_RECIPIENTS", "").split(",") if n.strip()
}
def guard_recipient(mobile: str) -> None:
if mobile not in ALLOWED_TEST_RECIPIENTS:
raise RuntimeError(
f"Refusing to send to {mobile}: not in SMS_TEST_RECIPIENTS allowlist"
)
live = pytest.mark.skipif(
os.environ.get("RUN_LIVE_SMS_TESTS") != "yes",
reason="live SMS tests are opt in",
)
@live
def test_credentials_and_sender_id_are_valid():
mobile = next(iter(ALLOWED_TEST_RECIPIENTS))
guard_recipient(mobile)
client = SmsClient.from_env()
result = client.send(
mobile=mobile,
msg="Smoke test from CI build " + os.environ.get("BUILD_ID", "local"),
test_message=True, # documented: message will not be delivered
)
assert result.ok
assert result.status_code == "200"
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.
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.
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 “our tests spent forty thousand rupees” from a discovery into a build failure.
Load testing pacing without paying for it
If you want to verify that your limiter and worker pool behave under volume, testMessage=true 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.
# Verify pacing behaviour against the real endpoint without delivering anything.
curl -s -X POST https://unify.smsgateway.center/SMSApi/send \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "userid=$SGC_USER" \
--data-urlencode "password=$SGC_PASS" \
--data-urlencode "sendMethod=quick" \
--data-urlencode "msgType=text" \
--data-urlencode "senderid=SGCTST" \
--data-urlencode "mobile=919999999999" \
--data-urlencode "msg=pacing probe" \
--data-urlencode "testMessage=true" \
--data-urlencode "output=json"
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 testMessage request may not exercise the same downstream path as a real one, so treat the observed latency as indicative rather than authoritative. The SMS API getting started guide covers the base request shape if you need the full parameter set.
For OTP flows, note that the generate and verify endpoints at generate OTP and verify OTP are stateful. The generate response carries createTime, expiryTime and retryAfter 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 expiryTime rather than by sleeping for five minutes in CI.
Wiring template validation into CI
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.
The mechanism: whenever a template string changes in your repository, call SMSApi/validateTemplate with the rendered message and the registered dltTemplateId. A statusCode of 188 means mismatch and the build fails with a precise message. A 200 means you are safe to merge.
#!/usr/bin/env python3
"""ci/validate_templates.py - fail the build on a DLT template mismatch."""
import os
import sys
import httpx
from app.templates import REGISTRY # {key: (dlt_template_id, template_text)}
from app.render import render
VALIDATE_URL = "https://unify.smsgateway.center/SMSApi/validateTemplate"
SENDER = os.environ["SGC_SENDER_ID"]
def sample_values(template: str) -> list[str]:
"""Deterministic filler so CI results are reproducible."""
return ["SAMPLE"] * template.count("{#var#}")
def main() -> int:
failures = []
with httpx.Client(timeout=20.0) as http:
for key, (dlt_id, text) in REGISTRY.items():
body = render(text, sample_values(text))
r = http.post(VALIDATE_URL, data={
"userid": os.environ["SGC_USER"],
"password": os.environ["SGC_PASS"],
"senderid": SENDER,
"msg": body,
"dltTemplateId": dlt_id,
"output": "json",
})
payload = r.json()
if str(payload.get("statusCode")) != "200":
failures.append(
f"{key}: statusCode={payload.get('statusCode')} "
f"reason={payload.get('reason')}"
)
for f in failures:
print(f"TEMPLATE VALIDATION FAILED {f}", file=sys.stderr)
return 1 if failures else 0
if __name__ == "__main__":
raise SystemExit(main())
Wire it as a required check:
# .github/workflows/messaging.yml
name: messaging
on: [pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements-dev.txt
- run: pytest -q # layers 1 and 2, offline, no secrets
templates:
runs-on: ubuntu-latest
if: github.event.pull_request.head.repo.full_name == github.repository
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements-dev.txt
- run: python ci/validate_templates.py
env:
SGC_USER: ${{ secrets.SGC_USER }}
SGC_PASS: ${{ secrets.SGC_PASS }}
SGC_SENDER_ID: ${{ secrets.SGC_SENDER_ID }}
The if 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.
Two additional scheduled jobs are worth adding:
- Nightly code list diff. Fetch
SMSApi/info/responsecodesandSMSApi/info/deliverycodesand diff against the codes your application classifies. New codes become a triage ticket rather than an outage. - Nightly fixture drift check. 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.
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 DLT registration step by step guide, and the regulation itself sits under TRAI’s TCCCPR framework.
What not to test
Test suites die of bloat as often as of neglect. Some things belong in your suite and some do not.
Do not test that the provider delivers. 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.
Do not assert an exact throughput number the provider has not published. You can assert your own configured policy. Naming it test_we_stay_under_our_configured_rate rather than test_we_respect_the_provider_limit is not pedantry, it is the difference between a test that stays true and one that quietly becomes a lie.
Do not snapshot entire response bodies. 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.
Do not test that your ORM saves a row. That is a framework test, not a messaging test.
Do not test the provider’s retry behaviour. 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.
Do not build a full fake of the provider’s API. 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.
Decision matrix: which layer catches which bug
| Bug | Layer that catches it | Test to write | Cost of missing it |
|---|---|---|---|
| Curly apostrophe flips the message to Unicode | 1, pure function | Segment count parametrised cases | Double or triple billing on every campaign |
| Substituted value corrupts the next variable slot | 1, pure function | Rendering with a token inside a value | Status code 188 and undelivered traffic |
| Two spellings of one number defeat deduplication | 1, pure function | Normalisation idempotence | Duplicate sends and duplicate charges |
| Posting JSON to a form encoded endpoint | 2, transport mock | Assert the content type header | Confusing auth errors, hours lost |
Missing output=json so a plain text body is returned | 2, transport mock | Assert the request body fields | Parser crash in production only |
| Credentials in the query string | 2, transport mock | Assert the URL carries no password | Credential leak via access logs |
| Read timeout retried as if it were safe | 2, transport mock | Force ReadTimeout, assert unknown | Duplicate customer messages and charges |
unknown drifting back to pending | 2, state machine | Illegal transition test | Silent duplicate sends on every sweep |
Quoted versus unquoted statusCode across channels | 3, contract | Normalisation tests for both shapes | One channel appears to fail entirely |
invalidMobile populated while status reads success | 3, contract | Partial rejection test | Silently dropped recipients, green dashboard |
| A new provider error code with no handler | 3, contract | Nightly code list diff | Infinite retry loop if defaulted to transient |
| Limiter counting requests instead of recipient segments | 1 and 2 | Cost unit test on the bucket | Flat graphs while real volume grows 500x |
| Backoff schedule uncapped or unjittered | 1, injected clock | Schedule assertion with a seeded RNG | Synchronised retry storms |
| Webhook receiver silently eating form encoded bodies | 2, receiver test | Post a form encoded body | Delivery reports vanish, no alarm |
| Late intermediate DLR overwriting a terminal state | 2, receiver test | Out of order report test | Wrong delivery reporting to customers |
| Idempotency guard that fails under concurrency | 2, with a real store | Two threads on one business key | Duplicate sends under load only |
| Template edited but not revalidated | CI gate | validateTemplate in the pipeline | 188 in production, campaign dead on arrival |
| Expired credentials or a deactivated sender ID | 4, live smoke | One gated real call | Everything fails at deploy time |
| SMPP bind level window saturation | Out of scope here | See the SMPP throughput guide | Throttling errors under load |
How to build a messaging test suite from nothing
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.
- Capture real fixtures. Run each endpoint once from the Postman collection and commit the actual response bodies. Do not type them from memory.
- Extract the pure functions. 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.
- Write the pure function tests. Aim for the traps, not for coverage percentage: extension table characters, curly apostrophes, emoji, tokens inside values, dollar signs, arity mismatches.
- Turn off the network in tests. Add
nock.disableNetConnect()or the equivalent so an accidental real call is a loud failure rather than a silent charge. - Move your mocks down to the transport. Delete every test that patches your own client class and rewrite it against
respx,nockor GuzzleMockHandler, asserting on the outgoing request. - Write the ambiguous timeout test. Force a read timeout, assert the record lands in
unknown, assert exactly one attempt, and assert thatunknowncannot transition back topending. - Inject the clock. Refactor your limiter and backoff to take a clock and a sleeper, then assert the schedule instead of sleeping through it.
- Add contract tests. Normalisation tests for both channel shapes, plus a nightly diff against the two documented error code endpoints.
- Gate CI on template validation. Run
validateTemplatefor every template in your registry on every pull request, with fork protection on the secrets. - Add three live smoke tests, behind an allowlist and an opt in flag. Credentials, sender ID and template binding. Nothing else. Run them before release, not on every commit.
Pre-merge checklist
Twenty six items. Print it, argue about it, adapt it.
Safety rails
- [ ] The test process cannot make an unmocked outbound HTTP call.
- [ ] Live tests are behind an explicit opt in environment variable.
- [ ] A recipient allowlist raises a hard error, not a warning.
- [ ] CI secrets are unavailable to pull requests from forks.
- [ ] No real customer phone number exists anywhere in the repository or fixtures.
- [ ] The layer one and layer two suites need zero credentials to run.
Pure functions
- [ ] Segment counting is tested at every boundary: 160, 161, 306, 307, 70, 71.
- [ ] The GSM-7 extension table two septet characters are covered.
- [ ] A curly apostrophe is proven to flip the encoding to Unicode.
- [ ] Astral plane characters are proven to count as two UCS-2 units.
- [ ] Template rendering is proven safe when a value contains the placeholder token.
- [ ] Template rendering is proven safe when a value contains a dollar sign.
- [ ] Arity mismatch raises before any network call is attempted.
- [ ] Phone normalisation is proven idempotent.
Transport layer
- [ ] The content type is asserted as form encoded.
- [ ]
sendMethod,msgType,senderidandoutputare asserted in the request body. - [ ] Credentials are proven absent from the request URL.
- [ ] Every mock interceptor is asserted to have been consumed.
- [ ] A read timeout produces
unknownand exactly one attempt. - [ ] A connect timeout produces
pendingand is retryable. - [ ] The transition from
unknowntopendingis proven impossible.
Contract
- [ ] Both the quoted and unquoted
statusCodeshapes parse correctly. - [ ] Both
transactionIdandmessageIdresolve through one normaliser. - [ ] A populated
invalidMobileis not reported as unqualified success. - [ ] Unknown error codes default to permanent, never to transient.
Receiver and concurrency
- [ ] The webhook receiver is tested with a form encoded body.
- [ ] The raw payload is persisted before parsing is attempted.
- [ ] Duplicate and out of order delivery reports are proven idempotent.
- [ ] The idempotency test runs two concurrent workers against a real store.
Ten mistakes that make messaging test suites useless
- Mocking your own client class. The test then asserts that your code called your code. Every request construction bug walks straight past it.
- Only testing the happy path. Success is the case that already works. The value is in timeouts, partial rejections and template mismatches.
- Letting tests hit the network. 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.
- Sleeping in tests. Real sleeps make suites slow, slow suites get skipped, and skipped suites protect nothing. Inject the clock and the sleeper.
- Unseeded jitter. A randomised backoff test without a seeded RNG is flaky, and flaky tests get deleted rather than fixed.
- Snapshotting whole third party payloads. Every irrelevant field addition breaks the build, and the team learns to approve snapshot updates without reading them.
- Treating a populated
invalidMobileas success. Recipients are dropped, dashboards stay green, and the discrepancy surfaces weeks later as a support ticket. - Defaulting unknown error codes to transient. That is an infinite retry loop that bills on every attempt. Default to permanent and triage.
- Testing idempotency against an in memory fake. 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.
- Running live tests on every commit. Your build frequency multiplied by your per message rate is a real number, and it is larger than anyone expects.
FAQs
Does testMessage=true cost credits?
The documentation states only that “Enable true to test your message and messages wont be delivered when enabled true.” 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.
Is there a sandbox environment?
Yes, at the account level. The demo page states that “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”, 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.
Should I use the sandbox or testMessage=true for CI?
Use neither for the bulk of CI. Use mocks. If you want one gated real call in a release pipeline, testMessage=true 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.
How do I test that I do not send duplicates?
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 unknown and that unknown cannot transition back to pending. Those two tests together cover the overwhelming majority of duplicate send incidents. Note that duplicatecheck is not the mechanism here: it removes repeated numbers within a single request and says nothing about two separate requests.
What is the difference between a connect timeout and a read timeout for testing purposes?
A connect timeout means nothing was submitted, so the message is safely retryable and stays pending. 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 unknown for reconciliation. Write one test for each. If your HTTP library collapses both into a single exception type, as requests does, then every timeout must be treated as ambiguous, and your test should assert that conservative behaviour explicitly.
How do I test a rate limiter without waiting in real time?
Pass the clock in as a dependency. A fake clock with an advance 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.
Can I test the webhook receiver without a public URL?
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.
What should my webhook fixtures contain, given the payload schema is not published?
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.
How many live tests should I have?
One to three. Credentials valid, sender ID active, template binding resolves. Template binding is better answered by the read only validateTemplate endpoint than by sending anything at all, so in practice many teams end up with a single live send in the whole suite.
How do I stop a test from reaching a real customer?
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’s phone.
Should I use contract testing tools like Pact?
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.
How do I test OTP expiry without waiting five minutes?
Inject the clock. The generate response carries createTime, expiryTime and retryAfter 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 expiryTime and assert the verification is refused. Also test the boundary at exactly expiryTime, because off by one on an inclusive comparison is a real and common bug.
Do I need different tests for WhatsApp and SMS?
You need one normaliser with tests for both response shapes, because the field names and types differ: messageId versus transactionId, unquoted versus quoted statusCode, description versus reason. 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 SMS API versus WhatsApp Business API comparison, and the endpoint details are at send WhatsApp Business message.
What is the single test to write if I only have time for one?
Force a read timeout in your mock and assert that the message record ends in unknown 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.
Build against the real API, safely. Grab the Postman collection to capture accurate fixtures, use testMessage=true for smoke checks that do not deliver, and gate your pipeline on validateTemplate so a template edit can never reach production broken. If you do not have credentials yet, start with a free demo account and activate the sandbox.