HTML Diff
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): """ &gt;&gt;&gt; inc(3) # Инкремент 3 вернет 4 4 """ return x + 1 import doctest doctest.testmod()<h3>pytest positive</h3>
6 def inc(x): """ &gt;&gt;&gt; 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): """ &gt;&gt;&gt; sqrt(-1) Traceback (most recent call last): ... ValueError: Расчет квадратного корня возможен для чисел &gt; 0 """ if x &lt; 0: raise ValueError("Расчет квадратного корня возможен для чисел &gt; 0") return math.sqrt(x) import doctest # Флаг ELIPSIS Позволяет писать ... вместо изменчивого стека исключения doctest.testmod(optionflags=doctest.ELLIPSIS)<h3>pytest negative</h3>
10 import math def sqrt(x): """ &gt;&gt;&gt; sqrt(-1) Traceback (most recent call last): ... ValueError: Расчет квадратного корня возможен для чисел &gt; 0 """ if x &lt; 0: raise ValueError("Расчет квадратного корня возможен для чисел &gt; 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 = ("Расчет квадратного корня возможен для чисел &gt; 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 = ("Расчет квадратного корня возможен для чисел &gt; 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, ("Расчет квадратного корня возможен для чисел &gt; 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, ("Расчет квадратного корня возможен для чисел &gt; 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): """ следующая строка не будет выполнена в тестах &gt;&gt;&gt; sqrt(-1) # doctest: +SKIP """ ... # код опущен<h3>pytest skip</h3>
15 import math def sqrt(x): """ следующая строка не будет выполнена в тестах &gt;&gt;&gt; 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): """ следующая строка не будет выполнена в тестах &gt;&gt;&gt; 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): """ следующая строка не будет выполнена в тестах &gt;&gt;&gt; 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