Антипаттерны юнит-тестирования
2026-03-10 23:48 Diff

Введение

Написание модульных тестов — это тоже программирование со своими антипаттернами. Для части из них нельзя выдать готовых обходных путей. Как и для большинства архитектурных проблем, решение исходит из основ системы, в данном случае тестируемой. Но для трех антипаттернов в пакетах модульного тестирования 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}' )

Заключение

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