Глава 9. Модульное тестирование (юнит-тестирование)

Добавлено 18 мая 2020 в 01:41

Certitude is not the test of certainty. We have been cocksure of many things that were not so.

Oliver Wendell Holmes, Jr.

Содержание главы

(Не) погружение

Современная молодежь. Избалованы быстрыми компьютерами и модными «динамическими» языками. Писать, потом распространять свой код, потом отлаживать (в лучшем случае). В наши дни была дисциплина. Я сказал, дисциплина! Нам приходилось писать программы руками, на бумаге, и вводить их в компьютер на перфокартах. И нам это нравилось!

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

Правила формирования римских чисел приводят нас к нескольким интересным наблюдениям:

  1. Существует только один правильный способ записать число римскими цифрами.
  2. Обратное также верно: если строка символов является последовательностью римских символов, она представляет только одно число (то есть может быть интерпретирована единственным способом).
  3. Диапазон чисел, которые могут быть записаны римскими цифрами, – от 1 до 3999. У римлян было несколько способов записывать более крупные числа, в частности, с помощью черты над числом, которая означала бы, что значение нужно умножить на 1000. Для целей этой главы нам достаточно ограничиться диапазоном 1–3999.
  4. В римской системе нет способа представить 0.
  5. В римской системе нет способа представить отрицательные числа.
  6. В римской системе нет способа представить дробные или нецелые числа.

Попробуем отразить, что должен делать модуль roman.py. Он будет содержать две основные функции, to_roman() и from_roman(). Функция to_roman() должна принимать целое число в диапазоне от 1 до 3999 и возвращать строку, содержащую римское представление этого числа…

Остановимся здесь. Давайте теперь сделаем кое-что неожиданное: опишем небольшой тестовый случай, который проверяет, работает ли функция to_roman() так, как мы этого ожидаем. Вы правильно поняли: мы собираемся написать код, который тестирует код, который еще не написан.

Это так называемая разработка через тестирование (test-driven development, TDD). Набор из двух функций конвертирования (to_roman(), from_roman()) может быть написан и протестирован как юнит (отдельный блок), в отдельности от любой крупной программы, которая импортирует их. В Python есть фреймворк для юнит-тестирования, модуль с соответствующим называнием unittest.

Юнит-тестирование – важная часть всей стратегии разработки, основанной на тестировании. Если вы пишете юнит-тесты, необходимо писать их на ранних этапах и обновлять их по мере изменений кода и требований. Многие люди пропагандируют написание тестов до написания тестируемого кода, и именно этот подход я собираюсь продемонстрировать в данной главе. Однако юнит-тесты выгодны вне зависимости от того, когда вы их пишете.

  • Написание юнит-тестов до написания кода заставляет детализировать требования в удобном виде.
  • Во время написания кода юнит-тесты защищают вас от лишнего кодирования. Когда проходят все тесты, тестируемый юнит готов.
  • Во время рефакторинга они помогают доказать, что новая версия ведет себя так же, как и старая.
  • Во время поддержки кода существование юнит-тестов прикроет вашу задницу, когда кто-то начнет кричать, что ваше последнее изменение сломало их код. («Но сэр, все тесты были пройдены успешно, когда я делал коммит.»)
  • Когда код пишется в команде, наличие всестороннего набора тестов значительно снижает риск того, что ваш код сломает код других разработчиков, поскольку вы можете выполнить их юнит-тесты. (Я видел, как это работает на практике на Code Sprint. Команда разбивает задание, участники разбирают спецификации своих задач, пишут для них юнит-тесты, затем обмениваются юнит-тестами со всей командой. Так никто не зайдет слишком далеко в разработке кода, который плохо пригоден для остальной команды.)

9.2 Единственный вопрос

Один тестовый случай (test case) отвечает на один вопрос о тестируемом коде. Тестовый случай должен быть способен…

  • … запускаться самостоятельно, без ввода данных от человека. Юнит-тестирование должно быть автоматизировано.
  • … определять самостоятельно, прошла ли тестируемая функция тест или нет, без вмешательства человека для интерпретирования результатов.
  • … запускаться изолировано, отдельно от остальных тестовых случаев (даже если они тестируют те же функции). Каждый тестовый случай – это как бы отдельный остров.

Учитывая это, давайте составим тестовый случай (тест) для первого требования:

  1. Функция to_roman() должна возвращать представление числа в римской системе счисления для всех чисел от 1 до 3999

Не сразу ясно, как этот код делает... ну, хоть что-то. Он определяет класс, не содержащий метод __init__(). Класс содержит другой метод, который никогда не вызывается. Скрипт содержит блок __main__, но тот не ссылается на класс или его методы. Но кое-что он делает, поверьте мне.

Скачать файл romantest1.py.

import roman1
import unittest

