Пориньте у Python 3/Модульне тестування

Матеріал з Вікіпідручника

Переконаності недостатньо для впевеності. Ми були цілком переконані в багатьох речах, які насправді не були.

Certitude is not the test of certainty. We have been cocksure of many things that were not so.
Олівер Венделл Холмс молодший

(Не) занурення[ред.]

Ох сьогоднішні діти. Зіпсуті цими швидкими комп'ютерами і модними "динамічними" мовами. Спочатку напиши, потім публікуй, і вже (якщо дійдуть руки) на третьому місці - зневадження. В наші часи в нас була дисципліна. Дисципліна, Я сказав! Ми мусили писати програми від руки, на папері, і згодовувати їх комп'ютеру на пефокартах. І нам це подобалось!

У цьому розділі, ми збираємось написати та зневадити набір допоміжних функцій для перетворення чисел в римську систему та навпаки. Ви бачили механізми створення та перевірки чисел на приналежність до римських в попередньому прикладі. Тепер давайте відійдемо трохи назад і подумаємо над тим що потрібно зробити щоб перетворити це на двосторонній інструмент.

Правила для римських чисел приводять до багатьох цікавих спостережень.

  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.

Модульне тестування є важливою частиною стратегії розробки що фокусується на тестуванні. Коли ви пишете модульні тести, важливо писати їх рано, і постійно їх оновлювати протягом того як код та вимоги до нього змінюються. Багато людей пропагують написання тестів перед написанням коду який тестуєтьс, і це підхід який я збираюсь продемонструвати в цьому розділі. Але модульні тести корисні не залежно від того коли ви їх напишете.

  • Написання тестів перед написанням коду змушує вас деталізувати вимоги в досить корисній формі.
  • Написання тестів перед написанням коду запобігає надмірній розробці. Якщо всі тести проходяться значить функцію завершено.
  • При рефакторингу вони допомагають довести що нова версія поводиться так само як і стара.
  • При підтримці коду тести допомагають прикрити власну задницю коли хтось підходить з криком що ваша остання зміна поламала роботу їх старого коду. ("Але пане, всі тести проходяться, я перевірив..")
  • При написанні коду в команді, підтримка вичерпного набору тестів на порядок зменшує шанси того що ваш код поламає якийсь інший код, тому що ви можете спершу запустити їхні модульні тести. (Я спостерігав такі речі під час спринтів розробки. Команда розбиває завдання, кожен бере специфікацію для своєї частини, пише тести для неї, потім ділиться своїми тестами з рештою команди. Таким чином, ніхто не заходить занадто далеко в розробці коду який не надто добре працює з кодом інших).


* * *


Єдине питання[ред.]

Кожен тест - це острів.

Тест відповідає на єдине запитання про код що тестується. Тест повинен бути здатним...

  • ... працювати цілком самостійно, без жодного людського вводу. Модульне тестування - це автоматизація.
  • ... самостійно визначати чи функція що він тестує пройшла тест чи його завалила, без людини що інтерпретує результат.
  • ... запускатись повністю окремо від інших тестів (навіть якщо вони тестують ту саму функцію). Кожен тест - це острів.

Знаючи це, давайте створимо тест для першої вимоги:

  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. Цей клас надає багато корисних методів які можна використовувати в тестах для перевірки різноманітних умов.

② Це кортеж з пар число/його римський запис, які я перевірив вручну. Він включає десять найменших чисел, найбільше число, кожне римське число що записується одним символом, і випадковий набір інших чисел. Не потрібно тестувати будь-який можливий випадок, але потрібно намагатись протестувати всі очевидні крайні випадки.

③ Кожен тест - це окремий метод. Метод тесту не приймає параметрів, не повертає даних, і повинен мати ім'я що починається з чотирьох символів: test. Якщо виклик методу завершується без винятків, тест вважається пройденим, якщо метод кидає виняток, тест вважається проваленим.

