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