class KnownValues(unittest.TestCase):               ①
    known_values = ( (1, 'I'),
                     (2, 'II'),
                     (3, 'III'),
                     (4, 'IV'),
                     (5, 'V'),
                     (6, 'VI'),
                     (7, 'VII'),
                     (8, 'VIII'),
                     (9, 'IX'),
                     (10, 'X'),
                     (50, 'L'),
                     (100, 'C'),
                     (500, 'D'),
                     (1000, 'M'),
                     (31, 'XXXI'),
                     (148, 'CXLVIII'),
                     (294, 'CCXCIV'),
                     (312, 'CCCXII'),
                     (421, 'CDXXI'),
                     (528, 'DXXVIII'),
                     (621, 'DCXXI'),
                     (782, 'DCCLXXXII'),
                     (870, 'DCCCLXX'),
                     (941, 'CMXLI'),
                     (1043, 'MXLIII'),
                     (1110, 'MCX'),
                     (1226, 'MCCXXVI'),
                     (1301, 'MCCCI'),
                     (1485, 'MCDLXXXV'),
                     (1509, 'MDIX'),
                     (1607, 'MDCVII'),
                     (1754, 'MDCCLIV'),
                     (1832, 'MDCCCXXXII'),
                     (1993, 'MCMXCIII'),
                     (2074, 'MMLXXIV'),
                     (2152, 'MMCLII'),
                     (2212, 'MMCCXII'),
                     (2343, 'MMCCCXLIII'),
                     (2499, 'MMCDXCIX'),
                     (2574, 'MMDLXXIV'),
                     (2646, 'MMDCXLVI'),
                     (2723, 'MMDCCXXIII'),
                     (2892, 'MMDCCCXCII'),
                     (2975, 'MMCMLXXV'),
                     (3051, 'MMMLI'),
                     (3185, 'MMMCLXXXV'),
                     (3250, 'MMMCCL'),
                     (3313, 'MMMCCCXIII'),
                     (3408, 'MMMCDVIII'),
                     (3501, 'MMMDI'),
                     (3610, 'MMMDCX'),
                     (3743, 'MMMDCCXLIII'),
                     (3844, 'MMMDCCCXLIV'),
                     (3888, 'MMMDCCCLXXXVIII'),
                     (3940, 'MMMCMXL'),
                     (3999, 'MMMCMXCIX'))           ②

    def test_to_roman_known_values(self):           ③
        '''to_roman should give known result with known input'''
        for integer, numeral in self.known_values:
            result = roman1.to_roman(integer)       ④
            self.assertEqual(numeral, result)       ⑤

if __name__ == '__main__':
    unittest.main()
  1. Строка 4. Для описания тестового случая сначала определим подкласс класса TestCase из модуля unittest. Этот класс содержит много полезных методов, которые вы можете использовать в ваших тестах для определенных условий.
  2. Строка 60. Это множество пар "число/значение" я проверил вручную. Оно включает минимальные 10 чисел, максимальное число (3999), все числа, которые в римском виде состоят из одного символа, а также набор случайных чисел. Не нужно тестировать все возможные варианты, но все уникальные варианты протестировать нужно.
  3. Строка 62. Каждый тест определен отдельным методом. Тестовый метод вызывается без параметров, не возвращает значения, и его имя должно начинаться с четырех букв test. Если метод завершается нормально, без выброса исключения – тест считается пройденным, если выброшено исключение – тест завален.
  4. Строка 65. Здесь и происходит вызов реальной функции to_roman(). (Ну, функция еще не написана, но когда будет, это будет строка, которая ее вызовет.) Заметьте, что вы только что определили интерфейс (API) функции to_roman(): она должна принимать целое число (которое необходимо преобразовать) и возвращать строку (преставление в виде римского числа). Если API отличается от вышеуказанного, тест вернет ошибку. Также отметьте, что вы не отлавливаете какие-либо исключения, когда вызываете to_roman(). Это сделано специально. to_roman() не должна возвращать исключение при вызове с правильными входными параметрами и правильными значениями этих параметров. Если to_roman() выбрасывает исключение, тест считается проваленным.
  5. Строка 66. Предполагая, что функция to_roman() определена корректно, вызвана корректно, выполнилась успешно, и вернула значение, последним шагом будет проверка правильности возвращенного значения. Это общий вопрос, поэтому для проверки равенства (эквивалентности) двух значений мы используем метод assertEqual класса TestCase. Если возвращенный функцией to_roman() результат (result) не равен известному значению, которое вы ожидаете получить (numeral), assertEqual выбросит исключение, и тест завершится с ошибкой. Если значения эквиваленты, assertEqual ничего не сделает. Если каждое значение, возвращенное to_roman() совпадет с известным ожидаемым значением, assertEqual никогда не выбросит исключение, а значит, test_to_roman_known_values в итоге выполнится нормально, что будет означать, что функция to_roman() успешно прошла тест.

