The post has been translated automatically. Original language: Russian
The previous part: "The cycle Red-Green-Refactor: three steps in practice." The next part is: "pytest in practice: fixtures, parameterization, exceptions."
TDD is based on tests, so the quality of the tests determines the quality of the entire practice. A bad test is worse than no test: it crashes for no reason, breaks with every refactoring, and doesn't explain what exactly went wrong. The developer spends time not correcting the code, but deciphering the test itself. In this part, we'll look at what a good unit test consists of: how to build it, how to name it, and how to isolate it from the rest.
Structure: Arrange, Act, Assert
It is convenient to divide the test into three blocks: Arrange (preparing data and objects), Act (one verifiable action), and Assert (verifying the result). In BDD terms, the same approach is called Given-When-Then. The blocks should be separated by an empty line: then the structure is visible at a glance.
def test_total_sums_price_times_quantity_for_all_items():
# Arrange
cart = Cart()
cart.add("book", price=500, qty=2)
cart.add("pen", price=50, qty=1)
# Act
total = cart.total()
# Assert
assert total == 1050
The main rule is that one test checks one behavior. Multiple statements are valid if they all describe the same result. If you have to come up with a name with the conjunction "and" for the test, most likely, these are two tests. Here is an example of a test that tries to test everything at once:
def test_cart():
cart = Cart()
cart.add("book", price=500, qty=2)
cart.add("pen", price=50, qty=1)
assert cart.total() == 1050
cart.remove("pen")
assert cart.total() == 1000
cart.clear()
assert cart.total() == 0
If the last statement falls, it will not be clear from the report what is broken: deletion, cleaning, or calculating the amount. It's better to break it down into separate tests.:
def test_remove_item_excludes_it_from_total():
cart = Cart()
cart.add("book", price=500, qty=2)
cart.add("pen", price=50, qty=1)
cart.remove("pen")
assert cart.total() == 1000
def test_clear_makes_total_zero():
cart = Cart()
cart.add("book", price=500, qty=2)
cart.clear()
assert cart.total() == 0
Naming
The name of the test serves as documentation. When a test turns red in the CI report, it should be clear by name which behavior is broken, without reading the code. A convenient scheme: what is being checked, under what conditions, and what is the result.
| Bad name | It's a good name |
| test_cart_1 | test_total_sums_price_times_quantity_for_all_items |
| test_error | test_book_overlapping_slot_raises_conflict |
| test_discount | test_discount_above_100_percent_is_rejected |
Long names for tests are normal: they are not called manually, but they are read every time they fail.
Isolation and the principles of F.I.R.S.T.
The requirements for unit tests are often described by the abbreviation F.I.R.S.T.:
- Fast (fast). Unit tests run in a fraction of a second, otherwise they stop running after each change.
- Independent. The test does not depend on the result or the order of other tests.
- Repeatable. The result is the same on any machine and at any time: without network access, random data, and the current time.
- Self-validating (self-checking). The test ""passed"" or "crashed" itself, without manually reading the logs.
- Timely. The test is written on time, and in TDD it means before the code.
Most often, the insulation breaks down the general condition. For example, an object is created at the module level and is used by several tests.:
cart = Cart() # shared object at the module level
def test_add_item():
cart.add("book", price=500, qty=1)
assert cart.total() == 500
def test_empty_cart_total_is_zero():
assert cart.total() == 0 # depends on the startup order
The second test will run in one startup order and crash in another. Such errors are difficult to find, so each test must create its own data anew. In the next part, let's see how pytest simplifies this with the help of fixtures.
Result
A good unit test is arranged according to the Arrange-Act-Assert scheme, checks one behavior, has a telling name and does not depend on other tests. Such tests can be read as a specification and run after each change. In the next part, we'll look at the pytest tools that make such tests shorter: fixtures, parameterization, and exception checking.
What do you call the tests in your projects, and have you ever encountered tests whose names make it impossible to understand what they are testing? Share examples of formulations in the comments.
Предыдущая часть: «Цикл Red-Green-Refactor: три шага на практике». Следующая часть: «pytest на практике: фикстуры, параметризация, исключения».
TDD стоит на тестах, поэтому качество тестов определяет качество всей практики. Плохой тест хуже, чем отсутствие теста: он падает без причины, ломается при каждом рефакторинге и не объясняет, что именно пошло не так. Разработчик тратит время не на исправление кода, а на расшифровку самого теста. В этой части разберём, из чего состоит хороший юнит-тест: как его строить, как называть и как изолировать от остальных.
Структура: Arrange, Act, Assert
Тест удобно делить на три блока: Arrange (подготовка данных и объектов), Act (одно проверяемое действие) и Assert (проверка результата). В терминах BDD тот же подход называется Given-When-Then. Блоки стоит разделять пустой строкой: тогда структура видна с первого взгляда.
def test_total_sums_price_times_quantity_for_all_items():
# Arrange
cart = Cart()
cart.add("book", price=500, qty=2)
cart.add("pen", price=50, qty=1)
# Act
total = cart.total()
# Assert
assert total == 1050
Главное правило: один тест проверяет одно поведение. Несколько утверждений допустимы, если все они описывают один и тот же результат. Если для теста приходится придумывать имя с союзом «и», скорее всего, это два теста. Вот пример теста, который пытается проверить всё сразу:
def test_cart():
cart = Cart()
cart.add("book", price=500, qty=2)
cart.add("pen", price=50, qty=1)
assert cart.total() == 1050
cart.remove("pen")
assert cart.total() == 1000
cart.clear()
assert cart.total() == 0
Если упадёт последнее утверждение, из отчёта не будет понятно, что сломалось: удаление, очистка или подсчёт суммы. Лучше разбить его на отдельные тесты:
def test_remove_item_excludes_it_from_total():
cart = Cart()
cart.add("book", price=500, qty=2)
cart.add("pen", price=50, qty=1)
cart.remove("pen")
assert cart.total() == 1000
def test_clear_makes_total_zero():
cart = Cart()
cart.add("book", price=500, qty=2)
cart.clear()
assert cart.total() == 0
Именование
Имя теста служит документацией. Когда тест краснеет в отчёте CI, по имени должно быть понятно, какое поведение нарушено, без чтения кода. Удобная схема: что проверяется, при каких условиях, какой результат.
| Плохое имя | Хорошее имя |
| test_cart_1 | test_total_sums_price_times_quantity_for_all_items |
| test_error | test_book_overlapping_slot_raises_conflict |
| test_discount | test_discount_above_100_percent_is_rejected |
Длинные имена для тестов нормальны: их не вызывают вручную, зато читают при каждом сбое.
Изоляция и принципы F.I.R.S.T.
Требования к юнит-тестам часто описывают аббревиатурой F.I.R.S.T.:
- Fast (быстрый). Юнит-тесты выполняются за доли секунды, иначе их перестают запускать после каждого изменения.
- Independent (независимый). Тест не зависит от результата или порядка других тестов.
- Repeatable (повторяемый). Результат одинаков на любой машине и в любое время: без обращений к сети, случайных данных и текущего времени.
- Self-validating (самопроверяющийся). Тест сам сообщает «прошёл» или «упал», без ручного чтения логов.
- Timely (своевременный). Тест пишется вовремя, а в TDD это значит до кода.
Чаще всего изоляцию ломает общее состояние. Например, объект создан на уровне модуля и используется несколькими тестами:
cart = Cart() # общий объект на уровне модуля
def test_add_item():
cart.add("book", price=500, qty=1)
assert cart.total() == 500
def test_empty_cart_total_is_zero():
assert cart.total() == 0 # зависит от порядка запуска
Второй тест пройдёт при одном порядке запуска и упадёт при другом. Такие ошибки трудно искать, поэтому каждый тест должен создавать свои данные заново. В следующей части посмотрим, как pytest упрощает это с помощью фикстур.
Итог
Хороший юнит-тест устроен по схеме Arrange-Act-Assert, проверяет одно поведение, имеет говорящее имя и не зависит от других тестов. Такие тесты можно читать как спецификацию и запускать после каждого изменения. В следующей части разберём инструменты pytest, которые делают такие тесты короче: фикстуры, параметризацию и проверку исключений.
Как вы называете тесты в своих проектах, и встречались ли вам тесты, по имени которых невозможно понять, что они проверяют? Поделитесь примерами формулировок в комментариях.