④ Ось тут ми робимо виклик до справжньої функції to_roman(). (Ну, функція все ще не написана, але як тільки вона буде, цей рядок її буде викликати.) Зауважте, що зараз ви описали API функції to_roman(): вона повинна приймати ціле значення (число для перетворення) і повертати рядок (його римський запис). Якщо API буде іншим, тест провалиться. Також зауважте що ми не ловимо жодних винятків при виклику to_roman(). Це навмисне. to_roman не повинен генерувати виняток коли йому передають дозволені значення, а всі перелічені значення дозволені. Якщо to_roman() згенерує виняток тест також провалиться.

⑤ Вважаючи що функція to_roman() була описана вірно, викликана вірно, завершилась успішно, і повернула значення, останнім кроком є перевірка того що повернене значення правильне. Це типова перевірка, і клас TestCase надає метод, assertEqual, для перевірки рівності двох значень. Якщо результат повернений з to_roman() не співпадає з очікуваним значенням, 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

На даному етапі, ми хочемо описати сигнатуру функції to_roman(), але ми ще не хочемо писати її код. (Спершу потрібно щоб тест запустився). Щоб створити заглушку використаємо ключове слово 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) ⑤

① Запуск скрипта запускає unittest.main(), який виконує кожен тест. Кожен тестовий приклад є окремим методом класу. Обов'язкового формату організації цих класів немає, кожен з них може містити єдиний тестовий метод, чи ви можете мати один клас що містить багато методів. Єдина вимога - кожен клас повинен наслідуватись від unittest.TestCase.

② Для кожного тестового методу модуль unittest роздрукує його рядок документації та результат проходження тесту. Як і очікувалось наш тест валиться.

③ Для кожного провального тесту, unittest виводить трейсбек що показує що саме трапилось. В даному випадку, виклик assertEqual() згенерував AssertionError тому що очікувалось що to_roman(1) поверне 'I', а вона не повернула. (Так як явного виклику return не було, функція повернула None - позначення "нічого" в 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), та значення кожного числа. Кожен внутрішній кортеж - це пара (numeral, value). Це не тільки односимвольні римські числа, тут також описуються двосимвольні пари на зразок CM (“на сотню менше ніж тисяча”). Це дозволяє спростити код функції to_roman(). ② В цьому місці структура даних roman_numeral_map себе окуповує, тому що нам не потрібна жодна спеціальна логіка для опрацювання правила віднімання. Для перетворення числа в римське, просто будемо проходитись по roman_numeral_map шукаючи найбільше ціле число що менше чи рівне за вхідне. Як тільки ми його знайдемо, треба додати його в кінець результату, відняти відповідне ціле від вхідного значення, намилити, змити і повторити :).

Якщо все ще не ясно як працює функція to_roman(), додайте виклик print() до кінця циклу while:

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

① Ура! Функція to_roman() проходить тест з відомими значеннями. Це звичайно не повний тест, але він прогяняє функцію крізь різноманітні вхідні дані, зокрема вхідні що перетворюються на кожне однолітерне римське число, найбільший можливий ввід, і ввід що створює найдовше можливе римське число (3888). В даний момент ви можете мати цілком обґрунтовану впевненість що функція працює для будь-яких дозволених значень які ви їй передасте.

"Дозволений" вхід? Хммм. А як там з недозволеним?


* * *


Зупинитись та загорітись[ред.]

Пітонівський спосіб зупинитись і загорітись - кинути виняток.

Недостатньо тестувати те що функція працює успішно при правильному вводі, також потрібно перевірити те що функція відмовляється працювати на недозволених вхідних даних. І це не повинна бути якась випадкова відмова, вони повинні провалюватись в спосіб який ми очікуємо.

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

① Це явно те не що ми хотіли - таких римських чисел взагалі не існує! Насправді кожне з вхідних неможливо представити в римській системі, але те що повертає функція все одно неприпустимо. Тихо повертати неправильні результати дууууже погааааано. Якщо програма збирається зробити помилку, набагато краще коли вона впаде швидко і шумно. "Halt and catch fire", як то кажуть. В Python аналогом інструкції halt and catch fire є генерація винятку.

