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
| Aspect | Unit Tests | Acceptance Tests |
|---|---|---|
| Scope | Single function/class | Entire feature |
| Speed | Very fast | Slower |
| Dependencies | Mocked | Real (or realistic fakes) |
| Isolation | Complete | Minimal |
| Purpose | Verify implementation | Verify 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
- Acceptance tests verify business requirements
- They complement (don't replace) unit tests
- Use fixtures to reduce duplication
- Organize tests by feature or user story
- 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.