Быстрая обратная связь от тестов
Начнём с Makefile.
Часть касающаяся тестов выглядит так
manage = poetry run python src/manage.py
SIMULTANEOUS_TEST_JOBS=4
test:
cd src && poetry run pytest -n ${SIMULTANEOUS_TEST_JOBS} -m 'not single_thread'
make test
- Запустит все тесты в 4 потока.
Тут не ошибок, но этот код можно улучшить.
Во-первых, сделаем SIMULTANEOUS_TEST_JOBS=auto
- это позволит ускорится тестам на компьютерах с большим числом процессоров, при этом однопроцессорные машины не будут захлёбываться от четырёх параллельных процессов.
Во-вторых, стоит добавить ключ --ff
. Если тесты падают, то следующий прогон начнётся с упавших тестов. Не придётся ждать, чтобы понять исправились они или нет.
В третьих, стоит добавить библиотеку pytest-testmon, с ней окончательный вариант будет выглядеть так:
manage = poetry run python src/manage.py
SIMULTANEOUS_TEST_JOBS=auto
test:
cd src && poetry run pytest -n ${SIMULTANEOUS_TEST_JOBS} -m 'not single_thread' --ff --testmon
testmon:
cd src && poetry run pytest -n ${SIMULTANEOUS_TEST_JOBS} --ff --testmon -x -l
testmon
собирает в БД связи между кодом и тестами, которые его покрывают, в локальную БД. Не забудьте добавить БД в .gitignore
.
Если запустить команду pytest --testmon
, то будут запущены только те тесты, которые относятся к изменившемуся коду. Это быстрее, чем запускать весь код. И надёжнее чем запускать единственный тест, который, как вам кажется, относится к вносимым вами изменениям.
Нужно учитывать одну особенность testmon
, если задан ключ -m
, то выполняются тесты подходящие под условие ключа, а не тесты текущих изменений. Именно поэтому в Makefile
теперь 2 команды.
make test
- нужно запускать перед началом работы, чтобы наполнить БД testmon связями. И после окончания работы, чтобы проверить, что все тесты отрабатывают.
make testmon
- можно запускать после каждого изменения кода. Это можно делать часто, так как запускаться будет, только малая часть тестов.
Ключ -x
, нужен чтобы не ждать окончания тестов, если найдена проблема
Ключ -l
, нужен чтобы в консоли отображались значения переменных, что упрощает исправление ошибки без отладчика.
Не держите фикстуры в файле тестов
В одном файле расположена фикстура paid_order
и тест test_break_if_current_user_could_not_be_captured
, который её использует.
Взглянем на тест поближе
def test_break_if_current_user_could_not_be_captured(mocker, refund): # ❌
mocker.patch("apps.orders.services.order_refunder.get_current_user", return_value=None)
with pytest.raises(AttributeError):
refund(paid_order, paid_order.price)
Тест проверяет, что код падает с ошибкой, если пользователь которому нужно сделать возврат равен None
.
Действительно, если добавить фикстуру paid_order
в аргументы теста, то произойдёт ошибка. Только это будет другая ошибка. Откуда же взялся AttributeError
? Он возникает здесь: paid_order.price
- это попытка обратиться к полю price
функции (а не фикстуры) paid_order
. До вызова refund
дело даже не доходит, а значит тест не проверяет то, что должен был. А всё потому, что IDE видит эту функцию и не подсвечивает проблему.
Чтобы такого не происходило, нужно держать все фикстуры в файле conftest.py
рядом с файлом теста. Так как фикстуры не импортируются, то они не видны IDE, пока не будут переданы в тест.
Offtop. В TDD такая ошибка в принципе не могла возникнуть, т.к. нельзя подглядеть как будет работать код и скопировать ошибку в тест. Ожидаемый результат приходится записывать заранее, до того как он появится в коде.
Держите фикстуры ближе к тестам
Хотя django создаёт для каждого приложения свой файл test.py
, но лучше делать отдельную папку с тестами, так как желательно, чтобы на рабочие сервера попадали только необходимые файлы, а файлы тестов и документации к таким не относятся.
Допустим у вас все тесты лежат в одной папке tests, в ней лежат папки для приложений django, а уже в них папки с тестами разбитыми по функциональности. В каждой из папок могут находится файлы conftest.py
.
Когда pytest ищет фикстуру, сначала она ищет фикстуру в том же классе, где находится тест, если класс есть, потом в том же файле, потом в conftest.py
в той же папке, потом conftest.py
родительских папок до корня тестов.
Где расположить фикстуру user
, которая нужна во всех тестах? Можно сделать вывод, что если писать на уровне приложений или уровне функциональности, то нарушается принцип DRY, и лучше расположить фикстуру в корень тестов. В реальности, общая для всех тестов фикстура, начинает со временем приносить проблемы, так как её будет очень сложно менять. Другими словами, корень тестов хорошее место только для фикстур, которые не меняются, вроде подключения к БД.
Мой алгоритм поиска места для фикстуры.
- Сначала фикстуру можно поместить в
conftest.py
в той же папке, где находится тест. Это лучшее место для неё. Не важно, что для соседней функциональности или приложения написана точно такая же фикстура. - Если мне нужно, чтобы в разных файлах одной папки или в разных тестах одного файла фикстура с одним именем обозначала разное, то оборачиваю тесты в классы и добавляю фикстуру в них.
- Если тестов слишком много, то выношу их в отдельную папку со своим
conftest.py
Таким образом, фикстура может находится в одном из 2х мест или в одном классе с тестом, или вconftest.py
в одной папке с тестом.
Не пользуйтесь одноимённым наследованием фикстур
В последних версиях Pycharm сломалась навигация по одноименным фикстурам с наследованием, а инструменты вроде поиска не удобны, так как из-за предыдущего совета одноимённые фикстуры встречаются часто.
Наследованием фикстур будем называть фикстуру, которая на отдаёт на выходе ту же фикстуру, которую получает на вход. Одноимённое наследование, это когда фикстура на вход получает фикстуру с таким же именем. Такое часто можно встретить в статьях по pytest.
Вот так выглядит пример на django:
@pytest.fixture
def user():
return User.objects.create(is_staff=True)
class TestLastLogin:
@pytest.fixture
def user(self, user): # ❌
user.last_login = datetime.now()
user.save()
return user
Можно было бы назвать дочернюю фикстуру по другому, это упростило бы чтение кода, но само по себе наследование фикстур опасно, так как приводит к сайд эффектам
@pytest.fixture
def user():
return User.objects.create(is_staff=True)
class TestLastLogin:
@pytest.fixture
def user_no_staff(self, user):
user.is_staff = False
user.save()
return user
def test(self, user, user_no_staff):
user.refresh_from_db()
assert user.is_staff # ❌
Этот тест упадёт, т.к. фикстура user_no_staff повлияла на фикстуру user, и это поломало тест.
Можно воспользоваться трюком
@pytest.fixture
def user():
return User.objects.create(is_staff=True)
class TestLastLogin:
@pytest.fixture
def user_no_staff(self, user):
user_no_staff = User.objects.get(user.id)
user_no_staff.id = None
user_no_staff.is_staff = False
user.save()
return user
def test(self, user, user_no_staff):
user.refresh_from_db()
assert user.is_staff # ✅
Это уже не наследование, а значит сайд-эффекта уже не будет.
Но лучше вообще не использовать наследование. Так тест читается максимально легко.
@pytest.fixture
def user():
return User.objects.create(is_staff=True)
class TestLastLogin:
@pytest.fixture
def user_no_staff(self, user):
return User.objects.create(is_staff=False)
def test(self, user, user_no_staff):
user.refresh_from_db()
assert user.is_staff # ✅
assert not user_no_staff.is_staff # ✅
Top comments (0)