Проверка выполнения условий

Like many programming languages, Python has an assert statement. Here’s how it works.

Подобно многим языкам программирования, Python имеет оператор подтверждения отсутствия ошибок. Вот как он работает.

>>> assert 1 + 1 == 2 ①
>>> assert 1 + 1 == 3 ②
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AssertionError
>>> assert 2 + 2 == 5, "Only for very large values of 2" ③
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AssertionError: Only for very large values of 2


① За словом assert следует любое допустимое в Питоне выражение. В данном случае, выражение 1 + 1 == 2 возвращает значение True, поэтому assert ничего не делает.

② Однако если выражение возвращает False, assert выбрасывает исключение.

③ Вы также можете добавить информативное сообщение, которое будет выведено при возбуждении исключения AssertionError.

Поэтому, эта строка кода

assert len(unique_characters) <= 10, 'Too many letters'

эквивалентна

if len(unique_characters) > 10:
raise AssertionError('Too many letters')


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

Выражения-генераторы

Выражения-генераторы, как генератор функций, но без функции.

>>> unique_characters = {'E', 'D', 'M', 'O', 'N', 'S', 'R', 'Y'}
>>> gen = (ord(c) for c in unique_characters) ①
>>> gen ②
<generator object <genexpr> at 0x00BADC10>
>>> next(gen) ③
69
>>> next(gen)
68
>>> tuple(ord(c) for c in unique_characters) ④
(69, 68, 77, 79, 78, 83, 82, 89)

① Выражение-генератор, это как анонимная функция которая выдает значения. Само выражение выглядит как список, но он обернут не в квадратные, а в фигурные скобки.

② Выражение-генератор возвращает итератор.

③ Вызов next(gen) возвращает следующее значение итератора.

④ Если вам нравится, вы можете повторить через все возможные значения и возвратить кортеж, список или множество, направив выражение-генератор в tuple(), list(), или set(). В этих случаях вам не нужно дополнительных скобок - просто передайте "голые" выражение ord(c) for c in unique_characters в tuple() функцию, и Python понимает, что это выражение-генератор.

Использование выражений-генераторов вместо списка помогает сохранить cpu и ram. Если вы используете список, чтобы потом выбросить его (например передать в tuple() или set()), используйте генератор вместо него!

Вот еще один способ сделать то же самое, используя генератор функции:

def ord_map(a_string):
for c in a_string:
yield ord(c)

gen = ord_map(unique_characters)

Выражения-генераторы более компактны, но функционально равны.

Тестирование

 

Не) Погружение

(Данная страница находится на стадии перевода)

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

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

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

1. Существует только один правильный способ записать число римскими цифрами.

2. Обратное также верно: если строка символов является последовательностью римских символов, она представляет только одно число, то есть может быть интерпретирована единственным способом.

3. Диапазон чисел, которые могут быть записаны римскими цифрами, — от 1 до 3999. У римлян было несколько способов записывать более крупные числа, в частности, с помощью черты над числом, которая означала 6ы, что значение нужно умножить на 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.

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

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

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

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

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

Каждый тестовый случай — это остров.

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

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

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()

① Для описания тестового случая первым делом определим класс TestCase подкласс модуля unittest. Этот класс содержит много полезных методов, которые вы можете использовать в ваших тестах для определенных условий.

② Это множество пар "число/значение", определенных мной вручную. Оно включает минимальные 10 чисел, наибольшее (3999), все числа, которые в преобразованном виде состоят из одного символа, а также набор случайных чисел. Не нужно тестировать все возможные варианты, но все уникальные варианты протестировать нужно.

③ Каждый тест определен отдельным методом, который вызывается без параметров и не возвращает значения. Если метод завершается нормально, без выброса исключения - тест считается пройденным, если выброшено исключение - тест завален.

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