Питання яке ми мусимо собі задати "Як виразити це як вимогу яку можна протестувати?". Для початку ось як:

Функція to_roman() повинна згенерувати виняток OutOfRangeError коли отримає на вхід ціле число більше ніж 3999.

Як виглядатиме тест?

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)  

① Як і в попередньому тесті, ми створємо клас що наслідується від unittest.TestCase. Ви можете вкладати більш ніж один тест всередину класу (як буде продемонстровано далі в цьому розділі), але я б надав перевагу окремому клас, тому що цей тест перевіряє дещо інше ніж попередній. Ми зберемо всі тести з гарними вхідними даними в одному класі, а всі тести з поганими вхідними даними - в іншому.

② Як і в попередньому тесті, сам тест - це метод класу, ім’я якого починається з префіксу test.

unittest.TestCase має метод assertRaises, який приймає такі аргументи: тип очікуваного винятку, функція що тестується, і аргументи що потрібно передати функції. (Якщо функція приймає багато різних аргументів передавайте їх в такому ж порядку і вони будуть передані далі.)

Добре придивіться до останнього рядка. Замість того щоб викликати to_roman() прямо, і вручну перевірити що вона генерує конкретний виняток (загортанням у блок try...except), ми дозволяємо методу assertRaises зробити все за нас. Єдине що ми робимо - уточняємо якого винятку ми очікуємо (roman2.OutOfRangeError), яку функцію викликати (to_roman()), та з якими аргументами (4000).

Також зверніть увагу що ви передаєте функцію 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)

① Ви повинні були очікувати що результатом тесту буде "fail" (провал) (так як ми ще не написали код який міг би його пройти), але насправді замість "fail" ми отримали "error". Різниця між ними тонка, але важлива. Тест може завершитись трьома способами: бути пройденим, провалитись, або повернути помилку. Провал тесту означає те що код, що тестується, повернув неправильні дані. Помилка тесту означає що код, що тестується, взагалі не вдалось виконати. ② Чому код не виконався правильно? Трейсбек все пояснює. Модуль який ми тестуємо не містить винятку що називається OutOfRangeError. Пам'ятаєте, ми передали цей виняток в метод assertRaises(), тому що це виняток який ми очікуємо від функції, що отримує дані в недозволеному діапазоні. Але такий виняток не існує, тому нам навіть не вдається викликати assertRaises(), який не отримує можливості протестувати функцію to_roman(), він просто до цього ще не дійшов.

Щоб розв'язати цю проблему потрібно описати OutOfRangeError в roman2.py.

class OutOfRangeError(ValueError):  
    pass                            

① Винятки є класами. Помилка "недозволений діапазон" - різновид помилки значення (ValueError) - значення аргументу не є дозволеним. Тому цей виняток наслідується від вбудованого винятку ValueError. Це не є обов'язковою вимогою (можна просто наслідуватись від базового класу Exception), але це видається правильним. ② Винятки нічого не роблять, але клас повинен мати хоча б один рядок коду. Виклик pass нічого не робить, але це рядок коду, тому клас створюється.

Тепер давайте знову запустимо тести.

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)

① Новий тест все ще не проходиться, але принаймі виконується без помилок, і повідомляє про провал. Це прогрес! Це означає що виклик assertRaises() цього разу був успішним, і фреймворк unittest справді тестував функцію to_roman(). ② Звісно, функція 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

Тут все прямолінійно: якщо даний ввід (n) більший за 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

Ураа! Обидва тести пройдено. Завдяки тому, що ви працювали ітеративно, перідично займаючись то тестуванням, то кодом, ви можете бути впевненими що два рядки коду які ми щойно написали були причиною того що тест який спочатку провалювався почав проходитись. Цей вид впевненості не приходить дешево, але він окупиться впродовж життя вашого коду.


* * *


Більше зупинок, більше вогню[ред.]

