Deploying a whole service with a Message Broker is a bit too much just for testing purposes, especially in your CI environment. Not to mention the possible loss of messages due to network failures when working with real brokers.
For this reason, FastStream has a special TestClient to make your broker work in InMemory mode.
Just use it like a regular async context manager - all published messages will be routed in-memory (without any external dependencies) and consumed by the correct handler.
The handle mock has a raw JSON message body. This way you can validate the incoming message itself and not a parsed python arguments.
Thus our example checks not mock.assert_called_with(name="John",user_id=1), but mock.assert_called_with({"name":"John","user_id":1}).
Scoping rule: the mock exists only inside the context manager. Once it exits, handle.mock raises a SetupError instead of answering for calls nobody made.
@pytest.mark.asyncioasyncdeftest_handle()->None:asyncwithTestKafkaBroker(broker)asbr:awaitbr.publish({"name":"John","user_id":1},topic="test-topic")handle.mock.assert_called_once_with({"name":"John","user_id":1})withpytest.raises(SetupError):# the mock leaves with the test brokerhandle.mock.assert_not_called()
@pytest.mark.asyncioasyncdeftest_handle()->None:asyncwithTestKafkaBroker(broker)asbr:awaitbr.publish({"name":"John","user_id":1},topic="test-topic")handle.mock.assert_called_once_with({"name":"John","user_id":1})withpytest.raises(SetupError):# the mock leaves with the test brokerhandle.mock.assert_not_called()
@pytest.mark.asyncioasyncdeftest_handle()->None:asyncwithTestRabbitBroker(broker)asbr:awaitbr.publish({"name":"John","user_id":1},queue="test-queue")handle.mock.assert_called_once_with({"name":"John","user_id":1})withpytest.raises(SetupError):# the mock leaves with the test brokerhandle.mock.assert_not_called()
@pytest.mark.asyncioasyncdeftest_handle()->None:asyncwithTestNatsBroker(broker)asbr:awaitbr.publish({"name":"John","user_id":1},subject="test-subject")handle.mock.assert_called_once_with({"name":"John","user_id":1})withpytest.raises(SetupError):# the mock leaves with the test brokerhandle.mock.assert_not_called()
@pytest.mark.asyncioasyncdeftest_handle()->None:asyncwithTestRedisBroker(broker)asbr:awaitbr.publish({"name":"John","user_id":1},channel="test-channel")handle.mock.assert_called_once_with({"name":"John","user_id":1})withpytest.raises(SetupError):# the mock leaves with the test brokerhandle.mock.assert_not_called()
@pytest.mark.asyncioasyncdeftest_handle()->None:asyncwithTestMQTTBroker(broker)asbr:awaitbr.publish({"name":"John","user_id":1},topic="test-topic")handle.mock.assert_called_once_with({"name":"John","user_id":1})withpytest.raises(SetupError):# the mock leaves with the test brokerhandle.mock.assert_not_called()
Every handler also has an assert_called_once_with method. It checks the message body the same way mock.assert_called_once_with does, and beside it the message fields the handler saw: headers, correlation_id, reply_to, content_type and path.
Using assert_called_once_with, you can check the body and the headers in one statement. The body may be a plain dict or your model: it goes through the broker codec before the comparison, so both spellings mean the same message.
Headers match as a subset: FastStream adds its own headers (content-type, correlation_id) beside yours, and they never get in the way. Every other field matches exactly. When several fields differ, the AssertionError lists all of them at once.
A handler that saw several messages answers with two more methods, named as in unittest.mock and taking the same arguments: assert_called_with checks the last message, as the mock's does, and assert_any_call passes when any of the messages matches. When none does, the AssertionError lists every message the handler saw with its own mismatches.
importpytestfrompydanticimportBaseModelfromfaststreamimportFastStream,Headerfromfaststream.kafkaimportKafkaBroker,TestKafkaBroker@pytest.mark.asyncioasyncdeftest_several_messages()->None:asyncwithTestKafkaBroker(broker)asbr:awaitbr.publish(Data(name="John",user_id=1),topic="test-topic",headers={"trace-id":"42"},correlation_id="first",)awaitbr.publish(Data(name="John",user_id=1),topic="test-topic",headers={"trace-id":"42"},correlation_id="second",)# the last message, as `mock.assert_called_with` reads itawaithandle.assert_called_with(Data(name="John",user_id=1),correlation_id="second",)# any of the messagesawaithandle.assert_any_call(Data(name="John",user_id=1),correlation_id="first",)
importpytestfrompydanticimportBaseModelfromfaststreamimportFastStream,Headerfromfaststream.confluentimportKafkaBroker,TestKafkaBroker@pytest.mark.asyncioasyncdeftest_several_messages()->None:asyncwithTestKafkaBroker(broker)asbr:awaitbr.publish(Data(name="John",user_id=1),topic="test-topic",headers={"trace-id":"42"},correlation_id="first",)awaitbr.publish(Data(name="John",user_id=1),topic="test-topic",headers={"trace-id":"42"},correlation_id="second",)# the last message, as `mock.assert_called_with` reads itawaithandle.assert_called_with(Data(name="John",user_id=1),correlation_id="second",)# any of the messagesawaithandle.assert_any_call(Data(name="John",user_id=1),correlation_id="first",)
importpytestfrompydanticimportBaseModelfromfaststreamimportFastStream,Headerfromfaststream.rabbitimportRabbitBroker,TestRabbitBroker@pytest.mark.asyncioasyncdeftest_several_messages()->None:asyncwithTestRabbitBroker(broker)asbr:awaitbr.publish(Data(name="John",user_id=1),queue="test-queue",headers={"trace-id":"42"},correlation_id="first",)awaitbr.publish(Data(name="John",user_id=1),queue="test-queue",headers={"trace-id":"42"},correlation_id="second",)# the last message, as `mock.assert_called_with` reads itawaithandle.assert_called_with(Data(name="John",user_id=1),correlation_id="second",)# any of the messagesawaithandle.assert_any_call(Data(name="John",user_id=1),correlation_id="first",)
importpytestfrompydanticimportBaseModelfromfaststreamimportFastStream,Headerfromfaststream.natsimportNatsBroker,TestNatsBroker@pytest.mark.asyncioasyncdeftest_several_messages()->None:asyncwithTestNatsBroker(broker)asbr:awaitbr.publish(Data(name="John",user_id=1),subject="test.subject",headers={"trace-id":"42"},correlation_id="first",)awaitbr.publish(Data(name="John",user_id=1),subject="test.subject",headers={"trace-id":"42"},correlation_id="second",)# the last message, as `mock.assert_called_with` reads itawaithandle.assert_called_with(Data(name="John",user_id=1),correlation_id="second",)# any of the messagesawaithandle.assert_any_call(Data(name="John",user_id=1),correlation_id="first",)
importpytestfrompydanticimportBaseModelfromfaststreamimportFastStream,Headerfromfaststream.redisimportRedisBroker,TestRedisBroker@pytest.mark.asyncioasyncdeftest_several_messages()->None:asyncwithTestRedisBroker(broker)asbr:awaitbr.publish(Data(name="John",user_id=1),channel="test-channel",headers={"trace-id":"42"},correlation_id="first",)awaitbr.publish(Data(name="John",user_id=1),channel="test-channel",headers={"trace-id":"42"},correlation_id="second",)# the last message, as `mock.assert_called_with` reads itawaithandle.assert_called_with(Data(name="John",user_id=1),correlation_id="second",)# any of the messagesawaithandle.assert_any_call(Data(name="John",user_id=1),correlation_id="first",)
importpytestfrompydanticimportBaseModelfromfaststreamimportFastStream,Headerfromfaststream.mqttimportMQTTBroker,TestMQTTBroker@pytest.mark.asyncioasyncdeftest_several_messages()->None:asyncwithTestMQTTBroker(broker)asbr:awaitbr.publish(Data(name="John",user_id=1),topic="test-topic",headers={"trace-id":"42"},correlation_id="first",)awaitbr.publish(Data(name="John",user_id=1),topic="test-topic",headers={"trace-id":"42"},correlation_id="second",)# the last message, as `mock.assert_called_with` reads itawaithandle.assert_called_with(Data(name="John",user_id=1),correlation_id="second",)# any of the messagesawaithandle.assert_any_call(Data(name="John",user_id=1),correlation_id="first",)
To check only a part of the body, put a dirty-equals matcher in its place. Anything the fields above do not cover, such as the Kafka message key, lives in the context: check it through context by the same path you would give to Context(), walking attributes and dict keys from a context name.
A context path reads attributes and keys, it never calls. Where a raw message answers with methods, as the Confluent one does, reach for what FastStream put in the context beside it, such as log_context.
Note
Both handle.mock and the three assertion methods exist only inside the test broker. Outside of it they raise a SetupError instead of answering for a handler nobody has called.
If you want to test your application in a real environment, you shouldn't have to rewrite all your tests: just pass with_real optional parameter to your TestClient context manager. This way, TestClient supports all the testing features but uses an unpatched broker to send and consume messages.
importpytestfrompydanticimportValidationErrorfromfaststream.exceptionsimportSetupErrorfromfaststream.kafkaimportTestKafkaBroker@pytest.mark.asyncioasyncdeftest_handle()->None:asyncwithTestKafkaBroker(broker,with_real=True)asbr:awaitbr.publish({"name":"John","user_id":1},topic="test-topic")awaithandle.wait_call(timeout=3)handle.mock.assert_called_once_with({"name":"John","user_id":1})withpytest.raises(SetupError):# the mock leaves with the test brokerhandle.mock.assert_not_called()@pytest.mark.asyncioasyncdeftest_validation_error()->None:asyncwithTestKafkaBroker(broker,with_real=True)asbr:withpytest.raises(ValidationError):awaitbr.publish("wrong message",topic="test-topic")awaithandle.wait_call(timeout=3)handle.mock.assert_called_once_with("wrong message")
importpytestfrompydanticimportValidationErrorfromfaststream.exceptionsimportSetupErrorfromfaststream.confluentimportTestKafkaBroker@pytest.mark.asyncioasyncdeftest_handle()->None:asyncwithTestKafkaBroker(broker,with_real=True)asbr:awaitbr.publish({"name":"John","user_id":1},topic="test-topic-confluent")awaithandle.wait_call(timeout=30)handle.mock.assert_called_once_with({"name":"John","user_id":1})withpytest.raises(SetupError):# the mock leaves with the test brokerhandle.mock.assert_not_called()@pytest.mark.asyncioasyncdeftest_validation_error()->None:asyncwithTestKafkaBroker(broker,with_real=True)asbr:withpytest.raises(ValidationError):awaitbr.publish("wrong message",topic="test-topic-confluent")awaithandle.wait_call(timeout=30)handle.mock.assert_called_once_with("wrong message")
importpytestfrompydanticimportValidationErrorfromfaststream.exceptionsimportSetupErrorfromfaststream.rabbitimportTestRabbitBroker@pytest.mark.asyncioasyncdeftest_handle()->None:asyncwithTestRabbitBroker(broker,with_real=True)asbr:awaitbr.publish({"name":"John","user_id":1},queue="test-queue")awaithandle.wait_call(timeout=3)handle.mock.assert_called_once_with({"name":"John","user_id":1})withpytest.raises(SetupError):# the mock leaves with the test brokerhandle.mock.assert_not_called()@pytest.mark.asyncioasyncdeftest_validation_error()->None:asyncwithTestRabbitBroker(broker,with_real=True)asbr:withpytest.raises(ValidationError):awaitbr.publish("wrong message",queue="test-queue")awaithandle.wait_call(timeout=3)handle.mock.assert_called_once_with("wrong message")
importpytestfrompydanticimportValidationErrorfromfaststream.exceptionsimportSetupErrorfromfaststream.natsimportTestNatsBroker@pytest.mark.asyncioasyncdeftest_handle()->None:asyncwithTestNatsBroker(broker,with_real=True)asbr:awaitbr.publish({"name":"John","user_id":1},subject="test-subject")awaithandle.wait_call(timeout=3)handle.mock.assert_called_once_with({"name":"John","user_id":1})withpytest.raises(SetupError):# the mock leaves with the test brokerhandle.mock.assert_not_called()@pytest.mark.asyncioasyncdeftest_validation_error()->None:asyncwithTestNatsBroker(broker,with_real=True)asbr:withpytest.raises(ValidationError):awaitbr.publish("wrong message",subject="test-subject")awaithandle.wait_call(timeout=3)handle.mock.assert_called_once_with("wrong message")
importpytestfrompydanticimportValidationErrorfromfaststream.exceptionsimportSetupErrorfromfaststream.redisimportTestRedisBroker@pytest.mark.asyncioasyncdeftest_handle()->None:asyncwithTestRedisBroker(broker,with_real=True)asbr:awaitbr.publish({"name":"John","user_id":1},channel="test-channel")awaithandle.wait_call(timeout=3)handle.mock.assert_called_once_with({"name":"John","user_id":1})withpytest.raises(SetupError):# the mock leaves with the test brokerhandle.mock.assert_not_called()@pytest.mark.asyncioasyncdeftest_validation_error()->None:asyncwithTestRedisBroker(broker,with_real=True)asbr:withpytest.raises(ValidationError):awaitbr.publish("wrong message",channel="test-channel")awaithandle.wait_call(timeout=3)handle.mock.assert_called_once_with("wrong message")
importpytestfrompydanticimportValidationErrorfromfaststream.exceptionsimportSetupErrorfromfaststream.mqttimportTestMQTTBroker@pytest.mark.asyncioasyncdeftest_handle()->None:asyncwithTestMQTTBroker(broker,with_real=True)asbr:awaitbr.publish({"name":"John","user_id":1},topic="test-topic")awaithandle.wait_call(timeout=3)handle.mock.assert_called_once_with({"name":"John","user_id":1})withpytest.raises(SetupError):# the mock leaves with the test brokerhandle.mock.assert_not_called()@pytest.mark.asyncioasyncdeftest_validation_error()->None:asyncwithTestMQTTBroker(broker,with_real=True)asbr:withpytest.raises(ValidationError):awaitbr.publish("wrong message",topic="test-topic")awaithandle.wait_call(timeout=3)handle.mock.assert_called_once_with("wrong message")
Tip
When you're using a patched broker to test your consumers, the publish method is called synchronously with a consumer one, so you need not wait until your message is consumed. But in the real broker's case, it doesn't.
For this reason, you have to wait for message consumption manually with the special handler.wait_call(timeout) method. Also, inner handler exceptions will be raised in this function, not broker.publish(...).
It can be very useful to set the with_real flag using an environment variable. This way, you will be able to choose the testing mode right from the command line:
WITH_REAL=True/Falsepytest...
To learn more about managing your application configuration visit this page.