⑤ Предполагая, что функция to_roman() определена корректно, вызвана корректно, выполнилась успешно, и вернула значение, последним шагом будет проверка правильности возвращенного значения. Это общий вопрос, поэтому используем метод AssertEqual класса TestCase для проверки равенства (эквивалентности) двух значений. Если возвращенный функцией to_roman() результат (result)не равен известному значению, которое Вы ожидаете (numeral), assertEqual выбросит исключение и тест завершится с ошибкой. Если значения эквиваленты, assertEqual ничего не сделает. Если каждое значение, возвращенное to_roman() совпадет с ожидаемым известным, assertEqual никогда не выбросит исключение, а значит test_to_roman_known_values в итоге выполнится нормально, что будет означать, что функция to_roman() успешно прошла тест.

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

# roman1.py
def to_roman(n):
'''convert integer to Roman numeral'''
pass ①

① На этом этапе Вы определяете API для функции to_roman(), но пока не хотите писать ее код. (Для первой проверки теста.) Чтобы заглушить функцию,используется зарезервированное слово Python - pass, которое... ничего не делает. Выполняем romantest1.py on в интерпретаторе для проверки теста. Если Вы вызвали скрипт с параметром -v, будет выведен подробности о работе скрипта (verbose), и Вы сможете подробно увидеть, что происходит в каждом тесте. Если повезло, увидите нечто подобное:

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) ⑤

① Запущенный скрипт выполняет метод unittest.main(), который запускает каждый тестовый случай. Каждый тестовый случай - метод класса в romantest.py. Нет особых требований к организации этих классов; они могут быть как класс с методом для отдельного тестового случая, mfr и один класс + несколько методов для всех тестовых случаев. Необходимо лишь, чтобы каждый класс был наследником unittest.TestCase.

② Для каждого тестового случая модуль unittest выведет строку документации метода и результат - успех или провал. Как видно, тест провален.

③ Для каждого проваленного теста система выводит детальную информацию о том, что конкретно произошло. В данном случае вызов assertEqual() вызвал ошибку объявления (AssertionError), поскольку ожидалось возвращения 'I' от to_roman(1), но этого не произошло. (Если у функции нет нет явного возврата, то она вернет None, значение null в Python.)

④ После детализации каждого тестового случая, unittest отображает суммарно, сколько тестов было выполнено и сколько это заняло времени.

⑤ В целом тест считается проваленным, если хоть один случай не пройден. Unittest различает ошибки и провалы. Провал вызывает метод assertXYZ, например assertEqual или assertRaises, который провалится, если объявленное условие неверно или ожидаемое исключение не выброшено. Ошибка - это другой тип исключения, который выбрасывается тестируемым кодом или тестовым юнитом и не является ожидаемым.

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

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

① roman_numeral_map - это кортеж кортежей, определяющий три вещи: представление базовых символов римских цифр и популярных их сочетаний; порядок римских символов (в обратном направлении, от M и до I); значения римских цифр. Каждый внутренний кортеж - это пара значений (представление, число). И это не только односимвольные римские цифры; это также пары символов типа CM (“тысяча без сотни”). Это сильно упрощает код функции to_roman().

② Вот здесь видно, в чем выигрыш такой структуры 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. Ура! Функция to_roman() прошла тест “known values”. Возможно не всесторонняя проверка, но в ее ходе проверены различные входные данные, включая числа, записываемые одним римским символом, наибольшее исходное значение (3999), и значение, дающее наибольшее римское число (3888). На этом этапе можно сказать, что функция корректно обрабатывает любые правильные исходные значения.

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

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

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

>>> import roman1
>>> roman1.to_roman(4000)
'MMMM'
>>> roman1.to_roman(5000)
'MMMMM'
>>> roman1.to_roman(9000) ①
'MMMMMMMMM'

1. Это определенно не то, что ожидалось — это не правильные римские числа! По сути, все эти числа выходят за возможные пределы, но функция все равно возвращает результат, только фиктивный. Тихое возвращение неверного значения - ооооооооооочень неверно; если возникает ошибка, лучше чтобы программа завершалась быстро и шумно. "Остановись и загорись", как говорится. "Питоновский" способ остановиться и загореться - это выбросить исключение.

