Skip to content

Testing

Running the package's own test suite

git clone https://github.com/mahmoudgshaker/drf-idempotency.git
cd drf-idempotency
pip install -e ".[dev]"
pytest
tox  # full Python/Django compatibility matrix

Redis-dependent tests use fakeredis by default (no real Redis server needed) and self-skip a small set of @pytest.mark.redis-marked tests against a real server when REDIS_URL is set in the environment.

Testing views protected by the middleware

import pytest
from rest_framework.test import APIClient


@pytest.mark.django_db
def test_retry_does_not_duplicate(client_setup):
    client = APIClient()
    first = client.post("/payments/", {"amount": 100}, format="json", HTTP_IDEMPOTENCY_KEY="k1")
    second = client.post("/payments/", {"amount": 100}, format="json", HTTP_IDEMPOTENCY_KEY="k1")
    assert Payment.objects.count() == 1
    assert second.content == first.content
    assert second.headers["Idempotent-Replayed"] == "true"

Note: compare .content, not .data — a replayed response is a plain HttpResponse, not a DRF Response, so .data isn't available on it (this is intentional; see Architecture).

Testing key reuse detection

def test_reuse_with_different_body_is_rejected(client):
    client.post("/payments/", {"amount": 100}, format="json", HTTP_IDEMPOTENCY_KEY="k1")
    response = client.post("/payments/", {"amount": 200}, format="json", HTTP_IDEMPOTENCY_KEY="k1")
    assert response.status_code == 422

Testing concurrent requests genuinely

Use real threads against a thread-safe backend (Redis, or fakeredis in tests) rather than simulating the race sequentially, to actually exercise the atomicity guarantee:

from concurrent.futures import ThreadPoolExecutor
from datetime import timedelta
import fakeredis
from drf_idempotency.backends.redis import RedisBackend


def test_only_one_concurrent_acquisition_succeeds():
    backend = RedisBackend(client=fakeredis.FakeRedis())

    def attempt(i):
        return backend.acquire_or_get("key", f"fp-{i}", lock_ttl=timedelta(seconds=30)).acquired

    with ThreadPoolExecutor(max_workers=20) as executor:
        results = list(executor.map(attempt, range(20)))

    assert sum(results) == 1

See tests/test_concurrency.py in this package's own repository for the full pattern, including why the database backend's atomicity is tested via sequential-conflict simulation rather than real OS threads (a SQLite :memory: database can't be safely shared across threads the way it can across a single connection — see the module docstring there).

Testing a custom backend

Write the same test suite against your backend that tests/test_backends_database.py and tests/test_backends_redis.py run against the built-in ones — acquire_or_get's atomicity is the property that matters most; test it explicitly with concurrent threads, not just sequentially.

Testing exception handling directly

For unit-level coverage of process_idempotent_request's exception path (which behaves differently under middleware vs. the decorator — see Architecture), call it directly rather than through a full HTTP request:

from drf_idempotency.core import process_idempotent_request


def test_exception_releases_the_lock(rf):
    request = build_request_with_key("key-1")

    def failing_view():
        raise ValueError("boom")

    with pytest.raises(ValueError):
        process_idempotent_request(request, failing_view)

    assert get_idempotency_status("key-1") is None