0%

Testing and Refactoring Object-oriented Programs · practice

Test Collaborating Objects

The tests so far each held one . report.py does not work that way:

def report_lines(attempt):
    fraction = percentage(attempt.score, attempt.quiz.total_points)
    return [
        f"{attempt.learner}: {attempt.score} of {attempt.quiz.total_points}",
        f"Grade: {grade_letter(fraction)}",
    ]

One , and reaching through it: an attempt, the quiz that attempt belongs to, and two from scoring. Chapter 6 built these collaborations deliberately. Now you have to test them.

Arrange the whole scene

The straightforward way is to build the real thing:

def test_the_report_names_the_learner_and_the_score():
    quiz = Quiz("Review")
    question = Question("Which keyword starts a function?", "def", 2)
    quiz.add(question)
    attempt = Attempt(quiz, "Mina")
    attempt.answer(question, "def")

    lines = report_lines(attempt)

    assert lines[0] == "Mina: 2 of 2"

Five lines of arranging for one line of acting. That ratio is worth noticing, and Lesson 8 comes back to what it is telling you. For now, it is simply the price of testing a function that works with a small graph of objects.

The upside is real: nothing here is pretending. If Attempt and Quiz stop agreeing with each other, this test finds out, and a test of either one alone would not.

Assert on the result, not on the collaborators

report_lines returns a of . That is the whole of what it promises, so that is what to check.

It is tempting to reach further and assert that grade_letter was called, or that it was called with 1.0. Resist it. Those are steps, not results. Rewriting report_lines to compute the letter differently while producing the same lines would break such a test while breaking nothing real.

Check the strings. If the strings are right, the collaboration worked.

A stand-in, when the real thing is in the way

Sometimes you want to test how a function handles a that the real objects make awkward to produce. Chapter 9 showed that Python cares what an object does, not what class it came from, and that applies here too:

class FakeQuiz:
    def __init__(self, total_points):
        self.total_points = total_points


class FakeAttempt:
    def __init__(self, learner, score, total_points):
        self.learner = learner
        self.score = score
        self.quiz = FakeQuiz(total_points)

report_lines never asks what class it was handed. It asks for .learner, .score, and .quiz.total_points, and a stand-in that has those works perfectly.

That buys you exactness. Want the report for a learner who scored 4 out of 5? Say so directly, instead of building a quiz whose questions happen to add up that way and answering enough of them.

It costs you something too: a stand-in is a claim about how the real object behaves, and it will happily go on being wrong after the real one changes. Use real objects by default. Reach for a stand-in when arranging the real thing starts to obscure what the test is about.

report_lines is given a FakeAttempt with learner, score, and quiz.total_points. Why does it work?

What the report promises

headline(quiz) returns one string: the title, then the total points in brackets.

report_lines(attempt) returns two: the learner with their score out of the total, and a grade line. For a learner who scored 2 out of 5, that is "Mina: 2 of 5" and "Grade: F".

Those are the promises. Pin them down.

Task

Write test_report.py, covering what report.py produces.

Import headline and report_lines from report, and whatever you need from quizapp.models. Write three tests:

  • test_the_headline_names_the_quiz_and_its_points: for a quiz titled “Chapter 13 Review” worth 5 points, headline returns "Chapter 13 Review (5 points)".

  • test_the_report_names_the_learner_and_the_score: build a real quiz and attempt where Mina scores 2 out of 5, and assert the first line is "Mina: 2 of 5".

  • test_the_grade_line_reports_the_letter: assert the second line of that same report is "Grade: F".

Use real Quiz, Question, and Attempt . Assert on the that come back, not on which scoring were called.