Раз у нас есть тест, мы можем начать писать саму функцию to_roman(). Во-первых, нам необходимо написать заглушку, пустую функцию и убедиться, что тест провалится. Если тест пройден удачно, когда функция еще ничего не делает, значит, тест не работает вообще! Юнит-тестирование – это как танец: тест ведет, код следует. Сначала пишете тест, который проваливается, потом – код, пока тест не будет пройден.

# roman1.py

def to_roman(n):
    '''convert integer to Roman numeral'''
    pass                                   ①
  1. Строка 5. На этом этапе мы определяем API для функции to_roman(), но пока не хотим писать ее код (для первой проверки теста). Для заглушки функции в Python используется зарезервированное слово pass, которое... ничего не делает.

Для проверки теста выполняем romantest1.py в интерпретаторе. Если вы вызвали скрипт с параметром -v, то получите подробный вывод о работе скрипта и сможете увидеть детали того, что происходит в каждом тесте. Если повезло, увидите нечто подобное:

you@localhost:~/diveintopython3/examples$ python3 romantest1.py -v
test_to_roman_known_values (__main__.KnownValues)                      ①
to_roman should give known result with known input ... FAIL            ②

======================================================================
FAIL: to_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest1.py", line 73, in test_to_roman_known_values
    self.assertEqual(numeral, result)
AssertionError: 'I' != None                                            ③

----------------------------------------------------------------------
Ran 1 test in 0.016s                                                   ④

FAILED (failures=1)                                                    ⑤
  1. Строка 2. Запущенный скрипт выполняет метод unittest.main(), который запускает каждый тестовый случай. Каждый тестовый случай – это метод класса в romantest.py. К организации этих тестовых классов особых требований нет; это могут быть классы, каждый с одним тестовым методом, или это может быть один класс с несколькими тестовыми методами. Необходимо лишь, чтобы каждый класс был наследником unittest.TestCase.
  2. Строка 3. Для каждого тестового случая модуль unittest выведет строку документации метода и результат (успех или провал). Как и ожидалось, тест провален.
  3. Строка 11. Для каждого проваленного теста unittest выводит детальную информацию о том, что конкретно произошло. В данном случае вызов assertEqual() вызвал ошибку объявления (AssertionError), поскольку от to_roman(1) ожидалось возвращения 'I', но этого не произошло (если у функции нет явного возврата, то она вернет None, недействительное значение в Python).
  4. Строка 14. После детализации каждого тестового случая unittest отображает суммарно, сколько тестов было выполнено, и сколько это заняло времени.
  5. Строка 16. В целом тест считается проваленным, если не пройден хоть один тестовый случай. unittest различает ошибки и провалы. Провал вызывает метод assertXYZ, например assertEqual или assertRaises, который провалится, если объявленное условие неверно или ожидаемое исключение не выброшено. Ошибка – это любой другой тип исключения, которое выбрасывается тестируемым кодом или тестовым модулем и не является ожидаемым.

Теперь, наконец, мы можем написать функцию to_roman().

Скачать файл roman1.py.

roman_numeral_map = (('M',  1000),
                     ('CM', 900),
                     ('D',  500),
                     ('CD', 400),
                     ('C',  100),
                     ('XC', 90),
                     ('L',  50),
                     ('XL', 40),
                     ('X',  10),
                     ('IX', 9),
                     ('V',  5),
                     ('IV', 4),
                     ('I',  1))                 ①

def to_roman(n):
    '''convert integer to Roman numeral'''
    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:                     ②
            result += numeral
            n -= integer
    return result
  1. Строка 13.roman_numeral_map – это кортеж кортежей, определяющий три вещи: представление символов базовых римских чисел, порядок римских чисел (в обратном направлении, от M и до I), значения римских чисел. Каждый внутренний кортеж – это пара значений (представление, число). И это не только односимвольные римские числа; это также пары символов, типа CM (“на одну сотню меньше, чем одна тысяча”). Это сильно упрощает код функции to_roman().
  2. Строка 19. Вот здесь видно, в чем выигрыш такой структуры roman_numeral_map, поскольку не требуется какой-то хитрой логики при обработке вычитанием. Для конвертирования в римское число необходимо просто пройти roman_numeral_map в цикле, находя наибольшее целое значение, которое меньше или равно входному значению. При нахождении такового, к выходному значению добавляется соответствующее римское представление, соответствующее целое значение вычитается из входного значения, и далее операция повторяется для следующего кортежа.

Если всё же не понятно, как работает функция to_roman(), добавим print() в конец цикла:

while n >= integer:
    result += numeral
    n -= integer
    print('subtracting {0} from input, adding {1} to output'.format(integer, numeral))

Этот отладочный вывод показывает следующее:

>>> import roman1
>>> roman1.to_roman(1424)
subtracting 1000 from input, adding M to output
subtracting 400 from input, adding CD to output
subtracting 10 from input, adding X to output
subtracting 10 from input, adding X to output
subtracting 4 from input, adding IV to output
'MCDXXIV'

