__toc__
Подобається вам це чи ні, але помилки трапляються. Незважаючи на ваші найкращі спроби писати вичерпні модульні тести, помилки трапляються. Що я маю на увазі під словом "помилка"? Помилка - це ще не написаний тест.
>>> import roman7 >>> roman7.from_roman('') ① 0
Це помилка. Порожній рядок має призводити до виключення 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 -v from_roman should fail with blank string ... FAIL from_roman should fail with malformed antecedents ... ok from_roman should fail with repeated pairs of numerals ... ok from_roman should fail with too many repeated numerals ... ok from_roman should give known result with known input ... ok to_roman should give known result with known input ... ok from_roman(to_roman(n))==n for all n ... ok to_roman should fail with negative input ... ok to_roman should fail with non-integer input ... ok to_roman should fail with large input ... ok to_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, )
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')
Потрібно лише два рядки коду: явна перевірка на те що рядок порожній, і команда raise.
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
Не думаю що я згадував про це десь в книзі, але нехай це послужить вашим останнім уроком форматування рядків. Починаючи з Python 3.1, можна опускати числа, при використанні позиційних індекстів в специфікаторі формату. Тобто замість того щоб використовувати специфікатор {0} для того щоб послатись на перший аргумент методу format(), ви можете просто написати {} і Python заповнить відповідний позиційний індекс за вас. Це працює для будь-якої кількості аргументів: перший {} відповідає {0}, другий {} - {1}, і так далі.
you@localhost:~/diveintopython3/examples$ python3 romantest8.py -v from_roman should fail with blank string ... ok from_roman should fail with malformed antecedents ... ok from_roman should fail with repeated pairs of numerals ... ok from_roman should fail with too many repeated numerals ... ok from_roman should give known result with known input ... ok to_roman should give known result with known input ... ok from_roman(to_roman(n))==n for all n ... ok to_roman should fail with negative input ... ok to_roman should fail with non-integer input ... ok to_roman should fail with large input ... ok to_roman should fail with 0 input ... ok
Ran 11 tests in 0.156s
OK
Тест з порожнім рядком тепер проходиться, помилка виправлена. І всі інші тести теж проходяться, що означає що виправляючи цю помилку ми нічого не поламали. Припиняємо кодування.
Кодування в такий спосіб не полегшує виправлення помилок. Прості помилки (як от ця) потребують простих тестів, складніші помилки потребуватимуть складніших тестів. В спрямованому на тестування середовищі може здатись'' ніби для виправлення помилки потрібно більше часу, так як потрібно точно описати кодом в чому полягає помилка (щоб написати тест), а потім виправити її саму. Тоді, якщо тест не проходиться, потрібно з'ясувати де помилка в виправленні помилки, чи де помилка в самому тесті. Тим не менш, в довгостроковій перспективі такий підхід окупає себе, тому що він збільшує ймовірність того що помилки виправляються правильно з першого разу. Також, так як можна просто перезапустити всі тести разом з новим, зменшується ймовірність того що ви зламаєте старий код поки будете тестувати новий. Сьогоднішній модульний тест є завтрашнім регресивним тестом.
Справляємось зі змінними вимогами Незважаючи на ваші найкращі спроби опустити своїх замовників на землю, та видобути з них точні вимоги, погрожуючи болем від жахливих загрозливих речей, що включають ножиці та гарячий віск, вимоги будуть змінюватись. Більшість замовників не знають що їм потрібно поки вони цього не побачать, а навіть якщо знають, то не настільки точно можуть це сформулювати, аби цим можна було б скористатись. А навіть якщо зможуть, то в будь-якому разі, вони захочуть ще більше в наступному випуску. Тому готуйтесь до оновлення набору тестів при зміні вимог.
Припустимо наприклад що ми хочемо розширити діапазон роботи функцій з перетворення римських чисел. Зазвичай в римській системі символ не може бути повтореним більш ніж тричі підряд. Але Римляни захотіли зробити виняток з правила, і писали 4 символи M підряд для того щоб записати число 4000. Якщо й ми зробимо таку зміну, ми зможемо розширити діапазон чисел що можна записати в римській системі до 1..4999. Але спершу потрібно зробити зміни в тестах.
class KnownValues(unittest.TestCase): known_values = ( (1, 'I'), . . . (3999, 'MMMCMXCIX'), (4000, 'MMMM'), (4500, 'MMMMD'), (4888, 'MMMMDCCCLXXXVIII'), (4999, 'MMMMCMXCIX') )
Існуючі значення не змінились (на них все ще можна проводити тести), але потрібно додати ще кілька в діапазоні 4000. Тут я додав 4000 (найкоротше), 4500 друге за довжиною, 4888 (найдовше) та 4999 (найбільше).
class ToRomanBadInput(unittest.TestCase): def test_too_large(self): '''to_roman should fail with large input''' self.assertRaises(roman8.OutOfRangeError, roman8.to_roman, 5000)
Означення "завеликого числа" змінилось. Цей тест викликав to_roman() з аргументом 4000 і очікував помилки. Тепер числа від 4000 до 4999 правильні, тому потрібно збільшити цей аргумент до 5000.
. . .
class FromRomanBadInput(unittest.TestCase): def test_too_many_repeated_numerals(self): '''from_roman should fail with too many repeated numerals''' for s in ('MMMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'): self.assertRaises(roman8.InvalidRomanNumeralError, roman8.from_roman, s)
Означення "забагато повторених символів" також змінилось. Цей тест викликав from_roman() передаючи йому 'MMMM' та очікував помилки. Тепер, коли MMMM вважається правильним римським числом, потрібно збільшити це значення до 'MMMMM'.
. . .
class RoundtripCheck(unittest.TestCase): def test_roundtrip(self): '''from_roman(to_roman(n))==n for all n''' for integer in range(1, 5000): numeral = roman8.to_roman(integer) result = roman8.from_roman(numeral) self.assertEqual(integer, result)
Перевірка на здоровий глузд перевіряла кожне число в діапазоні від 1 до 3999. Так як діапазон розширився, цикл також потрібно розширити до 4999.
Тепер ваші тести відповідають новим вимогам. Але код все ще ні, тому ми очікуємо що кілька тестів повинні провалитись.
you@localhost:~/diveintopython3/examples$ python3 romantest9.py -v from_roman should fail with blank string ... ok from_roman should fail with malformed antecedents ... ok from_roman should fail with non-string input ... ok from_roman should fail with repeated pairs of numerals ... ok from_roman should fail with too many repeated numerals ... ok from_roman should give known result with known input ... ERROR ① to_roman should give known result with known input ... ERROR ② from_roman(to_roman(n))==n for all n ... ERROR ③ to_roman should fail with negative input ... ok to_roman should fail with non-integer input ... ok to_roman should fail with large input ... ok to_roman should fail with 0 input ... ok
ERROR: from_roman should give known result with known input
Traceback (most recent call last): File "romantest9.py", line 82, in test_from_roman_known_values result = roman9.from_roman(numeral) File "C:\home\diveintopython3\examples\roman9.py", line 60, in from_roman raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
ERROR: to_roman should give known result with known input
Traceback (most recent call last): File "romantest9.py", line 76, in test_to_roman_known_values result = roman9.to_roman(integer) File "C:\home\diveintopython3\examples\roman9.py", line 42, in to_roman raise OutOfRangeError('number out of range (must be 0..3999)')
ERROR: from_roman(to_roman(n))==n for all n
Traceback (most recent call last): File "romantest9.py", line 131, in testSanity numeral = roman9.to_roman(integer) File "C:\home\diveintopython3\examples\roman9.py", line 42, in to_roman raise OutOfRangeError('number out of range (must be 0..3999)')
Ran 12 tests in 0.171s
FAILED (errors=3)
① Тест перетворення відомих значень з римської системи провалюється як тільки доходить до 'MMMM' тому що from_roman() досі думає що це недозволене римське число.
② Тест перетворення відомих значень в римську систему провалюється як тільки доходить до 4000, тому що to_roman() досі думає що це число виходить за межі діапазону
③ Кругова перевірка теж провалюється через ніби-то вихід за межі діапазону.
Тепер, коли ми маємо тести що провалюються згідно нових вимог, можна подумати і про те щоб виправити код так аби він відповідав тестам. (Коли ви тільки починаєте програмувати використовуючи тести, вам може бути дивно від того що код який тестується ніколи не "випереджає" тести. Поки він позаду, ви все ще повинні щось зробити, але як тільки код доганяє тести, ви припиняєте програмування. Після того як ви до цього звикнете, ви будете дивуватись як ви взагалі програмували без тестів.)
roman_numeral_pattern = re.compile(''' ^ # beginning of string M{0,4} # thousands - 0 to 4 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 to_roman(n): '''convert integer to Roman numeral''' if not isinstance(n, int): raise NotIntegerError('non-integers can not be converted') if not (0 < n < 5000): ② raise OutOfRangeError('number out of range (must be 1..4999)')
result = '' for numeral, integer in roman_numeral_map: while n >= integer: result += numeral n -= integer return result
def from_roman(s): . . .
① Вам взагалі не потрібно робити жодних змін в функції from_roman(). Єдине, що потрібно змінити - регулярний вираз roman_numeral_pattern. Якщо придивитись уважно, зрозуміло що я змінив максимально дозволену кількість символів M в першій частині регулярного виразу з 3 до 4. Ну а сама функція from_roman() досить загальна, вона просто рахує кількості повторюваних римських цифр і додає їх, абсолютно не зважаючи на те скільки разів вони повторюються. Єдина причина через яку вона не опрацьовувала рядок 'MMMM' - тому що ми явно заборонили їй це за допомогою регулярного виразу.