Testing SMS OTP in Python with pytest: Fake Provider, Frozen Clock, Rate Limits
Testing SMS OTP in Python with pytest comes down to controlling the two things a verification flow depends on: the SMS provider and the clock. Inject the provider so tests can swap in a fake that keeps an outbox, read the code from that outbox instead of guessing it, freeze time with time-machine or freezegun to test expiry to the second, and test rate limits by moving the clock rather than sleeping. Everything below was run with Python 3.14, pytest 9.2, time-machine 3.6 and freezegun 1.5, and all nine tests pass.
The code under test
A small service that sends a six-digit code, stores only an HMAC of it, accepts it once, burns it after five wrong guesses or five minutes, and allows three codes per number per hour:
# otp.py
import hashlib
import hmac
import secrets
import time
from dataclasses import dataclass
from typing import Protocol
class SmsSender(Protocol):
def send(self, to: str, body: str) -> None: ...
class RateLimited(Exception):
pass
@dataclass
class _Pending:
code_hash: str
expires_at: float
attempts: int = 0
class OtpService:
CODE_TTL = 300 # seconds a code stays valid
MAX_ATTEMPTS = 5 # wrong guesses before the code is burned
MAX_SENDS = 3 # codes per number per window
SEND_WINDOW = 3600 # seconds
def __init__(self, sender: SmsSender, secret: bytes):
self._sender = sender
self._secret = secret
self._pending: dict[str, _Pending] = {}
self._sends: dict[str, list[float]] = {}
def _hash(self, phone: str, code: str) -> str:
msg = f"{phone}:{code}".encode()
return hmac.new(self._secret, msg, hashlib.sha256).hexdigest()
def request_code(self, phone: str) -> None:
now = time.time()
recent = [t for t in self._sends.get(phone, []) if now - t < self.SEND_WINDOW]
if len(recent) >= self.MAX_SENDS:
raise RateLimited(phone)
code = f"{secrets.randbelow(10**6):06d}"
self._sender.send(phone, f"Your Example code is {code}. It expires in 5 minutes.")
# Only store the code once the provider accepted the message.
self._pending[phone] = _Pending(self._hash(phone, code), now + self.CODE_TTL)
self._sends[phone] = recent + [now]
def verify(self, phone: str, code: str) -> bool:
pending = self._pending.get(phone)
if pending is None or time.time() >= pending.expires_at:
self._pending.pop(phone, None)
return False
pending.attempts += 1
if pending.attempts > self.MAX_ATTEMPTS:
del self._pending[phone]
return False
if hmac.compare_digest(pending.code_hash, self._hash(phone, code)):
del self._pending[phone] # single use
return True
return False
Two choices make it testable:
- The sender is injected. Anything with a
send(to, body)method will do, so tests never call a real provider or need a network. - The clock is
time.time(). time-machine doesn't movetime.monotonic(), so expiry uses wall-clock timestamps, which you'd store in a database anyway.
A fake provider and an outbox fixture
# tests/conftest.py
import re
import pytest
from otp import OtpService
class FakeSms:
"""Stands in for the SMS provider and keeps every message."""
def __init__(self):
self.outbox: list[tuple[str, str]] = []
def send(self, to: str, body: str) -> None:
self.outbox.append((to, body))
def last_code(self, to: str) -> str:
for number, body in reversed(self.outbox):
if number == to:
return re.search(r"\b(\d{6})\b", body).group(1)
raise AssertionError(f"no SMS was sent to {to}")
@pytest.fixture
def sms():
return FakeSms()
@pytest.fixture
def otp(sms):
return OtpService(sender=sms, secret=b"test-only-secret")
Plus a pytest.ini so the tests can import otp from the project root:
[pytest]
pythonpath = .
testpaths = tests
Reading the code from the outbox, rather than patching the random generator, tests the real path from generation to message text.
Testing SMS OTP in Python with pytest: the core cases
# tests/test_otp.py
import datetime as dt
import re
from unittest.mock import Mock
import pytest
from freezegun import freeze_time
from otp import OtpService, RateLimited
PHONE = "+12015550123"
START = dt.datetime(2026, 1, 1, 12, 0, tzinfo=dt.timezone.utc)
def test_sends_one_sms_with_a_six_digit_code(otp, sms):
otp.request_code(PHONE)
assert len(sms.outbox) == 1
to, body = sms.outbox[0]
assert to == PHONE
assert body.startswith("Your Example code is ")
assert len(sms.last_code(PHONE)) == 6
def test_correct_code_works_once(otp, sms):
otp.request_code(PHONE)
code = sms.last_code(PHONE)
assert otp.verify(PHONE, code) is True
assert otp.verify(PHONE, code) is False
def test_code_is_burned_after_too_many_wrong_guesses(otp, sms):
otp.request_code(PHONE)
code = sms.last_code(PHONE)
wrong = f"{(int(code) + 1) % 10**6:06d}"
for _ in range(OtpService.MAX_ATTEMPTS):
assert otp.verify(PHONE, wrong) is False
assert otp.verify(PHONE, code) is False
The wrong guess is derived from the real code, so it can never be right by accident. The numbers are in the fictional 555-0100 to 555-0199 range, so a misconfigured test can't text a real person.
Code expiry with time-machine and freezegun
time-machine ships a time_machine pytest fixture. move_to(..., tick=False) stops the clock and shift() moves it forward:
@pytest.mark.parametrize(("elapsed", "accepted"), [(299, True), (300, False)])
def test_code_expiry(otp, sms, time_machine, elapsed, accepted):
time_machine.move_to(START, tick=False)
otp.request_code(PHONE)
time_machine.shift(elapsed)
assert otp.verify(PHONE, sms.last_code(PHONE)) is accepted
Testing 299 and 300 seconds pins the boundary: change >= to > in verify() and the 300-second case fails.
The same check with freezegun, if your project already uses it:
def test_code_expiry_with_freezegun(otp, sms):
with freeze_time("2026-01-01 12:00:00") as frozen:
otp.request_code(PHONE)
frozen.tick(301)
assert otp.verify(PHONE, sms.last_code(PHONE)) is False
Rate limits without sleeping
def test_fourth_code_within_an_hour_is_refused(otp, sms, time_machine):
time_machine.move_to(START, tick=False)
for _ in range(3):
otp.request_code(PHONE)
time_machine.shift(60)
with pytest.raises(RateLimited):
otp.request_code(PHONE)
assert len(sms.outbox) == 3
def test_rate_limit_resets_after_the_window(otp, sms, time_machine):
time_machine.move_to(START, tick=False)
for _ in range(3):
otp.request_code(PHONE)
time_machine.shift(3600)
otp.request_code(PHONE)
assert len(sms.outbox) == 4
Moving the clock instead of sleeping keeps both tests instant.
Provider failures with Mock
When the provider times out, the user must not end up with a code that works but never arrived:
def test_provider_failure_leaves_no_usable_code():
sender = Mock()
sender.send.side_effect = TimeoutError("provider timed out")
service = OtpService(sender=sender, secret=b"test-only-secret")
with pytest.raises(TimeoutError):
service.request_code(PHONE)
sender.send.assert_called_once()
code = re.search(r"\d{6}", sender.send.call_args.args[1]).group()
assert service.verify(PHONE, code) is False
Running it
python3 -m venv .venv
source .venv/bin/activate
pip install pytest time-machine freezegun
pytest -q
......... [100%]
9 passed in 0.05s
What unit tests can't tell you
Fakes prove your logic, not delivery. Before launch, send real codes to real numbers in each country you serve. For a quick manual check, free public numbers on Temp SMS show incoming texts on a web page with no account. Anyone can read them and the numbers are retired over time, so use them only for throwaway test accounts, never banking, a main email or two-factor authentication.
FAQ
Should I mock the SMS provider's SDK or my own wrapper?
Your own wrapper. A one-method send(to, body) interface is easy to fake, and the SDK call can be covered by a single integration test.
Should I use time-machine or freezegun?
Either works for this. time-machine is a C extension with a built-in pytest fixture; freezegun is the older library and also moves time.monotonic().
How do I test OTP expiry without waiting?
Freeze the clock, request a code, move the clock past the expiry time and verify. No time.sleep() needed.