0 added
0 removed
Original
2026-01-01
Modified
2026-03-10
1
<h2>Введение</h2>
1
<h2>Введение</h2>
2
<p>Написание модульных тестов - это тоже программирование со своими антипаттернами. Для части из них нельзя выдать готовых обходных путей. Как и для большинства архитектурных проблем, решение исходит из основ системы, в данном случае тестируемой. Но для трех антипаттернов в пакетах модульного тестирования Python уже готовы решения. О них и поговорим.</p>
2
<p>Написание модульных тестов - это тоже программирование со своими антипаттернами. Для части из них нельзя выдать готовых обходных путей. Как и для большинства архитектурных проблем, решение исходит из основ системы, в данном случае тестируемой. Но для трех антипаттернов в пакетах модульного тестирования Python уже готовы решения. О них и поговорим.</p>
3
<h2>Только позитивное тестирование</h2>
3
<h2>Только позитивное тестирование</h2>
4
<p>Сервис или библиотека вряд ли могут работать успешно при любых аргументах. Если никогда не происходит исключений, и всегда возвращается корректный ответ, например, о том, что обработка невозможна, то такой сценарий можно проверить штатным механизмом:</p>
4
<p>Сервис или библиотека вряд ли могут работать успешно при любых аргументах. Если никогда не происходит исключений, и всегда возвращается корректный ответ, например, о том, что обработка невозможна, то такой сценарий можно проверить штатным механизмом:</p>
5
<h3>doctest positive</h3>
5
<h3>doctest positive</h3>
6
def inc(x): """ >>> inc(3) # Инкремент 3 вернет 4 4 """ return x + 1 import doctest doctest.testmod()<h3>pytest positive</h3>
6
def inc(x): """ >>> inc(3) # Инкремент 3 вернет 4 4 """ return x + 1 import doctest doctest.testmod()<h3>pytest positive</h3>
7
def inc(x): return x + 1 def test_inc_three(): " Инкремент 3 вернет 4 " assert inc(3) == 4<h3>unittest positive</h3>
7
def inc(x): return x + 1 def test_inc_three(): " Инкремент 3 вернет 4 " assert inc(3) == 4<h3>unittest positive</h3>
8
def inc(x): return x + 1 import unittest class TestInc(unittest.TestCase): def test_three(self): " Инкремент 3 вернет 4 " self.assertEqual(inc(3), 4)<p>Возможные исключения тоже часть интерфейса и требует как тестирования, так и документации. Сделать это не сильно сложнее:</p>
8
def inc(x): return x + 1 import unittest class TestInc(unittest.TestCase): def test_three(self): " Инкремент 3 вернет 4 " self.assertEqual(inc(3), 4)<p>Возможные исключения тоже часть интерфейса и требует как тестирования, так и документации. Сделать это не сильно сложнее:</p>
9
<h3>doctest negative</h3>
9
<h3>doctest negative</h3>
10
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)<h3>pytest negative</h3>
10
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)<h3>pytest negative</h3>
11
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", )<h3>unittest negative</h3>
11
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", )<h3>unittest negative</h3>
12
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", ) )<h2>Медленные тесты</h2>
12
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", ) )<h2>Медленные тесты</h2>
13
<p>Полный набор модульных тестов бывает слишком медленным для запуска в процессе разработки отдельной фичи, которая затрагивает один-два теста. По окончанию работ полный запуск потребуется в финале, но в процессе полезно пропустить ту часть тестов, которую не планируется затрагивать.</p>
13
<p>Полный набор модульных тестов бывает слишком медленным для запуска в процессе разработки отдельной фичи, которая затрагивает один-два теста. По окончанию работ полный запуск потребуется в финале, но в процессе полезно пропустить ту часть тестов, которую не планируется затрагивать.</p>
14
<h3>doctest skip</h3>
14
<h3>doctest skip</h3>
15
import math def sqrt(x): """ следующая строка не будет выполнена в тестах >>> sqrt(-1) # doctest: +SKIP """ ... # код опущен<h3>pytest skip</h3>
15
import math def sqrt(x): """ следующая строка не будет выполнена в тестах >>> sqrt(-1) # doctest: +SKIP """ ... # код опущен<h3>pytest skip</h3>
16
import pytest def sqrt(x): ... # код опущен @pytest.mark.skip("Пока пропустим чтобы не тормозило") def test_sqrt(): ... # код опущен<h3>unittest skip</h3>
16
import pytest def sqrt(x): ... # код опущен @pytest.mark.skip("Пока пропустим чтобы не тормозило") def test_sqrt(): ... # код опущен<h3>unittest skip</h3>
17
import unittest def sqrt(x): ... # код опущен class TestSqrt(unittest.TestCase): @unittest.skip("Пока пропустим чтобы не тормозило") def test_sqrt(self): ... # код опущен<h3>envSkipIf</h3>
17
import unittest def sqrt(x): ... # код опущен class TestSqrt(unittest.TestCase): @unittest.skip("Пока пропустим чтобы не тормозило") def test_sqrt(self): ... # код опущен<h3>envSkipIf</h3>
18
<p>Конечно, хотелось бы, чтобы пропуском тестов можно было управлять, не меняя код. Примеры кода в doctest не получится оставить читаемыми при такой доработке. А вот у unittest и pytest есть декораторы skipIf и mark.skipif соответственно, которые пропускают кейс, если первым аргументом передан True. Их можно обернуть проверкой переменных окружения. Тогда управлять полнотой тестов можно будет не исправляя код, а просто устанавливая нужные переменные окружения перед запуском.</p>
18
<p>Конечно, хотелось бы, чтобы пропуском тестов можно было управлять, не меняя код. Примеры кода в doctest не получится оставить читаемыми при такой доработке. А вот у unittest и pytest есть декораторы skipIf и mark.skipif соответственно, которые пропускают кейс, если первым аргументом передан True. Их можно обернуть проверкой переменных окружения. Тогда управлять полнотой тестов можно будет не исправляя код, а просто устанавливая нужные переменные окружения перед запуском.</p>
19
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(): ... # код опущен<h2>Безымянные утверждения</h2>
19
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(): ... # код опущен<h2>Безымянные утверждения</h2>
20
<p>Даже при удачном нейминге тестовых сценариев смысл отдельных утверждений (assert) ускользает от понимания при разборе лога упавших тестов. В таких случаях приходится вникать в код тестов. Также, если тестовые данные лежат в списке и перебираются циклом, утверждения, оторванные от данных, могут терять смысл. Решить такую проблему можно не одним способом. В pytest и для тестовых данных, и для идентификации сценариев применяются фикстуры, в unittest - субтесты. Но иногда добавить строчку, аннотирующее утверждение, будет быстрее.</p>
20
<p>Даже при удачном нейминге тестовых сценариев смысл отдельных утверждений (assert) ускользает от понимания при разборе лога упавших тестов. В таких случаях приходится вникать в код тестов. Также, если тестовые данные лежат в списке и перебираются циклом, утверждения, оторванные от данных, могут терять смысл. Решить такую проблему можно не одним способом. В pytest и для тестовых данных, и для идентификации сценариев применяются фикстуры, в unittest - субтесты. Но иногда добавить строчку, аннотирующее утверждение, будет быстрее.</p>
21
<h3>doctest msg</h3>
21
<h3>doctest msg</h3>
22
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<h3>pytest msg</h3>
22
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<h3>pytest msg</h3>
23
def test_inc(): for arg, etalon in ( (-1, 0), (0, 1), (3, 4), ): result = inc(arg) assert result == etalon, f'Инкремент {arg} должен быть равен {etalon}, а не {result}'<h3>unittest msg</h3>
23
def test_inc(): for arg, etalon in ( (-1, 0), (0, 1), (3, 4), ): result = inc(arg) assert result == etalon, f'Инкремент {arg} должен быть равен {etalon}, а не {result}'<h3>unittest msg</h3>
24
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}' )<h2>Заключение</h2>
24
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}' )<h2>Заключение</h2>
25
<p>Эти решения трех проблем, которые не так заметны при написании тестов. Стоит применить сразу, как столкнетесь с ними при разборе упавших тестов. Другие проблемы, встречающиеся в юнит-тестах, можно найти<a>тут</a>.</p>
25
<p>Эти решения трех проблем, которые не так заметны при написании тестов. Стоит применить сразу, как столкнетесь с ними при разборе упавших тестов. Другие проблемы, встречающиеся в юнит-тестах, можно найти<a>тут</a>.</p>
26
26