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:
- ARRANGE: You prepare the state — input data, objects, configuration. Everything the system needs before running the action.
- ACT: You run exactly one action — the function, method, or behavior you're testing.
- 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 == ywith floats and suggestpytest.approx. - You suggest
pytest.raiseswhen an exception is expected.
Comparison: With AAA vs. Without AAA
| Aspect | Without AAA | With AAA |
|---|---|---|
| Readability | Setup, action, and asserts mixed | Three clear blocks |
| Debugging | Hard to tell which phase fails | Easy to identify ARRANGE/ACT/ASSERT |
| Maintenance | Changes spread confusion | Changes are localized |
| AI evaluation | Hard to judge quality | Clear 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.pyor in the file) for shared data. - You use
pytest.raisesfor validations that raise exceptions. - You use
pytest.approxfor 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.pydefines shared fixtures for all the tests in the directory. - ✅ Use
pytest.raisesfor exceptions andpytest.approxfor float comparisons. - ✅ Organize tests in
tests/, with thetest_prefix andTest*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
- pytest: How to use fixtures - Official fixtures documentation
- pytest: How to use parametrize - Next capsule, a useful reference
- Arrange-Act-Assert (AAA) Pattern - An explanation of the AAA pattern
- pytest: Assertions - Assertions and error messages
- Real Python: pytest fixtures - A pytest and fixtures tutorial
- Test Organizing (pytest docs) - Organizing projects with pytest
Module 2, Capsule 03 — Testing with Claude Code Guide Clear structure: arrange, act, assert