Module 2: Unit Tests with Claude Code

Pytest Patterns: Arrange-Act-Assert

Pytest Patterns: Arrange-Act-Assert

Capsule overview

In the previous capsule you learned how to ask Claude Code to generate quality tests: specific prompts, scenario categories, descriptive naming. But when Claude Code hands you back 20 tests, how do you evaluate whether they're well structured? How do you spot a confusing test that mixes setup, execution, and verification into a single indecipherable block?

This capsule teaches you the Arrange-Act-Assert (AAA) pattern — the standard structure that makes each test readable, debuggable, and maintainable. It also covers the pytest tools Claude Code uses constantly: fixtures to avoid repetition, assertions to validate results, and the file organization that scales to real projects. It's not a complete pytest course — it's exactly what you need to understand, evaluate, and improve what Claude Code generates.

By the end, you'll recognize the AAA structure in any test, you'll know how to use fixtures and assertions correctly, and you'll be able to ask Claude Code "use arrange-act-assert" with confidence because you understand what you're asking for.


The Arrange-Act-Assert (AAA) Pattern

What is AAA?

The Arrange-Act-Assert pattern splits each test into three clear phases:

  1. ARRANGE: You prepare the state — input data, objects, configuration. Everything the system needs before running the action.
  2. ACT: You run exactly one action — the function, method, or behavior you're testing.
  3. ASSERT: You verify the expected result — you compare the output with what it should be.

The key rule: a single action per test. If there are multiple actions (or multiple asserts verifying different things), the test loses focus and when it fails you don't know exactly what failed.

ARRANGE: Setting up the state

In this phase you create the objects, data, and conditions needed. Without this preparation, you can't run the action in isolation.

# calculator.py
def add(a: float, b: float) -> float:
    return a + b


def discount_price(price: float, discount_percent: float) -> float:
    """Apply discount and return rounded price."""
    if price < 0 or discount_percent < 0 or discount_percent > 100:
        raise ValueError("Invalid price or discount")
    return round(price * (1 - discount_percent / 100), 2)
# tests/test_calculator.py
import pytest
from calculator import add, discount_price


def test_add_positive_numbers():
    # ARRANGE: input data
    a = 2
    b = 3
    
    # ACT: run the action
    result = add(a, b)
    
    # ASSERT: verify the result
    assert result == 5


def test_discount_price_ten_percent():
    # ARRANGE: price and discount
    price = 100.0
    discount_percent = 10.0
    
    # ACT: apply the discount
    result = discount_price(price, discount_percent)
    
    # ASSERT: verify the final price
    assert result == 90.0