Ну, функция to_roman() вроде бы работает, по крайней мере, в этой выборочной проверке. Но пройдет ли она написанный ранее тест?

you@localhost:~/diveintopython3/examples$ python3 romantest1.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok               ①

----------------------------------------------------------------------
Ran 1 test in 0.016s

OK
  1. Строка 3. Ура! Функция to_roman() прошла тест “known values”. Возможно, это не всесторонняя проверка, но в ее ходе проверены различные входные данные, включая числа, записываемые одним римским символом, наибольшее входное значение (3999) и значение, дающее наиболее длинное римское число (3888). На этом этапе можно сказать, что функция корректно обрабатывает любые правильные исходные значения.

“Правильные” исходные значения? Хм. А как насчет неправильных?

9.3 «Остановись и гори»

Недостаточно проверить работу функции только с правильными входными данными; необходимо также убедиться, что функция выдаст ошибку при неправильном вводе. И не просто ошибку, а такую как ожидается.

>>> import roman1
>>> roman1.to_roman(4000)
'MMMM'
>>> roman1.to_roman(5000)
'MMMMM'
>>> roman1.to_roman(9000)  ①
'MMMMMMMMM'
  1. Строка 6. Это определенно не то, что ожидалось – это не правильные римские числа! По сути, все эти числа выходят за возможные пределы, но функция всё равно возвращает результат, только неправильный. Тихое возвращение неверного значения – это ооооооооооочень плохо; если возникает ошибка, лучше чтобы программа завершалась быстро и шумно. "Остановись и гори", как говорится.

"Питоновский" способ остановиться и загореться – это выбросить исключение.

Спрашивается, как же учесть это в требованиях к тестированию? Для начинающих:

  • функция to_roman() должна выбрасывать исключение типа OutOfRangeError, если ей передать число более 3999.

Как будет выглядеть тест?

Скачать файл romantest2.py.

import unittest, roman2
class ToRomanBadInput(unittest.TestCase):                                 ①
    def test_too_large(self):                                             ②
        '''to_roman should fail with large input'''
        self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)  ③
  1. Строка 2. Как и в предыдущем случае, мы создаем класс, который наследуется от unittest.TestCase. У вас может быть более одного теста на класс (как вы увидите далее в этой главе), и я решил создать отдельный класс для этого, потому что этот случай отличается от предыдущих. Мы поместили все тесты на «корректные входные данные» в одном классе, а на «ошибочные входные данные» – в другом.
  2. Строка 3. Как и в предыдущем случае, тест – это метод, имя которого начинается с test.
  3. Строка 5. Класс unittest.TestCase предоставляет метод assertRaises, который принимает следующие аргументы: тип ожидаемого исключения, имя тестируемой функции и аргументы этой функции. (Если тестируемая функция принимает более одного аргумента, все они передаются методу assertRaises по порядку, как будто передаете их тестируемой функции.)

Обратите особое внимание на последнюю строку кода. Вместо вызова функции to_roman() и проверки вручную того, что она выбрасывает конкретное исключение (с помощью обертывания ее в блок try...except), метод assertRaises делает всё это за нас. Всё, что вы делаете, – это говорите, какой тип исключения ожидаете (roman2.OutOfRangeError), имя функции (to_roman()), и ее аргументы (4000). Метод assertRaises позаботится о вызове функции to_roman() и проверит, возвращает ли она исключение roman2.OutOfRangeError.

Также заметьте, что вы передаете функцию to_roman() как аргумент; вы не вызываете ее и не передаете ее имя как строку. Кажется, я уже упоминал, что в Python всё является объектом?

Что же происходит, когда вы запускаете скрипт с новым тестом?

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ERROR                         ①

======================================================================
ERROR: to_roman should fail with large input                          
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest2.py", line 78, in test_too_large
    self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)
AttributeError: 'module' object has no attribute 'OutOfRangeError'      ②

----------------------------------------------------------------------
Ran 2 tests in 0.000s

FAILED (errors=1)
  1. Строка 5. Следовало ожидать этот провал (если конечно вы не написали дополнительного кода), но… это не совсем «провал», скорее это «ошибка». Это тонкое, но очень важное различие. Юнит-тест может вернуть три значения: успех, провал и ошибка. Успех, естественно, означает, что тест пройден – код делает, что ожидалось. «Провал» – то, что вернул тест выше, – код выполняется, но результат не соответствует ожидаемому. «Ошибка» означает, что ваш код даже работает неправильно.
  2. Строка 13. Почему код не выполняется правильно? Раскрутка стека всё объясняет. Тестируемый модуль не выбрасывает исключение типа OutOfRangeError. Вспомните, мы передали это исключение методу assertRaises(), потому что ожидаем, что функция выкинет его при входных данных, не попадающих в обрабатываемый диапазон. Но исключение не выбрасывается, поэтому вызов метода assertRaises() провален. Без шансов – функция to_roman() никогда не выбросит OutOfRangeError.

