Код
#Руководства

Руководство по Pytest: как тестировать код в Python

Гайд по самому популярному среди Python-разработчиков фреймворку для тестирования.

Иллюстрация: Катя Павловская для Skillbox Media

Написать рабочий код — полдела: нужно добиться, чтобы он выдавал корректные результаты.

Можно тестировать программу вручную: запускать её вновь и вновь в разных условиях и проверять, правильно ли всё работает. Но лучше, конечно, автоматизировать процесс и написать код, который будет проверять другой код. Чтобы упростить эту задачу, и придумали Pytest.

Из этой статьи вы узнаете:


Что такое Pytest

Pytest — это фреймворк для тестирования кода на Python. Он был разработан в 2004 году, но до сих пор регулярно обновляется и позволяет не только писать тесты, но и создавать для них окружение, а также настраивать параметры запуска.

Согласно исследованию JetBrains, Pytest использует каждый второй питонист.

Популярность фреймворков для тестирования (по статистике JetBrains)
Инфографика: Skillbox Media

Преимущества и недостатки Pytest

Успешность Pytest в сравнении с конкурентами (например, Unittest) легко объясняется его преимуществами:

  • Лаконичный код. В синтаксисе Pytest нет громоздких конструкций, как в том же Unittest. Простой тест может состоять всего из двух строк.
  • Подробные отчёты об ошибках. Если тест работает неправильно, Pytest сам объяснит, в чём дело.
  • Универсальный оператор assert. Не нужно запоминать разные его виды, как в Unittest.
  • Фикстуры. Позволяют создавать контекст сразу для группы тестов.
  • Метки. Можно настраивать поведение тестов: задавать условия запуска, передавать одному и тому же тесту разные входные данные и так далее.
  • Умеет запускать тесты других фреймворков. С Pytest совместимы Unittest, Doctest и Nose.
  • Множество плагинов. Если какой-то функции «из коробки» не хватает, для Pytest написано больше тысячи плагинов. Причём 180 из них обновлялись в 2023 году, ещё 360 — в 2022-м.

Тем не менее недостатки у фреймворка тоже есть:

  • Неявность и магия. Обратная сторона простоты и лаконичности есть: многие процессы происходят «под капотом». Чтобы разобраться в них детально, придётся штудировать документацию.
  • Не входит в стандартную библиотеку. Pytest нужно устанавливать отдельно. Если у вас старая (ниже 3.7) версия Python, то нужно будет подключать соответствующую версию фреймворка. Найти их список можно здесь.
  • Другие фреймворки несовместимы с Pytest. Неизбежное следствие лидерского, практически королевского, статуса: Pytest может запускать тесты других фреймворков, но ни один из других фреймворков не может запускать тесты Pytest. Вот такая современная трактовка древнеримской пословицы «Что позволено Юпитеру, не позволено быку».

Как установить Pytest

Pytest входит в большинство пакетов Python. Его последняя версия доступна для Python 3.7+ и PyPy 3. Чтобы установить его в свою виртуальную среду, используйте команду:

pip install -U pytest

Другой способ — пакетный менеджер вашей IDE. Найдите в нём модуль с названием pytest и загрузите его.

Как писать тесты

Для начала нужен код, который мы будем тестировать. Создадим файл main.py и функцию sum2. Она будет принимать на вход два аргумента и возвращать их сумму:

def sum2(x, y):
    return x + y

Теперь проверим, корректно ли она работает. Для этого создадим файл tests.py, импортируем в него sum2 и напишем test_sum2:

from main import sum2

def test_sum2():
    assert sum2(15, 8) == 23

Чтобы запустить тесты, введём в консоль команду pytest. Альтернативный вариант — использовать интерфейс вашей IDE. Например, PyCharm позволяет запустить файл целиком или тестовую функцию в отдельности.

Получаем вот такой результат:

tests.py::test_sum2 PASSED    [100%]

Теперь изменим наш тест: пусть он ожидает получить не 23, а 0:

def test_sum2():
    assert sum2(15, 8) == 0

Получим сообщение о том, что тест не пройден:

FAILED                                               [100%]
tests.py:3 (test_sum2)
23 != 0

Expected :0
Actual   :23
<Click to see difference>

def test_sum2():
>       assert sum2(15, 8) == 0
E       assert 23 == 0
E        +  where 23 = sum2(15, 8)

tests.py:5: AssertionError

Ограничения нейминга

Чтобы Pytest воспринимал функции тестовыми, файлы и сами тесты должны быть названы определённым образом:

  • название файла должно начинаться на test или заканчиваться на test.py;
  • название функции должно быть написано в нижнем регистре и начинаться с test_.