Спрашивается, как же учесть это в требованиях к тестированию? Для начинающих - вот так: функция to_roman() должна выбрасывать исключение типа OutOfRangeError, если ей передать число более 3999. Как будет выглядеть тест?

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. Как и в предыдущем случае, создаем класс-наследник от unittest.TestCase. У Вас может быть более одного теста на класс (как Вы увидите дальше в этой главе), но я решил создать отдельный класс для этого, потому что этот случай отличается от предыдущих. Мы поместили все тесты на "положительный выход" в одном классе, а на ошибки - в другом.

2. Как и в предыдущем случае, тест - это метод, имя которого - название теста.

3. Класс unittest.TestCase предоставляет метод assertRaises, который принимает следующие аргументы: тип ожидаемого исключения, имя тестируемой функции и аргументы этой функции. (Если тестируемая функция принимает более одного аргумента, все они передаются методу assertRaises по порядку, как будто передаете их тестируемой функции.)

Обратите особое внимание на последнюю строку кода. Вместо вызова функции to_roman() и проверки вручную того, что она выбрасывает исключение (путем обертывания ее в блок try-catch), метод 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. Следовало ожидать этот провал (если конечно Вы не написали дополнительного кода), но… это не совсем "провал", скорее это ошибка. Это тонкое но очень важное различие. Тест может вернуть три состояния: успех, провал и ошибку. Успех, естественно, означает, что тест пройден — код делает что положено. «Провал» - то что вернул тест выше — код выполняется, но возвращает не ожидаемое значение. «Ошибка» означает, что Ваш код работает неправильно.

2. Почему код не выполняется правильно? Раскрутка стека все объясняет. Тестируемый модуль не выбрасывает исключение типа OutOfRangeError. То самое, которое мы скормили методу assertRaises(), потому что ожидаем его при вводе большого числа. Но исключение не выбрасывается, потому вызов метода assertRaises() провален. Без шансов - функция to_roman() никогда не выбросит OutOfRangeError.

Решим эту проблему - определим класс исключения OutOfRangeError в roman2.py.

class OutOfRangeError(ValueError): ①
pass ②

1. Исключения - это классы. Ошибка «out of range» это разновидность ошибки — аргумент выходит за допустимые пределы. Поэтому это исключение наследуется от исключения ValueError. Это не строго необходимо (по идее достаточно наследования от класса Exception), однако так правильно.

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. Тест по-прежнему не проходит, хотя уже и не выдает ошибку. Это прогресс! Значит, метод assertRaises() был выполнен и тест функции to_roman() был произведен.

2. Конечно, функция to_roman() не выбрасывает только что определенное исключение OutOfRangeError, так как Вы ее еще "не заставили". И это хорошие новости! Значит, тест работает, а проваливаться он будет, пока Вы не напишете условие его успешного прохождения.

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

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. Все просто: если переданный параметр больше 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. Ура! Оба теста пройдены. Так как Вы работали, переключаясь между кодированием и тестированием, то Вы с уверенностью можете сказать, что именно последние 2 строки кода позволили тесту вернуть "успех", а не "провал". Такая уверенность далась не дешево, но окупит себя с лихвой в дальнейшем.

Больше СТОПов, больше "Огня"

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

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

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

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. Метод test_too_large() не изменился. Я включил его сюда, чтобы показать схожесть кода.

2. Это новый тест: test_zero(). Как и test_too_large(), мы заставляем метод assertRaises(), определенный в unittest.TestCase, вызвать нашу функцию to_roman() с параметром "0", и проверить, что она выбрасывает соответствующее исключение, OutOfRangeError.

3. Метод 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)

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

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. Отличный пример сокращения Python: множественное сравнение в одну строку. Это эквивалентно выражению "Если не ((0 < n) и (n < 4000))", но читается проще. Этот однострочный код охватывает "плохой" диапазон входных данных.

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

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

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

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

Еще одно требование к функциональности - обработка нецелых чисел.

>>> import roman3
>>> roman3.to_roman(0.5) ①
''
>>> roman3.to_roman(1.0) ②
'I'

1. О, это плохо.

2. О, а это еще хуже.

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

Тестирование не-чисел весьма сложно. Во-первых, определим исключение 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. Встроенная функция isinstance() проверяет, принадлежит ли переменная определенному типу (точнее, технически - к наследнику типа).

2. Если аргумент 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().

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

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

