Redis Pub/Sub and Streams Testing Patterns
Redis Pub/Sub and Redis Streams are fundamentally different messaging primitives, and each requires distinct testing approaches. Pub/Sub is ephemeral—messages are lost if no subscriber is connected. Streams are persistent—messages are stored until explicitly deleted. This guide shows how to test both reliably.
Redis Pub/Sub Testing
The Challenge
Pub/Sub involves concurrent producers and subscribers. A test that publishes a message must wait for the subscriber to receive it without using sleep. This requires synchronization primitives—events, promises, or queues.
Basic Pub/Sub Test Setup
# notification_service.py
import json
import redis
import threading
class NotificationService:
def __init__(self, redis_url: str):
self.publisher = redis.Redis.from_url(redis_url, decode_responses=True)
self.subscriber = redis.Redis.from_url(redis_url, decode_responses=True)
self._handlers = {}
self._pubsub = None
self._thread = None
def publish(self, channel: str, event_type: str, data: dict):
message = json.dumps({'type': event_type, 'data': data})
subscriber_count = self.publisher.publish(channel, message)
return subscriber_count
def subscribe(self, channel: str, handler):
self._handlers[channel] = handler
self._pubsub = self.subscriber.pubsub()
self._pubsub.subscribe(channel)
self._thread = threading.Thread(target=self._listen, daemon=True)
self._thread.start()
def _listen(self):
for message in self._pubsub.listen():
if message['type'] == 'message':
channel = message['channel']
data = json.loads(message['data'])
handler = self._handlers.get(channel)
if handler:
handler(data)
def unsubscribe(self):
if self._pubsub:
self._pubsub.unsubscribe()
self._pubsub.close()# test_notification_service.py
import threading
import pytest
from testcontainers.redis import RedisContainer
from notification_service import NotificationService
@pytest.fixture(scope='module')
def redis_url():
with RedisContainer('redis:7-alpine') as container:
host = container.get_container_host_ip()
port = container.get_exposed_port(6379)
yield f'redis://{host}:{port}'
def test_subscriber_receives_published_message(redis_url):
service = NotificationService(redis_url)
received = []
received_event = threading.Event()
def handler(message):
received.append(message)
received_event.set()
service.subscribe('notifications', handler)
# Give subscriber thread time to connect
import time
time.sleep(0.1)
service.publish('notifications', 'user.created', {'id': 1, 'name': 'Alice'})
# Wait for message (timeout = test failure)
assert received_event.wait(timeout=2), "Message not received within 2 seconds"
assert len(received) == 1
assert received[0]['type'] == 'user.created'
assert received[0]['data']['name'] == 'Alice'
service.unsubscribe()
def test_multiple_subscribers_all_receive(redis_url):
service1 = NotificationService(redis_url)
service2 = NotificationService(redis_url)
events1 = threading.Event()
events2 = threading.Event()
messages1 = []
messages2 = []
service1.subscribe('alerts', lambda m: (messages1.append(m), events1.set()))
service2.subscribe('alerts', lambda m: (messages2.append(m), events2.set()))
import time
time.sleep(0.1)
service1.publish('alerts', 'system.down', {'service': 'payment'})
assert events1.wait(timeout=2)
assert events2.wait(timeout=2)
assert messages1[0]['data']['service'] == 'payment'
assert messages2[0]['data']['service'] == 'payment'
service1.unsubscribe()
service2.unsubscribe()
def test_unsubscribed_client_misses_messages(redis_url):
"""
Pub/Sub is ephemeral — messages published before subscription are lost.
This test documents that behavior.
"""
service = NotificationService(redis_url)
received = []
# Publish BEFORE subscribing
service.publish('ephemeral', 'event', {'data': 'lost'})
# Now subscribe
service.subscribe('ephemeral', lambda m: received.append(m))
import time
time.sleep(0.2) # Wait to confirm nothing arrives
assert len(received) == 0, "Expected no messages — pub/sub is ephemeral"
service.unsubscribe()Testing Pattern-Based Subscriptions
def test_pattern_subscribe_matches_multiple_channels(redis_url):
import redis as redis_lib
pub = redis_lib.Redis.from_url(redis_url, decode_responses=True)
sub = redis_lib.Redis.from_url(redis_url, decode_responses=True)
received = []
done = threading.Event()
def listen():
pubsub = sub.pubsub()
pubsub.psubscribe('user:*')
for message in pubsub.listen():
if message['type'] == 'pmessage':
received.append(message)
if len(received) >= 2:
done.set()
break
t = threading.Thread(target=listen, daemon=True)
t.start()
import time
time.sleep(0.1)
pub.publish('user:created', 'alice')
pub.publish('user:updated', 'alice:email')
pub.publish('order:created', 'should_not_match')
assert done.wait(timeout=2), "Expected 2 messages"
channels = [m['channel'] for m in received]
assert 'user:created' in channels
assert 'user:updated' in channels
assert 'order:created' not in channelsRedis Streams Testing
Streams vs Pub/Sub
Redis Streams persist messages. Consumers can read messages from any point in the stream, replay history, and use consumer groups for at-least-once delivery. This is closer to Kafka than to a traditional message queue.
Basic Stream Producer/Consumer
# order_stream.py
import json
import redis
r = redis.Redis.from_url('redis://localhost', decode_responses=True)
STREAM_KEY = 'orders:stream'
GROUP_NAME = 'order_processors'
CONSUMER_NAME = 'processor-1'
def publish_order(order_id: str, data: dict) -> str:
"""Publish to stream. Returns message ID."""
return r.xadd(STREAM_KEY, {
'order_id': order_id,
'data': json.dumps(data)
})
def process_orders(batch_size: int = 10, block_ms: int = 1000) -> list:
"""Read and process pending messages from consumer group."""
try:
r.xgroup_create(STREAM_KEY, GROUP_NAME, id='0', mkstream=True)
except redis.exceptions.ResponseError:
pass # Group already exists
messages = r.xreadgroup(
GROUP_NAME,
CONSUMER_NAME,
{STREAM_KEY: '>'}, # '>' = new messages only
count=batch_size,
block=block_ms
)
processed = []
if messages:
for stream, entries in messages:
for msg_id, fields in entries:
order = {
'msg_id': msg_id,
'order_id': fields['order_id'],
'data': json.loads(fields['data'])
}
processed.append(order)
# Acknowledge processing
r.xack(STREAM_KEY, GROUP_NAME, msg_id)
return processedTesting Streams with Consumer Groups
# test_order_stream.py
import pytest
import redis as redis_lib
from testcontainers.redis import RedisContainer
import order_stream
@pytest.fixture(scope='module')
def redis_container():
with RedisContainer('redis:7-alpine') as container:
yield container
@pytest.fixture
def stream_client(redis_container, monkeypatch):
client = redis_lib.Redis(
host=redis_container.get_container_host_ip(),
port=redis_container.get_exposed_port(6379),
decode_responses=True
)
monkeypatch.setattr(order_stream, 'r', client)
yield client
client.delete(order_stream.STREAM_KEY)
def test_published_message_readable_from_stream(stream_client):
msg_id = order_stream.publish_order('ord-001', {'items': ['a', 'b'], 'total': 99.99})
# Verify message is in the stream
messages = stream_client.xrange(order_stream.STREAM_KEY, '-', '+')
assert len(messages) == 1
assert messages[0][0] == msg_id
assert messages[0][1]['order_id'] == 'ord-001'
def test_consumer_group_processes_messages(stream_client):
order_stream.publish_order('ord-001', {'total': 10})
order_stream.publish_order('ord-002', {'total': 20})
order_stream.publish_order('ord-003', {'total': 30})
processed = order_stream.process_orders(batch_size=10, block_ms=100)
assert len(processed) == 3
order_ids = [p['order_id'] for p in processed]
assert 'ord-001' in order_ids
assert 'ord-002' in order_ids
assert 'ord-003' in order_ids
def test_acknowledged_messages_not_redelivered(stream_client):
order_stream.publish_order('ord-001', {'total': 10})
# Process and acknowledge
first_batch = order_stream.process_orders(batch_size=10, block_ms=100)
assert len(first_batch) == 1
# Second read should return no messages (already acknowledged)
second_batch = order_stream.process_orders(batch_size=10, block_ms=100)
assert len(second_batch) == 0
def test_pending_messages_redeliverable(stream_client):
"""Messages not acknowledged remain in PEL (Pending Entry List)."""
r = stream_client
# Publish a message
msg_id = order_stream.publish_order('ord-001', {'total': 10})
# Read but DON'T acknowledge
r.xreadgroup(
order_stream.GROUP_NAME,
order_stream.CONSUMER_NAME,
{order_stream.STREAM_KEY: '>'},
count=1,
block=100
)
# Check PEL — should have 1 pending entry
pending = r.xpending(order_stream.STREAM_KEY, order_stream.GROUP_NAME)
assert pending['pending'] == 1
# Claim and reprocess (simulating failure recovery)
pending_detail = r.xpending_range(
order_stream.STREAM_KEY,
order_stream.GROUP_NAME,
min='-', max='+', count=10
)
for entry in pending_detail:
r.xack(order_stream.STREAM_KEY, order_stream.GROUP_NAME, entry['message_id'])
# PEL should be empty now
pending_after = r.xpending(order_stream.STREAM_KEY, order_stream.GROUP_NAME)
assert pending_after['pending'] == 0Testing Stream Trimming and Retention
def test_stream_maxlen_trims_old_messages(stream_client):
# Publish more than maxlen messages
for i in range(20):
stream_client.xadd(
order_stream.STREAM_KEY,
{'order_id': f'ord-{i}'},
maxlen=10,
approximate=False
)
# Stream should have at most 10 messages
length = stream_client.xlen(order_stream.STREAM_KEY)
assert length <= 10
def test_xrange_reads_historical_messages(stream_client):
ids = []
for i in range(5):
msg_id = stream_client.xadd(
order_stream.STREAM_KEY,
{'seq': str(i)}
)
ids.append(msg_id)
# Read range between first and last ID
messages = stream_client.xrange(
order_stream.STREAM_KEY,
ids[1], # Start from second message
ids[3] # End at fourth message
)
assert len(messages) == 3 # msgs 1, 2, 3 (inclusive)
seqs = [int(m[1]['seq']) for m in messages]
assert seqs == [1, 2, 3]Testing Multiple Consumer Groups
Real-world streams often have multiple consumer groups (e.g., an analytics group and a billing group that both need to process every order).
def test_independent_consumer_groups(stream_client):
ANALYTICS_GROUP = 'analytics'
BILLING_GROUP = 'billing'
# Create both groups
try:
stream_client.xgroup_create(order_stream.STREAM_KEY, ANALYTICS_GROUP, id='0', mkstream=True)
except redis_lib.exceptions.ResponseError:
pass
try:
stream_client.xgroup_create(order_stream.STREAM_KEY, BILLING_GROUP, id='0', mkstream=True)
except redis_lib.exceptions.ResponseError:
pass
# Publish one order
stream_client.xadd(order_stream.STREAM_KEY, {'order_id': 'ord-999'})
# Both groups can read the same message independently
analytics_msgs = stream_client.xreadgroup(
ANALYTICS_GROUP, 'analytics-consumer',
{order_stream.STREAM_KEY: '>'}, count=10, block=100
)
billing_msgs = stream_client.xreadgroup(
BILLING_GROUP, 'billing-consumer',
{order_stream.STREAM_KEY: '>'}, count=10, block=100
)
assert len(analytics_msgs[0][1]) == 1
assert len(billing_msgs[0][1]) == 1
analytics_order_id = analytics_msgs[0][1][0][1]['order_id']
billing_order_id = billing_msgs[0][1][0][1]['order_id']
assert analytics_order_id == billing_order_id == 'ord-999'Testing with fakeredis2 for Streams
For unit tests that don't need Docker, fakeredis2 supports Streams:
import fakeredis
import pytest
def test_stream_with_fakeredis():
r = fakeredis.FakeRedis(decode_responses=True)
# Add to stream
r.xadd('test:stream', {'field': 'value1'})
r.xadd('test:stream', {'field': 'value2'})
# Create consumer group
r.xgroup_create('test:stream', 'test-group', id='0')
# Read as consumer group
messages = r.xreadgroup(
'test-group', 'consumer-1',
{'test:stream': '>'}, count=10
)
assert len(messages[0][1]) == 2
assert messages[0][1][0][1]['field'] == 'value1'
# Acknowledge
msg_id = messages[0][1][0][0]
r.xack('test:stream', 'test-group', msg_id)
# Verify acknowledged
pending = r.xpending('test:stream', 'test-group')
assert pending['pending'] == 1 # Only one pending (second not acked)Common Mistakes
Mistake 1: Not waiting for subscriber thread to connect
After starting a subscribe thread, publish immediately without a small delay. The subscription may not be active yet:
# Bad
service.subscribe('channel', handler)
service.publish('channel', 'event', {}) # May miss
# Good
service.subscribe('channel', handler)
time.sleep(0.05) # Let subscription establish
service.publish('channel', 'event', {})Mistake 2: Not cleaning up streams between tests
Streams accumulate messages across tests. Use DELETE on the stream key or use a unique key per test:
@pytest.fixture(autouse=True)
def clean_stream(stream_client):
stream_client.delete(order_stream.STREAM_KEY)
yield
stream_client.delete(order_stream.STREAM_KEY)Mistake 3: Ignoring XGROUP_CREATE errors
Consumer group creation fails if the group already exists. Always wrap in try/except or use mkstream=True only on first creation.
Conclusion
Redis Pub/Sub tests need synchronization primitives (events, queues) to avoid flaky timing. Streams tests need Testcontainers or fakeredis2 for full consumer group support. The key properties to verify are: message delivery, consumer group isolation, acknowledgment semantics, and pending entry list behavior for failure recovery.