Чтобы решить эту проблему, определим исключение OutOfRangeError в roman2.py.

class OutOfRangeError(ValueError):  ①
    pass                            ②
  1. Строка 1. Исключения – это классы. Ошибка «out of range» – это тип ошибки по значению – аргумент выходит за допустимые пределы. Поэтому это исключение наследуется от встроенного исключения ValueError. Это не строго необходимо (по идее достаточно наследования от класса Exception), однако так правильнее.
  2. Строка 2. Исключение, в общем-то, ничего и не делает, но нам нужна хотя бы одна строка в классе. Встроенный оператор pass ничего не делает, однако необходим для минимального определения кода класса в Python.

Теперь запустим тест еще раз.

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... FAIL                          ①

======================================================================
FAIL: to_roman should fail with large input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest2.py", line 78, in test_too_large
    self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)
AssertionError: OutOfRangeError not raised by to_roman                 ②

----------------------------------------------------------------------
Ran 2 tests in 0.016s

FAILED (failures=1)
  1. Строка 5. Тест, по-прежнему, не проходит, хотя уже и не выдает ошибку. Это прогресс! Значит, метод assertRaises() был выполнен, и тест функции to_roman() был произведен.
  2. Строка 13. Конечно функция to_roman() не выбрасывает только что определенное исключение OutOfRangeError, так как мы еще не сказали ей сделать это. И это хорошие новости! Значит, тест работает, а проваливаться он будет, пока вы не напишете условие его успешного прохождения.

Этим и займемся.

Скачать файл roman2.py.

def to_roman(n):
    '''convert integer to Roman numeral'''
    if n > 3999:
        raise OutOfRangeError('number out of range (must be less than 4000)')  ①

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result
  1. Строка 4. Всё просто: если переданный параметр больше 3999, выбрасываем исключение OutOfRangeError. Тест не ищет текстовую строку, объясняющую причину исключения, хотя вы можете написать тест для проверки этого (но учтите трудности интернационализации, связанные со строками, и которые зависят от языка пользователя или окружения).

Позволит ли это пройти тест? Узнаем:

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok                            ①

----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK
  1. Строка 5. Ура! Оба теста пройдены. Так как мы работали, переключаясь между кодированием и тестированием, то с уверенностью можем сказать, что именно последние 2 строки кода позволили тесту вернуть «успех», а не «провал». Такая уверенность далась не дешево, но в дальнейшем окупит себя с лихвой.

9.4 Больше «стопов», больше «огня»

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

>>> import roman2
>>> roman2.to_roman(0)
''
>>> roman2.to_roman(-1)
''

Это не хорошо. Добавим тесты для каждого случая.

Скачать файл romantest3.py.

