DEV Community

Гимаев Наиль
Гимаев Наиль

Posted on

1. Тесты

Быстрая обратная связь от тестов

Начнём с 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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Тест проверяет, что код падает с ошибкой, если пользователь которому нужно сделать возврат равен 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, и лучше расположить фикстуру в корень тестов. В реальности, общая для всех тестов фикстура, начинает со временем приносить проблемы, так как её будет очень сложно менять. Другими словами, корень тестов хорошее место только для фикстур, которые не меняются, вроде подключения к БД.

Мой алгоритм поиска места для фикстуры.

  1. Сначала фикстуру можно поместить в conftest.py в той же папке, где находится тест. Это лучшее место для неё. Не важно, что для соседней функциональности или приложения написана точно такая же фикстура.
  2. Если мне нужно, чтобы в разных файлах одной папки или в разных тестах одного файла фикстура с одним именем обозначала разное, то оборачиваю тесты в классы и добавляю фикстуру в них.
  3. Если тестов слишком много, то выношу их в отдельную папку со своим 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
Enter fullscreen mode Exit fullscreen mode

Можно было бы назвать дочернюю фикстуру по другому, это упростило бы чтение кода, но само по себе наследование фикстур опасно, так как приводит к сайд эффектам

@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  # ❌
Enter fullscreen mode Exit fullscreen mode

Этот тест упадёт, т.к. фикстура 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  # ✅
Enter fullscreen mode Exit fullscreen mode

Это уже не наследование, а значит сайд-эффекта уже не будет.
Но лучше вообще не использовать наследование. Так тест читается максимально легко.

@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  # ✅ 
Enter fullscreen mode Exit fullscreen mode

Top comments (0)