0%

Testing and Refactoring Object-oriented Programs · practice

Arrange, Act, Assert

Your three tests for Question all had the same shape, whether or not you noticed while writing them:

def test_a_matching_response_is_correct():
    question = Question("Which keyword starts a function?", "def", 2)

    assert question.is_correct("def")

Build something. Do something to it. Check what happened.

That shape has a name, arrange, act, assert, and it is worth naming because once you see it you can tell at a glance whether a test is doing one thing or four.

The three phases

def test_answering_correctly_adds_the_points():
    quiz = Quiz("Review")                       # arrange
    question = Question("Which keyword?", "def", 2)
    quiz.add(question)
    attempt = Attempt(quiz, "Mina")

    attempt.answer(question, "def")             # act

    assert attempt.score == 2                   # assert

Arrange puts the world into the state the test is about. Act is the single thing being tested. Assert checks the result.

The blank lines are doing real work here. A reader who wants to know what this test is about reads the middle line and stops.

One behavior per test

The shape gives you a rule that is easier to follow than “keep tests small”: a test has one act.

When you catch yourself writing a second act, you have found a second test:

def test_answering():
    ...
    attempt.answer(first, "def")
    assert attempt.score == 2

    attempt.answer(second, "wrong")      # a second act
    assert attempt.score == 2

Split that and each half gets a name that says what it checks. Better still, when one breaks, the other still runs, and the pair of results tells you more than a single failure would.

There is one honest . Sometimes the behavior genuinely is a sequence: answering two questions in a row should accumulate. Then the sequence is the act, and the test name says so.

The name is documentation

test_1 tells a future reader nothing. When it fails, they have to read the body to find out what broke.

A good name is a sentence about behavior, with test_ in front of it:

  • test_total_points_sums_every_question

  • test_a_new_quiz_has_no_points

  • test_a_wrong_answer_does_not_change_the_score

Long is fine. The name appears in the test report alongside failure details, so it should tell you what the program was supposed to do.

A test checks that a quiz totals its points, then separately checks that a question ignores capitalization. Why split those checks into two tests?

Testing what a method promises not to do

Quiz.questions() hands back a . Look at how:

def questions(self):
    return list(self._questions)

That list(...) is a promise: what you get back is yours, and scribbling on it will not reach inside the quiz. Chapter 6 called this keeping ownership of your own state.

A promise like that deserves a test, because it is exactly the kind of thing a later “simplification” removes:

def test_the_returned_list_is_a_copy():
    quiz = Quiz("Review")
    quiz.add(Question("Which keyword?", "def", 2))

    quiz.questions().append("not a question")

    assert len(quiz.questions()) == 1

Delete the list(...) and that test fails immediately. That is what a test is for.

Task

Write test_quiz.py, covering what Quiz promises.

Import Question and Quiz from quizapp.models, then write three tests:

  • test_a_new_quiz_has_no_points: a quiz with nothing added is worth 0 points.

  • test_total_points_sums_every_question: adding questions worth 2 and 3 makes total_points 5.

  • test_the_returned_list_is_a_copy: appending to whatever questions() returns does not change the quiz.

Give every test the three phases, separated by blank lines: arrange the quiz, act once, then assert. Keep one act per test.