ይህ ትምህርት ገና ወደ አማርኛ አልተተረጎመም፤ ስለዚህ በእንግሊዝኛ ቀርቧል። የእንግሊዝኛውን ገጽ ክፈቱ
Thinking in edge cases
In a study of students' own test suites, the tests caught only about 15% of the bugs in the code, mostly because they tried the normal case and little else. By the end of this lesson you'll choose tests the way bug-hunters do: to catch bugs, not to pass.
Tests that catch nothing
Below, grades.py is lesson 5.8's grade_for with lesson 7.3's range check: it raises
a ValueError for scores outside 0 to 100. Four copies of it each have one bug planted.
The starting tests try one normal score per grade: the
.
Press Hunt: it runs your tests on the correct code and on each planted bug, and a
bug is caught when a test fails on it.
Bug hunt
4 buggy versions of grades.py are planted below. Good tests pass on the correct code and fail on every bug.
test_grades.py
from grades import grade_for
def test_a():
assert grade_for(90) == "A"
def test_b():
assert grade_for(75) == "B"
def test_c():
assert grade_for(60) == "C"
def test_f():
assert grade_for(30) == "F"In the editor, press Escape then Tab to move on.
grades.py
def grade_for(score):
if score < 0 or score > 100:
raise ValueError("score must be 0-100")
if score >= 85:
return "A"
elif score >= 70:
return "B"
elif score >= 50:
return "C"
else:
return "F"Planted bugs
- 85 gets a B
- 150 gets an A
- Negative scores get an F
- 101 gets an A
Show one answerHide the answer
One set of tests that catches every bug, not the only one.
import pytest
from grades import grade_for
@pytest.mark.parametrize("score, expected", [
(0, "F"),
(49, "F"),
(50, "C"),
(69, "C"),
(70, "B"),
(84, "B"),
(85, "A"),
(100, "A"),
])
def test_grades(score, expected):
assert grade_for(score) == expected
@pytest.mark.parametrize("score", [-1, 101])
def test_out_of_range(score):
with pytest.raises(ValueError):
grade_for(score)Caught 0 of 4. Every test passes on every bug, because no bug changes 90, 75, 60, or 30.
Ten more tests of normal scores would catch nothing new either. Add a test at a
,
grade_for(85) == "A", and hunt again: the first bug is caught. The other three are
about scores that should raise an error, and for those you need one more tool.
Testing that an error is raised
For 101, the right answer isn't a letter: grade_for should raise a ValueError. The
first test most people write compares with the error:
test_grades.py
from grades import grade_for
def test_too_high():
assert grade_for(101) == ValueErrorIn the editor, press Escape then Tab to move on.
grades.py
def grade_for(score):
if score < 0 or score > 100:
raise ValueError("score must be 0-100")
if score >= 85:
return "A"
elif score >= 70:
return "B"
elif score >= 50:
return "C"
else:
return "F"In the editor, press Escape then Tab to move on.
It fails with the very error it hoped for:
FAILED test_grades.py::test_too_high - ValueError: score must be 0-100. An error isn't
a value the function returns. It stops the test on that line, before == runs (lesson
7.3). To expect an error, pytest has a tool, pytest.raises:
test_grades.py
import pytest
from grades import grade_for
def test_too_high():
with pytest.raises(ValueError):
grade_for(101)In the editor, press Escape then Tab to move on.
grades.py
def grade_for(score):
if score < 0 or score > 100:
raise ValueError("score must be 0-100")
if score >= 85:
return "A"
elif score >= 70:
return "B"
elif score >= 50:
return "C"
else:
return "F"In the editor, press Escape then Tab to move on.
Read the with line aloud as "the block below must raise a ValueError". Piece by
piece:
import pytestgoes at the top. Forget it and the test fails withNameError: name 'pytest' is not defined.with ...:works as it does withopen(lesson 8.1): a colon, then an indented block.pytest.raises(ValueError)names the kind of error, with no brackets afterValueError: you're naming a type, not making an error.- The indented block holds only the call that should fail.
When the call raises a ValueError, pytest catches it and the test passes. If it raises
nothing, the test fails.
ጥያቄ
You change 101 to 100 in that test. grade_for(100) returns 'A' and raises nothing. What does pytest report?
Make the change and run it. pytest says E Failed: DID NOT RAISE <class 'ValueError'>.
Change it back to 101. Now go back to the hunt and add two tests like this one, for -1
and 101 (with import pytest at the top). All four bugs are caught. A test of 150 would
catch "150 gets an A" but not "101 gets an A": 101 is the boundary, the first score that
must be refused.
Four kinds of input
The bugs above live where most bugs live, at an . For each function, test four kinds of input:
- Normal: one ordinary value, the happy path.
- Boundaries: both sides of every place the answer changes, like 84 and 85.
- Bad input: values the code should refuse, with
pytest.raises. - Empty input:
"",[],{}: nothing at all.
Empty input is the one people forget. Predict:
ውጤቱን ገምቱ
Decide before you look. Guessing wrong is how this sticks.
def average(scores):
return sum(scores) / len(scores)
print(average([]))Python prints
Traceback (most recent call last):
File "<your code>", line 5, in <module>
print(average([]))
~~~~~~~^^^^
File "<your code>", line 2, in average
return sum(scores) / len(scores)
~~~~~~~~~~~~^~~~~~~~~~~~~
ZeroDivisionError: division by zerosum([]) is 0 and len([]) is 0, so it divides 0 by 0. If you expected 0, you pictured an empty list as 'nothing, so zero'; Python just follows the code.
A failing test doesn't always mean the code is wrong. The test can be wrong too: a test
saying grade_for(70) == "C" fails on correct code, because lesson 2.6's table says
70 is a B. When a test fails, check the requirement first, then decide which to fix.
The hunt checks this for you: if your tests fail on the correct code, it says so and
counts no catches.
ጥያቄ
grade_for has > 70 where it should have >= 70. Your tests try 90, 75, 60, and 30. Which new test catches the bug?
Tests first
You can write the tests before the code. That's : write the tests, watch them fail, then write the code until they pass. It makes you decide what the code should do before you write it. Research on whether it makes programs better is mixed, so treat it as a tool, not a rule.
መልመጃ
parse_amount, test-first
These three tests for parse_amount were written first: it takes text like
"ETB 250" (lesson 6.2's payment messages) and returns the whole number. Run them on
the empty function: 3 failed. Then write parse_amount until they pass.
Your code
money.py
def parse_amount(text):
passIn the editor, press Escape then Tab to move on.
test_money.py
import pytest
from money import parse_amount
def test_amount():
assert parse_amount("ETB 250") == 250
def test_spaces():
assert parse_amount(" ETB 40 ") == 40
def test_not_etb():
with pytest.raises(ValueError):
parse_amount("250")In the editor, press Escape then Tab to move on.
መፍትሄውን አሳይHide the solution
This is one way to solve it, not the only one. If yours prints the same thing, it works.
def parse_amount(text):
text = text.strip()
if not text.startswith("ETB "):
raise ValueError("amount must start with ETB")
return int(text[4:])Your turn: hunt phone bugs
phone.py is lesson 6.5's phone pattern. The starting tests try two good numbers and
one obvious word. Use 6.5's list of numbers that should fail until every bug is caught.
Bug hunt
4 buggy versions of phone.py are planted below. Good tests pass on the correct code and fail on every bug.
test_phone.py
from phone import is_phone
def test_ethio_telecom():
assert is_phone("0911234567")
def test_from_abroad():
assert is_phone("+251712345678")
def test_word():
assert not is_phone("hello")In the editor, press Escape then Tab to move on.
phone.py
import re
PHONE = r"(?:\+251|0)[79]\d{8}"
def is_phone(number):
return bool(re.fullmatch(PHONE, number))Planted bugs
- Extra digits pass
- 08 numbers pass
- A digit short passes
- +251 with the 0 kept passes
Show one answerHide the answer
One set of tests that catches every bug, not the only one.
import pytest
from phone import is_phone
@pytest.mark.parametrize("number", ["0911234567", "0712345678", "+251912345678"])
def test_valid(number):
assert is_phone(number)
@pytest.mark.parametrize("number", [
"0811234567",
"091123456",
"09112345678",
"+2510911234567",
"0911 234 567",
"",
])
def test_invalid(number):
assert not is_phone(number)ዋና ዋና ነጥቦች
- Happy-path tests pass on most bugs. Choose tests to catch bugs, not to pass.
- Test four kinds of input: normal, boundaries (both sides), bad input, and empty input.
with pytest.raises(ValueError):passes only if the block raises; otherwiseDID NOT RAISE.- A failing test can be wrong too: check the requirement before you change the code.
- Test-first: write the tests, watch them fail, then write the code until they pass.