Skip to main content

Introduction to Acceptance Tests

In this chapter, we'll learn about acceptance tests - high-level tests that verify your application works correctly from a user's perspective. We'll cover:

  • What are acceptance tests?
  • Difference between unit tests and acceptance tests
  • Writing acceptance tests for a real application
  • Testing end-to-end scenarios
  • Test organization strategies

What are acceptance tests?

Acceptance tests verify that your application meets business requirements. They test the system as a whole, from the user's perspective:

Why acceptance tests matter

1. Confidence

Unit tests might pass while the system is broken:

# Unit test passes
def test_user_service_creates_user():
user = user_service.create_user("Alice", "alice@example.com")
assert user.name == "Alice"

# But the full flow might be broken
# (database not connected, email service down, etc.)

2. Documentation

Acceptance tests document what the system actually does:

def test_user_can_register_and_login():
# Register
client.post("/register", json={
"email": "alice@example.com",
"password": "secret123"
})

# Login
response = client.post("/login", json={
"email": "alice@example.com",
"password": "secret123"
})

assert response.status_code == 200
assert "token" in response.json()

3. Refactoring safety

You can refactor internals while acceptance tests ensure behavior stays the same.

Unit tests vs acceptance tests

AspectUnit TestsAcceptance Tests
ScopeSingle function/classEntire feature
SpeedVery fastSlower
DependenciesMockedReal (or realistic fakes)
IsolationCompleteMinimal
PurposeVerify implementationVerify behavior

Setting up for acceptance tests

Project structure

Conftest for acceptance tests

# tests/acceptance/conftest.py
import pytest
from app import create_app
from app.database import init_db, clear_db


@pytest.fixture
def app():
"""Create application for testing."""
app = create_app(testing=True)
with app.app_context():
init_db()
yield app
clear_db()


@pytest.fixture
def client(app):
"""Test client for making requests."""
return app.test_client()

Writing acceptance tests

User story: Registration

As a new user
I want to register an account
So that I can access the application

Test:

# tests/acceptance/test_registration.py
import pytest


class TestUserRegistration:
def test_new_user_can_register(self, client):
# When: A new user registers
response = client.post("/api/register", json={
"email": "newuser@example.com",
"password": "securepass123",
"name": "New User"
})

# Then: Registration succeeds
assert response.status_code == 201
data = response.json()
assert data["email"] == "newuser@example.com"
assert data["name"] == "New User"
assert "id" in data

def test_cannot_register_with_existing_email(self, client):
# Given: A user already exists
client.post("/api/register", json={
"email": "existing@example.com",
"password": "password123",
"name": "Existing User"
})

# When: Someone tries to register with the same email
response = client.post("/api/register", json={
"email": "existing@example.com",
"password": "different123",
"name": "Another User"
})

# Then: Registration fails
assert response.status_code == 400
assert "already exists" in response.json()["error"]

def test_registration_requires_valid_email(self, client):
response = client.post("/api/register", json={
"email": "not-an-email",
"password": "password123",
"name": "Test User"
})

assert response.status_code == 400
assert "email" in response.json()["error"].lower()

User story: Login flow

class TestLoginFlow:
def test_user_can_login_after_registration(self, client):
# Given: A registered user
client.post("/api/register", json={
"email": "user@example.com",
"password": "mypassword",
"name": "Test User"
})

# When: They login
response = client.post("/api/login", json={
"email": "user@example.com",
"password": "mypassword"
})

# Then: They receive a token
assert response.status_code == 200
assert "token" in response.json()

def test_login_fails_with_wrong_password(self, client):
# Given: A registered user
client.post("/api/register", json={
"email": "user@example.com",
"password": "correctpassword",
"name": "Test User"
})

# When: They login with wrong password
response = client.post("/api/login", json={
"email": "user@example.com",
"password": "wrongpassword"
})

# Then: Login fails
assert response.status_code == 401

End-to-end scenarios

Complete user journey

class TestUserJourney:
def test_complete_shopping_flow(self, client):
# 1. Register
reg_response = client.post("/api/register", json={
"email": "shopper@example.com",
"password": "password123",
"name": "Shopper"
})
assert reg_response.status_code == 201

