Acceptance Testing Patterns — Deep Dive
Architecture of an acceptance test suite
A well-structured acceptance test suite separates concerns into layers:
tests/acceptance/
├── features/ # .feature files (Gherkin)
│ ├── auth.feature
│ ├── checkout.feature
│ └── search.feature
├── steps/ # Step implementations
│ ├── auth_steps.py
│ ├── checkout_steps.py
│ └── common_steps.py
├── fixtures/ # Test data and state management
│ ├── users.py
│ └── products.py
├── pages/ # Page objects (if testing through UI)
│ ├── login_page.py
│ └── cart_page.py
└── conftest.py # Shared hooks and configuration
This separation means feature files can evolve with product requirements while step implementations handle the technical details independently.
Implementing with pytest-bdd
pytest-bdd integrates acceptance tests into your existing pytest infrastructure, which means fixtures, markers, and plugins work seamlessly.
Feature file (tests/acceptance/features/checkout.feature):
@checkout @critical
Feature: Checkout process
Background:
Given a registered customer with a verified address
Scenario: Successful checkout with valid payment
Given the customer has 2 items in their cart
And a valid payment method on file
When the customer initiates checkout
Then the order should be created with status "pending"
And the payment should be charged
And a confirmation email should be queued
Scenario: Checkout blocked without payment method
Given the customer has 1 item in their cart
And no payment method on file
When the customer initiates checkout
Then the checkout should fail with "Payment method required"
And no order should be created
Step implementation:
# tests/acceptance/steps/checkout_steps.py
from pytest_bdd import given, when, then, parsers, scenarios
from myapp.services import OrderService, PaymentService
from myapp.models import Cart, User
scenarios("../features/checkout.feature")
@given("a registered customer with a verified address")
def registered_customer(db_session):
user = User(
email="test@example.com",
address_verified=True,
)
db_session.add(user)
db_session.flush()
return user
@given(
parsers.parse("the customer has {count:d} items in their cart"),
target_fixture="cart",
)
def cart_with_items(registered_customer, product_factory, count):
cart = Cart(user=registered_customer)
for _ in range(count):
cart.add(product_factory.create())
return cart
@given("a valid payment method on file")
def valid_payment(registered_customer):
registered_customer.payment_method = "tok_visa_test"
@given("no payment method on file")
def no_payment(registered_customer):
registered_customer.payment_method = None
@when("the customer initiates checkout", target_fixture="checkout_result")
def initiate_checkout(registered_customer, cart, order_service):
try:
order = order_service.checkout(
user=registered_customer, cart=cart
)
return {"success": True, "order": order}
except Exception as exc:
return {"success": False, "error": str(exc)}
@then(parsers.parse('the order should be created with status "{status}"'))
def verify_order_status(checkout_result, status):
assert checkout_result["success"] is True
assert checkout_result["order"].status == status
@then("the payment should be charged")
def verify_payment(checkout_result, payment_service_mock):
assert payment_service_mock.charge.called
@then("a confirmation email should be queued")
def verify_email(checkout_result, email_outbox):
assert len(email_outbox) == 1
assert "confirmation" in email_outbox[0].subject.lower()
@then(parsers.parse('the checkout should fail with "{message}"'))
def verify_failure(checkout_result, message):
assert checkout_result["success"] is False
assert message in checkout_result["error"]
@then("no order should be created")
def verify_no_order(db_session):
from myapp.models import Order
assert db_session.query(Order).count() == 0
Step libraries and reuse
As acceptance suites grow, step duplication becomes a maintenance problem. Build step libraries for common operations:
# tests/acceptance/steps/common_steps.py
from pytest_bdd import given, then, parsers
@given("the system clock is set to {date}")
def set_system_clock(freezer, date):
freezer.move_to(date)
@then(parsers.parse("the response time should be under {ms:d}ms"))
def check_response_time(last_response, ms):
assert last_response.elapsed_ms < ms
@then(parsers.parse("{count:d} audit log entries should exist"))
def check_audit_log(db_session, count):
from myapp.models import AuditLog
assert db_session.query(AuditLog).count() == count
Parameterized steps with parsers.cfparse support custom types for even more reuse:
from pytest_bdd import parsers
CONVERTERS = {
"amount": float,
"currency": str,
}
@then(parsers.cfparse(
"the charge should be {amount:Amount} {currency:Currency}",
extra_types={"Amount": float, "Currency": str},
))
def verify_charge_amount(payment_mock, amount, currency):
call = payment_mock.charge.call_args
assert call.kwargs["amount"] == amount
assert call.kwargs["currency"] == currency
Test data management
Acceptance tests need realistic data without coupling to production databases. Factory patterns work well:
# tests/acceptance/fixtures/users.py
import factory
from myapp.models import User
class UserFactory(factory.Factory):
class Meta:
model = User
email = factory.Sequence(lambda n: f"user{n}@test.com")
name = factory.Faker("name")
address_verified = True
tier = "standard"
class PremiumUserFactory(UserFactory):
tier = "premium"
payment_method = "tok_visa_test"
Combine factories with Gherkin data tables for scenario-specific overrides without hardcoding values in step files.
Environment management
Acceptance tests often need different backends depending on context:
# conftest.py
import pytest
@pytest.fixture
def order_service(request):
backend = request.config.getoption("--acceptance-backend", default="mock")
if backend == "mock":
return MockOrderService()
elif backend == "api":
return ApiOrderService(base_url="http://localhost:8000")
elif backend == "full":
return FullOrderService(db_session=request.getfixturevalue("db_session"))
Run against mocks for speed in development, against the API in staging, and full-stack in pre-production. Same feature files, different wiring.
CI pipeline integration
Structure your CI to run acceptance tests at the right granularity:
# .github/workflows/acceptance.yml
jobs:
acceptance-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install -e ".[test]"
- run: pytest tests/acceptance/ -m "critical" --acceptance-backend=mock
name: Critical scenarios (mocked, fast)
acceptance-full:
needs: acceptance-smoke
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
steps:
- uses: actions/checkout@v4
- run: pip install -e ".[test]"
- run: pytest tests/acceptance/ --acceptance-backend=full
name: Full acceptance suite
Tag-based filtering means @critical scenarios run on every push (under 2 minutes) while the full suite runs on merge to main.
Reporting for stakeholders
Generate human-readable reports that non-technical stakeholders can review:
pytest tests/acceptance/ --gherkin-terminal-reporter -v
For richer output, use pytest-html or allure-pytest to produce reports with scenario names, step details, and failure screenshots. Publish these as CI artifacts so product managers can verify acceptance criteria without reading code.
Tradeoffs and boundaries
Acceptance tests are slower than unit tests by design — they exercise more of the system. Keep the suite under 10 minutes by:
- Mocking external services (payment gateways, email providers)
- Using in-memory databases for fast scenarios
- Parallelizing with pytest-xdist when scenarios are independent
- Pruning scenarios that duplicate unit test coverage
The boundary between acceptance and integration tests is sometimes blurry. The litmus test: if removing the scenario would mean no test verifies a specific business requirement, it belongs in acceptance. If another test already covers the behavior and this scenario just tests wiring, it’s integration.
The one thing to remember: Acceptance tests are the contract between the product vision and the implementation — structure them as executable specifications that stakeholders can read and developers can run.
See Also
- Python Approval Testing How approval testing lets you verify complex Python output by comparing it to a saved 'golden' copy you already checked.
- Python Behavior Driven Development Get an intuitive feel for Behavior Driven Development so Python behavior stops feeling unpredictable.
- Python Browser Automation Testing How Python can control a web browser like a robot to test websites automatically.
- Python Chaos Testing Applications Why breaking your own Python systems on purpose makes them stronger.
- Python Contract Testing Why contract testing is like having a written agreement between two teams so neither one accidentally breaks the other's work.