Огляд розробки через тестування.
Розробка через тестування (TDD) - це стиль програмування, у якому тести пишуть, щоб вони скеровували втілення структури програми в коді.
Один або кілька тестів (зокрема модульні тести) пишуть перед написанням коду. Тести покликані охопити один аспект поведінки програми, який може стосуватися однієї функції чи методу. Написання тестів - це спосіб перетворити вимоги до програми та загальну архітектуру на структуру, привʼязану до конкретної реалізації. Тести запускають, і вони мають зазнати невдачі, бо код ще не реалізовано. Код реалізують і тести запускають знову. Якщо тести проходять, то або реалізацію цієї поведінки завершено, або, можливо, потрібно створити ще тести. Якщо тести не проходять, то код налагоджують і тести запускають знову. Цикл тестування й написання коду повторюють, доки не пройдуть усі потрібні тести, і тоді реалізацію цього аспекту поведінки програми завершено... наразі.
Рефакторинг - це переписування коду, щоб покращити його структуру. Це не просто переписування коду, щоб виправити помилки. Іноді кажуть «рефакторинг» про зміну коду, щоб він проходив тести. Хоча зміна коду може включати покращення структури як спосіб пройти тести, саме лише налагодження не обовʼязково покращує структуру коду, а отже, не обовʼязково є рефакторингом.
Ось приклад налагодження без рефакторингу:
# A function intended to return x added to y.
# x and y are bad parameter names, but we ignore that for now.
def add(x, y):
# used multiply operator by mistake. It fails the tests.
return x * y
# Function corrected. It passes the tests. It has been debugged, but not refactored.
def add(x, y):
return x + y
Ось приклад рефакторингу, а потім налагодження:
# Function name and parameter names are modified to something more meaningful. This is refactoring.
def lot_inventory(old_cars, new_cars):
# Introduced multiply operator by mistake. It fails the tests. This is why we test.
return old_cars * new_cars
# Function corrected. It passes the tests. This is debugging.
def lot_inventory(old_cars, new_cars):
return old_cars + new_cars
У вправах треку Python на Exercism застосовують методологію TDD. Модульні тести вже написано. Учень може переглянути тести, щоб докладніше зрозуміти, що потрібно, аби рішення пройшло. Учневі можуть надати заготовку рішення.
Коли один або кілька тестів для рішення Python зазнають невдачі, відповідні завдання не матимуть зеленого фону. Першу область із невдалим завданням буде розгорнуто, і її заголовок матиме приблизно такий вигляд
Task 1 Extract coordinates -
Натиснувши на знак мінуса, ми згорнемо завдання, щоб подивитися на інші, але поки що зупинимося на цьому.
Під ним буде розгорнута область Test, яка матиме приблизно такий вигляд
Test 1 ⌄
FAILED TisburyTreasure > get coordinate
де Tisbury Treasure вказує на вправу, а get_coordinate - на функцію чи метод, що зазнав невдачі.
Test 1 зазвичай буде своєрідним шаблоном із розділом коду для налаштування тестів.
У ньому немає інформації про конкретні тести, що зазнали невдачі.
Ближче до низу буде написано, що
One or more variations of this test failed. Details can be found under each [variant#].
Натиснувши на ⌄, ми згорнемо тест.
Під ним буде згорнутий тест, який матиме приблизно такий вигляд:
Test 2 >
FAILED TisburyTreasure > get coordinate [variation #1] (item=
("Scrimshaw Whale's Tooth", '2A'), result='2A')
Його вигляд залежатиме від ширини, встановленої для правої панелі.
Натиснувши на >, ми розгорнемо тест.
Вхідні дані та очікувані дані результату, ймовірно, буде показано в розділі коду.
Дані можуть стосуватися всіх тестів для цього завдання.
Унизу, у розділі Test Failure, наведено конкретну причину, чому цей тест зазнав невдачі.
Це може мати такий вигляд:
AssertionError: ['2A'] != '2A'
У цьому конкретному випадку це означає, що повернене значення ['2A'] не дорівнювало очікуваному значенню '2A'.
Якщо подивитися на код get_coordinate, то побачимо, що його реалізовано так
def get_coordinate(record):
return [record[1]]
Якщо прибрати квадратні дужки списку (наприклад, return record[1]) і знову запустити тести, тести для завдання 1 пройдуть.
Якщо одне або кілька завдань усе ще не проходять, то описаний процес повторюють із кожним із них, доки не пройдуть усі тести.
Іноді очікувані й повернені дані завеликі, щоб уміститися повністю в розділі Test Failure.
Це може мати приблизно такий вигляд:
AssertionError: '("Sc[67 chars]\')\n\n(\'Brass Spyglass\', \'Abandoned Lighth[952 chars]')\n' != '("Sc[67 chars]\')\n(\'Brass Spyglass\', \'Abandoned Lighthou[928 chars]')\n'
Diff is 970 characters long. Set self.maxDiff to None to see it.
Даних усе ще може бути достатньо, щоб побачити, у чому проблема.
У наведеному випадку повернуто два символи переведення рядка (наприклад, \n\n(\'Brass Spyglass), тоді як очікується лише один (наприклад, \n(\'Brass Spyglass).
Вітаємо! Усі тести пройдено. Що далі? Рішення можна опублікувати одразу. Або, тепер, коли код працює, якщо ми захочемо з якоїсь причини його відрефакторити, можна змінити код і надіслати іншу ітерацію. Якщо нам здається, що код міг би бути кращим, але ми не знаємо як, можна попросити наставництва для цього рішення. Якщо є доступний наставник, він може звʼязатися з нами з ідеями щодо інших підходів до рішення. Публікуючи рішення, можна дозволити коментарі, і тоді інші учні зможуть скористатися нагодою й залишити коментарі чи поставити запитання.
Хоча «передчасна оптимізація - корінь усіх бід» (вислів, який приписують і Тоні Гоару, і Дональду Кнуту), настає час, коли, навіть попри те, що рішення працює, хочеться покращити його швидкодію.
Один із таких моментів настає тоді, коли рішення проходить одні тести, але вичерпує ліміт часу на інших.
Буває корисно точно знати, скільки часу займає той чи інший фрагмент коду.
Модуль timeit дає змогу вимірювати час виконання коду аж до дуже малих тривалостей.
Функція timeit може приймати до пʼяти аргументів: timeit.timeit(stmt='pass', setup='pass', timer=<default timer>, number=1000000, globals=None).
Параметр stmt визначає власне код, який запускають і час якого вимірюють.
Параметр number визначає, скільки разів буде запущено код stmt.
Параметр setup визначає код, який запускають лише один раз, щоб підготуватися до запуску коду stmt.
Час виконання коду setup входить у загальний час.
Чим більше ітерацій виконує код stmt, тим менше часу setup припадає на кожну ітерацію.
Параметр timer дає змогу передати інший Timer, ніж типовий.
Типовим аргументом для параметра timer є perf_counter, чого має бути достатньо для більшості випадків.
Типовим аргументом для параметра number є 1_000_000.
Параметр globals задає простір імен, у якому виконувати код.
Типовим аргументом для параметра globals є None.
Ось приклад використання timeit, щоб дізнатися, скільки часу потрібно, аби визначити, чи містить речення всі англійські голосні:
import timeit
# run one million times
loops = 1_000_000
# first positional argument is for stmt
# second positional argument is for setup
# third (named) argument is for number
print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
"""
VOWELS = "AEIOU"
def has_all_vowels(sentence):
return all(letter in sentence.casefold() for letter in VOWELS)
""", number=loops) / loops)
Запуск коду мільйон разів зайняв у середньому 4.965089999896008e-07 секунди на виклик (близько 497 наносекунд на виклик).
Наступний приклад показує, чи економить час винесення виклику casefold за межі спискового виразу:
import timeit
loops = 1_000_000
print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
"""
VOWELS = "AEIOU"
def has_all_vowels(sentence):
sentence = sentence.casefold()
return all(letter in sentence for letter in VOWELS)
""", number=loops) / loops)
Запуск коду мільйон разів зайняв у середньому 4.923898000270128e-07 секунди на виклик (близько 492 наносекунди на виклик.)
Отже, винесення casefold за межі спискового виразу заощадило близько 5 наносекунд на виклик, або близько 5 мілісекунд загалом на мільйон викликів.
cProfile також можна використовувати для профілювання коду, однак він не такий детальний, бо сягає лише мілісекундних тривалостей.