# 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:

```python
# 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 move `time.monotonic()`, so expiry uses wall-clock timestamps, which you'd store in a database anyway.

## A fake provider and an outbox fixture

```python
# 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:

```ini
[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

```python
# 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:

```python
@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:

```python
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

```python
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:

```python
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

```bash
python3 -m venv .venv
source .venv/bin/activate
pip install pytest time-machine freezegun
pytest -q
```

```text
.........                                                                [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](https://tempsmss.com/) 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.

---

**More on SMS verification for developers:**

- [Testing SMS OTP with Playwright: stubs, fake timers and rate limits](https://tempsmss.hashnode.dev/testing-sms-otp-playwright)
- [How to build a secure SMS OTP verification endpoint in Node.js](https://dev.to/tempsmss/how-to-build-a-secure-sms-otp-verification-endpoint-in-nodejs-3elo)

