Course outline · 0% complete

0/29 lessons0%

Course overview →

Arrange, Act, Assert

lesson 2-1 · ~9 min · 4/29

What proved the refactor was safe

In lesson 1-2 you refactored count_vowels from a loop into a one-line generator expression. The proof that the refactor was safe was that the three untouched asserts still passed afterwards.

That is the safety net idea in one sentence. Tests pin down behavior, so any change that keeps them green is proven to preserve that behavior. Shorter code and careful reading help you write the change, but only the unchanged, passing tests are evidence.

The word untouched carries the weight here. Tests edited during a refactor prove nothing at all, because you can always make a test agree with whatever the code now does.

The three-part shape of every test

Almost every good test has the same skeleton, called arrange, act, assert (AAA):

  1. Arrange: set up the input data and any objects you need
  2. Act: call the one function or method under test, once
  3. Assert: check the result against what you expected

Why enforce a shape? Because tests are documentation. A teammate (or you, in six months) should be able to open any test and answer three questions instantly: what was the situation, what did we do, what should have happened. When those three phases blur together, a failing test becomes a puzzle instead of a message.

Arrangeset the stageActone callAssertcheck the resultevery test tells the same three-beat story
Arrange, act, assert: the fixed skeleton that makes any test readable at a glance.

One test in AAA shape

A test of cart_total with the three phases marked by comments and exactly one act line.

def cart_total(prices, tax_rate):
    subtotal = sum(prices)
    return round(subtotal * (1 + tax_rate), 2)

def test_cart_total_applies_tax():
    # Arrange
    prices = [10.0, 5.0]
    tax_rate = 0.10
    # Act
    total = cart_total(prices, tax_rate)
    # Assert
    assert total == 16.5

test_cart_total_applies_tax()
print("PASS test_cart_total_applies_tax")

Output

PASS test_cart_total_applies_tax

The arranged values are named rather than inlined, so the assert can be read against them. Written as assert cart_total([10.0, 5.0], 0.10) == 16.5, the test would work identically and be harder to scan, because the situation and the action collapse into one line.

The single act line is the rule that matters most. One call means a failure can only have come from that one call, so there is nothing to narrow down. Two calls in the act phase would leave you wondering which one produced the wrong value.

The expected 16.5 is written as a literal rather than computed. Recomputing it as sum(prices) * 1.1 in the assert would make the test agree with the function's own logic and stop testing anything, which is a trap worth naming now.

Note that round(..., 2) in the function is doing real work, since 15 times 1.1 in floating point is 16.500000000000002. Without the rounding, this assert would fail.

An AAA test for a discount function

The same skeleton on a new function: two arranged variables, one call, one assert.

def apply_discount(price, percent):
    return round(price * (1 - percent / 100), 2)

def test_apply_discount_takes_percent_off():
    # Arrange
    price = 80.0
    percent = 25
    # Act
    result = apply_discount(price, percent)
    # Assert
    assert result == 60.0

test_apply_discount_takes_percent_off()
print("PASS test_apply_discount_takes_percent_off")

Output

PASS test_apply_discount_takes_percent_off

Twenty-five percent off 80.0 is 80.0 times 0.75, which is 60.0, and working that out by hand is part of the exercise. An expected value derived from the function itself would test nothing, so the arithmetic has to come from outside the code.

The two arranged values are chosen to make that arithmetic easy. A price of 79.99 with 17 percent off would test the same behavior while forcing you to trust a calculator, and a test whose expected value you cannot verify at a glance is a test you will eventually edit to match the code.

The last two lines call the test and print the PASS line, which is the same manual approach lesson 1-3 replaced with a loop. Doing it by hand here keeps the focus on the test's shape.

An AAA test for a whitespace edge case

A single arranged string, one call, one assert. The interesting part is the input, which contains a run of three spaces.

def word_count(text):
    return len(text.split())

def test_word_count_ignores_extra_spaces():
    # Arrange
    text = "hello   world"
    # Act
    count = word_count(text)
    # Assert
    assert count == 2

test_word_count_ignores_extra_spaces()
print("PASS test_word_count_ignores_extra_spaces")

Output

PASS test_word_count_ignores_extra_spaces

split() with no argument treats a run of whitespace as a single separator, so the count really is 2. Calling split(" ") instead would return five items, three of them empty strings, and the test would fail with a count of 5. That difference is exactly the behavior this test exists to lock down.

The test name says what the input is unusual about, and that is what makes it worth having alongside an ordinary two-word case. A name like test_word_count would leave a reader guessing why the string looked odd.

Naming the arranged variable text rather than inlining the string also has a practical benefit, since the triple space is easy to miss inside a function call and slightly easier to notice on its own line.