Testing¶
Basics¶
To test an agent when unit testing or functional testing, use the special
Agent.test() mode to send items to the stream while processing it locally:
app = faust.App('test-example')
class Order(faust.Record, serializer='json'):
account_id: str
product_id: str
amount: int
price: float
orders_topic = app.topic('orders', value_type=Order)
orders_for_account = app.Table('order-count-by-account', default=int)
@app.agent(orders_topic)
async def order(orders):
async for order in orders.group_by(Order.account_id):
orders_for_account[order.account_id] += 1
yield order
Our agent reads a stream of orders and keeps a count of them by account id in a distributed table also partitioned by the account id.
To test this agent we use order.test_context():
async def test_order():
# start and stop the agent in this block
async with order.test_context() as agent:
order = Order(account_id='1', product_id='2', amount=1, price=300)
# sent order to the test agents local channel, and wait
# the agent to process it.
await agent.put(order)
# at this point the agent already updated the table
assert orders_for_account[order.account_id] == 1
await agent.put(order)
assert orders_for_account[order.account_id] == 2
async def run_tests():
app.conf.store = 'memory://' # tables must be in-memory
await test_order()
if __name__ == '__main__':
import asyncio
loop = asyncio.get_event_loop()
loop.run_until_complete(run_tests())
For the rest of this guide we’ll be using https://pypi.org/project/pytest/ and https://pypi.org/project/pytest-asyncio/ for our examples. If you’re using a different testing framework you may have to adapt them a bit to work.
Testing with https://pypi.org/project/pytest/¶
Testing that an agent sends to topic/calls another agent.¶
When unit testing you should mock any dependencies of the agent being tested,
If your agent calls another function: mock that function to verify it was called.
If your agent sends a message to a topic: mock that topic to verify a message was sent.
If your agent calls another agent: mock the other agent to verify it was called.
Here’s an example agent that calls another agent:
import faust
app = faust.App('example-test-agent-call')
@app.agent()
async def foo(stream):
async for value in stream:
await bar.send(value)
yield value
@app.agent()
async def bar(stream):
async for value in stream:
yield value + 'YOLO'
To test these two agents you have to test them in isolation of each other:
first test foo with bar mocked, then in a different test do bar:
import pytest
from unittest.mock import Mock, patch
from example import app, foo, bar
@pytest.fixture(scope="function")
def test_app(event_loop):
"""passing in event_loop helps avoid 'attached to a different loop' error"""
app.loop = event_loop
app.finalize()
app.conf.store = 'memory://'
app.flow_control.resume()
return app
@pytest.mark.asyncio()
async def test_foo(test_app):
with patch(__name__ + '.bar') as mocked_bar:
mocked_bar.send = mock_coro()
async with foo.test_context() as agent:
await agent.put('hey')
mocked_bar.send.assert_called_with('hey')
def mock_coro(return_value=None, **kwargs):
"""Create mock coroutine function."""
async def wrapped(*args, **kwargs):
return return_value
return Mock(wraps=wrapped, **kwargs)
@pytest.mark.asyncio()
async def test_bar(test_app):
async with bar.test_context() as agent:
event = await agent.put('hey')
assert agent.results[event.message.offset] == 'heyYOLO'
You can put the test_app fixture into a [conftest.py file](https://docs.pytest.org/en/6.2.x/fixture.html#scope-sharing-fixtures-across-classes-modules-packages-or-session). If the fixture is not in the same file as the app’s definition (which should be the case) you must import the app the fixture definition:
Note
The https://pypi.org/project/pytest-asyncio/ extension must be installed to run these tests. If you don’t have it use pip to install it:
$ pip install -U pytest-asyncio
Setting up a test suite¶
A faust app is normally a module-level singleton whose agents are attached to it at import time, so a suite needs a little setup before those agents can be started in isolation.
The four files below are a complete, working suite: copy them into an empty
directory, pip install faust-streaming pytest pytest-asyncio, run
pytest, and four tests pass. The rest of this section explains
what each piece is doing.
The application under test – myapp.py¶
The agent here is a pure consumer: it updates a table, calls a service and forwards to another topic, and never yields. See Testing agents that don’t yield for why that shape needs no sink.
import faust
class Order(faust.Record, serializer='json'):
account_id: str
amount: int
app = faust.App('orders-app', broker='kafka://localhost:9092')
orders_topic = app.topic('orders', value_type=Order)
shipped_topic = app.topic('shipped', value_type=Order)
orders_for_account = app.Table('order-count-by-account', default=int)
async def notify_warehouse(order: Order) -> None:
"""Pretend this calls an external HTTP service."""
...
@app.agent(orders_topic)
async def track_order(orders):
# NOTE: no ``yield`` -- this agent is a pure consumer.
async for order in orders:
orders_for_account[order.account_id] += 1
await notify_warehouse(order)
await shipped_topic.send(value=order)
Enabling async tests – pytest.ini¶
These examples use https://pypi.org/project/pytest-asyncio/ in auto mode, so async tests and
fixtures need no decorator:
[pytest]
asyncio_mode = auto
Fixtures – conftest.py¶
import asyncio
from unittest.mock import AsyncMock
import pytest
import myapp
from myapp import app as _app
@pytest.fixture()
async def app():
# Bind the app to the event loop pytest-asyncio created for this test.
_app.loop = asyncio.get_running_loop()
_app.finalize()
_app.conf.store = 'memory://'
_app.flow_control.resume()
return _app
@pytest.fixture()
def shipped(monkeypatch):
"""Keep the agent from reaching a real broker."""
send = AsyncMock()
monkeypatch.setattr(myapp.shipped_topic, 'send', send)
return send
@pytest.fixture()
def warehouse(monkeypatch):
"""Keep the agent from calling the real service."""
notify = AsyncMock()
monkeypatch.setattr(myapp, 'notify_warehouse', notify)
return notify
The tests – test_myapp.py¶
from myapp import Order, orders_for_account, track_order
async def test_counts_orders_per_account(app, shipped, warehouse):
async with track_order.test_context() as agent:
await agent.put(Order(account_id='A', amount=1))
assert orders_for_account['A'] == 1
await agent.put(Order(account_id='A', amount=2))
assert orders_for_account['A'] == 2
async def test_notifies_warehouse(app, shipped, warehouse):
async with track_order.test_context() as agent:
order = Order(account_id='B', amount=3)
await agent.put(order)
warehouse.assert_awaited_once_with(order)
async def test_forwards_to_shipped_topic(app, shipped, warehouse):
async with track_order.test_context() as agent:
order = Order(account_id='C', amount=4)
await agent.put(order)
shipped.assert_awaited_once_with(value=order)
async def test_results_records_input_values(app, shipped, warehouse):
async with track_order.test_context() as agent:
order = Order(account_id='D', amount=5)
await agent.put(order)
# For a sink-less agent ``results`` holds what went IN.
assert agent.results[0] == order
What the app fixture is doing¶
Each line earns its place:
_app.loopRe-binds the app to the event loop https://pypi.org/project/pytest-asyncio/ created for this test. A module-level app otherwise stays bound to the loop of whichever test ran first, and every later test fails with
RuntimeError: Event loop is closed._app.finalize()Completes app configuration. Normally the worker does this for you.
_app.conf.store = 'memory://'Tables must be in-memory in tests; the default (RocksDB) wants a real data directory. Faust warns that the setting arrives after your topics and agents were declared – expected here and harmless, since no table has started yet. Set
storeon theAppitself if you would rather not see the warning._app.flow_control.resume()Stream queues start out suspended. Omit this and the agent never receives anything, so
put()hangs forever.
Note
The test_app fixture shown earlier does the same job, but reaches the
loop by requesting https://pypi.org/project/pytest-asyncio/’s event_loop fixture. Recent
releases deprecate that (“Asynchronous fixtures and test functions should
use asyncio.get_running_loop() instead”), so prefer the async fixture
above in new test suites.
Mock anything that leaves the process¶
test_context() feeds the agent through a local channel, but it does not
stub out the rest of your app. If the agent sends to a topic or calls an
external service it will genuinely try to, and the test fails with:
aiokafka.errors.KafkaConnectionError: Unable to bootstrap from [('localhost', 9092, ...)]
That is what the shipped and warehouse fixtures are for: give every
outbound dependency its own fixture, so each test both stays offline and gets
a mock it can assert on.
Testing agents that don’t yield¶
Every earlier example ends in yield and reads its output back through
agent.results. Plenty of real agents never yield: they update a table,
call a service, or forward to another topic and stop there. Such an agent
cannot use sinks at all – attaching one raises
ImproperlyConfigured('Agent must yield to use sinks') – yet
test_context() tests them perfectly well. track_order above is
exactly that shape.
Because nothing is yielded, you assert on what the agent did rather than on
what it returned: the table it wrote (test_counts_orders_per_account), the
service it called (test_notifies_warehouse), and the message it forwarded
(test_forwards_to_shipped_topic).
What agent.results holds¶
results is populated for both kinds of agent, but it does not mean the
same thing in each case:
Agent |
|
|---|---|
yields |
the value the agent yielded (its output) |
does not yield |
the value that was sent in (its input) |
Which row applies is decided from the running agent, not from how its function
is written. An agent implemented as a callable object with an
async-generator __call__, or as a plain function returning an async
generator, yields just as much as an async def that yields directly, and
its results hold its output.
There is no output to capture for a sink-less agent, so faust records the
incoming value instead. That still makes results useful for confirming
which values reached the agent – as test_results_records_input_values
does above – but do not read it expecting a return value.
When put() returns
await agent.put(value) waits for the value to be picked up by the
agent, and for an agent body that does not await anything mid-loop the
side effects are already visible when put() returns – which is why the
assertions above can follow it directly.
If the body does await something (an HTTP call, asyncio.sleep, a real
send), put() can return before the body has finished with that
value, and asserting immediately will be flaky. Wait for the effect rather
than assuming it – for example by leaving the async with block, since
stopping the agent drains what is in flight:
async def test_slow_agent(app):
processed = []
async with slow_agent.test_context() as agent:
await agent.put('a')
# the context manager stopped the agent: work is finished
assert processed == ['a']
Testing and windowed tables¶
If your table is windowed and you want to verify that the value for a key is
correctly set, use table[k].current(event) to get the value placed within
the window of the current event:
import faust
import pytest
@pytest.mark.asyncio()
async def test_process_order():
app.conf.store = 'memory://'
async with process_order.test_context() as agent:
order = Order(account_id='1', product_id='2', amount=1, price=300)
event = await agent.put(order)
# windowed table: we select window relative to the current event
assert orders_for_account['1'].current(event) == 1
# in the window 3 hours ago there were no orders:
assert orders_for_account['1'].delta(3600 * 3, event)
class Order(faust.Record, serializer='json'):
account_id: str
product_id: str
amount: int
price: float
app = faust.App('test-example')
orders_topic = app.topic('orders', value_type=Order)
# order count within the last hour (window is a 1-hour TumblingWindow).
orders_for_account = app.Table(
'order-count-by-account', default=int,
).tumbling(3600).relative_to_stream()
@app.agent(orders_topic)
async def process_order(orders):
async for order in orders.group_by(Order.account_id):
orders_for_account[order.account_id] += 1
yield order