Простота є кінцевим досягненням. Після того як хтось зіграє величезну кількість нот, а потім ще більше нот, простота виникне як королівська нагорода мистецтва.
Фредерік Шопен

Подобається вам це чи ні, але помилки трапляються. Незважаючи на ваші найкращі спроби писати вичерпні модульні тести, помилки трапляються. Що я маю на увазі під словом "помилка"? Помилка - це ще не написаний тест.

>>> 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, )
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')

Потрібно лише два рядки коду: явна перевірка на те що рядок порожній, і команда 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))
roman9.InvalidRomanNumeralError: Invalid Roman numeral: MMMM


======================================================================
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)')
roman9.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)')
roman9.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' - тому що ми явно заборонили їй це за допомогою регулярного виразу.

② В функції to_roman() потрібна лише одна маленька зміна - перевірка діапазону. Там де в нас стояла умова 0 < n < 4000, тепер стоїть 0 < n < 5000. А також потрібно змінити повідомлення про помилку, яке тепер повинно вказувати на новий дозволений діапазон. Ніяких змін в решті коду функції робити не потрібно, вона вже і так справляється з новими випадками. (Вона просто радісно додає 'M' для кожної знайденої тисячі, якщо їй передати 4000, вона поверне 'MMMM'. Єдина причина через яку вона не зробила це раніше - ми примусово її зупинили за допомогою перевірки діапазону.)

Ви можете засумніватись що такі невеликі зміни це все що нам необхідно. Але не потрібно вірити мені на слово, переконайтесь самі.

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 ... 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 12 tests in 0.203s

OK

Всі тести пройдено. Припиніть програмування.

Вичерпний набір тестів означає що вам не доведеться покладатись на програміста який каже "повір мені".


* * *


Рефакторинг ред.

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

Рефакторинг - це процес зміни працюючого коду на краще. Зазвичай "краще" означає "швидше", але також може означати "використовуючи менше пам'яті", чи "використовуючи менше простору на диску", чи просто "більш елегантно". Що б це для вас, вашого проекту чи оточення не означало, рефакторинг дуже важливий для довготермінового здоров'я програми.

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

Відповідь: їх всього лише 5000, чому б не побудувати таблицю? Ця ідея виглядає навіть кращою коли ви усвідомлюєте що використовувати регулярні вирази взагалі не доведеться. Як тільки ви побудуєте таблицю для перетворення цілих чисел в римські, можна побудувати зворотню таблицю для перетворення римських чисел в звичайні. І коли виникне потреба перевірити що певний рядок є римським числом, можна буде просто подивитись в словник.

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

class OutOfRangeError(ValueError): pass
class NotIntegerError(ValueError): pass
class InvalidRomanNumeralError(ValueError): pass

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

to_roman_table = [ None ]
from_roman_table = {}

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 5000):
        raise OutOfRangeError('number out of range (must be 1..4999)')
    if int(n) != n:
        raise NotIntegerError('non-integers can not be converted')
    return to_roman_table[n]

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not isinstance(s, str):
        raise InvalidRomanNumeralError('Input must be a string')
    if not s:
        raise InvalidRomanNumeralError('Input can not be blank')
    if s not in from_roman_table:
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
    return from_roman_table[s]

def build_lookup_tables():
    def to_roman(n):
        result = ''
        for numeral, integer in roman_numeral_map:
            if n >= integer:
                result = numeral
                n -= integer
                break
        if n > 0:
            result += to_roman_table[n]
        return result

    for integer in range(1, 5000):
        roman_numeral = to_roman(integer)
        to_roman_table.append(roman_numeral)
        from_roman_table[roman_numeral] = integer

build_lookup_tables()

Давайте розіб'ємо це на засвоювані кусочки. Безсумнівно, що найважливіший - останній:

build_lookup_tables()

Ви можете зауважити, що це виклик функції, який не поміщений в блок if. Це не блок if __name__ == '__main__', він викликається коли модуль імпортується. (Важливо розуміти що модулі імпортуються лише раз, потім кешуються.) Якщо ви імпортуєте вже імпортований модуль, нічого не відбувається. Тому цей код буде викликано лише при першому імпорті модуля.)

То що робить функція build_lookup_tables()? Радий що ви спитали.

to_roman_table = [ None ]
from_roman_table = {}
.
.
.
def build_lookup_tables():
    def to_roman(n):                                
        result = ''
        for numeral, integer in roman_numeral_map:
            if n >= integer:
                result = numeral
                n -= integer
                break
        if n > 0:
            result += to_roman_table[n]
        return result

    for integer in range(1, 5000):
        roman_numeral = to_roman(integer)          
        to_roman_table.append(roman_numeral)       
        from_roman_table[roman_numeral] = integer

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

② Цей рядок коду викликає переозначену функцію to_roman(), яка насправді обчислює римські числа.

③ Щойно ми отримуємо результат, можна додати число і його римське представлення до обох таблиць.

Як тільки обидві таблиці створено, решта коду працює дуже просто і швидко.

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 5000):
        raise OutOfRangeError('number out of range (must be 1..4999)')
    if int(n) != n:
        raise NotIntegerError('non-integers can not be converted')
    return to_roman_table[n]

Після такої ж перевірки діапазону як і раніше, функція to_roman() просто знаходить відповідне значення в таблиці і повертає його.

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not isinstance(s, str):
        raise InvalidRomanNumeralError('Input must be a string')
    if not s:
        raise InvalidRomanNumeralError('Input can not be blank')
    if s not in from_roman_table:
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
    return from_roman_table[s]

Аналогічно, функція from_roman() зменшилась до кількох перевірок вхідних даних і одного рядка коду. Більше ніяких регулярних виразів. Ніяких циклів. Перетворення працює за O(1) в обидві сторони.

Але чи правильно воно працює? О, так, так, правильно. І я можу довести.

you@localhost:~/diveintopython3/examples$ python3 romantest10.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 ... 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 12 tests in 0.031s 

OK

Не те щоб ви цікавились, але все також працює швидко! Погляньте, майже вдесятеро швидше. Звичайно це не зовсім чесне порівняння, тому що ця версія довше імпортується (бо мусить побудувати таблиці). Але так як імпорт відбувається всього лише раз, ціна цього запуску поступово компенсується швидшими викликами функцій to_roman() та from_roman(). Так як під час тестів робиться кілька тисячи викликів цих функцій (одна лише перевірка еквівалентності зворотнього перетворення робить 10000), все дуже швидко окупається.

Мораль цієї історії?

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


* * *


Підсумок ред.

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

Ці кілька розділів покрили багато основного, багато з чого навіть не стосується конкретно мови Python. Існує багато фреймворків для модульного тестування в різних мовах, і всі вони вимагають від вас розуміння наступних базових ідей:

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


Модульне тестування · Файли