Testing¶
django-liveops uses a three-tier test approach.
Tier 1: Unit tests (InMemory channel layer)¶
Fast tests with no external dependencies. Use
channels.layers.InMemoryChannelLayer (the default in test settings).
# tests/settings.py
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels.layers.InMemoryChannelLayer",
}
}
Test the consumer with WebsocketCommunicator:
import json
import pytest
from channels.testing import WebsocketCommunicator
from liveops.consumers import LiveOperationConsumer
from liveops.security import make_subscription_token
@pytest.mark.django_db(transaction=True)
async def test_snapshot_on_connect(user, finished_op):
token = make_subscription_token(user, finished_op)
app = LiveOperationConsumer.as_asgi()
communicator = WebsocketCommunicator(
app, f"/asgi/notifications/?subscription_token={token}"
)
communicator.scope["user"] = user
connected, _ = await communicator.connect()
assert connected
response = await communicator.receive_from(timeout=2)
data = json.loads(response)
assert "liveop_html" in data
assert "op-result" in data["liveop_html"]
await communicator.disconnect()
Tier 2: Progress tests (fake channel layer)¶
Test WebProgress by capturing what group_send receives, without a real
channel layer:
import pytest
from asgiref.sync import sync_to_async
from unittest.mock import MagicMock
from liveops.progress import WebProgress
@pytest.mark.django_db
def test_progress_status_pushes_envelope(running_op):
sent = []
mock_layer = MagicMock()
mock_layer.group_send = sync_to_async(
lambda channel, msg: sent.append(msg)
)
p = WebProgress(running_op, mock_layer)
p.status("Testing…")
assert sent
assert "op-status" in sent[0]["liveop_html"]
Tier 3: Round-trip tests (real Redis via testcontainers)¶
The strongest proof: worker → Redis → consumer → client. Requires Docker.
# tests/test_roundtrip.py
import pytest
from testcontainers.redis import RedisContainer
@pytest.fixture(scope="session")
def redis_url():
with RedisContainer("redis:7-alpine") as c:
host = c.get_container_host_ip()
port = c.get_exposed_port(6379)
yield f"redis://{host}:{port}"
@pytest.fixture
def redis_channel_layer(settings, redis_url):
settings.CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {"hosts": [redis_url]},
}
}
yield
@pytest.mark.django_db(transaction=True)
async def test_fd388(user, redis_channel_layer):
# finish op before connect, then assert snapshot == result
...
See the bundled tests/test_roundtrip.py for the complete implementation
including the FD#388 case and §19.4 ordering test. If Docker is unavailable the
module skips itself with a clear reason (never a false pass).
pytest configuration¶
# pyproject.toml
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "tests.settings"
asyncio_mode = "auto"
testpaths = ["tests"]
Use @pytest.mark.django_db(transaction=True) for all async consumer tests.
With transaction=True, Django commits real transactions — required for
on_commit callbacks (used by p.result()) to fire correctly.
Tips¶
- The
settingsfixture (pytest-django) is the cleanest way to overrideCHANNEL_LAYERSper-test. Django'sChannelLayerManagerclears its backend cache automatically when the setting changes (via thesetting_changedsignal), so the nextget_channel_layer()returns the new backend. - Session-scoped Redis container + function-scoped settings override = one container for the whole session, fresh channel-layer config per test.