В первой части мы начали писать факториальную функцию в стиле test-driven. Мы полагаемся на скромный оператор assert для запуска наших тестов, и мы закончили с этим:

def factorial(n):
    return n * factorial(n-1) if n else 1
assert factorial(0) == 1
assert factorial(2) == 2
assert factorial(5) == 120

Код правильный, по крайней мере, судя по написанным нами тестам, но это не значит, что в нем нет ошибок.

>>> factorial(-1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "fac9.py", line 24, in factorial
    return n * factorial(n-1) if n else 1
  File "fac9.py", line 24, in factorial
    return n * factorial(n-1) if n else 1
  File "fac9.py", line 24, in factorial
    return n * factorial(n-1) if n else 1
  [Previous line repeated 996 more times]
RecursionError: maximum recursion depth exceeded

Упс. Кто-то передал отрицательное число нашему факториалу и переполнил стек.

Когда мы находим ошибку, поскольку мы сначала пишем тесты, мы не начинаем с ее исправления. Вместо этого мы начинаем с написания теста, который выявляет ошибку.

Здесь мы забыли проверить «отрицательный случай». «Отрицательное тестирование» — скользкое понятие, но оно более или менее означает тестирование на наличие ошибок.

Мы могли бы возразить, что наш текущий код работает правильно: факториалы отрицательных значений определены нечетко, поэтому выдача ошибки уместна. Но эта ошибка не помогает. Мы должны выдать более информативную ошибку, чтобы вызывающие могли лучше понять, что они сделали не так.

Однако мы не можем написать это утверждение, как другие: чтобы проверить, что функция выдает исключение, нам нужно перехватить это исключение:

def factorial(n):
    return n * factorial(n-1) if n else 1
...
try:
    factorial(-1)
except:
    pass

И запустим наш тест:

$ python fac.py
$

Упс. Тишина… вот почему мы сначала пишем неудачный тест. Если мы не убедимся, что тест не пройден, мы не тестировали тест. Здесь тест проверяет совсем не то, что мы намеревались; он просто подавляет ошибку. Мы легко можем пропустить такую ​​ошибку в наших тестах, если не позаботимся о том, чтобы наш тест не прошел так, как мы ожидаем, прежде, чем мы напишем код для его прохождения.

Нам нужно проверить, что, когда наша функция вызывает ошибку, она вызывает тип ошибки, который мы ожидаем. ValueError — подходящий встроенный тип, поэтому мы добавим его в строку исключения.

def factorial(n):
    return n * factorial(n-1) if n else 1
...
try:
    factorial(-1)
except ValueError as e:
    pass

И снова у нас «красное» состояние — тест не пройден — и это хорошо.

$ python fac.py
Traceback (most recent call last):
  File "fac.py", line 28, in <module>
    factorial(-1)
  File "fac.py", line 23, in factorial
    return n * factorial(n-1) if n else 1
  File "fac.py", line 23, in factorial
    return n * factorial(n-1) if n else 1
  File "fac.py", line 23, in factorial
    return n * factorial(n-1) if n else 1
  [Previous line repeated 996 more times]
RecursionError: maximum recursion depth exceeded

Итак, давайте исправим наш тест самым простым способом.

def factorial(n):
    if n < 0: return
    return n * factorial(n-1) if n else 1
...
try:
    factorial(-1)
except ValueError as e:
    pass

Ладно, тишина:

$ python fac.py
$

Но подождите, мы хотели, чтобы наша функция выдавала ошибку при неправильном вводе, а она вообще ничего не выдавала. Мы добавляем утверждение, чтобы убедиться, что оно не возвращается успешно для недопустимого ввода:

def factorial(n):
    if n < 0: return
    return n * factorial(n-1) if n else 1
...
try:
    factorial(-1)
    assert False, 'Factorial of < 0 should have raised an error'
except ValueError as e:
    pass

Теперь тест проваливается, как мы и хотели:

$ python fac.py
Traceback (most recent call last):
  ...
AssertionError: factorial of < 0 should have raised an error

И мы можем пройти этот последний тест.

def factorial(n):
    if n < 0:
        raise ValueError()
    return n * factorial(n-1) if n else 1
...
try:
    factorial(-1)
    assert False, 'Factorial of < 0 should have raised an error'
except ValueError as e:
    pass

ValueError сам по себе не очень информативен. Мы можем изменить блок exclude в нашем тесте, чтобы подтвердить, что исключение имеет полезное сообщение.

def factorial(n):
    if n < 0:
        raise ValueError()
    return n * factorial(n-1) if n else 1
...
try:
    factorial(-1)
    assert False, 'Factorial of < 0 should have raised an error'
except ValueError as e:
    assert str(e) == 'Factorial of negative is undefined'

Упражнение для читателя: пройдите этот тест:

$ python fac.py
...
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
  File "fac.py", line 19, in <module>
    assert str(e) == 'Factorial of negative is undefined'
AssertionError

В следующий раз мы проведем тестирование за пределами простого оператора assert.