Обнаружение неверных методов утверждения
Во время одного из недавних обзоров кода я наткнулся на проблему, которую не сразу заметили - вместо assertEqual()
использовался assertTrue()
, что в основном привело к тесту, который ничего не тестировал. Вот упрощенный пример:
from unittest import TestCase
class MyTestCase(TestCase):
def test_two_things_equal(self):
self.assertTrue("a", "b")
Проблема в том, что тест пройдет; и технически код действителен, так как assertTrue
имеет этот необязательный аргумент msg
(который получает значение "b"
в этом случае).
Можем ли мы лучше, чем полагаться на человека, просматривающего код, чтобы выявить такие проблемы? Есть ли способ автоматически обнаружить его, используя статический анализ кода с помощью flake8
или pylint
?
Ответы
Ответ 1
Несколько лет назад я придумал общий подход/методологию для обеспечения качества тестов. Спецификация теста может быть сведена к двум статьям:
- Он должен пройти для правильной реализации тестируемой функции и
- Он должен терпеть неудачу для неправильной/сломанной реализации тестируемой функции
Насколько я знаю, в то время как требование 1. регулярно выполняется, мало внимания уделяется требованию 2.
Как правило
- создан тестовый набор,
- код запускается против него,
- любые сбои (из-за ошибок либо в коде, либо в тестах) фиксированы.
- и мы приходим к ситуации, когда считаем, что наш код и тесты хороши.
Реальная ситуация может заключаться в том, что (некоторые из) тестов содержат ошибки, которые (будут) мешать им перехватывать ошибки в коде. Таким образом, рассмотрение тестов не должно предполагать много спокойствия для человека, заботящегося о качестве системы, пока они не убедятся в том, что тесты действительно способны выявлять проблемы, которые они были разработаны против 1. И простой способ сделать это - фактически ввести такие проблемы и проверить, что они не остаются незамеченными тестами!
В TDD (разработка, основанная на тестах) эта идея выполняется лишь частично - рекомендуется добавить тест перед кодом, увидеть его сбой (он должен, поскольку кода еще нет), а затем исправить его, написав код. Но сбой теста из-за недостающего кода автоматически не означает, что он также будет терпеть неудачу в случае багги-кода (это, похоже, верно для вашего случая)!
Таким образом, качество тестового набора можно измерить как процент ошибок, которые он мог бы обнаружить. Любая разумная ошибка 2 которая ускользает от тестового набора, предлагает новый тестовый пример, охватывающий этот сценарий (или, если тестовый набор должен поймать эту ошибку, ошибка в наборе тестов обнаружена). Это также означает, что каждый тест пакета должен улавливать хотя бы одну ошибку (в противном случае этот тест совершенно бессмыслен).
Я думал о внедрении программной системы, которая облегчает принятие этой методологии (т.е. позволяет вводить и поддерживать искусственные ошибки в базе кода и проверяет, как тесты отвечают на них). Этот вопрос стал триггером, с которым я сейчас начну работать. Надеюсь положить что-то вместе в течение недели. Оставайтесь с нами!
ИЗМЕНИТЬ
Версия прототипа инструмента теперь доступна в https://bitbucket.org/leon_manukyan/trit. Я рекомендую клонировать репозиторий и запускать демонстрационный поток.
1 Более обобщенная версия этого утверждения верна для более широкого диапазона систем/ситуаций (обычно это связано с безопасностью/безопасностью):
Система, разработанная для определенных событий, должна регулярно тестироваться на такие события, в противном случае она подвержена деградации до полной неспособности реагировать на события, представляющие интерес.
Просто пример - у вас есть система пожарной сигнализации дома? Когда вы стали свидетелями этого в последний раз? Что, если он тоже молчит во время огня? Пойдите, сделайте некоторый дым в комнате прямо сейчас!
2 В рамках этой методологии, такой как ошибка (например, когда функция неверно работает, только если переданный URL равен https://www.formatmyharddrive.com/?confirm=yesofcourse
), не является разумным
Ответ 2
Python теперь имеет систему подсказок типа, которая выполняет статический анализ кода. Используя эту систему, вы можете потребовать, чтобы первый аргумент функции типа assertTrue
всегда был логическим. Проблема в том, что assertTrue
не определяется вами, а пакетом unittest. К сожалению, пакет unittest не добавлял подсказки типа. Там есть достаточно простой способ: просто определите свою собственную оболочку.
from unittest import TestCase
class TestCaseWrapper(TestCase):
def assertTrue(self, expr: bool, msg=None): #The ": bool" requires that the expr parameter is boolean.
TestCase.assertTrue(self, expr, msg)
class MyTestCase(TestCaseWrapper):
def test_two_things_equal(self):
self.assertTrue("a", "b") #Would give a warning about the type of "a".
Затем вы можете запустить проверку типов следующим образом:
python -m mypy my_test_case.py
Это должно затем дать вам предупреждение о том, что "a" - это строка, а не логическая. Самое приятное в том, что его можно запускать автоматически в автоматизированной тестовой среде. Кроме того, PyCharm проверит типы в вашем коде, если вы предоставите их, и выделите что-то неправильное.
Ответ 3
Быстрое решение - предоставить Mixin, который проверяет правильность:
import unittest
class Mixin(object):
def assertTrue(self, *args, **kwargs):
if len(args) > 1:
# TypeError is just an example, it could also do some warning/logging
# stuff in here.
raise TypeError('msg should be given as keyword parameter.')
super().assertTrue(*args, **kwargs)
class TestMixin(Mixin, unittest.TestCase): # Mixin before other parent classes
def test_two_things_equal(self):
self.assertTrue("a", "b")
Mixin также может проверить, является ли переданное выражение логическим:
class Mixin(object):
def assertTrue(self, *args, **kwargs):
if type(args[0]) is bool:
raise TypeError('expression should be a boolean')
if len(args) > 1:
raise TypeError('msg should be given as keyword parameter.')
super().assertTrue(*args, **kwargs)
Однако это не является статичным и требует ручного изменения ваших тестовых классов (добавления Mixin) и запуска тестов. Кроме того, это вызовет много ложных срабатываний, поскольку передача сообщения в качестве ключевого слова не является обычным (по крайней мере, не там, где я его видел), и во многих случаях вы хотите проверить неявную правдивость выражения вместо явного bool
. Как проверить не-пустоту if a
, когда a
- это list
, dict
и т.д.
Вы также можете использовать код setUp
, teardown
, который изменяет метод assertTrue
для конкретного класса:
import unittest
def decorator(func):
def wrapper(*args, **kwargs):
if len(args) > 1:
raise TypeError()
return func(*args, **kwargs)
return wrapper
class TestMixin(unittest.TestCase):
def setUp(self):
self._old = self.assertTrue
self.assertTrue = decorator(self.assertTrue)
def tearDown(self):
self.assertTrue = self._old
def test_two_things_equal(self):
self.assertTrue("a", "b")
Но предостережение перед тем, как применить любой из этих подходов: всегда будьте осторожны, прежде чем изменять существующие тесты. К сожалению, тесты иногда плохо документируются, поэтому не всегда очевидно, что они тестируют и как они тестируют. Когда-то тест не имеет смысла, и он безопасен для его изменения, но иногда он испытывает определенную функцию странным образом, и когда вы меняете его, вы меняете то, что тестируется. Поэтому, по крайней мере, убедитесь, что при изменении тестового примера нет изменений покрытия. При необходимости убедитесь, что вы прояснили цель теста, обновив имя метода, документацию по методу или комментарии в строке.
Ответ 4
Одним из решений этой проблемы является использование тестирование мутаций. Эта идея состоит в том, чтобы автоматически генерировать" мутанты" вашего кода, внося небольшие изменения в него. Затем ваш тестовый пакет запускается против этих мутантов, и если он хорош, большинство из них должно быть убито, что означает, что ваш тестовый пакет обнаруживает мутацию, и тесты не работают.
Тестирование на мутацию фактически оценивает качество ваших тестов. В вашем примере никакие мутанты не будут убиты, и вы легко обнаружите, что что-то не так с тестом.
В python существует несколько фреймворков мутаций: