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:

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.