doctest — тестування інтерактивних прикладів
Якщо ви пишете тести перед кодом, doctest
дозволяє писати їх просто у документації. Один приклад — і це одночасно пояснення, специфікація й тест.
У цьому розділі:
Docstrings
В офіційної документації Python часто присутні приклади виконання тієї чи іншої функції. Це дуже зручно, тому що окрім опису функції можна одразу подивитись приклади її вживання та що вона повертає. Наприклад, ось документація вбудованої функції bin
яка перетворює десяткове число у двійкове:
Тут запис, який починається з трьох символів «більше» >>> bin(3)
вказує на приклад використання функції, а наступним рядком показане відповідне значення, яке ця функція повертає: '0b11'
. Цей запис відповідає тому, як працює інтерактивний інтерпретатор Python.
Коли команди зчитуються з терміналу, інтерпретатор перебуває в інтерактивному режимі. У цьому режимі він очікує наступну команду з основним запрошенням (primary prompt), зазвичай це три знаки “більше” (>>>
); для рядків-продовжень він використовує допоміжне запрошення (secondary prompt), яке за замовчуванням складається з трьох крапок (...
). Допоміжне запрошення використовують, щоб відрізнити рядки-продовження багаторядкової команди від нового введення. Воно сигналізує користувачеві, що інтерпретатор очікує продовження конструкції — наприклад, після відкритої дужки, двокрапки в тілі циклу чи функції, або незавершеного виразу.
Якщо запустити інтерпретатор з командного рядка операційної системи, скопіювати туди ці приклади з документації, то ми отримаємо той самий результат:
Також якщо відкрити вихідні коди стандартної бібліотеки Python або інші бібліотеки з відкритим вихідним кодом, то там теж часто присутні коментарі для функцій та методів з прикладами використання, наприклад, функція shorten
зі стандартної бібліотеки textwrap
:
def shorten(text, width, **kwargs):
"""Collapse and truncate the given text to fit in the given width.
The text first has its whitespace collapsed. If it then fits in
the *width*, it is returned as is. Otherwise, as many words
as possible are joined and then the placeholder is appended::
>>> textwrap.shorten("Hello world!", width=12)
'Hello world!'
>>> textwrap.shorten("Hello world!", width=11)
'Hello [...]'
"""
w = TextWrapper(width=width, max_lines=1, **kwargs)
return w.fill(' '.join(text.strip().split()))
Тут надається приклад використання textwrap.shorten("Hello world!", width=11)
та що цей приклад повертає: 'Hello [...]'
.
Такий запис коментарів називається docstrings (від англ. documentation strings — рядки документації) — це спеціальний рядок у потрійних лапках ("""..."""
або '''...'''
), який розміщується на початку модуля, класу, функції чи методу та служить для документування їхнього призначення. Docstring пояснює, що саме робить обʼєкт, які параметри приймає, що повертає, і може містити приклади використання. На відміну від звичайних коментарів, docstring доступний програмно під час виконання через атрибут __doc__
, тому його можуть читати як розробники (у вихідному коді), так і автоматизовані інструменти (наприклад, вбудована функція help()
, Sphinx чи середа розробки). Це стандартний спосіб робити код самодокументованим і зрозумілим для інших розробників.
Модуль doctest
Існує ще один інструмент, який обробляє рядки з документацією особливим способом, дуже корисним для тестування коду, — це вбудований модуль doctest
. Він знаходить у вихідних файлах рядки, які за синтаксисом збігаються з інтерактивними Python-сесіями (тобто які починаються з символів основного запрошення >>>
), виконує їх і звіряє з очікуваним результатом, який записаний поруч у документації. Назва doctest — це скорочення від documentation test, тобто тест документації. Модуль doctest постачається разом із інтерпретатором Python і не потребує встановлення.
Такі тести називаються доктестами (doctest). Доктести — це найпростіший спосіб додати до коду прості автоматичні тести.
Доктести:
- перевіряють, що документація до функцій усе ще актуальна й відповідає поточному коду;
- виконують регресійне тестування;
- демонструють приклади використання.
Розглянемо приклад оформлення коментарів з доктестами для функції сортування бульбашкою (bubble sort):
def bubble_sort(seq):
"""
This function sorts lists of integers:
>>> bubble_sort([3, 2, 1])
[1, 2, 3]
as well as characters:
>>> bubble_sort(['h', 'e', 'l', 'l', 'o'])
['e', 'h', 'l', 'l', 'o']
"""
changed = True
while changed:
changed = False
for i in range(len(seq) - 1):
if seq[i] > seq[i + 1]:
seq[i], seq[i + 1] = seq[i + 1], seq[i]
changed = True
return seq
У цьому прикладі є два тести:
Тест | Очікування |
---|---|
bubble_sort([3, 2, 1]) |
[1, 2, 3] |
bubble_sort(['h', 'e', 'l', 'l', 'o']) |
['e', 'h', 'l', 'l', 'o'] |
Запуск доктестів
Щоб перевірити всі доктести в одному файлі (у нашому випадку це bubble_sort.py
), необхідно виконати команду в консолі:
python3 -m doctest bubble_sort.py
У цьому прикладі:
python3
— виклик інтерпретатора Python 3;-m doctest
— запуск модуляdoctest
;bubble_sort.py
— вказівка перевірити усі доктести в модуліbubble_sort.py
.
У загальному випадку доктести запускаються такою командою в терміналі:
python -m doctest [-v] [-o OPTION] [-f] file [file ...]
-v
або--verbose
- На екран виводиться детальний звіт з перевірки усіх тестових прикладів, а в кінці — підсумки.
-o OPTION
або--option OPTION
- Додаткові опції.
-f
або--fail-fast
- Працює так само як
-o FAIL_FAST
, тобто виконання тестів завершується після першого прикладу з помилкою, і решта прикладів не запускаються.
Аналіз звіту
Якщо всі тести проходять успішно, то команда запуска доктестів нічого не виводить. Але якщо якийсь із тестів не проходить, у консолі буде виведена інформація про збій тесту. Важливо: щоб отримати цей вивід, в початковий текст bubble_sort.py
була навмисно внесена помилка.
Вводимо команду в термінал:
python3 -m doctest bubble_sort.py
на якую отримаємо відповідь:
**********************************************************************
File "bubble_sort.py", line 6, in bubble_sort.bubble_sort
Failed example:
bubble_sort([3, 2, 1])
Expected:
[1, 2, 3]
Got:
[1, 1, 1]
**********************************************************************
File "bubble_sort.py", line 11, in bubble_sort.bubble_sort
Failed example:
bubble_sort(['h', 'e', 'l', 'l', 'o'])
Expected:
['e', 'h', 'l', 'l', 'o']
Got:
['e', 'e', 'l', 'l', 'o']
**********************************************************************
1 items had failures:
2 of 2 in bubble_sort.bubble_sort
***Test Failed*** 2 failures.
Для кожного тесту, що не пройшов, вказується:
- Failed example — який тест не пройшов:
bubble_sort([3, 2, 1])
, - Expected — очікуваний результат:
[1, 2, 3]
, - Got — фактичний результат виконання прикладу:
[1, 1, 1]
.
Якщо всі тести успішно проходять, усе одно можна отримати підтвердження цього в консолі, якщо додати ключ -v
(що означає verbose, детальний вивід):
python -m doctest bubble_sort.py -v
У такому разі вивід у консоль буде таким (у цьому прикладі у коді немає помилок):
Trying:
bubble_sort([3, 2, 1])
Expecting:
[1, 2, 3]
ok
Trying:
bubble_sort(['h', 'e', 'l', 'l', 'o'])
Expecting:
['e', 'h', 'l', 'l', 'o']
ok
1 items had no tests:
bubble_sort
1 items passed all tests:
2 tests in bubble_sort.bubble_sort
2 tests in 2 items.
2 passed and 0 failed.
Test passed.
Тут показано ті приклади та тести, які перевіряються (trying), очікуваний результат роботи функції (expecting), а також слово ok
, що означає, що тест пройшов успішно.
Як записувати доктести?
Щоб записати приклади використання у доктести у більшості випадків достатьно скопіювати текст із консолі інтерактивного інтерпретатора Python. Таким чином:
>>> bubble_sort(['h', 'e', 'l', 'l', 'o'])
['e', 'h', 'l', 'l', 'o']
стає:
def bubble_sort(seq):
"""
>>> bubble_sort(['h', 'e', 'l', 'l', 'o'])
['e', 'h', 'l', 'l', 'o']
"""
...
Будь-який очікуваний вивід має одразу йти після останнього рядка з основним >>>
чи допоміжним ...
запрошенням, що містить код, і охоплює весь текст аж до наступного рядка з основним запрошенням >>>
або до порожнього рядка.
Багаторядкові приклади коду
Якщо потрібно записати приклад, у якому декілька рядків коду, то треба використовувати допоміжне запрошення ...
замість основного >>>
починаючи з другого рядка, тобто, так само як це робить інтерактивний інтерпретатор. У наступному прикладі ми не можемо записати цикл в один рядок (синтаксис Python цього не дозволяє), тому виклик функції print_matrix(size)
ми вже робимо на другому рядку прикладу, який повинен починатися з допоміжного запрошення ...
:
def print_matrix(n: int) -> None:
"""Prints an n x n matrix with numbers from 1 to n^2.
>>> print_matrix(3)
1 2 3
4 5 6
7 8 9
>>> for size in range(2, 4):
... print_matrix(size)
1 2
3 4
1 2 3
4 5 6
7 8 9
"""
count = 1
for i in range(n):
row = []
for j in range(n):
row.append(str(count))
count += 1
print(" ".join(row))
import doctest
doctest.run_docstring_examples(print_matrix, globals(), verbose=True)
Обробка порожніх рядків у прикладах
Вище ми писали, що doctest
порівнює вивід прикладу до першого порожнього рядка або до першого основного запрошення >>>
. Як бути, якщо приклад мусить мати порожній рядок? Розглянемо наступний приклад, де між рядком у виводі між Hello, Alice!
та Bye!
є порожній рядок:
def greet(name: str) -> None:
"""
>>> greet("Alice")
Hello, Alice!
Bye!
"""
print(f"Hello, {name}!")
print() # Виведе порожній рядок.
print("Bye!")
import doctest
doctest.run_docstring_examples(greet, globals(), verbose=True)
У такому випадку doctest
проігнорує Bye!
у прикладі (нагадаю, що вивід порівнюється до першого порожнього рядка), та виведе помилку у звіт:
Failed example:
greet("Alice")
Expected:
Hello, Alice!
Got:
Hello, Alice!
Bye!
Як бачите, doctest
очікує (Exptected:
) лише один рядок Hello, Alice
, а по факту виконання отримав (Got:
) три рядки. У такому випадку в doctest
використовують спеціальне позначення <BLANKLINE>
. Воно вказує, що вивід прикладу містить порожній рядок у цьому місці. Сам порожній рядок у docstring
не можна залишати, бо тоді doctest
вирішить, що приклад закінчився. Тому якщо ваш код друкує порожній рядок, у прикладі треба написати <BLANKLINE>
. Ось приклад, який працює корректно з порожніми рядками:
def greet(name: str) -> None:
"""
>>> greet("Alice")
Hello, Alice!
<BLANKLINE>
Bye!
"""
print(f"Hello, {name}!")
print() # Виведе порожній рядок.
print("Bye!")
import doctest
doctest.run_docstring_examples(greet, globals(), verbose=True)
Нормалізація пробілів у прикладах
Як ми вже бачили,doctest
порівнює очікуваний та фактичний вивід буквально, включно з усіма пробілами. Це створює проблему, якщо результат містить відступи або форматування, яке може змінюватися — наприклад, у випадку з JSON.
Розглянемо приклад:
import json
def print_json(data: dict) -> None:
"""
>>> print_json({'id': 1, 'name': 'Alice'})
{
"id": 1,
"name": "Alice"
}
"""
print(json.dumps(data, indent=4))
import doctest
doctest.run_docstring_examples(print_json, globals(), verbose=True)
Тут doctest
, швидше за все, провалиться, тому що в docstring відступи можуть не збігатися буквально з відступами, які створює json.dumps(indent=4)
. В нашому прикладі відформатовано з двома відступами, а код виводить з чотирма.
Щоб doctest
ігнорував різницю в кількості пробілів, достатньо додати директиву # doctest: +NORMALIZE_WHITESPACE
:
import json
def print_json(data: dict) -> None:
"""
>>> print_json({'id': 1, 'name': 'Alice'}) # doctest: +NORMALIZE_WHITESPACE
{
"id": 1,
"name": "Alice"
}
"""
print(json.dumps(data, indent=4))
import doctest
doctest.run_docstring_examples(print_json, globals(), verbose=True)
Тепер doctest
сприйматиме будь-яку кількість пробілів як еквівалентну, і тест пройде навіть тоді, коли форматування буде різним. Директива +NORMALIZE_WHITESPACE
особливо корисна для прикладів, де результат містить вирівнювання, відступи чи табуляцію (JSON, таблиці, SQL-запити, форматований текст). Вона дозволяє зосередитися на суті виводу, а не на дрібницях у пробілах.
Тестування лише важливих фрагментів результату
Бувають ситуації, коли вивід містить частини, що постійно змінюються або занадто довгі, і ми не хочемо їх перевіряти дослівно. А безкінечні частини перевірити взагалі неможливо. Для цього в doctest
є директива +ELLIPSIS
, яка дозволяє використовувати три крапки ...
як “підстановку” будь-якого тексту.
Приклад із нескінченним генератором дуже наочно показує користь +ELLIPSIS
. У цьому прикладі порівнюється лише перші 10 чисел Фібоначчі, решта ігнорується:
from itertools import islice
def fibonacci():
"""Генератор нескінченного ряду Фібоначчі.
>>> list(islice(fibonacci(), 50)) # doctest: +ELLIPSIS
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, ...]
"""
a, b = 0, 1
while True:
yield a
a, b = b, a + b
import doctest
doctest.run_docstring_examples(fibonacci, globals(), verbose=True)
Якщо писати повний результат виконання, то такий вивід був би дуже великим та неінформативним: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946, 17711, 28657, 46368, 75025, 121393, 196418, 317811, 514229, 832040, 1346269, 2178309, 3524578, 5702887, 9227465, 14930352, 24157817, 39088169, 63245986, 102334155, 165580141, 267914296, 433494437, 701408733, 1134903170, 1836311903, 2971215073, 4807526976, 7778742049]
. Дериктива +ELLIPSIS
рятує у таких випадках.
Запис прикладів виняткових ситуацій
У doctest
можна не лише перевіряти правильний вивід функцій, а й фіксувати ситуації, коли код має підняти виняток (exception). Це корисно, бо документація тоді показує і «негативні» сценарії використання.
Як це працює:
- Ви пишете виклик, що спричиняє виняток.
- Далі в
docstring
копіюєте типовий вивід інтерпретатора: рядок ізTraceback
, три крапки...
(щоб не вимагати точний шлях до файлу чи номер рядка) і тип винятку з повідомленням. Важливо, що повідомлення про помилку (у нашому прикладі цеdivision by zero
) треба наводити дослівно.
def divide(a: int, b: int) -> float:
"""Divide two integers.
>>> divide(10, 2)
5.0
>>> divide(5, 0)
Traceback (most recent call last):
...
ZeroDivisionError: division by zero
"""
return a / b
import doctest
doctest.run_docstring_examples(divide, globals(), verbose=True)
Ігнорування деяких прикладів (директива SKIP)
У doctest
іноді приклади не можна протестувати буквально, бо їхній результат змінюється щоразу. Класичний приклад — генерація випадкового ідентифікатора, як показано нижче. Такий тест завжди буде не проходити, тому що неможливо надати приклад в документації випадкового значення — воно тому і випадкове, що кожен раз буде іншим. В такому разі використовується директива # doctest: +SKIP
, яку треба записати наприкінці рядка з прикладом, який треба проігнорувати.
import random
def random_user_id(length: int = 4) -> str:
"""Generate a random user ID of given length (at least 4).
If `length < 4`, raise ValueError.
>>> random_user_id(4) # doctest: +SKIP
'user_6943'
>>> random_user_id(3)
Traceback (most recent call last):
...
ValueError: length must be >= 4
"""
if length < 4:
raise ValueError("length must be >= 4")
digits = "".join(str(random.randint(0, 9)) for _ in range(length))
return "user_" + digits
import doctest
doctest.run_docstring_examples(random_user_id, globals(), verbose=True)
В цьому коді є два приклади використання: random_user_id(4)
та random_user_id(3)
, але doctest
протестує лише другий приклад, а перши проігнорує.
Doctest і TDD: природна звʼязка
У підході Test Driven Development (TDD) розробник починає не з написання коду, а з написання тестів, що описують бажану поведінку. Модуль doctest
у Python чудово вписується в цю практику, оскільки дозволяє описувати приклади використання функцій прямо в їхньому docstring. Фактично, ви пишете тест одночасно з документацією: описали, що функція має робити, додали приклад виклику з очікуваним результатом — і вже маєте тест-кейс.
Миттєвий зворотний звʼязок. Doctest надає дуже швидкий цикл перевірки. Ви запускаєте python -m doctest your_module.py
, і одразу бачите, чи відповідає код вашим очікуванням. Це ідеально лягає у філософію TDD: червоний тест (очікування не виконується), мінімальний код для проходження тесту, потім рефакторинг. Doctest робить цей цикл ще більш природним, адже приклад у документації відразу перетворюється на тест.
Поєднання документації та тестів. Важливий бонус doctest у TDD — він зменшує дублювання. Вам не треба окремо писати “ось приклад у документації” і потім “ось той самий приклад у тестах”. Одне джерело правди. Це підвищує надійність і полегшує підтримку: якщо приклад у документації більше не проходить тест, значить код змінився і документацію треба оновити.
Коли використовувати doctest
doctest
у Python добре підходить не завжди, а саме тоді, коли ваш код можна пояснити короткими прикладами. Основна сила цього інструмента — поєднання документації та тестів в одному місці:
- Навчальні та демонстраційні приклади. Якщо ви пишете бібліотеку чи модуль, де важливо показати типовий сценарій використання функцій, doctest дозволяє зробити приклади живими: вони не тільки пояснюють, але й перевіряються автоматично.
- Прості функції та утиліти. Коли логіка функції компактна й зрозуміла, doctest можна використати для швидкої валідації. Наприклад, арифметичні обчислення, робота з рядками чи базові алгоритми.
- Підтримка документації в актуальному стані. Doctest виявить, якщо приклад у docstring більше не відповідає реальній поведінці коду. Це особливо корисно в бібліотеках, що розвиваються, — приклади не застаріють непомітно.
- Ранні етапи розробки (TDD-стиль). Ви можете почати із запису бажаної поведінки у вигляді прикладу, а потім написати код, який змусить doctest пройти. Це надає швидкий зворотний звʼязок і допомагає мислити через конкретні випадки.
Проте doctest
не замінює повноцінних тестових фреймворків на кшталт unittest
чи pytest
. Попри зручність, doctest
має низку обмежень і не підходить для всіх сценаріїв:
- Складна логіка. Якщо функція має багато умов, обробку помилок чи складні структури даних на виході,
doctest
буде громіздким і незручним. У таких випадках краще використовуватиpytest
абоunittest
, де легко організувати різні тестові сценарії. - Великі проєкти. У масштабних системах приклади в
docstring
швидко втрачають керованість. Doctest не має зручних фікстур, ізольованих середовищ чи налаштувань для складних залежностей. - Вимога до зрозумілого форматування.
Doctest
перевіряє вивід буквально. Якщо результат містить непередбачувані пробіли, порядок елементів у множинах чи випадкові числа — тести почнуть падати без реальної причини. - Документація для нефахівців. Якщо ви пишете текст для користувачів, які не знають Python, вставляти приклади з основним запрошенням
>>>
і перевіряти їх за допомогоюdoctest
може бути зайвим і навіть таким, що відштовхує. - Інтеграційні або системні тести. Doctest не призначений для перевірки взаємодії з базами даних, API чи файловою системою. Там потрібні окремі фреймворки з розширеними можливостями.
Отже, doctest
найкраще працює як доповнення до класичних тестів: він зручний для простих прикладів і одночасно підтримує документацію в актуальному стані. Але коли йдеться про серйозну перевірку якості, слід покладатися на більш потужні інструменти.
Приклади
У цьому прикладі показано, як doctest
може використовуватися для перевірки правил валідації пароля без написання окремих тестових файлів. У docstring описані чотири умови: пароль має бути довшим за 8 символів, містити хоча б одну велику та одну малу літеру, а також хоча б одну цифру. Далі наведені три приклади: занадто короткий пароль "short"
повертає False
, пароль без цифри теж повертає False
, а достатньо складний "VeryStrong1"
— True
. Таким чином, приклади у docstring виконують роль одночасно документації (користувач одразу бачить, які правила діють) і тестів (doctest
запускає ці приклади й перевіряє, чи відповідає функція заявленій поведінці).
def is_valid_password(password: str) -> bool:
"""Tests a password for these rules:
1. Longer than 8 chars.
2. At least one uppercase letter.
3. At least one lowercase letter.
4. At least one digit.
>>> is_valid_password("short")
False
>>> is_valid_password("PasswordWithoutDigit")
False
>>> is_valid_password("VeryStrong1")
True
"""
return all([
len(password) > 8,
any(char.isdigit() for char in password),
any(char.islower() for char in password),
any(char.isupper() for char in password)
])
import doctest
doctest.run_docstring_examples(is_valid_password, globals(), verbose=True)
Питання для самоконтролю
- Що таке
docstring
у Python і чим він відрізняється від звичайних коментарів? - Якою командою з терміналу запускають доктести в одному файлі? Яке призначення ключа
-v
? - Для чого потрібні основне запрошення
>>>
і допоміжне...
? Коли використовується кожне з них? - Як
doctest
визначає межі очікуваного виводу? Що робити, якщо у виводі потрібен порожній рядок? - У чому різниця між директивами
+NORMALIZE_WHITESPACE
,+ELLIPSIS
та+SKIP
. Для чого кожна з них застосовується? - Наведіть приклад доктесту, який перевіряє підняття винятку (наприклад,
ValueError
). Чому у трасуванні корисно ставити...
? - Коли
doctest
доречний, а коли варто віддати перевагуpytest
/unittest
? Назвіть принаймні по два сценарії для кожного випадку. - Чому «буквальне» порівняння виводу може ламати доктести на JSON/таблицях і як це виправити?
- Що робить опція
--fail-fast
(або-f
)? У яких ситуаціях вона корисна під час налагодження?