Антипаттерны юнит-тестирования | OTUS
🔥 Начинаем BLACK FRIDAY!
Максимальная скидка -25% на всё. Успейте начать обучение по самой выгодной цене.
Выбрать курс

Курсы

Программирование
iOS Developer. Basic
-25%
Python Developer. Professional
-25%
Разработчик на Spring Framework
-25%
Golang Developer. Professional
-25%
Python Developer. Basic
-25%
iOS Developer. Professional
-25%
Highload Architect
-25%
JavaScript Developer. Basic
-25%
Kotlin Backend Developer
-25%
JavaScript Developer. Professional
-25%
Android Developer. Basic
-25%
Unity Game Developer. Basic
-25%
Разработчик C#
-25%
Программист С Web-разработчик на Python Алгоритмы и структуры данных Framework Laravel PostgreSQL Reverse-Engineering. Professional CI/CD Vue.js разработчик VOIP инженер Программист 1С Flutter Mobile Developer Супер - интенсив по Kubernetes Symfony Framework Advanced Fullstack JavaScript developer Супер-интенсив "Azure для разработчиков"
Инфраструктура
Мониторинг и логирование: Zabbix, Prometheus, ELK
-25%
DevOps практики и инструменты
-25%
Архитектор сетей
-25%
Инфраструктурная платформа на основе Kubernetes
-25%
Супер-интенсив «ELK»
-16%
Супер-интенсив «IaC Ansible»
-16%
Супер-интенсив "SQL для анализа данных"
-16%
Базы данных Сетевой инженер AWS для разработчиков Cloud Solution Architecture Разработчик голосовых ассистентов и чат-ботов Внедрение и работа в DevSecOps Администратор Linux. Виртуализация и кластеризация Нереляционные базы данных Супер-практикум по использованию и настройке GIT IoT-разработчик Супер-интенсив «СУБД в высоконагруженных системах»
Специализации Курсы в разработке Подготовительные курсы
+7 499 938-92-02

Антипаттерны юнит-тестирования

Введение

Написание модульных тестов — это тоже программирование со своими антипаттернами. Для части из них нельзя выдать готовых обходных путей. Как и для большинства архитектурных проблем, решение исходит из основ системы, в данном случае тестируемой. Но для трех антипаттернов в пакетах модульного тестирования Python уже готовы решения. О них и поговорим.

Только позитивное тестирование

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

doctest positive

def inc(x):
    """
    >>> inc(3) # Инкремент 3 вернет 4
    4
    """
    return x + 1

import doctest
doctest.testmod()

pytest positive

def inc(x):
    return x + 1

def test_inc_three():
    " Инкремент 3 вернет 4 "
    assert inc(3) == 4

unittest positive

def inc(x):
    return x + 1

import unittest

class TestInc(unittest.TestCase):
    def test_three(self):
        " Инкремент 3 вернет 4 "
        self.assertEqual(inc(3), 4)

Возможные исключения тоже часть интерфейса и требует как тестирования, так и документации. Сделать это не сильно сложнее:

doctest negative

import math
def sqrt(x):
    """
    >>> sqrt(-1)
    Traceback (most recent call last):
        ...
    ValueError: Расчет квадратного корня возможен для чисел > 0
    """
    if x < 0:
        raise ValueError("Расчет квадратного корня возможен для чисел > 0")
    return math.sqrt(x)

import doctest

# Флаг ELIPSIS Позволяет писать ... вместо изменчивого стека исключения
doctest.testmod(optionflags=doctest.ELLIPSIS)

pytest negative

import pytest
def sqrt(x):
    ...  # код опущен

def test_sqrt_from_negative():
    " Исключение вместо квадратного корня из -1 "
    with pytest.raises(ValueError) as exc_info:
        sqrt(-1)
    # Pytest кладет объект исключения в поле value
    assert exc_info.value.args = ("Расчет квадратного корня возможен для чисел > 0", )

unittest negative

import unittest

def sqrt(x):
    ...  # код опущен