The explicit comments (# ARRANGE, # ACT, # ASSERT) are optional but useful while you're learning. In mature tests, the structure is evident from the blank spacing between the three sections.

ACT: A single action

The ACT phase should contain exactly one call to the function or method you're testing. If you have several actions, you're probably testing several things — and when the test fails, you won't know which one failed.

# ❌ BAD: multiple actions
def test_user_operations():
    user = User("alice")           # Setup? Action?
    user.add_role("admin")         # Action 1
    user.add_role("editor")        # Action 2
    assert user.roles == ["admin", "editor"]  # Which action failed?


# ✅ GOOD: one action per test
def test_add_role_append_to_list():
    # ARRANGE
    user = User("alice")
    
    # ACT: a single action
    user.add_role("admin")
    
    # ASSERT
    assert "admin" in user.roles


def test_add_multiple_roles_maintains_order():
    # ARRANGE
    user = User("alice")
    
    # ACT: a compound action but conceptually one (add_multiple_roles)
    user.add_roles(["admin", "editor"])
    
    # ASSERT
    assert user.roles == ["admin", "editor"]

If you have complex logic, extract auxiliary actions into helper functions or use multiple tests.

ASSERT: Verifying the result

The asserts should verify one clear expectation. Avoid asserts that validate many things at once; if one fails, the rest don't run and you lose information.

# ❌ BAD: several independent asserts
def test_api_response():
    response = fetch_user(1)
    assert response.status_code == 200
    assert response.json["name"] == "Alice"
    assert response.json["email"] == "alice@example.com"
    assert len(response.json["roles"]) == 2
# If it fails on name, you don't know if email and roles are fine


# ✅ GOOD: one assert per test (or asserts that verify a single concept)
def test_fetch_user_returns_200():
    response = fetch_user(1)
    assert response.status_code == 200


def test_fetch_user_returns_correct_name():
    response = fetch_user(1)
    assert response.status_code == 200  # Precondition for the real assert
    assert response.json["name"] == "Alice"

Sometimes you need a precondition assert (e.g., status_code == 200) before the main assert. That's acceptable — but if you have 5 asserts verifying different things, consider splitting into separate tests.

Why AAA matters for clarity and debugging

When a test fails, the pytest message tells you where it failed (the assert line) but not why. With AAA:

  • ✅ If it fails at ASSERT, you know ARRANGE and ACT are fine — the problem is the logic or the expected data.
  • ✅ If it fails earlier (an exception in ACT), you know ARRANGE might be wrong or the function has a bug.
  • ✅ When reading the test, anyone understands in 3 seconds what's being tested.

Without AAA, a 50-line test with setup, loops, and multiple asserts is a nightmare to debug.


pytest Fixtures: DRY in your tests

The problem: repetition in every test

Without fixtures, you repeat the same setup in every test:

def test_validate_email_standard():
    email = "user@example.com"  # ARRANGE
    result = validate_email(email)
    assert result == True

def test_validate_email_subdomain():
    email = "user@sub.domain.com"  # Similar ARRANGE
    result = validate_email(email)
    assert result == True

def test_format_user_data():
    user = {"name": "Alice", "email": "alice@example.com"}  # Repeated object
    result = format_user(user)
    assert "Alice" in result

When the format of the test data changes (e.g., you add a field), you have to touch 20 tests. Fixtures solve this.

@pytest.fixture: defining reusable data

A fixture is a function decorated with @pytest.fixture. pytest runs it automatically and passes its result as a parameter to the test.

# tests/test_user_validation.py
import pytest
from user_validator import validate_email


@pytest.fixture
def valid_email():
    return "user@example.com"


@pytest.fixture
def sample_user():
    return {"name": "Alice", "email": "alice@example.com", "age": 30}


def test_valid_email_returns_true(valid_email):
    # ARRANGE: already comes from the fixture
    # ACT
    result = validate_email(valid_email)
    # ASSERT
    assert result == True


def test_format_includes_name(sample_user):
    from formatter import format_user
    result = format_user(sample_user)
    assert "Alice" in result

The name of the test parameter must match the name of the fixture. pytest injects the value automatically.

Fixtures that depend on other fixtures

Fixtures can use other fixtures as parameters:

@pytest.fixture
def base_price():
    return 100.0


@pytest.fixture
def quantity():
    return 2


@pytest.fixture
def order(base_price, quantity):
    """Order built from base_price and quantity."""
    return {"base_price": base_price, "quantity": quantity, "discount": 0}


def test_order_subtotal(order):
    from pricing import calculate_subtotal
    result = calculate_subtotal(order)
    assert result == 200.0

conftest.py: shared fixtures

When several folders or files need the same fixtures, you define them in conftest.py. pytest discovers them automatically.

project/
├── src/
│   ├── calculator.py
│   └── validator.py
├── tests/
│   ├── conftest.py          # Fixtures visible to all tests
│   ├── test_calculator.py
│   └── test_validator.py
# tests/conftest.py
import pytest


@pytest.fixture
def sample_user():
    return {"name": "Alice", "email": "alice@example.com"}


@pytest.fixture
def empty_list():
    return []

Now test_calculator.py and test_validator.py can use sample_user and empty_list without importing them — pytest injects them if the parameter matches.

Rule: Fixtures in conftest.py are available to all tests in that directory and subdirectories. For fixtures used in only one file, define them in that file.


pytest Assert Patterns

assert with descriptive messages

An assert with no message lets pytest show only the value that failed. With a message, you explain the context:

def test_discount_calculation():
    result = discount_price(100, 10)
    assert result == 90.0, f"Expected 90.0 after 10% discount on 100, got {result}"

In pytest you can use the format:

assert result == expected, "Message when it fails"

pytest.raises: verifying exceptions

When the action should raise an exception, you use pytest.raises:

import pytest
from calculator import discount_price


def test_negative_price_raises_value_error():
    with pytest.raises(ValueError):
        discount_price(-10, 5)


def test_negative_price_raises_with_message():
    with pytest.raises(ValueError, match="Invalid price"):
        discount_price(-10, 5)
  • pytest.raises(ValueError): verifies that this exception is raised.
  • match="Invalid price": verifies that the message contains that text (regex).

To capture the exception and make asserts about it:

def test_exception_has_correct_message():
    with pytest.raises(ValueError) as exc_info:
        discount_price(-10, 5)
    assert "price" in str(exc_info.value).lower()

pytest.approx: comparing floats

Floats have precision errors. == can fail on values that are conceptually equal:

# ❌ Fragile
def test_member_discount():
    result = calculate_price(100, 1, is_member=True)
    assert result["final_price"] == 95.0  # Can fail: 94.99999999999999
# ✅ Robust
def test_member_discount():
    result = calculate_price(100, 1, is_member=True)
    assert result["final_price"] == pytest.approx(95.0)

pytest.approx(95.0) uses a default tolerance for float comparison. For explicit tolerance:

assert value == pytest.approx(95.0, rel=1e-2)  # 1% relative tolerance
assert value == pytest.approx(95.0, abs=0.01)  # ±0.01 absolute

Asserts with collections (lists, dictionaries)

For lists and dicts, a direct assert works if the order and structure are exact:

def test_sort_returns_ordered_list():
    result = sort([3, 1, 2])
    assert result == [1, 2, 3]


def test_user_dict_has_required_keys():
    user = get_user(1)
    assert user == {"id": 1, "name": "Alice", "email": "alice@example.com"}

To verify only part of the structure:

def test_user_has_name_and_email():
    user = get_user(1)
    assert "name" in user
    assert "email" in user
    assert user["name"] == "Alice"


def test_items_are_in_result():
    result = get_tags()
    assert "python" in result
    assert "testing" in result

Organizing tests

The test_ convention for discovery

pytest discovers tests by looking for functions that start with test_ and classes that start with Test. No configuration needed.

# ✅ Discovered by pytest
def test_add_numbers():
    assert add(2, 3) == 5


# ❌ Not discovered
def check_addition():
    assert add(2, 3) == 5

Classes to group tests

Use Test* classes to group related tests:

class TestValidateEmail:
    def test_valid_email_returns_true(self):
        assert validate_email("user@example.com") == True
    
    def test_empty_email_returns_false(self):
        assert validate_email("") == False
    
    def test_missing_at_returns_false(self):
        assert validate_email("userexample.com") == False


class TestValidateEmailErrorHandling:
    def test_none_raises_type_error(self):
        with pytest.raises(TypeError):
            validate_email(None)
    
    def test_int_raises_type_error(self):
        with pytest.raises(TypeError):
            validate_email(123)

Classes aren't required, but they help when you have many tests. Each method must start with test_.

The tests/ directory structure

Typical structure:

project/
├── src/
│   └── my_module.py
├── tests/
│   ├── conftest.py           # Shared fixtures
│   ├── __init__.py           # Optional: for imports
│   ├── test_my_module.py     # Tests for the main module
│   └── test_validators.py    # Tests grouped by domain
├── pytest.ini                 # Optional: configuration
└── pyproject.toml

Alternative with a mirror structure:

project/
├── src/
│   ├── validators/
│   │   └── email.py
│   └── pricing/
│       └── calculator.py
├── tests/
│   ├── conftest.py
│   ├── validators/
│   │   └── test_email.py
│   └── pricing/
│       └── test_calculator.py

conftest.py location

  • tests/conftest.py: fixtures for all the project's tests.
  • tests/integration/conftest.py: fixtures only for integration tests (e.g., a DB client).

pytest applies the nearest fixture. A fixture in tests/integration/conftest.py overrides one of the same name in tests/conftest.py only for tests in tests/integration/.


Claude Code and the pytest patterns

Evaluating the structure of generated tests

When Claude Code generates tests, you can evaluate them quickly with AAA:

  • ✅ Is there a clear ARRANGE phase?
  • ✅ Does ACT have a single action?
  • ✅ Does ASSERT verify a specific expectation?
  • ✅ Is there repetition that could be a fixture?

If the test is a monolithic block with no separation, ask: "Restructure this test using arrange-act-assert, with a single action in ACT."

Explicitly asking for arrange-act-assert

In your prompts:

Generate unit tests for [function].
Use the arrange-act-assert pattern in each test:
- ARRANGE: prepare the necessary data and objects
- ACT: run exactly one call to the function
- ASSERT: verify the expected result
One assert per test.

Claude Code tends to generate more structured tests when you ask for it explicitly.

Reading Claude Code's output with knowledge of the patterns

If you know AAA, fixtures, and assertions:

  • You know which parts of the test are configuration vs. verification.
  • You recognize when a fixture would eliminate repetition.
  • You spot assert x == y with floats and suggest pytest.approx.
  • You suggest pytest.raises when an exception is expected.

Comparison: With AAA vs. Without AAA

AspectWithout AAAWith AAA
ReadabilitySetup, action, and asserts mixedThree clear blocks
DebuggingHard to tell which phase failsEasy to identify ARRANGE/ACT/ASSERT
MaintenanceChanges spread confusionChanges are localized
AI evaluationHard to judge qualityClear structure to review

Project Connection

In the Generated unit test suite project (capsule 06), you'll generate a complete suite of tests for a utilities module (data_utils.py). The tests you ask Claude Code for should follow the AAA pattern.

Practical criteria:

  • Each test has ARRANGE, ACT, and ASSERT well separated.
  • You use fixtures (in conftest.py or in the file) for shared data.
  • You use pytest.raises for validations that raise exceptions.
  • You use pytest.approx for float comparisons.
  • The tests are in tests/ with clear names.

When you evaluate what Claude Code generates, review the AAA structure before signing off on the tests.


Troubleshooting

Problem 1: "Fixture not found" or the test doesn't receive the fixture

Cause: The name of the test parameter doesn't match the name of the fixture.

Solution: The parameter must have exactly the same name as the fixture:

@pytest.fixture
def sample_user():
    return {"name": "Alice"}

def test_user(sample_user):  # Identical name
    assert sample_user["name"] == "Alice"

Problem 2: assert fails with floats (94.999999 vs 95.0)

Cause: Exact comparison of floats (==) with precision errors.

Solution: Use pytest.approx:

assert result == pytest.approx(95.0)

Problem 3: pytest.raises doesn't catch the exception

Cause: The exception is raised outside the with block (e.g., in ARRANGE) or is caught elsewhere.

Solution: The call that should fail must be inside the with:

# ❌ BAD
with pytest.raises(ValueError):
    value = get_invalid_value()  # An exception here might not be the expected one
    process(value)  # Or here

# ✅ GOOD
with pytest.raises(ValueError):
    discount_price(-10, 5)  # The call that should fail

Problem 4: Tests that pass in isolation but fail together

Cause: Shared state between tests (global variables, files, DB).

Solution: Use fixtures that create fresh state for each test. Avoid mutating global objects in the tests.

Problem 5: conftest.py isn't discovered

Cause: Incorrect location or file name.

Solution: The file must be named exactly conftest.py and be in the tests directory or a subdirectory. Run pytest from the project root: pytest tests/.


Exercises

Exercise 1: Identify AAA (Easy)

Rewrite this test applying arrange-act-assert with explicit comments:

def test_full_name():
    first = "John"
    last = "Smith"
    assert full_name(first, last) == "John Smith"
See solution
def test_full_name():
    # ARRANGE: prepare the input data
    first = "John"
    last = "Smith"
    
    # ACT: run the function
    result = full_name(first, last)
    
    # ASSERT: verify the result
    assert result == "John Smith"

The test already had the structure; the comments make it explicit. In the ACT phase the result is stored in a variable before the assert, which improves readability when the assert is more complex.

Exercise 2: Create a fixture (Easy)

You have several tests that use the same user {"name": "Alice", "email": "alice@example.com"}. Create a sample_user fixture and refactor at least two tests to use it.

See solution
import pytest


@pytest.fixture
def sample_user():
    return {"name": "Alice", "email": "alice@example.com"}


def test_format_includes_name(sample_user):
    result = format_user(sample_user)
    assert "Alice" in result


def test_validate_user_email(sample_user):
    result = validate_user(sample_user)
    assert result["valid"] == True

If you need variations (e.g., an invalid user), you can have separate fixtures:

@pytest.fixture
def invalid_user():
    return {"name": "", "email": "not-an-email"}

Exercise 3: pytest.raises and pytest.approx (Medium)

Write two tests for this function: one that verifies it raises ValueError with a negative input, and another that verifies the calculation with floats using pytest.approx:

def safe_sqrt(x: float) -> float:
    if x < 0:
        raise ValueError("x must be non-negative")
    return x ** 0.5
See solution
import pytest
from my_module import safe_sqrt


def test_negative_input_raises_value_error():
    with pytest.raises(ValueError, match="non-negative"):
        safe_sqrt(-1)


def test_sqrt_of_two_approximate():
    result = safe_sqrt(2)
    assert result == pytest.approx(1.41421356)

Alternative for the exception message with regex:

def test_negative_input_raises_value_error():
    with pytest.raises(ValueError, match="must be non-negative"):
        safe_sqrt(-1.5)

Exercise 4: Split a test with multiple actions (Medium)

This test does too many things. Split it into several tests following AAA, with one action per test:

def test_shopping_cart():
    cart = ShoppingCart()
    cart.add_item("apple", 1.0)
    cart.add_item("banana", 0.5)
    assert cart.total() == 1.5
    cart.remove_item("apple")
    assert cart.total() == 0.5
See solution
def test_add_item_increases_total():
    # ARRANGE
    cart = ShoppingCart()
    
    # ACT
    cart.add_item("apple", 1.0)
    
    # ASSERT
    assert cart.total() == 1.0


def test_add_multiple_items_sums_total():
    # ARRANGE
    cart = ShoppingCart()
    
    # ACT
    cart.add_item("apple", 1.0)
    cart.add_item("banana", 0.5)
    
    # ASSERT
    assert cart.total() == pytest.approx(1.5)


def test_remove_item_decreases_total():
    # ARRANGE
    cart = ShoppingCart()
    cart.add_item("apple", 1.0)
    cart.add_item("banana", 0.5)
    
    # ACT
    cart.remove_item("apple")
    
    # ASSERT
    assert cart.total() == pytest.approx(0.5)

add_item is called twice in the third test, but the action being tested is remove_item. The add is only part of the ARRANGE. If you want to isolate it further, you can use a fixture for a pre-populated cart.

Exercise 5: conftest.py (Medium)

Create tests/conftest.py with a sample_product fixture that returns {"name": "Widget", "price": 9.99}. Then write a test in tests/test_products.py that uses that fixture without defining it in the same file.

See solution
# tests/conftest.py
import pytest


@pytest.fixture
def sample_product():
    return {"name": "Widget", "price": 9.99}
# tests/test_products.py
def test_product_has_name_and_price(sample_product):
    assert sample_product["name"] == "Widget"
    assert sample_product["price"] == 9.99

To confirm the fixture is injected from conftest.py, run:

pytest tests/test_products.py -v

If the test passes, pytest is resolving the fixture from conftest.py.

Exercise 6: Prompt for Claude Code (Medium)

Write a prompt for Claude Code that asks for unit tests for a function parse_csv_line(line: str) -> list[str] with these conditions: use arrange-act-assert, one assert per test, pytest.raises where appropriate, and descriptive test names.

See solution
Generate unit tests for parse_csv_line(line: str) -> list[str].

The function parses a CSV line and returns a list of strings.
It handles quotes and commas inside fields.

Requirements:
- Use the arrange-act-assert pattern in each test
- One assert per test
- Use pytest.raises for invalid inputs that should raise an exception
- Descriptive names: test_parse_csv_line_[condition]_[result]

Categories to cover:
- Happy path: simple lines and lines with quotes
- Edge cases: empty line, empty fields, spaces
- Error handling: incorrect types, invalid format

With this, Claude Code will have enough context to generate well-structured tests.


Summary

  • ✅ AAA structures each test into ARRANGE (prepare), ACT (run one action), ASSERT (verify).
  • ✅ A single action in ACT and an assert focused on one behavior makes debugging easier.
  • ✅ Fixtures (@pytest.fixture) avoid repetition and are injected by parameter name.
  • ✅ conftest.py defines shared fixtures for all the tests in the directory.
  • ✅ Use pytest.raises for exceptions and pytest.approx for float comparisons.
  • ✅ Organize tests in tests/, with the test_ prefix and Test* classes to group them.
  • ✅ Asking Claude Code for "arrange-act-assert" improves the quality and structure of the generated tests.
  • ✅ Knowing these patterns helps you review and refine tests produced by the AI.

Next capsule: @pytest.mark.parametrize to cover multiple scenarios with a single test.


Additional Resources

  1. pytest: How to use fixtures - Official fixtures documentation
  2. pytest: How to use parametrize - Next capsule, a useful reference
  3. Arrange-Act-Assert (AAA) Pattern - An explanation of the AAA pattern
  4. pytest: Assertions - Assertions and error messages
  5. Real Python: pytest fixtures - A pytest and fixtures tutorial
  6. Test Organizing (pytest docs) - Organizing projects with pytest

Module 2, Capsule 03 — Testing with Claude Code Guide Clear structure: arrange, act, assert