Как работает assert

Ключевому слову assert можно передать любое условие. Если оно правдиво (результат True) — тест пройден, если ложно (результат False) — не пройден.

Таким образом можно писать минимальные тесты:

def test_true():
    assert True

def test_false():
    assert False

Результат:

tests.py::test_true PASSED      [50%]
tests.py::test_false FAILED     [100%]
tests.py:9 (test_false)
def test_false():
>       assert False
E       assert False

tests.py:11: AssertionError

Через запятую после условия можно написать отладочное сообщение. Pytest выведет его, если тест провалится:

def test_message():
    assert False, 'Тест всегда провален'

Результат:

tests.py::test_message FAILED       [100%]
tests.py:0 (test_message)
def test_message():
>       assert False, 'Тест всегда провален'
E       AssertionError: Тест всегда провален
E       assert False

tests.py:2: AssertionError

Если в тесте нет assert, он считается пройденным:

def test_pass():
    pass  # оператор-заглушка, не делает ничего

Результат:

tests.py::test_pass PASSED       [100%]

Примечание

В одном тесте может быть сразу несколько операторов assert, но делать так мы не рекомендуем. Лучше руководствоваться правилом «Один тест — одна сущность, одна функция — один assert».

Запуск тестов

Команда терминала pytest запускает все тесты текущего каталога. Чтобы управлять условиями запуска, укажите после неё путь до файла или даже отдельной функции.

  • Команда для запуска файла tests.py: pytest tests.py.
  • Команда для запуска функции test_sum2 и только её: pytest tests.py: test_sum2.

Для более гибкого запуска можно дополнительно добавлять флаги. Их список есть в документации Pytest.

Помимо команд терминала можно использовать графический интерфейс вашей IDE. Описанные в этой статье тесты мы запускаем через инструменты PyCharm.

Фикстуры в Pytest

Фикстуры — это функции, которые создают окружение вокруг тестов. Они удобны, когда нужно передать одни и те же входные данные нескольким тестам.

Допустим, у нас есть несколько функций в main.py:

# прибавляет 2 к каждому элементу коллекции
def plus2(nums):
    result = []
    for num in nums:
        result.append(num + 2)
    return result

# умножает на 2 каждый элемент коллекции
def multiply2(nums):
    result = []
    for num in nums:
        result.append(num * 2)
    return result

# возводит в степень 2 каждый элемент коллекции
def exponent2(nums):
    result = []
    for num in nums:
        result.append(num ** 2)
    return result

Напишем для каждой из них по тесту в файле tests.py. В качестве тестового массива возьмём список простых чисел от 1 до 50. Создавать его будем с помощью цикла for-else:

from main import *

def test_plus2():
    prime_nums = []
    for num in range(1, 50):
        for div in range(2, num):
            if num % div == 0:
                break
        else:
            prime_nums.append(num)
    assert plus2(prime_nums) == [3, 4, 5, 7, 9, 13, 15, 19, 21, 25, 31, 33, 39, 43, 45, 49]

def test_multiply2():
    prime_nums = []
    for num in range(1, 50):
        for div in range(2, num):
            if num % div == 0:
                break
        else:
            prime_nums.append(num)
    assert multiply2(prime_nums) == [2, 4, 6, 10, 14, 22, 26, 34, 38, 46, 58, 62, 74, 82, 86, 94]

def test_exponent2():
    prime_nums = []
    for num in range(1, 50):
        for div in range(2, num):
            if num % div == 0:
                break
        else:
            prime_nums.append(num)
    assert exponent2(prime_nums) == [1, 4, 9, 25, 49, 121, 169, 289, 361, 529, 841, 961, 1369, 1681, 1849, 2209]

Пока во всех тестовых функциях мы используем одну и ту же громоздкую конструкцию, создающую список простых чисел. Вынесем её в отдельную фикстуру. Для этого явно импортируем модуль pytest:

import pytest

Чтобы объявить функцию фикстурой, используем перед ней декоратор @pytest.fixture():

@pytest.fixture()
def get_prime_nums():
    prime_nums = []
    for num in range(1, 50):
        for div in range(2, num):
            if num % div == 0:
                break
        else:
            prime_nums.append(num)
    return prime_nums

Теперь передадим эту фикстуру во все тесты, где она нужна. Обращаясь к фикстуре, у неё не нужно писать круглые скобки: как будто это не функция, а переменная.

Сами тесты в итоге получаются такие:

def test_plus2(get_prime_nums):
    prime_nums = get_prime_nums
    assert plus2(prime_nums) == [3, 4, 5, 7, 9, 13, 15, 19, 21, 25, 31, 33, 39, 43, 45, 49]

