Testing Asynchronous Message Driven Architecture
Tests are vital parts of our systems. Easy to understand and modify tests will help us in keeping the project in good shape for long period of time.
First we may start with synchronous code, however sooner or later we will need to start processing part of our flows asynchronously, and this is when Message Driven Architecture becomes handy.
Message Driven Architecture we mostly aim for loose coupling of our components, quick recover from issues and possibility to handle high load.
However due to decoupled and asynchronous way of handling things, testing becomes much bigger challenge.
Testing this kind of architecture can easily become nightmare from the perspective of the speed and maintenance of such tests.
In this article we will aim on making those tests quick, easy to understand and write using examples from Ecotone Framework.
When we are dealing with synchronous code, writing tests is pretty straightforward.
We set up
state, we call some
API, and we
assert our expectations.
In case of asynchronous code we have
publisher of a message (e.g. command/event) and
message consumer which runs in separate processes. This make testing more tricky if we want to test full scenario, as it requires two processes to communicate.
Our test scenario, will be placing an order and sending asynchronous confirmation notification to the customer.
asynchronousattribute is related to channel (queue/transport).
Consumer with same name as channel (
notifications) can be run to start consuming messages and executing our Event Handler.
Running Publisher and Consumer in separate processes
We may run the test case which will publish given message (publisher side), and run the consumer in the background. Then we will be looping and awaiting for our expectation to be fulfilled.
This solution has few drawbacks however:
- It increase time for test to run, as now we bootstrap new process
- It becomes hard to debug, as it runs in the background and we don not have full control over the execution
- We can not use
dummyimplementations, as changes in one process, will not be visible in the second one
Having a lot of tests like this will slow your test suite dramatically.
When test suite fails it may be really hard to debug what is the cause, as consumer process is a background process.
Besides due to lack of shared memory stack, we will be required to build some tooling to support this (Like continues checking if state in database have changed for X seconds before we will consider test a failure).
The biggest advantage of running publisher and consumer in separate processes is it's the closest way to how things are running on the production.
However this comes with huge cost, as those kind of tests are slow and hard to debug and often starting to deviate from production due to required support tooling.
When we will run consumer process it will block our test suite, as consumer after handling given message will be waiting for next ones.
Consumers can be implemented with possibility to intercept execution.
Interception happens mostly for starting transactions, logging and error handling, but can also be used for testing purposes.
Using this technique we may implement
limit of handled messages and
execution time limit.
Handled message limit will ensure that we finish test as fast as the message is handled,
execution time limit on other hand protects in case of failure that the test will finish.
When running consumer in test be sure to intercept it.
This will decrease test suite time and ensure no zombie processes running in the background.
Running asynchronous code as synchronous
In most of the cases the
consuming process is actually the same application, which means we can actually run it from
publishing process too.
Running consumer within same process as our test scenario, will decrease our test suite time and will make debugging much easier.
It will also allow us for using in memory implementations, as changes will happen within same process. This is huge advantage, as we can mock things out for particular scenario with ease.
Running publisher and consumer within same process (test scenario), is still much like production run, as executed code is the same.
Running Consumer with In Memory Channel
In most of the cases when we run test with real Message Broker behind the scenes, we can't run our tests in parallel.
Besides that when we interact with Message Broker our test scenario takes much longer than it would be with in memory implementations.
Message Queue which is consumed by the Consumer is just a
If our Consumer implementation is abstracted from specific broker implementation, then we will be able to replace it with
In Memory Message Channel.
This is exactly the case for Ecotone, you may switch Message Channel implementation as it suits you.
Using In Memory implementations for Message Channels, speeds up tests to and allows to run them in parallel.
Switch from Polling to Event-Driven
Pollable channels creates
Pollable consumers, which means the code will be executed asynchronously.
The second option is so called
Event-Driven consumer, which means code is triggered synchronously (imagine synchronous Event/Command Handler).
Ecotone Framework supports Message Channels and different Consumer implementations, therefore we are able to switch our code from running asynchronous to synchronous and vice versa.
With good messaging framework asynchronicity is abstracted away and we can write code that is unaware of the consumption process.
This allow us to write simpler tests and switch Message Broker implementations without affecting our production code.
Limit scope of your tests
In messaging architecture it often happens that given message is consumed by multiple handlers. However in given test scenario, we may be only interested in small portion of the flow and we want to skip the rest.
With Ecotone testing support we provide list of classes that should be resolved for given test scenario. This way we can easily test small portion of our code base in given test scenario.
$ecotoneTestSupport = EcotoneLite::bootstrapForTesting( // pass list of classes that should be included in this test [OrderService::class, OrderNotifier::class, $dependencyContainer );
Testing message driven architecture may be challenging, due to decoupled nature and asynchronicity. However it comes with so many pros, especially when the system grows, that is becomes a need at some point of time.
We may lower the bar with good supporting tools that messaging frameworks provide.
So at the end we can have all the messaging benefits and still be able to write simple yet effective tests.