Осталась задача преобразования самой строки. Как мы увидим через минуту, благодаря определенной нами структуре данных, ставящей в соответствие целым числам римские, реализация функции from_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(), затем вызывает 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

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

Если вам еще не совсем понятно, как работает функция 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)
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() может иметь "обратный" баг, из-за которого результатом выполнения является число, по счастливой случайности совпадающее с исходным. Если вас это беспокоит, напишите более сложные тесты.)

Больше плохих "вводов"

Now that the from_roman() function works properly with good input, it's time to fit in the last piece of the puzzle: making it work properly with bad input. That means finding a way to look at a string and determine if it's a valid Roman numeral. This is inherently more difficult than validating numeric input in the to_roman() function, but you have a powerful tool at your disposal: regular expressions. (If you’re not familiar with regular expressions, now would be a good time to read the regular expressions chapter.) As you saw in Case Study: Roman Numerals, there are several simple rules for constructing a Roman numeral, using the letters M, D, C, L, X, V, and I. Let's review the rules: 1. Sometimes characters are additive. I is 1, II is 2, and III is 3. VI is 6 (literally, “5 and 1”), VII is 7, and VIII is 8. 2. The tens characters (I, X, C, and M) can be repeated up to three times. At 4, you need to subtract from the next highest fives character. You can't represent 4 as IIII; instead, it is represented as IV (“1 less than 5”). 40 is written as XL (“10 less than 50”), 41 as XLI, 42 as XLII, 43 as XLIII, and then 44 as XLIV (“10 less than 50, then 1 less than 5”). 3. Sometimes characters are… the opposite of additive. By putting certain characters before others, you subtract from the final value. For example, at 9, you need to subtract from the next highest tens character: 8 is VIII, but 9 is IX (“1 less than 10”), not VIIII (since the I character can not be repeated four times). 90 is XC, 900 is CM. 4. The fives characters can not be repeated. 10 is always represented as X, never as VV. 100 is always C, never LL. 5. Roman numerals are read left to right, so the order of characters matters very much. DC is 600; CD is a completely different number (400, “100 less than 500”). CI is 101; IC is not even a valid Roman numeral (because you can't subtract 1 directly from 100; you would need to write it as XCIX, “10 less than 100, then 1 less than 10”). Thus, one useful test would be to ensure that the from_roman() function should fail when you pass it a string with too many repeated numerals. How many is “too many” depends on the numeral.

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)

Another useful test would be to check that certain patterns aren’t repeated. For example, IX is 9, but IXIX is never valid.

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)

A third test could check that numerals appear in the correct order, from highest to lowest value. For example, CL is 150, but LC is never valid, because the numeral for 50 can never come before the numeral for 100. This test includes a randomly chosen set of invalid antecedents: I before M, V before X, and so on.

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)

Each of these tests relies the from_roman() function raising a new exception, InvalidRomanNumeralError, which we haven’t defined yet.

def test_malformed_antecedents(self):
# roman6.py
class InvalidRomanNumeralError(ValueError): pass

All three of these tests should fail, since the from_roman() function doesn’t currently have any validity checking. (If they don’t fail now, then what the heck are they testing?)

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)

Good deal. Now, all we need to do is add the regular expression to test for valid Roman numerals into the from_roman() function.

CD

И опять тестируем...

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

И премия за главное разочарование в этом году достается... я так волнуюсь... слову "ОК", выведенному как результат успешного прохождения теста.

Рефакторинг

 

Погружение

Нравится Вам или нет, но баги случаются. Несмотря на все усилия при создании полных модульных тестов, баги всё равно существуют. Что я подразумеваю под словом «баг»? Баг - это тестовый случай который ещё не написан.

>>> import roman7>>> roman7.from_roman('') ①0

① Собственно, баг. Вызов from_roman с параметром пустой строки (или любой другой последовательности символов не являющейся правильным римским числом) должен завершиться исключением InvalidRomanNumeralError.

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

class FromRomanBadInput(unittest.TestCase): . . . def testBlank(self): '''from_roman should fail with blank string''' self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, '') ①