def test_multiply2(get_prime_nums):
    prime_nums = get_prime_nums
    assert multiply2(prime_nums) == [2, 4, 6, 10, 14, 22, 26, 34, 38, 46, 58, 62, 74, 82, 86, 94]

def test_exponent2(get_prime_nums):
    prime_nums = get_prime_nums
    assert exponent2(prime_nums) == [1, 4, 9, 25, 49, 121, 169, 289, 361, 529, 841, 961, 1369, 1681, 1849, 2209]

Финализатор

Если хотите, чтобы после запуска теста выполнялся ещё какой-то скрипт, это также можно сделать через фикстуры. Для этого вместо ключевого слова return используйте yield. Код, написанный после yield, и будет выполняться по завершении теста.

Изменим в нашем примере с простыми числами фикстуру get_prime_nums() и добавим в неё финализатор:

@pytest.fixture()
def get_prime_nums():
    print('\nРабота фикстуры')
    prime_nums = []
    for num in range(1, 50):
        for div in range(2, num):
            if num % div == 0:
                break
        else:
            prime_nums.append(num)
    yield prime_nums
    print('\nРабота финализатора')

При запуске тестов получаем такой результат:

tests.py::test_plus2 
Работа фикстуры
PASSED                                              [ 33%]
Работа финализатора

tests.py::test_multiply2 
Работа фикстуры
PASSED                                          [ 66%]
Работа финализатора

tests.py::test_exponent2 
Работа фикстуры
PASSED                                          [100%]
Работа финализатора

Если тест не был пройден (то есть assert получил False), код из финализатора всё равно выполняется.

Области действия фикстур

У фикстур можно настраивать область действия, в которой они существуют. По умолчанию она равна функции. Это значит, что, когда тестовая функция прекращает свою работу, фикстура финализируется и уничтожается. При следующем вызове фикстура создаётся заново. Это хорошо видно по прошлому примеру с финализатором.

Область действия фикстуры указывается в её декораторе аргументом scope='область действия'. Всего есть пять уровней:

  • 'function' — для функции;
  • 'class' — для класса;
  • 'module' — для модуля (то есть py-файла);
  • 'package' — для пакета;
  • 'session' — для всей сессии тестирования.

Изменим у фикстуры get_prime_nums область действия на module.

@pytest.fixture(scope='module')
def get_prime_nums():
    print('\nРабота фикстуры')
    prime_nums = []
    for num in range(1, 50):
        for div in range(2, num):
            if num % div == 0:
                break
        else:
            prime_nums.append(num)
    yield prime_nums
    print('\nРабота финализатора')

Посмотрим, как изменится работа тестов:

tests.py::test_plus2 
Работа фикстуры
PASSED         [ 33%]
tests.py::test_multiply2 PASSED        [ 66%]
tests.py::test_exponent2 PASSED       [100%]
Работа финализатора

В отличие от прошлого примера, фикстура здесь вызывается только один раз — в первой функции, которая её использует. Затем результат работы кэшируется. Финализатор тоже срабатывает только единожды, когда заканчивается файл.

Иерархии фикстур

Одному тесту можно передать сколько угодно фикстур, указывая их через запятую. Их можно передавать и другим фикстурам — тоже в любом количестве.

Например, фикстуру get_prime_nums можно разбить на несколько (хотя в нашем случае в этом нет практического смысла):

@pytest.fixture()
def get_min_num():
    return 1

@pytest.fixture()
def get_max_num():
    return 50

@pytest.fixture()
def get_prime_nums(get_min_num, get_max_num):
    prime_nums = []
    for num in range(get_min_num, get_max_num):
        for div in range(2, num):
            if num % div == 0:
                break
        else:
            prime_nums.append(num)
    return prime_nums

Вы всегда явно указываете, какие фикстуры используете в функции или другой фикстуре. Это позволяет удобно отслеживать зависимости данных и управлять ими.

Примечание

Фикстуры с более широкой областью действия нельзя встраивать с фикстуры меньшего уровня.

Автоиспользование фикстур

Иногда может быть полезным, чтобы фикстура запускалась всегда, даже если функция её не вызывает. Например, когда перед выполнением тестов нужно залогиниться в системе.

В таких случаях можно указать параметр autouse в декораторе: @pytest.fixture(autouse=True).

Будьте осторожны при использовании autouse. Такие фикстуры могут создавать неявные зависимости и менять данные непредсказуемым для вас путём. Особенно если их много и они находятся в сложной иерархии.

Метки тестов

