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.

pythontestingquality

See Also