# 2. Login
login_response = client.post("/api/login", json={
"email": "shopper@example.com",
"password": "password123"
})
token = login_response.json()["token"]
headers = {"Authorization": f"Bearer {token}"}

# 3. Browse products
products = client.get("/api/products").json()
assert len(products) > 0
product_id = products[0]["id"]

# 4. Add to cart
cart_response = client.post(
"/api/cart/items",
json={"product_id": product_id, "quantity": 2},
headers=headers
)
assert cart_response.status_code == 201

# 5. Checkout
checkout_response = client.post(
"/api/checkout",
json={"payment_method": "card"},
headers=headers
)
assert checkout_response.status_code == 200
order = checkout_response.json()
assert order["status"] == "confirmed"

# 6. View order history
orders = client.get("/api/orders", headers=headers).json()
assert len(orders) == 1
assert orders[0]["id"] == order["id"]

Testing patterns

Given-When-Then

Structure tests clearly:

def test_user_can_update_profile(self, client, authenticated_user):
# Given: An authenticated user
user, headers = authenticated_user

# When: They update their profile
response = client.put(
f"/api/users/{user['id']}",
json={"name": "New Name"},
headers=headers
)

# Then: The profile is updated
assert response.status_code == 200
assert response.json()["name"] == "New Name"

Helper fixtures

@pytest.fixture
def registered_user(client):
"""Create a registered user."""
response = client.post("/api/register", json={
"email": "testuser@example.com",
"password": "password123",
"name": "Test User"
})
return response.json()


@pytest.fixture
def authenticated_user(client, registered_user):
"""Create an authenticated user with token."""
response = client.post("/api/login", json={
"email": "testuser@example.com",
"password": "password123"
})
token = response.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
return registered_user, headers

Data builders

class UserBuilder:
def __init__(self):
self._data = {
"email": "default@example.com",
"password": "password123",
"name": "Default User"
}

def with_email(self, email):
self._data["email"] = email
return self

def with_name(self, name):
self._data["name"] = name
return self

def build(self):
return self._data


# Usage
def test_user_with_custom_data(client):
user_data = (UserBuilder()
.with_email("custom@example.com")
.with_name("Custom User")
.build())

response = client.post("/api/register", json=user_data)
assert response.status_code == 201

Testing against external services

Using fakes

# tests/acceptance/fakes.py
class FakeEmailService:
def __init__(self):
self.sent_emails = []

def send(self, to, subject, body):
self.sent_emails.append({
"to": to,
"subject": subject,
"body": body
})


# conftest.py
@pytest.fixture
def fake_email(app, monkeypatch):
fake = FakeEmailService()
monkeypatch.setattr("app.services.email_service", fake)
return fake


# test
def test_welcome_email_sent_on_registration(client, fake_email):
client.post("/api/register", json={
"email": "new@example.com",
"password": "password123",
"name": "New User"
})

assert len(fake_email.sent_emails) == 1
assert fake_email.sent_emails[0]["to"] == "new@example.com"
assert "welcome" in fake_email.sent_emails[0]["subject"].lower()

Running acceptance tests

Separate from unit tests

# Run only unit tests (fast)
pytest tests/unit

# Run only acceptance tests (slower)
pytest tests/acceptance

# Run all tests
pytest

Marking tests

import pytest


@pytest.mark.acceptance
class TestUserRegistration:
def test_new_user_can_register(self, client):
...


@pytest.mark.slow
def test_complete_user_journey(client):
...
# Run only acceptance tests
pytest -m acceptance

# Skip slow tests
pytest -m "not slow"

Wrapping up

We've covered:

  • Acceptance tests - Test complete user scenarios
  • Given-When-Then - Structure tests clearly
  • End-to-end flows - Test complete user journeys
  • Fixtures - Set up test data efficiently
  • Fakes - Substitute external services
  • Test organization - Separate unit/integration/acceptance

Key takeaways

  1. Acceptance tests verify business requirements
  2. They complement (don't replace) unit tests
  3. Use fixtures to reduce duplication
  4. Organize tests by feature or user story
  5. Keep acceptance tests focused and readable

The testing pyramid

Most tests should be unit tests. Acceptance tests are valuable but slower, so keep them focused on critical user journeys.