Acceptance Testing — Complete Guide with Examples
In this tutorial, you'll learn about Acceptance Testing. We cover key concepts, practical examples, and best practices.
Acceptance testing is the final validation phase where real-world scenarios confirm that the software meets business requirements, user expectations, and acceptance criteria — ensuring the team builds the right product, not just a correct one.
What You'll Learn
- The difference between acceptance testing and other test levels
- How to write acceptance criteria using Given-When-Then format
- Tools and frameworks for automating acceptance tests
- How acceptance testing fits into CI/CD pipelines
Why It Matters
A feature that passes unit and integration tests can still fail in the hands of a user. Acceptance testing catches mismatches between what developers built and what stakeholders actually need. Without it, teams ship features that work technically but fail business — costing rework, missed deadlines, and lost revenue.
Real-World Use
A banking application's "transfer funds" feature passed all unit tests. But in acceptance testing, the product owner noticed the confirmation screen showed a transaction ID without the recipient's name. Users couldn't verify they sent money to the right person. The fix — adding recipient details — took two hours. Catching that after launch would have generated thousands of support tickets.
What Is Acceptance Testing?
Acceptance testing evaluates whether a system meets its acceptance criteria — the conditions that define a feature as complete from the stakeholder's perspective. Unlike unit tests that verify a single function, or integration tests that check component interaction, acceptance tests validate end-to-end business scenarios.
There are several types:
| Type | Who Performs It | Focus |
|---|---|---|
| User Acceptance Testing (UAT) | End users or their representatives | Usability and workflow correctness |
| Business Acceptance Testing | Product owners, business analysts | Alignment with business requirements |
| Operational Acceptance Testing | Operations team | Deployability, backup, monitoring |
| Contract Acceptance Testing | Customers or regulators | Contractual and legal obligations |
| Alpha/Beta Testing | Internal teams / external users | Pre-release validation |
Writing Acceptance Criteria
Acceptance criteria define when a feature is done. They are written in Given-When-Then format, which originated in behavior-driven development (BDD):
Given [some initial context]
When [an action occurs]
Then [expected outcome]
Let's look at an example for a login feature:
Scenario: Successful login with valid credentials
Given the user is on the login page
When the user enters a valid username and password
and clicks the "Sign In" button
Then the user should be redirected to the dashboard
and see a welcome message with their name
This format is understandable by non-technical stakeholders and directly translatable into automated tests.
# acceptance_test_login.py — automated acceptance test using pytest
# Requires: pip install pytest
def test_successful_login():
# Given — set up initial state
user = {"username": "alice", "password": "secure123"}
db = {"alice": {"password": "secure123", "name": "Alice"}}
# When — perform the action
stored = db.get(user["username"])
login_ok = stored is not None and stored["password"] == user["password"]
# Then — verify expected outcome
assert login_ok is True
if login_ok:
print(f"Welcome, {stored['name']}!")
test_successful_login()
Expected output:
Welcome, Alice!
Automated Acceptance Testing with pytest-bdd
For larger projects, automate acceptance tests using BDD frameworks. Here is an example using pytest-bdd:
# test_checkout.py — BDD-style acceptance test for an e-commerce checkout
# Requires: pip install pytest-bdd
from pytest_bdd import scenario, given, when, then, parsers
@scenario("checkout.feature", "Customer completes checkout with valid cart")
def test_checkout():
pass
@given("the customer has items in the cart", target_fixture="cart")
def cart_with_items():
cart = {"items": [{"id": 1, "price": 25.0}, {"id": 2, "price": 35.0}]}
return cart
@given("the customer is logged in", target_fixture="customer")
def logged_in_customer():
return {"id": 42, "name": "Bob"}
@when("the customer proceeds to checkout")
def proceed_to_checkout(cart, customer):
cart["customer_id"] = customer["id"]
cart["status"] = "checkout"
@when(parsers.parse("the customer enters a valid {payment_method}"))
def enter_payment(cart, payment_method):
cart["payment"] = payment_method
@then(parsers.parse("the order should be confirmed with {expected_total}"))
def verify_order(cart, expected_total):
total = sum(item["price"] for item in cart["items"])
assert total == float(expected_total)
assert cart["status"] == "checkout"
print(f"Order confirmed. Total: ${total:.2f}")
Expected output when run with pytest test_checkout.py -v -s:
collected 1 item
test_checkout.py::test_checkout Order confirmed. Total: $60.00
PASSED
Acceptance Criteria for Non-Functional Requirements
Acceptance testing isn't only about features. Non-functional requirements (performance, security, usability) need acceptance criteria too:
# acceptance_performance.py — acceptance test for page load time
import time
def measure_response_time(url):
"""Simulates measuring HTTP response time."""
# In production, use requests.get(url).elapsed
time.sleep(0.15) # simulate network latency
return 0.15
def test_page_loads_under_200ms():
url = "https://api.example.com/products"
elapsed = measure_response_time(url)
assert elapsed < 0.200, f"Page load {elapsed*1000:.0f}ms exceeds 200ms limit"
print(f"Performance check passed: {elapsed*1000:.0f}ms")
def test_search_returns_results_under_500ms():
# Simulate a search query
elapsed = 0.32
assert elapsed < 0.500, "Search response time exceeds limit"
print(f"Search performance OK: {elapsed*1000:.0f}ms")
test_page_loads_under_200ms()
test_search_returns_results_under_500ms()
Expected output:
Performance check passed: 150ms
Search performance OK: 320ms
Integrating Acceptance Tests into CI/CD
Automated acceptance tests should run after unit and integration tests pass, typically in a staging environment:
# .github/workflows/acceptance.yml — CI pipeline stage for acceptance tests
name: Acceptance Tests
on:
deployment_status:
environments: staging
jobs:
acceptance:
if: github.event.deployment_status.state == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
pip install pytest pytest-bdd requests
- name: Run acceptance tests
run: pytest tests/acceptance/ -v --junitxml=reports/acceptance.xml
- name: Upload report
uses: actions/upload-artifact@v4
with:
name: acceptance-report
path: reports/acceptance.xml
Acceptance Testing vs Other Test Levels
Understanding where acceptance testing fits in the test pyramid helps teams allocate effort wisely:
flowchart TB
subgraph "Test Pyramid"
E2E["End-to-End / Acceptance Tests (few)"]
INT["Integration Tests (some)"]
UNIT["Unit Tests (many)"]
end
subgraph "Validation Focus"
BUS["Business rules, workflows, user satisfaction"]
API["Component interaction, data flow"]
LOGIC["Functions, methods, edge cases"]
end
UNIT -->|"components combined"| INT
INT -->|"system deployed"| E2E
E2E -->|"stakeholder signs off"| PROD[Production Release]
style E2E fill:#4a90d9,stroke:#fff,color:#fff
style INT fill:#e67e22,stroke:#fff,color:#fff
style UNIT fill:#27ae60,stroke:#fff,color:#fff
The pyramid makes an important point: acceptance tests are the slowest and most expensive to maintain, so you write fewer of them. But they provide the highest confidence that the system works for the user.
Creating an Acceptance Test Plan
A structured test plan ensures coverage of all critical workflows:
import json
from datetime import datetime
def generate_acceptance_test_plan(features, output_path):
plan = {
"generated_at": datetime.utcnow().isoformat(),
"total_features": len(features),
"features": []
}
for feature in features:
entry = {
"name": feature["name"],
"priority": feature.get("priority", "medium"),
"scenarios": []
}
for scenario in feature["scenarios"]:
entry["scenarios"].append({
"title": scenario["title"],
"given": scenario["given"],
"when": scenario["when"],
"then": scenario["then"],
"automated": scenario.get("automated", False)
})
plan["features"].append(entry)
with open(output_path, "w") as f:
json.dump(plan, f, indent=2)
print(f"Test plan generated: {output_path}")
print(f"Features: {plan['total_features']}, Scenarios: {sum(len(f['scenarios']) for f in plan['features'])}")
sample_features = [
{
"name": "User Authentication",
"priority": "critical",
"scenarios": [
{
"title": "Successful login",
"given": "user is on login page",
"when": "user enters valid credentials",
"then": "user is redirected to dashboard",
"automated": True
}
]
}
]
generate_acceptance_test_plan(sample_features, "/tmp/test-plan.json")
Expected output:
Test plan generated: /tmp/test-plan.json
Features: 1, Scenarios: 1
Security-Focused Acceptance Testing
Security acceptance criteria ensure that features don't introduce vulnerabilities. This aligns with the DodaTech approach of treating security as a quality attribute:
Scenario: Password change prevents reuse of last 5 passwords
Given the user's password history contains "Pass#2023", "Pass#2024"
When the user tries to change password to "Pass#2024"
Then the system should reject the change
and display "Password has been used recently"
Scenario: API endpoint rejects unauthenticated requests
Given no authentication token is provided
When the client sends a GET request to /api/orders
Then the response status should be 401
and the body should contain "Unauthorized"
# test_security_acceptance.py — security-related acceptance criteria
import json
def test_password_reuse_prevention():
history = ["Pass#2023", "Pass#2024"]
new_password = "Pass#2024"
is_reused = new_password in history
assert is_reused is True, "Password was not in history but should have been"
if is_reused:
print("Password rejected: has been used recently")
def test_api_unauthorized_access():
headers = {} # no auth token
response_status = 401 # simulated
response_body = json.dumps({"error": "Unauthorized"})
assert response_status == 401
body = json.loads(response_body)
assert "Unauthorized" in body["error"]
print(f"API correctly rejected request with status {response_status}")
test_password_reuse_prevention()
test_api_unauthorized_access()
Expected output:
Password rejected: has been used recently
API correctly rejected request with status 401
Common Errors in Acceptance Testing
| # | Mistake | Explanation | Fix |
|---|---|---|---|
| 1 | Writing criteria after coding | Acceptance criteria must exist before development starts | Write criteria during refinement, not after implementation |
| 2 | Criteria too vague | "The page should load fast" is not testable | Use specific thresholds: "Page loads under 2 seconds on 3G" |
| 3 | Automating everything | UAT needs human judgment — not all acceptance tests should be automated | Automate regression acceptance; keep exploratory UAT manual |
| 4 | No stakeholder sign-off | Automated tests pass but product owner never validated the workflow | Schedule UAT sessions before the release candidate |
| 5 | Testing only happy paths | Acceptance tests that always pass give false confidence | Include negative scenarios: invalid data, timeouts, permission errors |
| 6 | Skipping non-functional criteria | Performance, security, accessibility are left out of acceptance | Add NFR acceptance criteria as separate scenarios |
Learning Path
flowchart LR
A[Integration Testing] --> B[Acceptance Testing]
B --> C[Test Automation Frameworks]
A --> D[Continuous Testing]
D --> B
B --> E[Production Monitoring]
C --> E
style B fill:#4a90d9,stroke:#fff,color:#fff
style A fill:#e67e22,stroke:#fff,color:#fff
style D fill:#e67e22,stroke:#fff,color:#fff
Before acceptance testing, you should understand Integration Testing and Continuous Testing. After mastery, explore Test Automation Frameworks for scaling your test suite.
Practice Questions
1. What format is used to write acceptance criteria?
Given-When-Then format, originating from behavior-driven development (BDD).2. How does acceptance testing differ from unit testing?
Unit testing verifies that individual functions work correctly. Acceptance testing validates that the system meets business requirements from the user's perspective.3. What is the role of the product owner in acceptance testing?
The product owner defines acceptance criteria, reviews test results, and makes the final decision on whether a feature meets the business need.4. Why should acceptance tests be fewer than unit tests?
Acceptance tests are slower, more expensive to maintain, and test broader scenarios. Writing too many leads to brittle, slow test suites.5. What is the difference between UAT and operational acceptance testing?
UAT focuses on whether end users can accomplish their goals. Operational acceptance testing focuses on whether the operations team can deploy, monitor, and support the system.Challenge
Write a complete acceptance test suite for a "password reset" feature using pytest-bdd. Include at least 4 scenarios: successful reset, expired token, mismatched passwords, and rate-limiting for too many requests.
Real-World Task
Pick a feature from a project you work on. Write its acceptance criteria in Given-When-Then format. Then automate one scenario as a pytest test. Share both the criteria and the automated test with your team for feedback.
FAQ
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Next lesson: Test Automation Frameworks — explore tools for scaling your automated test suite across unit, integration, and acceptance levels.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro