Test Code Writing Guide | Understand the Basics of Unit Test Structure with the Arrange-Act-Assert (AAA) Pattern

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

  • The role of each phase in the AAA pattern — Arrange, Act, and Assert
  • The “one test, one purpose” principle and how to write clear test function names
  • Practical pytest test code examples you can use right away
  • Common mistakes and how to fix them

👨‍💻 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

  • Structure test code as Arrange (set up) → Act (execute) → Assert (verify)
  • Each test function should verify one clear purpose
  • Include what, under what condition, and what should happen in the function name for maximum readability

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.0

It 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
PhaseRoleWhat You Do Here
Arrange (Set up)Establish the conditions for the testDefine variables, create objects, configure mocks
Act (Execute)Run the behavior being testedCall a function, make an API request, click a button
Assert (Verify)Check that the result matches expectationsUse 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 function tests exactly one case
  • The # Arrange / Act / Assert comments make the structure visible at a glance
  • The function name alone tells you what’s being tested
  • When a test fails, you immediately know which case broke

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.

Arrange
→ shared via fixture
Act
run the target
Assert
verify the result

② 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 NameBetter 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. @pytest.mark.parametrize lets you cover multiple inputs in one function while keeping the structure clean.

import pytest

@pytest.mark.parametrize("a, b, expected", [
    (2,  3,   5),   # normal case
    (0,  0,   0),   # boundary
    (-1, 1,   0),   # negative numbers
])
def test_add_returns_correct_sum(a, b, expected):
    result = add(a, b)          # Act
    assert result == expected   # Assert

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 == -7

Example ②: 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 == 0

Common 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 test_add_with_zero_returns_other_value — include what is being tested so you don’t have to open the file to understand.

④ 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 @pytest.mark.parametrize to keep them organized.

⑤ Skipping the phase comments

As you get comfortable, skipping # Arrange / # Act / # Assert comments is tempting. But in team code reviews, those comments let reviewers scan the structure instantly. At least while building the habit, keep them in.

👨‍💻 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

Q. Is the AAA pattern required?

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.

Q. Is it okay for Act and Assert to merge in exception tests?

Completely fine. When using pytest.raises(), Act and Assert naturally merge into one block. Comment it as # Act & Assert to make the intent clear.

Q. How many assertions can I have per test?

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.

Q. How does AAA compare to Given-When-Then?

Conceptually identical. Given-When-Then is the BDD (Behavior-Driven Development) equivalent, written in more natural language. The structure maps directly:

AAA PatternGiven-When-Then (BDD)Meaning
ArrangeGivenGiven this state exists…
ActWhenWhen I perform this action…
AssertThenThen I expect this result
Q. Does AAA relate to TDD (Test-Driven Development)?

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.

Q. Can I use AAA for UI tests with Selenium or Playwright?

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.

Q. Can I use @pytest.mark.parametrize with the AAA pattern?

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.

Q. Does pytest’s fixture replace the Arrange phase?

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 structures every test as Arrange → Act → Assert
  • Each test function should have one clear purpose. In practice, multiple steps in Act are fine — what matters is that the test’s goal is clear
  • Test function names should include what, under what condition, and expected outcome
  • Aim for 1–3 assertions per test. Use pytest fixtures to keep Arrange lean
  • Use @pytest.mark.parametrize when testing multiple conditions

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.

📚 Related Articles

Copied title and URL