Application Tests
DDD CQRS ES
Application tests validate the orchestration layer, commands, command handlers, event handlers, and application services. They naturally follow a BDD structure: Given a domain state, When a command is processed, Then verify the outcomes.
We recommend pytest-bdd for writing these tests. It uses Gherkin feature files to express scenarios in plain language and maps each step to a Python function, integrating with pytest fixtures.
Application tests exercise the full processing pipeline, from command dispatch through handler execution to persistence. They use synchronous processing and in-memory adapters, so assertions are deterministic and the tests need no infrastructure.
Installing pytest-bdd
pip install pytest-bdd
Or with uv:
uv add --group test pytest-bdd
Project Structure
Organize feature files by domain concept and co-locate step definitions nearby:
tests/
├── conftest.py # Root fixtures (domain, context)
├── bdd/
│ ├── conftest.py # Shared BDD steps and fixtures
│ ├── registration/
│ │ ├── features/
│ │ │ ├── register_user.feature
│ │ │ └── activate_user.feature
│ │ ├── conftest.py # Registration shared steps
│ │ ├── test_register_user.py
│ │ └── test_activate_user.py
│ ├── ordering/
│ │ ├── features/
│ │ │ ├── place_order.feature
│ │ │ └── cancel_order.feature
│ │ ├── conftest.py # Ordering shared steps
│ │ ├── test_place_order.py
│ │ └── test_cancel_order.py
│ └── catalog/
│ ├── features/
│ │ └── manage_books.feature
│ ├── conftest.py
│ └── test_manage_books.py
└── unit/
└── ... # Domain model tests (plain pytest)
Each domain concept gets its own directory with feature files and step
definitions. Shared steps (like creating common entities) are defined in
conftest.py files at the appropriate level.
Writing Feature Files
Feature files describe application behavior in Gherkin. A structured, readable format that maps directly to your domain language.
A Command Processing Scenario
# tests/bdd/registration/features/register_user.feature
Feature: User Registration
Users should be able to register through the Register command
and receive a welcome notification.
Background:
Given the domain is initialized
Scenario: Register a new user
When a Register command is processed for "john@example.com"
Then a user should exist with email "john@example.com"
And the user status should be "INACTIVE"
Scenario: Registration triggers a welcome notification
When a Register command is processed for "jane@example.com"
Then a welcome notification should have been sent to "jane@example.com"
Scenario: Duplicate registration is rejected
Given a user already exists with email "john@example.com"
When a Register command is attempted for "john@example.com"
Then the command should fail with error "already registered"
Use Background to express setup steps shared by every scenario in a feature
file. Only Given steps belong in a Background.
An Event Handler Scenario
# tests/bdd/ordering/features/place_order.feature
@ordering @smoke
Feature: Place Order
Customers can place orders that trigger inventory updates
and customer history tracking.
Background:
Given the domain is initialized
@happy_path
Scenario: Placing an order updates inventory
Given a book "Dune" with 10 copies in stock
And a customer "Alice"
When "Alice" places an order for 2 copies of "Dune"
Then the order should be confirmed
And "Dune" should have 8 copies in stock
Scenario: Placing an order records customer history
Given a book "1984" with 5 copies in stock
And a customer "Bob"
When "Bob" places an order for 2 copies of "1984"
Then "Bob" should have 1 order in their history
@validation
Scenario: Cannot order more than available stock
Given a book "Rare Book" with 2 copies in stock
And a customer "Charlie"
When "Charlie" tries to order 10 copies of "Rare Book"
Then the order should be rejected with "Insufficient stock"
Scenario Outlines for Data-Driven Tests
Use Scenario Outlines when the same flow applies to multiple inputs:
Scenario Outline: Order total is calculated correctly
Given a customer "Alice"
And "Alice" adds <quantity> copies of a book priced at <price>
When "Alice" places the order
Then the order total should be <total>
Examples:
| quantity | price | total |
| 1 | 10.00 | 10.00 |
| 3 | 20.00 | 60.00 |
| 2 | 15.50 | 31.00 |
Writing Step Definitions
Step definitions are Python functions decorated with @given, @when, or
@then. They connect Gherkin steps to your Protean domain code.
Binding Scenarios
Use scenarios() to bind all scenarios in a feature file to a test module. It
automatically creates a test function for each scenario. You only need to
provide the step definitions:
# fragment
# tests/bdd/registration/test_register_user.py
from uuid import uuid4
from myapp import domain
from myapp.commands import Register
from myapp.models import Notification, User
from pytest_bdd import given, parsers, scenarios, then, when
from protean.exceptions import ValidationError
scenarios("features/register_user.feature")
@when(
parsers.parse('a Register command is processed for "{email}"'),
target_fixture="email",
)
def process_register(email):
domain.process(Register(user_id=str(uuid4()), email=email, name="Test User"))
return email
@then(parsers.parse('a user should exist with email "{email}"'))
def user_exists(email):
users = domain.repository_for(User).query.all()
match = [u for u in users.items if u.email == email]
assert len(match) == 1
@then(parsers.parse('the user status should be "{status}"'))
def user_has_status(email, status):
users = domain.repository_for(User).query.all()
user = next(u for u in users.items if u.email == email)
assert user.status == status
@then(parsers.parse('a welcome notification should have been sent to "{email}"'))
def welcome_sent(email):
sent = domain.repository_for(Notification).query.filter(recipient=email).all()
assert [n.kind for n in sent.items] == ["welcome"]
@given(parsers.parse('a user already exists with email "{email}"'))
def user_already_exists(email):
domain.process(Register(user_id=str(uuid4()), email=email, name="First User"))
@when(
parsers.parse('a Register command is attempted for "{email}"'),
target_fixture="registration_result",
)
def attempt_register(email):
try:
domain.process(Register(user_id=str(uuid4()), email=email, name="Test User"))
return {"success": True}
except ValidationError as exc:
return {"success": False, "error": str(exc.messages)}
@then(parsers.parse('the command should fail with error "{message}"'))
def registration_failed(registration_result, message):
assert registration_result["success"] is False
assert message in registration_result["error"]
assert domain.repository_for(User).query.all().total == 1
Sharing State with target_fixture
Use target_fixture to pass data between steps. The return value of the step
becomes a pytest fixture available to all subsequent steps in the same
scenario. The Given steps that return the book and customer fixtures are
shared steps, so tests/bdd/ordering/conftest.py defines them, as shown under
Domain-Specific Shared Steps. The test module
holds the rest:
# fragment
# tests/bdd/ordering/test_place_order.py
from myapp import domain
from myapp.commands import PlaceOrder
from myapp.models import Book, Customer, Inventory, Money, Order, OrderItemVO
from pytest_bdd import given, parsers, scenarios, then, when
scenarios("features/place_order.feature")
@when(
parsers.parse('"{name}" places an order for {qty:d} copies of "{title}"'),
target_fixture="order",
)
def place_order(customer, book, name, qty, title):
domain.process(
PlaceOrder(
customer_id=customer.id,
items=[OrderItemVO(book_id=book.id, quantity=qty)],
total=book.price.amount * qty,
)
)
orders = domain.repository_for(Order).query.all()
return orders.items[0]
@then("the order should be confirmed")
def order_confirmed(order):
assert order.status == "CONFIRMED"
@then(parsers.parse('"{title}" should have {expected:d} copies in stock'))
def check_stock(title, expected):
inventories = domain.repository_for(Inventory).query.all()
inv = next(i for i in inventories.items if i.title == title)
assert inv.quantity == expected
@then(parsers.parse('"{name}" should have {count:d} order in their history'))
def check_history(customer, name, count):
customer = domain.repository_for(Customer).get(customer.id)
assert len(customer.order_history) == count
The flow of fixtures through a scenario looks like this:
Handling Expected Failures
When testing that a command is rejected, capture the exception in the @when
step and verify it in @then:
# fragment
from protean.exceptions import ValidationError
@when(
parsers.parse('"{name}" tries to order {qty:d} copies of "{title}"'),
target_fixture="order_result",
)
def try_place_order(customer, book, name, qty, title):
try:
domain.process(
PlaceOrder(
customer_id=customer.id,
items=[OrderItemVO(book_id=book.id, quantity=qty)],
total=book.price.amount * qty,
)
)
return {"success": True}
except ValidationError as exc:
return {"success": False, "error": str(exc.messages)}
@then(parsers.parse('the order should be rejected with "{message}"'))
def order_rejected(order_result, message):
assert order_result["success"] is False
assert message in order_result["error"]
Scenario Outline Steps
For Scenario Outlines, use parsers.parse with format specifiers to extract
typed values:
# fragment
@given(
parsers.parse('"{name}" adds {quantity:d} copies of a book priced at {price:f}'),
target_fixture="cart",
)
def add_to_cart(customer, name, quantity, price):
book = Book(title="Test Book", author="Author", price=Money(amount=price))
domain.repository_for(Book).add(book)
domain.repository_for(Inventory).add(
Inventory(book_id=book.id, title=book.title, quantity=quantity)
)
return {"book": book, "quantity": quantity}
@when(
parsers.parse('"{name}" places the order'),
target_fixture="order",
)
def place_order_from_cart(customer, cart, name):
domain.process(
PlaceOrder(
customer_id=customer.id,
items=[OrderItemVO(book_id=cart["book"].id, quantity=cart["quantity"])],
total=cart["book"].price.amount * cart["quantity"],
)
)
orders = domain.repository_for(Order).query.all()
return orders.items[0]
@then(parsers.parse("the order total should be {expected:f}"))
def check_total(order, expected):
assert float(order.total) == expected
Common format specifiers for parsers.parse:
| Specifier | Type | Example |
|---|---|---|
{name} |
str (default) |
"Alice" |
{count:d} |
int |
42 |
{price:f} |
float |
19.99 |
{word:w} |
word (no spaces) | ACTIVE |
Shared Steps in conftest.py
Steps that appear across multiple feature files belong in conftest.py.
pytest-bdd discovers step definitions defined there automatically.
Root-Level Shared Steps
The root conftest.py uses DomainFixture to initialize the domain and clean
up after each test (see Fixtures and Patterns for
the full setup):
The root conftest.py these tests run under
# fragment
# tests/conftest.py
import pytest
from myapp import domain
from protean.integrations.pytest import DomainFixture
@pytest.fixture(scope="session")
def app_fixture():
domain.config["event_processing"] = "sync"
domain.config["command_processing"] = "sync"
fixture = DomainFixture(domain)
fixture.setup()
yield fixture
fixture.teardown()
@pytest.fixture(autouse=True)
def _ctx(app_fixture):
with app_fixture.domain_context():
yield
The BDD-level conftest.py only needs to define shared steps:
# fragment
# tests/bdd/conftest.py
from pytest_bdd import given
@given("the domain is initialized")
def domain_initialized():
"""The root conftest has already initialized the domain."""
Since the root conftest.py uses DomainFixture to call domain.init(),
all decorated domain elements (e.g. @domain.aggregate,
@domain.command_handler) are discovered and wired automatically. No manual
registration is needed.
Domain-Specific Shared Steps
Steps shared within a single domain concept go in that directory's
conftest.py:
# fragment
# tests/bdd/ordering/conftest.py
from myapp import domain
from myapp.models import Book, Customer, Inventory, Money
from pytest_bdd import given, parsers
@given(
parsers.parse('a customer "{name}"'),
target_fixture="customer",
)
def existing_customer(name):
customer = Customer(name=name)
domain.repository_for(Customer).add(customer)
return customer
@given(
parsers.parse('a book "{title}" with {stock:d} copies in stock'),
target_fixture="book",
)
def book_in_stock(title, stock):
book = Book(title=title, author="Test Author", price=Money(amount=15.99))
domain.repository_for(Book).add(book)
inventory = Inventory(book_id=book.id, title=title, quantity=stock)
domain.repository_for(Inventory).add(inventory)
return book
These steps are now available to all test modules under
tests/bdd/ordering/ without re-definition. The step hierarchy follows
pytest's conftest.py discovery:
tests/bdd/conftest.py → steps available everywhere
tests/bdd/ordering/conftest.py → steps available in ordering/ tests
tests/bdd/registration/conftest.py → steps available in registration/ tests
Testing Application Services
Application services coordinate use cases. Write feature files that describe the use case, and step definitions that invoke the service:
# tests/bdd/registration/features/activate_user.feature
Feature: User Activation
Registered users can be activated through the application service.
Background:
Given the domain is initialized
Scenario: Activate a registered user
Given a registered user "john@example.com"
When the user is activated
Then the user status should be "ACTIVE"
Scenario: Cannot activate a non-existent user
When activation is attempted for a non-existent user
Then the operation should fail with "does not exist"
# fragment
# tests/bdd/registration/test_activate_user.py
from myapp import domain
from myapp.models import User
from myapp.services import UserApplicationServices
from pytest_bdd import given, parsers, scenarios, then, when
from protean.exceptions import ObjectNotFoundError
scenarios("features/activate_user.feature")
@given(
parsers.parse('a registered user "{email}"'),
target_fixture="user_id",
)
def registered_user(email):
app_service = UserApplicationServices()
return app_service.register_user(email=email, name="Test User")
@when("the user is activated")
def activate_user(user_id):
app_service = UserApplicationServices()
app_service.activate_user(user_id)
@when(
"activation is attempted for a non-existent user",
target_fixture="activation_result",
)
def activate_nonexistent():
try:
app_service = UserApplicationServices()
app_service.activate_user("nonexistent-id")
return {"success": True}
except ObjectNotFoundError as exc:
return {"success": False, "error": str(exc)}
@then(parsers.parse('the user status should be "{status}"'))
def check_status(user_id, status):
user = domain.repository_for(User).get(user_id)
assert user.status == status
@then(parsers.parse('the operation should fail with "{message}"'))
def check_failure(activation_result, message):
assert activation_result["success"] is False
assert message in activation_result["error"]
Using Tags for Selective Execution
Gherkin tags are converted to pytest markers. Use them to organize and filter
scenarios. The ordering feature above carries @ordering and @smoke on the
feature, @happy_path on the first scenario and @validation on the stock
check:
@ordering @smoke
Feature: Place Order
...
@happy_path
Scenario: Placing an order updates inventory
...
@validation
Scenario: Cannot order more than available stock
...
Register the tags as markers in tests/bdd/conftest.py, so pytest does not
warn about unknown marks and --strict-markers accepts them:
# fragment
def pytest_configure(config):
for tag in ("ordering", "smoke", "happy_path", "validation"):
config.addinivalue_line("markers", f"{tag}: Gherkin tag")
Run selectively:
# Run only ordering scenarios
pytest tests/bdd/ -m ordering
# Run only smoke tests
pytest tests/bdd/ -m smoke
# Run happy path scenarios, skip validation
pytest tests/bdd/ -m "happy_path and not validation"
Tips for Good Feature Files
- Name features after domain concepts: "User Registration", not "Test UserCommandHandler".
- Write from the user's perspective: Describe what happens, not how it's implemented.
- Keep Background sections short: Only shared
Givensteps belong there. - Use concrete examples:
"john@example.com"reads better than abstract placeholders in non-outline scenarios. - One scenario, one behavior: Test a single outcome per scenario. Use separate scenarios for the happy path and each error case.
- Reuse steps: Write steps general enough to appear in multiple
scenarios. Put them in
conftest.py.
Note
Setting command_processing and event_processing to "sync" is
essential for application tests. With these settings, Protean processes
commands immediately and dispatches events synchronously, so your @then
steps can verify outcomes deterministically.