The post has been translated automatically. Original language: Russian
The previous part: "A good unit test: structure, naming, isolation." The next part: "Test backups: stabs, mockups, and fakes."
The first tests in the project are easy to write: one object, one check, three lines of code. After a couple of months, the situation is different: the preparation of objects is repeated in dozens of tests, and any change in the interface is reflected by edits throughout the file. pytest does not solve this problem by itself, but it provides convenient tools for it. In this part, we'll go all the way through one example: a discount on a shopping cart, from the first test to parameterization, exceptions, and a fix that will appear only when it is really needed.
The easier pytest is for unittest
Compared to the standard unittest, pytest immediately removes some of the formalities. You don't need an inheritor class, and you don't need methods like assertEqual: a regular function named test_... and the usual assert. If the check fails, pytest itself expands the values on both sides of the comparison, so there is no need to explain what exactly did not match.
One test, then the boundary cases
New requirement: the price of the product is reduced by the discount percentage. Let's start with one very simple case.
python
def test_discounted_price_with_ten_percent():
assert discounted_price(100, discount=10) == 90The test is failing: there is no discounted_price function yet. The minimum implementation is the usual formula. Further, the requirement is overgrown with boundary cases: 0 discount, 100 discount, 50 discount. It's not worth copying the test four times with a one-digit difference: it's easy to forget to update one of the copies, and it starts checking the wrong thing. Instead, @pytest.mark.parametrize:
python
@pytest.mark.parametrize(
("discount", "expected"),
[(0, 100), (10, 90), (50, 50), (100, 0)],
ids=["none", "ten-percent", "half", "free"],
)
def test_discounted_price(discount, expected):
assert discounted_price(100, discount) == expectedEach row of data is run as a separate test, and ids signs them with understandable names instead of numbers: the report immediately shows that it was the half case that fell, and not some unnamed test_discounted_price[2]. The rule is simple: parameterization is good as long as all strings are checked using the same logic. If a different check is needed for part of the input data, it is often easier to put such cases into separate tests than to pull them into a common table.
Error checking
A negative discount should be rejected.:
python
def test_rejects_negative_discount():
with pytest.raises(ValueError, match="discount"):
discounted_price(100, discount=-1)pytest.raises requires that an exception of the required type be thrown inside the with block (its subclass is also suitable), otherwise the test itself crashes. The match parameter checks the text of the message against the regular expression, but it only checks the wording, not that the exception occurred in exactly the right call: if there is more than one operation inside with, the matching message may not come from where you expect it to come from. Therefore, only the call that is being checked should be left inside the block.
Fixture: when the preparation begins to repeat itself
So far, each test has been limited to a single function call. But the following requirement — the discount is applied to a basket of several products, not to a single price — forces each test to start with the creation of a basket. As soon as this is repeated in the third or fourth test in a row, it makes sense to put the preparation in the fixture.:
python
@pytest.fixture
def cart():
return Cart()
def test_empty_cart_total_is_zero(cart):
assert cart.total() == 0A fixture is a function with the @pytest decorator.a fixture, and the test gets its result by simply specifying the name of the fixture among its parameters. The point is not only not to repeat the code: the fixture also clearly shows which condition the test depends on — this can be seen right in the signature, without reading the function body. "Fixtures" can be built on top of each other: a "basket with one book" is based on an "empty basket" and simply adds an item there.
If a resource requires explicit release — an open connection, a temporary file — yield is used in the fixture: the code before it is executed before the test, the code after it after the test. And if several files need a fixture in the same test tree, you can put it in a suitable one. conftest.py this tree — the tests inside it will not need to import, files outside the tree will not see it.
How many times should I recreate a fixture?
By default, the fixture is created anew for each test, and this is a reasonable default value: it prevents tests from silently sharing state. It is worth expanding the scope to a module or session only when the state of the tests remains isolated. For example, if the common test database is raised once per session, but each test runs in its own transaction, which is then rolled back, or receives its own set of data. If the tests change the overall configuration and its data is not separated in any way, one test will inherit the changes of the other, and you will get the same hidden dependency between the tests that was discussed in the last part.
Commands worth keeping handy
In everyday work, a full launch of pytest without keys is not always necessary.:
| Team | What does |
| pytest -v | Shows the name of each test and the result, not just the dots. |
| pytest -x | Stops at the first dropped test |
| pytest -k "discount" | Selects tests based on the selection expression: a match is searched among the names of functions, classes, and files. |
| pytest --lf | Repeats the tests that crashed last time; if there are no saved crashes, it runs the entire set by default. |
| pytest file.py::test_name | Runs one specific test by name |
In a short TDD cycle, it is most often most convenient to run a specific test on a file.::test_name: this way you only see what belongs to the current step. -x and --lf are also useful, but these are options to choose from, not a mandatory set for each cycle.
Result
The same example with a discount on the basket showed three pytest mechanisms in a natural order: parameterization closed the boundary cases without copying the tests, pytest.raises checked the rejection of incorrect data, and the fix appeared only when the preparation of the basket began to really repeat. In the next part, we'll talk about dependencies that can neither be parameterized nor fixed directly: payment gateways, mail, database — we'll analyze the test backups.
Which pytest feature saved you the most time when you finally mastered it? Share it in the comments.
Предыдущая часть: «Хороший юнит-тест: структура, именование, изоляция». Следующая часть: «Тестовые дублёры: стабы, моки и фейки».
Первые тесты в проекте пишутся легко: один объект, одна проверка, три строки кода. Через пару месяцев ситуация другая: подготовка объектов повторяется в десятках тестов, а любое изменение в интерфейсе расходится правками по всему файлу. pytest не решает эту проблему сам по себе, но даёт для неё удобные инструменты. В этой части пройдём весь путь на одном примере: скидка на корзину, от первого теста до параметризации, исключений и фикстуры, которая появится только тогда, когда в ней действительно возникнет нужда.
Чем pytest легче unittest
По сравнению со стандартным unittest, pytest сразу снимает часть формальностей. Не нужен класс-наследник, не нужны методы вида assertEqual: обычная функция с именем test_... и обычный assert. Если проверка не проходит, pytest сам разворачивает значения по обе стороны сравнения, поэтому объяснять, что именно не совпало, не приходится.
Один тест, потом граничные случаи
Новое требование: цена товара уменьшается на процент скидки. Начинаем с одного самого простого случая.
python
def test_discounted_price_with_ten_percent():
assert discounted_price(100, discount=10) == 90Тест падает: функции discounted_price ещё нет. Минимальная реализация — обычная формула. Дальше требование обрастает граничными случаями: скидка 0, скидка 100, скидка 50. Копировать тест четыре раза с разницей в одной цифре не стоит: легко забыть обновить одну из копий, и она начнёт проверять не то, что задумано. Вместо этого — @pytest.mark.parametrize:
python
@pytest.mark.parametrize(
("discount", "expected"),
[(0, 100), (10, 90), (50, 50), (100, 0)],
ids=["none", "ten-percent", "half", "free"],
)
def test_discounted_price(discount, expected):
assert discounted_price(100, discount) == expectedКаждая строка данных прогоняется как отдельный тест, и ids подписывает их понятными именами вместо номеров: в отчёте сразу видно, что упал именно случай half, а не какой-то безымянный test_discounted_price[2]. Правило простое: параметризация хороша, пока все строки проверяются одной и той же логикой. Если для части входных данных нужна другая проверка, часто проще вынести такие случаи в отдельные тесты, чем тянуть их в общую таблицу.
Проверка ошибки
Отрицательная скидка должна быть отклонена:
python
def test_rejects_negative_discount():
with pytest.raises(ValueError, match="discount"):
discounted_price(100, discount=-1)pytest.raises требует, чтобы внутри блока with было выброшено исключение нужного типа (подойдёт и его подкласс), иначе падает сам тест. Параметр match сверяет текст сообщения с регулярным выражением, но он проверяет только формулировку, а не то, что исключение возникло именно в нужном вызове: если внутри with окажется больше одной операции, совпадающее сообщение может прийти совсем не оттуда, откуда вы ждёте. Поэтому внутри блока стоит оставлять только тот вызов, который проверяется.
Фикстура: когда подготовка начинает повторяться
Пока каждый тест обходился одним вызовом функции. Но следующее требование — скидка применяется к корзине из нескольких товаров, а не к одной цене, — заставляет каждый тест начинаться с создания корзины. Как только это повторяется в третьем-четвёртом тесте подряд, есть смысл вынести подготовку в фикстуру:
python
@pytest.fixture
def cart():
return Cart()
def test_empty_cart_total_is_zero(cart):
assert cart.total() == 0Фикстура — функция с декоратором @pytest.fixture, а тест получает её результат, просто указав имя фикстуры среди своих параметров. Смысл не только в том, чтобы не повторять код: фикстура ещё и явно показывает, от какого состояния зависит тест, — это видно прямо в сигнатуре, без чтения тела функции. Фикстуры могут строиться друг на друге: «корзина с одной книгой» опирается на «пустую корзину» и просто добавляет туда товар.
Если ресурс требует явного освобождения — открытое соединение, временный файл, — в фикстуре используется yield: код до него выполняется перед тестом, код после него после теста. А если фикстура нужна нескольким файлам в одном дереве тестов, её можно поместить в подходящий conftest.py этого дерева — тестам внутри него импорт не понадобится, файлы за пределами дерева её не увидят.
Сколько раз пересоздавать фикстуру
По умолчанию фикстура создаётся заново для каждого теста, и это разумное значение по умолчанию: оно не даёт тестам молча делиться состоянием. Расширять область видимости до модуля или сессии стоит только тогда, когда состояние тестов при этом остаётся изолированным — например, если общая тестовая база поднимается один раз на сессию, но каждый тест работает в своей транзакции, которая потом откатывается, или получает собственный набор данных. Если тесты меняют общую фикстуру и её данные никак не разделены, один тест унаследует изменения другого, и вы получите ту самую скрытую зависимость между тестами, о которой шла речь в прошлой части.
Команды, которые стоит держать под рукой
В повседневной работе полный запуск pytest без ключей не всегда нужен:
| Команда | Что делает |
| pytest -v | Показывает имя каждого теста и результат, а не только точки |
| pytest -x | Останавливается на первом же упавшем тесте |
| pytest -k "discount" | Выбирает тесты по выражению отбора: совпадение ищется среди имён функций, классов и файлов |
| pytest --lf | Повторяет тесты, упавшие в прошлый раз; если сохранённых падений нет, по умолчанию запускает весь набор |
| pytest file.py::test_name | Запускает один конкретный тест по имени |
В коротком цикле TDD чаще всего удобнее всего запускать конкретный тест по файл::имя_теста: так вы видите только то, что относится к текущему шагу. -x и --lf тоже полезны, но это варианты на выбор, а не обязательный набор для каждого цикла.
Итог
Один и тот же пример со скидкой на корзину показал три механизма pytest в естественном порядке: параметризация закрыла граничные случаи без копирования тестов, pytest.raises проверил отклонение неверных данных, а фикстура появилась только тогда, когда подготовка корзины начала действительно повторяться. В следующей части поговорим о зависимостях, которые нельзя ни параметризовать, ни собрать фикстурой напрямую: платёжные шлюзы, почта, база данных — разберём тестовые дублёры.
Какая возможность pytest сэкономила вам больше всего времени, когда вы её наконец освоили? Поделитесь в комментариях.