① Всё предельно просто: вызываем from_roman() с пустой строкой и проверяем, что выбрасывается исключение InvalidRomanNumeralError. Самое сложное было найти баг; теперь, когда известно, что такая ошибка существует, кодирование проверки не займёт много времени.

Так как код содержит баг и мы имеет тест проверки этого бага, то данный тестовый случай завершается ошибкой:

you@localhost:~/diveintopython3/examples$ python3 romantest8.py -vfrom_roman should fail with blank string ... FAILfrom_roman should fail with malformed antecedents ... okfrom_roman should fail with repeated pairs of numerals ... okfrom_roman should fail with too many repeated numerals ... okfrom_roman should give known result with known input ... okto_roman should give known result with known input ... okfrom_roman(to_roman(n))==n for all n ... okto_roman should fail with negative input ... okto_roman should fail with non-integer input ... okto_roman should fail with large input ... okto_roman should fail with 0 input ... ok ======================================================================FAIL: from_roman should fail with blank string----------------------------------------------------------------------Traceback (most recent call last): File "romantest8.py", line 117, in test_blank self.assertRaises(roman8.InvalidRomanNumeralError, roman8.from_roman, '')AssertionError: InvalidRomanNumeralError not raised by from_roman ----------------------------------------------------------------------Ran 11 tests in 0.171s FAILED (failures=1)

Только теперь Вы можете исправлять баг.

def from_roman(s): '''convert Roman numeral to integer''' if not s: ① raise InvalidRomanNumeralError('Input can not be blank') if not re.search(romanNumeralPattern, s): raise InvalidRomanNumeralError('Invalid Roman numeral: {}'.format(s)) ② result = 0 index = 0 for numeral, integer in romanNumeralMap: while s[index:index+len(numeral)] == numeral: result += integer index += len(numeral) return result

① Требуется всего 2 строчки кода: явная проверка с пустой строкой и выброс исключения.

② Не уверен, упоминалось ли ранее в книге, поэтому пусть это будет последний трюк при форматировании строк. Начиная с Python 3.1 разрешается опускать числа при использовании индексов позиции в строке форматирования. То есть, вместо использования {0} для ссылки на первый параметр метода format(), Вы можете писать {} и Python заполнит соответсвующиий индекс позиции за Вас. Это выполняется для любого количества аргументов: первые скобки {} равносильны {0}, вторые скобки {} равносильны {1} и так далее.

you@localhost:~/diveintopython3/examples$ python3 romantest8.py -vfrom_roman should fail with blank string ... ok ①from_roman should fail with malformed antecedents ... okfrom_roman should fail with repeated pairs of numerals ... okfrom_roman should fail with too many repeated numerals ... okfrom_roman should give known result with known input ... okto_roman should give known result with known input ... okfrom_roman(to_roman(n))==n for all n ... okto_roman should fail with negative input ... okto_roman should fail with non-integer input ... okto_roman should fail with large input ... okto_roman should fail with 0 input ... ok ----------------------------------------------------------------------Ran 11 tests in 0.156s OK ②

① Тест на обработку пустой строки теперь проходит, значит баг исправлен.

② Все остальные тестовые случаи по прежнему выполняются без ошибок, а это значит, что при исправлении ошибки не мы не добавили новых. Самое время, чтобы остановиться править код!

Кодирование через написание тестов не облегчает процесс исправления багов. Для исправления простых ошибок (как в приведённом примере) необходимы простые тесты; сложные ошибки, конечно же, требуют сложных тестов. Если ведётся разработка проекта через тестирование, то может показаться, что фиксация бага займёт больше времени, так как Вам придётся найти строчки кода с багом (собственно, написать тестовый случай для проверки этих строчек) и затем исправить баг. Если тест опять завершается не успешно, то придётся разобраться верно ли был исправлен баг или сам тест содержит ошибки. Тем не менее, при длительной разработке эти исправления в коде-в тесте-в коде окупают себя, так как скорее всего баг будет исправлен с первого раза. Также, так как Вы можете легко перезапускать все тесты, включая новый, Вы навряд ли "испортите" старый код при фиксации бага. Сегодняшние модульные тесты завтра превратятся в тесты регрессии.