class ToRomanBadInput(unittest.TestCase):
    def test_too_large(self):
        '''to_roman should fail with large input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 4000)  ①

    def test_zero(self):
        '''to_roman should fail with 0 input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 0)     ②

    def test_negative(self):
        '''to_roman should fail with negative input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, -1)    ③
  1. Строка 4. Метод test_too_large() не изменился. Я включил его сюда, чтобы показать схожесть кода.
  2. Строка 8. Это новый тест: test_zero(). Как и test_too_large(), он говорит методу assertRaises(), определенному в unittest.TestCase, вызвать нашу функцию to_roman() с параметром "0", и проверить, выбрасывает ли она соответствующее исключение, OutOfRangeError.
  3. Строка 12. Метод test_negative() почти аналогичный, только он передает -1 в функцию to_roman(). Если ни один из этих новых тестов не выбросит OutOfRangeError (либо потому что наша функция возвращает значение, либо потому что она выбрасывает какое-то другое исключение), и тест считается проваленным.

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

you@localhost:~/diveintopython3/examples$ python3 romantest3.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... FAIL
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... FAIL

======================================================================
FAIL: to_roman should fail with negative input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest3.py", line 86, in test_negative
    self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, -1)
AssertionError: OutOfRangeError not raised by to_roman

======================================================================
FAIL: to_roman should fail with 0 input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest3.py", line 82, in test_zero
    self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 0)
AssertionError: OutOfRangeError not raised by to_roman

----------------------------------------------------------------------
Ran 4 tests in 0.000s

FAILED (failures=2)

Отлично. Оба теста провалены, как и ожидалось. А теперь обратимся к коду и посмотрим, что можно сделать для успешного прохождения теста.

Скачать файл roman3.py.

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 4000):                                              ①
        raise OutOfRangeError('number out of range (must be 1..3999)')  ②

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result
  1. Строка 3. Отличный пример сокращения Python: множественное сравнение в одну строку. Это эквивалентно выражению "if not ((0 < n) and (n < 4000))", но читается проще. Эта одна строка кода должна охватывать входные данные, которые слишком большие, отрицательные или равны нулю.
  2. Строка 4. Изменение условия требует изменения сообщения исключения. Фрэймворку unittest всё равно, а вот при отладке вручную могут возникнуть трудности, если сообщение будет неправильно описывать ситуацию.

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

you@localhost:~/diveintopython3/examples$ python3 romantest3.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.016s

OK

9.5 И еще одна штука...

Еще одно требование к функциональности при преобразовании чисел в римскую систему счисления – это работа с нецелыми числами.

>>> import roman3
>>> roman3.to_roman(0.5)  ①
''
>>> roman3.to_roman(1.0)  ②
'I'
  1. Строка 2. О, это плохо.
  2. Строка 4. О, а это еще хуже.

Оба случая должны выбросить исключение. Вместо этого функция возвращает неправильный результат.

Тестирование нецелых чисел не сложное. Во-первых, определим исключение NotIntegerError.

# roman4.py
class OutOfRangeError(ValueError): pass
class NotIntegerError(ValueError): pass

Далее напишем тестовый случай для проверки выброса исключения NotIntegerError.

class ToRomanBadInput(unittest.TestCase):
    .
    .
    .
    def test_non_integer(self):
        '''to_roman should fail with non-integer input'''
        self.assertRaises(roman4.NotIntegerError, roman4.to_roman, 0.5)

Убеждаемся, что тест провален.

you@localhost:~/diveintopython3/examples$ python3 romantest4.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_non_integer (__main__.ToRomanBadInput)
to_roman should fail with non-integer input ... FAIL
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

======================================================================
FAIL: to_roman should fail with non-integer input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest4.py", line 90, in test_non_integer
    self.assertRaises(roman4.NotIntegerError, roman4.to_roman, 0.5)
AssertionError: NotIntegerError not raised by to_roman

----------------------------------------------------------------------
Ran 5 tests in 0.000s

FAILED (failures=1)

Пишем код для прохождения теста.

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 4000):
        raise OutOfRangeError('number out of range (must be 1..3999)')
    if not isinstance(n, int):                                          ①
        raise NotIntegerError('non-integers can not be converted')      ②

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result
  1. Строка 5. Встроенная функция isinstance() проверяет, принадлежит ли переменная определенному типу (или, технически, к наследнику типа).
  2. Строка 6. Если аргумент n не целое число, выбрасываем наше новое исключение NotIntegerError.

Наконец, проверим, пройдет ли код тест.

you@localhost:~/diveintopython3/examples$ python3 romantest4.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_non_integer (__main__.ToRomanBadInput)
to_roman should fail with non-integer input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 5 tests in 0.000s

OK

Функция to_roman() успешно прошла все тесты, и больше тестов мне в голову не приходит, поэтому пора переходить к функции from_roman().

9.6 Приятная симметрия

Преобразование римского представления числа в десятичное выглядит более сложным, чем преобразование десятичной формы в римскую. Основная сложность заключается в валидации. Достаточно просто проверить, является ли целое число положительным; однако немного сложнее проверить, является ли строка корректным римским числом. К счастью, мы уже написали регулярное выражение, проверяющее римские числа.

Осталась задача преобразования самой строки. Как мы увидим через минуту, благодаря определенной нами структуре данных, ставящей в соответствие целым числам римские, основа функции from_roman() будет такой же, как в функции to_roman().

Но сначала тесты. Нам понадобятся известные значения для выборочной проверки правильности преобразования. В качестве этих значений мы будем использовать описанный ранее набор known_values:

    def test_from_roman_known_values(self):
        '''from_roman should give known result with known input'''
        for integer, numeral in self.known_values:
            result = roman5.from_roman(numeral)
            self.assertEqual(integer, result)

Здесь мы наблюдаем интересную симметрию. Функции to_roman() и from_roman() являются взаимообратными. Первая преобразует десятичное представление числа в римское, вторая же делает обратное преобразование. В теории мы должны иметь возможность "замкнуть круг", передав функции to_roman() число, затем передать результат выполнения функции from_roman(), возвращенное значение которой должно совпасть с исходным числом:

n = from_roman(to_roman(n)) for all values of n

В этом случае “all values” означает любое число в интервале 1..3999, поскольку это корректный диапазон входных данных функции to_roman(). Мы можем выразить эту симметрию в тестовом случае, который проходится по всем значениям 1..3999, вызывает to_roman(), вызывает from_roman() и проверяет соответствие результата исходному числу:

class RoundtripCheck(unittest.TestCase):
    def test_roundtrip(self):
        '''from_roman(to_roman(n))==n for all n'''
        for integer in range(1, 4000):
            numeral = roman5.to_roman(integer)
            result = roman5.from_roman(numeral)
            self.assertEqual(integer, result)

Эти новые тесты пока не являются даже провальными. Мы даже не определили функцию from_roman(), поэтому они завершились с ошибкой.

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
E.E....
======================================================================
ERROR: test_from_roman_known_values (__main__.KnownValues)
from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 78, in test_from_roman_known_values
    result = roman5.from_roman(numeral)
AttributeError: 'module' object has no attribute 'from_roman'

======================================================================
ERROR: test_roundtrip (__main__.RoundtripCheck)
from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 103, in test_roundtrip
    result = roman5.from_roman(numeral)
AttributeError: 'module' object has no attribute 'from_roman'

----------------------------------------------------------------------
Ran 7 tests in 0.019s

FAILED (errors=2)

Создание заглушки функции решит эту проблему.

# roman5.py
def from_roman(s):
    '''convert Roman numeral to integer'''

Вы заметили? Я написал функцию, в которой нет ничего, кроме строки документации. В Python это нормально. На самом деле, многие разработчики придерживаются именно такого стиля. «Не делай заглушек; документируй!»

Теперь тесты действительно являются провальными.

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
F.F....
======================================================================
FAIL: test_from_roman_known_values (__main__.KnownValues)
from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 79, in test_from_roman_known_values
    self.assertEqual(integer, result)
AssertionError: 1 != None

======================================================================
FAIL: test_roundtrip (__main__.RoundtripCheck)
from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 104, in test_roundtrip
    self.assertEqual(integer, result)
AssertionError: 1 != None

----------------------------------------------------------------------
Ran 7 tests in 0.002s

FAILED (failures=2)

Теперь напишем функцию from_roman().

def from_roman(s):
    """convert Roman numeral to integer"""
    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index:index+len(numeral)] == numeral:  ①
            result += integer
            index += len(numeral)
    return result
  1. Строка 6. Действия здесь точно такие же, как и в функции to_roman(). Мы проходимся по нашей структуре данных с римскими числами (кортеж кортежей roman_numeral_map), но вместо того, чтобы брать максимальные целые числа, пока это возможно, мы берем «максимальные» строки символов римского представления числа, пока это возможно.

Если вам еще не совсем понятно, как работает функция from_roman(), добавьте оператор print в конце цикла while:

def from_roman(s):
    """convert Roman numeral to integer"""
    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
            print('found', numeral, 'of length', len(numeral), ', adding', integer)
>>> import roman5
>>> roman5.from_roman('MCMLXXII')
found M of length 1, adding 1000
found CM of length 2, adding 900
found L of length 1, adding 50
found X of length 1, adding 10
found X of length 1, adding 10
found I of length 1, adding 1
found I of length 1, adding 1
1972

Пора перезапустить тесты.

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
.......
----------------------------------------------------------------------
Ran 7 tests in 0.060s

OK

У меня есть для вас две новости. Обе хорошие. Во-первых, функция from_roman() работает для правильных входных данных (по крайней мере, для известных значений). Во-вторых, тест с «путешествием по кругу» также был пройден. Два этих факта позволяют вам быть уверенным в том, что функции to_roman() и from_roman() работают правильно для всех корректных значений. (На самом деле, правильность работы не гарантирована. Теоретически, функция to_roman() может иметь баг в реализации, из-за которого получается неправильное представление числа в римской форме для некоторых входных данных, а функция from_roman() может иметь «обратный» баг, из-за которого результатом выполнения является такое же неправильное целое число, для того же римского числа, которое неправильно генерирует to_roman(). В зависимости от вашего приложения и ваших требований такая возможность может вызывать беспокойство: если это так, то напишите более сложные тесты.)

9.7 Больше плохих входных данных

Теперь, когда функция from_roman() работает правильно с корректными входными данными, пришло время заполнить последний фрагмент головоломки: заставить ее работать правильно с некорректными входными данными. Это означает, что нужно найти способ посмотреть на строку и определить, является ли она корректным римским числом. По своей сути это сложнее, чем проверка числового ввода в функции to_roman(), но в вашем распоряжении мощный инструмент: регулярные выражения. (Если вы не знакомы с регулярными выражениями, сейчас самое время прочитать главу «Регулярные выражения».)

Как вы видели в учебном примере «римские цифры», существует несколько простых правил построения римских чисел с использованием букв M, D, C, L, X, V и I. Давайте вспомним эти правила:

  • Иногда символы складываются. I это 1, II это 2, и III это 3. VI это 6 (посимвольно, «5 и 1»), VII это 7, и VIII это 8.
  • Десятичные символы (I, X, C, и M) могут быть повторены до 3 раз. Для образования 4 вам необходимо отнять от следующего высшего символа пятёрки. Нельзя писать 4 как IIII; вместо этого, она записывается как IV («на 1 меньше 5»). 40 записывается как XL («на 10 меньше 50»), 41 как XLI, 42 как XLII, 43 как XLIII, и 44 как XLIV («на 10 меньше 50, и на 1 меньше 5»).
  • Иногда символы… обратны сложению. Разместив определённые символы до других, вы вычитаете их от конечного значения. Например, 9, вам необходимо отнять от следующего высшего символа десять: 8 это VIII, но 9 это IX («на 1 меньше 10»), не VIIII (так как символ I не может быть повторён 4 раза). 90 это XC, 900 это CM.
  • Пятёрки не могут повторяться. 10 всегда отображается как X, никогда как VV. 100 всегда C, никогда LL.
  • Римские цифры читаются слева направо, поэтому положение символа имеет большое значение. DC это 600; CD это совершенно другая цифра (400, «на 100 меньше 500»). CI это 101; IC это даже не является допустимым римским числом (так как вы не можете вычитать 1 прямо из 100; вам необходимо записать это как XCIX, «на 10 меньше 100, и на 1 меньше 10»).

Таким образом, одним полезным тестом было бы убедиться, что функция from_roman() не будет выполнена, если вы передадите ей строку со слишком большим количеством повторяющихся чисел. Сколько значит это «слишком большое количество», зависит от числа.

class FromRomanBadInput(unittest.TestCase):
    def test_too_many_repeated_numerals(self):
        '''from_roman should fail with too many repeated numerals'''
        for s in ('MMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)

Еще одним полезным тестом будет проверка того, что определенные шаблоны не повторяются. Например, IX равен 9, но IXIX никогда не будет корректен.

    def test_repeated_pairs(self):
        '''from_roman should fail with repeated pairs of numerals'''
        for s in ('CMCM', 'CDCD', 'XCXC', 'XLXL', 'IXIX', 'IVIV'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)

Третий тест может проверить, что числа появляются в правильном порядке, от самого высокого до самого низкого значения. Например, CL равно 150, но LC никогда не будет корректен, потому что число для 50 никогда не может предшествовать числу для 100. Этот тест включает в себя случайно выбранный набор недопустимых предшествующих символов: I до M, V до X, и так далее.

    def test_malformed_antecedents(self):
        '''from_roman should fail with malformed antecedents'''
        for s in ('IIMXCC', 'VX', 'DCM', 'CMM', 'IXIV',
                  'MCMC', 'XCX', 'IVI', 'LM', 'LD', 'LC'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)

В каждом из этих тестов используется функция from_roman(), вызывающая новое исключение InvalidRomanNumeralError, которое мы еще не определили.

# roman6.py
class InvalidRomanNumeralError(ValueError): pass

Все три этих теста должны завершиться провалом, так как функция from_roman() в настоящее время не проверяет правильность входных данных. (Если они сейчас не завершаются провалом, то, что они тестируют?)

you@localhost:~/diveintopython3/examples$ python3 romantest6.py
FFF.......
======================================================================
FAIL: test_malformed_antecedents (__main__.FromRomanBadInput)
from_roman should fail with malformed antecedents
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 113, in test_malformed_antecedents
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

======================================================================
FAIL: test_repeated_pairs (__main__.FromRomanBadInput)
from_roman should fail with repeated pairs of numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 107, in test_repeated_pairs
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

======================================================================
FAIL: test_too_many_repeated_numerals (__main__.FromRomanBadInput)
from_roman should fail with too many repeated numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 102, in test_too_many_repeated_numerals
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

----------------------------------------------------------------------
Ran 10 tests in 0.058s

FAILED (failures=3)

Всё хорошо. Теперь всё, что нам нужно сделать, это добавить в функцию from_roman()регулярное выражение для проверки корректности римских чисел.

roman_numeral_pattern = re.compile('''
    ^                   # beginning of string
    M{0,3}              # thousands - 0 to 3 Ms
    (CM|CD|D?C{0,3})    # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 Cs),
                        #            or 500-800 (D, followed by 0 to 3 Cs)
    (XC|XL|L?X{0,3})    # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 Xs),
                        #        or 50-80 (L, followed by 0 to 3 Xs)
    (IX|IV|V?I{0,3})    # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 Is),
                        #        or 5-8 (V, followed by 0 to 3 Is)
    $                   # end of string
    ''', re.VERBOSE)

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not roman_numeral_pattern.search(s):
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))

    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index : index + len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result

И повторим тесты...

you@localhost:~/diveintopython3/examples$ python3 romantest7.py
..........
----------------------------------------------------------------------
Ran 10 tests in 0.066s

OK

А награда года за снятие напряжения уходит... слову «ОК», которое печатается модулем unittest после успешного прохождения всех тестов.

Источник:

  • Mark Pilgrim. Dive Into Python 3

Теги

Exception / ИсключениеPythonВысокоуровневые языки программированияМодульное тестирование / Юнит-тестирование / Unit testingОбучениеПрограммированиеРегулярные выраженияТестированиеЯзыки программирования

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.