Pytest позволяет настраивать запуск тестов, применяя к ним метки. Использовать их можно не только с тестовыми функциями, но и с целыми классами. Чтобы добавить метку, нужно написать декоратор: @pytest.mark.*название метки*.

В Pytest можно сделать так, чтобы запускались только помеченные тесты. Для этого используют команду терминала с аргументом -m: pytest -m *название метки*. Можно и наоборот: запустить все тесты, кроме помеченных. В таком случае команда выглядит так: pytest -m 'not название метки'.

У одного теста или класса может быть сколько угодно меток. Посмотреть их список можно командой pytest --markers и в документации. Мы расскажем об основных.

Пропуск теста

Чтобы пропустить тест, поставьте метку skip. В качестве аргумента ей можно передать необязательный параметр reason='причина пропуска'. Например:

@pytest.mark.skip(reason='Тестовый пропуск')
def test_skipped():
    pass

Результат:

SKIPPED (Тестовый пропуск)                        [100%]
Skipped: Тестовый пропуск

Пропуск теста при условии

Метка skipif получает два аргумента. Первый — это условие. Если оно выполняется (результат True) — тест пропускается, если нет (результат False) — тест выполняется как обычно. Во втором аргументе, как и в случае со skip, можно передать строку с причиной пропуска:

x = 1
@pytest.mark.skipif(x > 0, reason='Тестовый пропуск')
def test_skipped_if():
    pass

Результат тот же самый, что и в прошлом случае:

Ожидаемый провал теста

Тест под меткой xfail может выдать два результата. Если тест будет пройден, Pytest пометит его XPASS, если ожидаемо провален — XFAIL. Ни один из вариантов не вызовет провала общего набора тестов:

@pytest.mark.xfail(reason='Намеренный провал')
def test_xfailed():
    assert False

Результат:

XFAIL (Намеренный провал)                         [100%]
@pytest.mark.xfail(reason='Намеренный провал')
    def test_xfailed():
>       assert False
E       assert False

tests.py:49: AssertionError

У xfail есть несколько аргументов. Как в skipif, вы можете указать условие (и ожидать провал только при нём) и передать параметр reason. Дополнительно к этому xfail позволяет:

  • добавить исключение в raises=*название исключения*;
  • вообще не выполнять тест в run=False (тогда он автоматически будет засчитан как XFAIL);
  • сделать, чтобы провал теста вызывал провал всего тестового набора в strict=True.

Подробнее о возможностях xfail — в документации.

Параметризация

Метка parametrize позволяет вызывать один и тот же тест с разными входными данными. Это полезно, когда мы хотим проверить несколько случаев.

Например, у нас есть функция, которая пишет, положительное число или отрицательное:

def positive_or_negative(x):
    if x > 0:
        return 'positive'
    elif x < 0:
        return 'negative'
    else:
        return 'zero'

Сначала проверим, правильно ли она обрабатывает положительные числа: целые, дробные и очень маленькие. Без параметризации нам пришлось бы писать сразу три однотипных теста:

from main import positive_or_negative

def test_positive_or_negative_if_positive_int():
    assert positive_or_negative(165) == 'positive'

def test_positive_or_negative_if_positive_float():
    assert positive_or_negative(1.2) == 'positive'

def test_positive_or_negative_if_positive_small():
    assert positive_or_negative(0.0000001) == 'positive'

Результат:

tests.py::test_positive_or_negative_if_positive_int PASSED   [ 33%]
tests.py::test_positive_or_negative_if_positive_float PASSED [ 66%]
tests.py::test_positive_or_negative_if_positive_small PASSED [100%]

А потом нужно будет писать такие же три теста для отрицательных чисел и ещё один для нуля. Итого семь тестов для одной маленькой функции. Нерационально. Тут-то на помощь и приходит параметризация.

Метка parametrize получает два аргумента: название переменной и список её значений. Название переменной передаём тесту (точно так же, как фикстуру). Получается вот так:

import pytest
from main import positive_or_negative

@pytest.mark.parametrize('x', [165, 1.2, 0.0000001])
def test_positive_or_negative_if_positive(x):
    assert positive_or_negative(x) == 'positive'

Результат:

tests.py::test_positive_or_negative_if_positive[165] 
tests.py::test_positive_or_negative_if_positive[1.2] 
tests.py::test_positive_or_negative_if_positive[1e-07] 

PASSED            [33%]
PASSED            [66%]
PASSED            [100%]

Можно в одной строке через запятую передать сразу несколько переменных. Тогда каждый элемент списка задаётся кортежем, в котором мы по очереди перечисляем значения этих переменных:

import pytest
from main import positive_or_negative