class TestSqrt(unittest.TestCase):
    def test_sqrt_from_negative(self):
        " Исключение вместо квадратного корня из -1 "
        with self.assertRaises(ValueError) as exc_info:
            sqrt(-1)
        # unittest кладет объект исключения в поле exception
        self.assertEqual(
            exc_info.exception.args,
            ("Расчет квадратного корня возможен для чисел > 0", )
        )

Медленные тесты

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

doctest skip

import math
def sqrt(x):
    """
    следующая строка не будет выполнена в тестах
    >>> sqrt(-1) # doctest: +SKIP
    """
    ... # код опущен

pytest skip

import pytest
def sqrt(x):
    ...  # код опущен

@pytest.mark.skip("Пока пропустим чтобы не тормозило")
def test_sqrt():
    ... # код опущен

unittest skip

import unittest

def sqrt(x):
    ...  # код опущен

class TestSqrt(unittest.TestCase):
    @unittest.skip("Пока пропустим чтобы не тормозило")
    def test_sqrt(self):
        ... # код опущен

envSkipIf

Конечно, хотелось бы, чтобы пропуском тестов можно было управлять, не меняя код. Примеры кода в doctest не получится оставить читаемыми при такой доработке. А вот у unittest и pytest есть декораторы skipIf и mark.skipif соответственно, которые пропускают кейс, если первым аргументом передан True. Их можно обернуть проверкой переменных окружения. Тогда управлять полнотой тестов можно будет не исправляя код, а просто устанавливая нужные переменные окружения перед запуском.

from os import environ

def skip_by_env(conditional_skipper):
    "Декоратор для пропуска тестов"
    def skip_wrapper(test_case):
        """
        Вызывает conditional_skipper c переданной функцией и признаком пропуска.
        """
        env_name = 'SKIP_CASE_' + func.__qualname__
        return conditional_skipper(
            env_name in environ
            f"Пропущен, так как обнаружена переменная окружения {env_name}"
        )(test_case)
    return skip_wrapper

@skip_by_env(pytest.mark.skipif) # или unittest.skipIf
def test_sqrt():
    ... # код опущен

Безымянные утверждения

Даже при удачном нейминге тестовых сценариев смысл отдельных утверждений (assert) ускользает от понимания при разборе лога упавших тестов. В таких случаях приходится вникать в код тестов. Также, если тестовые данные лежат в списке и перебираются циклом, утверждения, оторванные от данных, могут терять смысл. Решить такую проблему можно не одним способом. В pytest и для тестовых данных, и для идентификации сценариев применяются фикстуры, в unittest — субтесты. Но иногда добавить строчку, аннотирующее утверждение, будет быстрее.

doctest msg

def inc(x):
    """
    следующая строка не будет выполнена в тестах
    >>> for arg, etalon in (
    ...        (-1, 0),
    ...        (0, 1),
    ...        (3, 4),
    ...     ):
    ...     result = inc(arg)
    ...     assert etalon == result, f'Инкремент {arg} должен быть равен {etalon}, а не {result}'
    """
    return x + 1

pytest msg

def test_inc():
    for arg, etalon in (
            (-1, 0),
            (0, 1),
            (3, 4),
        ):
        result = inc(arg)
        assert result == etalon, f'Инкремент {arg} должен быть равен {etalon}, а не {result}'

unittest msg

import unittest

class TestInc(unittest.TestCase):
    def test_inc(self):
        for arg, etalon in (
                    (-1, 0),
                    (0, 1),
                    (3, 4),
                ):
            result = inc(arg)
            self.assertEqual(
                result,
                etalon,
                f'Инкремент {arg} должен быть равен {etalon}, а не {result}'
            )

Заключение

Эти решения трех проблем, которые не так заметны при написании тестов. Стоит применить сразу, как столкнетесь с ними при разборе упавших тестов. Другие проблемы, встречающиеся в юнит-тестах, можно найти тут.

Не пропустите новые полезные статьи!

Спасибо за подписку!

Мы отправили вам письмо для подтверждения вашего email.
С уважением, OTUS!

Автор
0 комментариев
Для комментирования необходимо авторизоваться
🎁 Максимальная скидка!
Черная пятница уже в OTUS! Скидка -25% на всё!