Поряд з тестуванням чисел які занадто великі, потрібно тестувати числа які занадто малі. Як ми зауважили в функціональних вимогах, римський запис не може виражати 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)    

① Метод test_too_large() не змінився з попереднього кроку. Я включаю його тут для того щоб показати куди додається новий код.

② Ось новий тетс: метод test_zero(). Як і метод test_too_large() він викликає метод assertRaises() що описаний в класі unittest.TestCase щоб викликати нашу функію to_roman() з параметром 0, і перевірити що вона генерує відповідний виняток, OutOfRangeError.

③ Метод test_negative() майже ідентичний, окрім того що він передає в функцію to_roman() значення -1. Якщо будь-який з цих методів не згенерує 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

① Це гарне скорочення Python: кілька порівнянь за раз. Воно еквівалентне if not ((0 < n) and (n < 4000)), але читається набагато простіше. Цей один рядок коду повинен виявляти ввід який завеликий, від'ємний чи нуль.

② Після того як ви змінили умову, змініть також і пояснення помилки. Фреймворку 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


* * *


І ще одне[ред.]

Була ще одна функціональна вимога щодо перетворення чисел в римський запис: робота з нецілими числами.

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

① Ой, це погано.

② Ой, це навіть гірше. Обидва випадки повинні були б генерувати виняток, а замість цього вони повертають неприпустимі результати.

Тестуваня на нецілі нескладне. Спершу, давайте опишемо виняток 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

① Вбудована функція isinstance() перевіряє чи належить змінна певному типу (чи, технічно, будь-якому типу нащадку).

② Якщо аргумент n - не типу int, згенерувати наш новий 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() такі ж прості як і в функції to_roman().

Але спочатку тести. Для першого тесту нам потрібні "відомі значення". Наш набір тестів, вже має такі значення, давайте використаємо їх повторно.

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)) для всіх n

В цьому випадку "для всіх" означає будь-яке число в межах 1..3999, так як тільки такі дозволено передавати в to_roman(). Цю симетрію можна виразити в тесті, що перебирає всі значення цього діапазону, викликає 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(). Ми ітеруємо крізь структуру даних (кортеж кортежів), але замість того щоб вибирати найбільше можливе ціле число, так часто як можемо, ми вибираємо "найбільшу" послідовність римських цифр, так часто як це можливо.

Якщо вам не ясно як працює from_roman(), додайте команду print в кінець циклу:

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() отримала на вхід. Залежно від застосування і вимог така можливість може вас хвилювати. В такому разі пишіть більше тестів, поки не перестанете хвилюватись.)


* * *


Більше поганого вводу[ред.]

Тепер коли, функція 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) можуть повторюватись до трьох разів. Далі потрібно віднімати від наступного за значенням символа, зазвичай кратного 5. Не можна записувати 4 як IIII, замість цього пишуть IV (на 1 менше ніж 5). 40 записується як XL (на 10 менше ніж 50), 41 як XLI, 42 як XLII, 43 як XLIII, а 44 як XLIV (на 10 менше ніж 50, і ще на одиницю менше ніж 5).
  • Іноді символи ... пряма протилежність аддитивним. Ставлячи деякі символи перед іншими, ви віднімаєте їх від загального значення. Наприклад щоб записати 9, ви віднімаєте 1 від 10, записуючи це як IX. 8 це VIII, але 9 - це IX, бо не можна писати VIIII (дивись попереднє правило). 90 це XC, 900 - CM.
  • Символи що не є степенями 10 не повторюються двічі підряд. 10 записується як X, і ніколи як VV. 100 - завжди C, і ніколи не LL.
  • Римські цифри читаються зліва направо, і порядок символів має велике значення. DC - це 600, а CD - зовсім інше значення - 400 (на 100 менше ніж 500). CI це 101, а IC навіть не є правильним римським числом, бо не можна віднімати 1 одразу від 100. 99 записується як 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

І нагорода "заспокійливий засіб року" присуджується... слову "OK", яке друкується модулем unittest коли всі тести пройдено.


Детальніше про ітератори · Рефакторинг