@pytest.mark.parametrize('x, expected_result',
                         [(165, 'positive'),
                          (1.2, 'positive'),
                          (0.0000001, 'positive'),
                          (-165, 'negative'),
                          (-1.2, 'negative'),
                          (-0.0000001, 'negative'),
                          (0, 'zero')])
def test_positive_or_negative(x, expected_result):
    assert positive_or_negative(x) == expected_result

Результат:

tests.py::test_positive_or_negative[165-positive] 
tests.py::test_positive_or_negative[1.2-positive] 
tests.py::test_positive_or_negative[1e-07-positive] 
tests.py::test_positive_or_negative[-165-negative] 
tests.py::test_positive_or_negative[-1.2-negative] 
tests.py::test_positive_or_negative[-1e-07-negative] 
tests.py::test_positive_or_negative[0-zero] 

PASSED               [14%]
PASSED               [28%]
PASSED               [42%]
PASSED               [57%]
PASSED               [71%]
PASSED               [85%]
PASSED               [100%]

Если одному тесту передать сразу несколько меток parametrize, он запустит все их возможные комбинации. Пример для наглядности:

import pytest

@pytest.mark.parametrize('x', [1, 0, -1])
@pytest.mark.parametrize('y', [1, 0, -1])
def test_coordinate_zone(x, y):
    print(x, y)

Результат:

tests.py::test_coordinate_zone[1-1] 
tests.py::test_coordinate_zone[1-0] 
tests.py::test_coordinate_zone[1--1] 
tests.py::test_coordinate_zone[0-1] 
tests.py::test_coordinate_zone[0-0] 
tests.py::test_coordinate_zone[0--1] 
tests.py::test_coordinate_zone[-1-1] 
tests.py::test_coordinate_zone[-1-0] 
tests.py::test_coordinate_zone[-1--1]

PASSED                                [11%]1 1
PASSED                                [22%]0 1
PASSED                               [33%]-1 1
PASSED                                [44%]1 0
PASSED                                [55%]0 0
PASSED                               [66%]-1 0
PASSED                               [77%]1 -1
PASSED                               [88%]0 -1
PASSED                             [100%]-1 -1

Пользовательские метки

Помимо встроенных меток вы можете использовать свои собственные. Это удобно, когда нужно разбить все тесты по нескольким группам и запускать отдельно друг от друга.

Чтобы создать собственную метку, просто используйте её имя:

import pytest

@pytest.mark.my_mark
def test_1():
    pass

def test_2():
    pass

@pytest.mark.my_mark
def test_3():
    pass

@pytest.mark.my_mark
def test_4():
    pass

def test_5():
    pass

Используем команду pytest --no-summary -m my_mark tests.py, чтобы запустить только тесты с меткой my_mark. Получим такой результат:

collected 5 items / 2 deselected / 3 selected  

tests.py ..  [100%]
===== 3 passed, 2 deselected, 3 warnings in 0.02s =====

Как видим, из пяти файлов было запущено только три — на которых стояла нужная метка.

Также мы получили три предупреждения. Это Pytest обращает наше внимание на то, что метка my_mark не зарегистрирована в файле pytest.ini. Он уточняет: вы точно воспользовались собственной меткой, а не опечатались при написании встроенной?

О том, как зарегистрировать собственную метку, тоже можно прочитать в документации.

Резюмируем

Pytest — самый популярный среди питонистов фреймворк для тестирования. Он позволяет писать меньше однотипного кода, чем встроенный Unittest, и может работать без тестовых классов. Вот его базовые инструменты:

  • Ключевое слово assert отвечает за результат тестирования. Если заданное после него условие правдиво — тест пройден, если оно ложно — провален.
  • Фикстуры — дополнительные функции, в которых можно задавать окружение тестов. Они могут использовать другие фикстуры, создавая целые иерархии.
  • Метки — декораторы, которые позволяют корректировать поведение тестов: пропускать их, ожидать определённых результатов, передавать разные входные данные и так далее. Можно создавать свои пользовательские метки.

Больше интересного про код — в нашем телеграм-канале. Подписывайтесь!

Изучайте IT на практике — бесплатно

Курсы за 2990 0 р.

Я не знаю, с чего начать
Освойте топовые нейросети за три дня. Бесплатно
Знакомимся с ChatGPT-4, DALLE-3, Midjourney, Stable Diffusion, Gen-2 и нейросетями для создания музыки. Практика в реальном времени. Подробности — по клику.
Узнать больше
Понравилась статья?
Да

Пользуясь нашим сайтом, вы соглашаетесь с тем, что мы используем cookies 🍪

Ссылка скопирована