Testing and Refactoring Object-oriented Programs · practice
Test Expected Exceptions
Chapter 12 argued that refusing to do something is part of what a Question refuses to exist with fewer than one point. Attempt refuses to score a question that is not in its quiz.
Those refusals are behavior. They deserve tests as much as the successes do, and they need a different shape, because the thing you are checking is that the code stopped.
Why the obvious version does not work
def test_a_question_needs_points():
Question("Which keyword?", "def", 0)
assert ???
There is nothing to assert on. Either the call raised, in which case the test never reached the assertion, or it did not, in which case the promise was broken and the test still passes.
You could reach for try and except, and it works, but read what it takes:
def test_a_question_needs_points():
try:
Question("Which keyword?", "def", 0)
except ValueError:
return
raise AssertionError("a question with 0 points should have been refused")
Five lines of plumbing around one line of behavior, and the
raises
Python Land gives you one:
from pythonland_test import raises
def test_a_question_needs_points():
with raises(ValueError):
Question("Which keyword starts a function?", "def", 0)
Read it as a sentence: this block raises ValueError. If it does, the test passes. If the block runs to the end without raising, the test fails. And if it raises something else entirely, that error comes straight through, because an unexpected error is a different problem from a failed expectation and should not be disguised as one.
raises comes from pythonland_test, a
with, briefly
That with is new.
A with statement runs a block under something’s supervision. The with gets told when the block starts and when it ends, including when it ends because of an error, and it can decide what to do about that. raises uses the ending: it looks at how the block finished and judges it.
You will meet with again for files and connections, where the supervision is about cleaning up afterwards. Here the only thing to hold on to is the shape: with something, then an indented block, and the something gets a say in how the block turns out.
Checking which error, and what it says
The seeded quizapp/errors.py gives UnknownQuestionError a prompt attribute so a caller could report the problem properly. A test can look at it:
def test_answering_a_foreign_question_is_refused():
quiz = Quiz("Review")
stray = Question("Not in this quiz", "nope", 1)
attempt = Attempt(quiz, "Mina")
with raises(UnknownQuestionError) as caught:
attempt.answer(stray, "nope")
assert caught.value.prompt == "Not in this quiz"
Like except, raises accepts the named
as caught gives you a handle, and caught.value is the exception object itself, after the block has finished. From there it is an ordinary object: read its attributes, or pass it to str() for the message.
This is worth the extra line. “It raised something” is a much weaker promise than “it raised the error that names the question at fault”, and Chapter 12 built the second one on purpose.
UnknownQuestionError is a subclass of QuizError. A test says with raises(QuizError): around code that raises UnknownQuestionError. What happens?
Which refusals to pin down
The quiz application makes two, and they are of different kinds.
Question refuses invalid construction with a plain ValueError. It is a bad
Attempt.answer refuses a question from outside its quiz with UnknownQuestionError, which the application defined itself, because a caller might reasonably want to catch exactly this and report it. Test both, and for the second one, check that it carries the prompt.
Task
Write test_rules.py, covering the two refusals the quiz application makes.
Import raises from pythonland_test, UnknownQuestionError from quizapp.errors, and what you need from quizapp.models. Write three tests:
test_a_question_needs_at_least_one_point: constructing aQuestionworth 0 points raises. test_answering_a_foreign_question_is_refused: answering a question that was never added to the quiz raisesUnknownQuestionError.test_the_refusal_names_the_question: that same error carries the stray question’s prompt on itspromptattribute.
Keep each with raises(...) block down to the single call that should be refused. Anything you arrange first belongs above the block, where a failure would be reported honestly instead of counting as the expected error.