When you start writing tests with pytest, a common question is: “How should I actually structure my test code?” The answer is the Arrange-Act-Assert (AAA) pattern. By organizing every test into three clear phases — set up, execute, verify — you can write test code that is easy to read and easy to maintain.
📌 Who This Article Is For
| ✅ Pytest beginners who are unsure how to structure their test code |
| ✅ Engineers who have written tests but find them hard to read or hard to understand |
| ✅ QA engineers and developers who want a clear standard for “good test code” |
| ✅ Anyone looking to build a solid foundation before diving into a full pytest guide |
✅ What You’ll Learn
|
👨💻 About the Author Written by a QA engineer with 15+ years of hands-on experience in test automation. The motivation for this article came from a real experience: writing tests that worked fine but that teammates found impossible to read. The AAA pattern solved that problem, and it has been the team standard ever since. |
📌 Key Takeaways
|
When I first started writing tests, I wrote code like this:
# ❌ No structure — it's not clear what's being tested
def test_calc():
assert add(1, 2) == 3
assert add(-1, 1) == 0
assert add(0, 0) == 0
result = add(100, 200)
assert result == 300
assert add(1.5, 2.5) == 4.0It looks fine at first glance — but when one of those assertions fails, it’s not immediately obvious which test case broke or why. The intent is also buried under a pile of assertions. The Arrange-Act-Assert pattern solves exactly this problem.
What Is the Arrange-Act-Assert (AAA) Pattern?
The AAA pattern is a way of structuring test code into three distinct phases. It works with any programming language and any test framework, and is widely considered the de facto standard in the testing world.
| Arrange Set up | → | Act Execute | → | Assert Verify |
| Phase | Role | What You Do Here |
|---|---|---|
| Arrange (Set up) | Establish the conditions for the test | Define variables, create objects, configure mocks |
| Act (Execute) | Run the behavior being tested | Call a function, make an API request, click a button |
| Assert (Verify) | Check that the result matches expectations | Use assert to verify values, state, or exceptions |
The AAA Pattern in Practice
Here’s what the earlier test looks like rewritten with the AAA pattern:
# ✅ Rewritten with the AAA pattern
def test_add_returns_correct_sum_with_positive_numbers():
# Arrange: prepare the data needed for the test
a = 2
b = 3
# Act: run the behavior being tested
result = add(a, b)
# Assert: verify the result matches expectations
assert result == 5
def test_add_returns_zero_when_adding_opposite_numbers():
# Arrange
a = -1
b = 1
# Act
result = add(a, b)
# Assert
assert result == 0💡 What changed?
|
Each Phase in Detail
① Arrange: Build the Test’s Preconditions
The Arrange phase is where you prepare everything the test needs — variables, objects, or mock data. When Arrange starts getting long, pytest fixtures are the right tool to clean it up.
def test_user_full_name():
# Arrange: create the object needed for the test
user = User(first_name="Alice", last_name="Smith")
# Act
result = user.get_full_name()
# Assert
assert result == "Alice Smith"⚠️ Warning: When Arrange Gets Too Long If Arrange runs past 10 lines, it’s a signal that either the test is too complex, or the setup should be extracted into a fixture. Long setup code buries the test’s real purpose. |
💡 How AAA and Fixtures Relate Fixtures don’t replace the AAA pattern — they share the Arrange phase across multiple tests.
|
② Act: Run the Behavior You’re Testing
The Act phase is where you execute the behavior under test. Ideally it focuses on one specific thing, but in practice — logging in before taking an action, adding items to a cart then checking the total — multiple steps are perfectly normal. What matters is that the purpose of the test stays clear.
# ⚠️ Arrange and Act are mixed together — hard to tell what's being tested
def test_bad_example():
cart = ShoppingCart()
cart.add_item("apple", price=100)
cart.add_item("banana", price=150)
cart.apply_discount(10) # ← Are we testing the discount? The total?
result = cart.total()
assert result == 225
# ✅ Clear structure — the test's purpose is explicit
def test_total_is_correct_after_adding_two_items():
# Arrange (precondition: two items in the cart)
cart = ShoppingCart()
cart.add_item("apple", price=100)
cart.add_item("banana", price=150)
# Act (what we're verifying: the total calculation)
result = cart.total()
# Assert
assert result == 250③ Assert: Verify the Result
The Assert phase is where you compare actual results against expected values using assert. Aim for 1–3 assertions per test function. pytest automatically shows a detailed diff when an assertion fails, so you can keep your assert statements simple and readable — no special assertion libraries needed.
# ✅ Simple, readable assertions (recommended)
assert result == 5
assert result is not None
assert "error" not in response_body
assert status_code in (200, 201)
# ✅ With an error message (easier to diagnose failures)
assert result == 5, f"Expected 5, got {result}"
# ❌ Too many assertions in one function
def test_user():
user = create_user("Alice", "alice@example.com")
assert user.name == "Alice"
assert user.email == "alice@example.com"
assert user.is_active is True
assert user.role == "user"
assert user.created_at is not None # 5 assertions — which one failed?💡 When You Need Multiple Assertions If you need to verify multiple aspects of a result — for example, a user’s name, email, and role after creation — either split them into separate test functions, or group closely related checks (2–3 max). The guiding question: if one assertion fails, can I immediately tell what broke? |
Writing Readable Test Function Names
Test function names are just as important as the AAA structure. When a test fails in CI/CD, the function name is often the first thing you look at. A good name tells you what was tested, under what condition, and what was expected — without opening the code.
Recommended Pattern: test_[what]_[condition]_[expected outcome]
| Poor Name | Better Name |
|---|---|
test_login() | test_login_with_valid_credentials_returns_200() |
test_add() | test_add_returns_correct_sum_with_positive_numbers() |
test_error() | test_login_with_wrong_password_raises_auth_error() |
test_user_save() | test_save_user_with_duplicate_email_raises_value_error() |
The names look long, but when test results scroll past in a terminal, the function name is the only context you have. Clear names save real debugging time.
💡 Testing Multiple Conditions? Use parametrize Writing a separate AAA test function for every case gets repetitive fast. See pytest Complete Guide for more. |
Practice: Common Test Scenarios with the AAA Pattern
Example ①: Testing a Function
def add(a: float, b: float) -> float:
return a + b
def test_add_returns_correct_sum_with_positive_numbers():
# Arrange
a = 3
b = 4
# Act
result = add(a, b)
# Assert
assert result == 7
def test_add_returns_negative_when_both_negative():
# Arrange
a = -3
b = -4
# Act
result = add(a, b)
# Assert
assert result == -7Example ②: Testing That an Exception Is Raised
import pytest
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def test_divide_raises_value_error_when_divisor_is_zero():
# Arrange
a = 10
b = 0
# Act & Assert (exception tests naturally merge these two phases)
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(a, b)Example ③: Testing a Class Method
class ShoppingCart:
def __init__(self) -> None:
self._items: list[dict] = []
def add_item(self, name: str, price: int) -> None:
self._items.append({"name": name, "price": price})
def total(self) -> int:
return sum(item["price"] for item in self._items)
def test_total_returns_sum_of_all_item_prices():
# Arrange
cart = ShoppingCart()
cart.add_item("apple", price=100)
cart.add_item("banana", price=200)
# Act
result = cart.total()
# Assert
assert result == 300
def test_total_returns_zero_when_cart_is_empty():
# Arrange
cart = ShoppingCart()
# Act
result = cart.total()
# Assert
assert result == 0Common Pitfalls with the AAA Pattern
🚧 Watch Out for These ① Mixing setup and action in the same block The Act phase should contain only the behavior you’re verifying. Any setup operations belong in Arrange. When Act and Arrange blur together, it gets harder to tell what the test is actually checking. ② Putting an assert inside Arrange The urge to verify preconditions inside Arrange is understandable, but those assertions will be confused with the test’s actual verification. Keep all assertions in the Assert phase. ③ Meaningless test names like test_1 or test_func When a test fails in CI, the function name is your first clue. Name it like ④ Packing multiple conditions into one test Testing happy path, error path, and edge cases all in one function makes failures ambiguous. Split them into separate functions, or use ⑤ Skipping the phase comments As you get comfortable, skipping |
👨💻 What I Check First in Code Reviews When reviewing test code, the first thing I look for is whether the AAA structure is present. Code with no Arrange phase, or assertions mixed into the setup, is hard to read and even harder to maintain later. Test code isn’t just about passing — it’s shared documentation for the whole team, and structure is what makes it readable a year from now. |
Frequently Asked Questions
Not required, but strongly recommended for team projects and long-term maintainability. For small personal scripts, you might skip it. The value becomes obvious when someone else reads your test six months from now.
Completely fine. When using pytest.raises(), Act and Assert naturally merge into one block. Comment it as # Act & Assert to make the intent clear.
There’s no hard rule, but 1–3 is a good guideline. Closely related checks — like verifying both the status code and response body — can be grouped. The more assertions you add, the harder it becomes to pinpoint the cause of a failure.
Conceptually identical. Given-When-Then is the BDD (Behavior-Driven Development) equivalent, written in more natural language. The structure maps directly:
| AAA Pattern | Given-When-Then (BDD) | Meaning |
|---|---|---|
| Arrange | Given | Given this state exists… |
| Act | When | When I perform this action… |
| Assert | Then | Then I expect this result |
TDD is a development workflow where you write tests before writing the implementation. The AAA pattern is about how to structure a test. They pair well: when writing tests first in TDD, organizing them with AAA keeps them readable from the start.
Yes. In UI tests, Arrange typically includes launching the browser and logging in. Act contains the specific interaction you’re testing — clicking a button, submitting a form. Assert checks the resulting page state, title, or element content. The structure is the same; the tools just differ.
Absolutely. Parametrize feeds test data in from outside. The function body still follows the AAA structure internally. Together they give you multiple test cases with clean, readable code — a natural next step once you’re comfortable with the AAA pattern.
Think of fixtures as a way to share the Arrange phase across multiple tests — not replace it. When multiple tests need the same setup (a database connection, an authenticated session, a user object), a fixture keeps that logic in one place. It’s Arrange, just centralized. See pytest Complete Guide for details.
Summary
|
The AAA pattern isn’t a difficult technique — it’s a courtesy to the people who will read your tests. Writing tests that anyone on the team can understand at a glance is the foundation of a maintainable test suite. Start applying it to the very next test function you write.
