Как подойти к юнит-тестированию и TDD (используя Python + нос)
Я пытался освоить TDD и модульное тестирование (в Python, используя нос), и есть несколько базовых концепций, на которых я застрял. Я много читал на эту тему, но, кажется, ничто не решает мои проблемы - возможно, потому что они настолько просты, что предполагается, что их поняли.
Идея TDD состоит в том, что модульные тесты пишутся перед кодом, который они тестируют. Модульное тестирование должно проверять небольшие части кода (например, функции), которые для целей теста являются автономными и изолированными. Однако мне кажется, что это сильно зависит от реализации. Во время реализации или во время более позднего исправления может возникнуть необходимость абстрагировать часть кода в новую функцию. Должен ли я пройти все мои тесты и смоделировать эту функцию, чтобы сохранить их изолированность? Конечно, при этом существует опасность появления новых ошибок в тестах, и тесты больше не будут тестировать точно такую же ситуацию?
Из моего ограниченного опыта в написании модульных тестов оказалось, что полная изоляция функции иногда приводит к тому, что тест оказывается длиннее и сложнее, чем код, который он тестирует. Поэтому, если тест не пройден, все, что он говорит вам, это то, что в коде или в тесте есть ошибка, но не очевидно, какая именно. Отсутствие изоляции может означать гораздо более короткий и легкий для чтения тест, но тогда это не модульный тест...
Часто, будучи изолированными, модульные тесты просто повторяют функцию. Например, если есть простая функция, которая добавляет два числа, то тест, вероятно, будет выглядеть примерно так:
assert add(a, b) == a + b
, Поскольку реализация простоreturn a + b
какой смысл в тесте? Гораздо более полезным тестом было бы посмотреть, как эта функция работает в системе, но это противоречит модульному тестированию, потому что оно больше не изолировано.Мой вывод заключается в том, что модульные тесты хороши в некоторых ситуациях, но не везде, и что системные тесты, как правило, более полезны. Подход, который это подразумевает, состоит в том, чтобы сначала написать системные тесты, а затем, в случае их неудачи, изолировать части системы в модульные тесты, чтобы точно определить отказ. Проблема с этим, очевидно, состоит в том, что это не так легко проверить угловые случаи. Это также означает, что разработка не полностью ориентирована на тестирование, поскольку модульные тесты пишутся только по мере необходимости.
Итак, мои основные вопросы:
- Должны ли модульные тесты использоваться повсеместно, как бы мало и просто ни была функция
- Как справиться с изменением реализаций? Т.е. реализация тестов также должна постоянно меняться, и не снижает ли это их полезность?
- Что нужно сделать, когда тест становится более сложным, чем тестируемый код?
- Всегда ли лучше начинать с модульных тестов, или лучше начинать с системных тестов, которые в начале разработки гораздо проще написать?
2 ответа
Что касается вашего заключения в первую очередь: и модульные тесты, и системные тесты (интеграционные тесты) оба имеют свое применение и, на мой взгляд, не менее полезны. Во время разработки мне легче начинать с модульных тестов, но для тестирования унаследованного кода я нахожу ваш подход, при котором вам проще начать интеграционные тесты. Я не думаю, что есть правильный или неправильный способ сделать это, цель состоит в том, чтобы создать безопасный набор, который позволит вам писать надежный и хорошо протестированный код, а не сам метод.
- Я считаю полезным думать о каждой функции как о API в этом контексте. Модульное тестирование - это тестирование API, а не реализации. Если реализация изменится, тест должен остаться прежним, это сеть безопасности, которая позволяет вам с уверенностью рефакторинг вашего кода. Даже если рефакторинг означает переход части реализации к новой функции, я скажу, что можно оставить тест таким, какой он есть, без озвучивания или насмешки над частью, которая была подвергнута рефакторингу. Возможно, вам понадобится новый набор тестов для новой функции.
- Модульные тесты не являются святым Граалем! Тестовый код должен быть довольно простым, на мой взгляд, и не должно быть особых причин для сбоя самого тестового кода. Если тест становится более сложным, чем тестируемая функция, это, вероятно, означает, что вам необходимо изменить рефакторинг кода по-другому. Пример из моего прошлого: у меня был некоторый код, который брал некоторый ввод и выводил некоторый вывод, сохраненный как XML. Синтаксический анализ XML для проверки правильности вывода привел к большим сложностям в моих тестах. Однако, понимая, что XML-представление не имело значения, я смог провести рефакторинг кода, чтобы можно было проверить вывод, не вмешиваясь в детали XML.
- Некоторые функции настолько тривиальны, что отдельный тест для них не добавляет никакой ценности. В вашем примере вы на самом деле не тестируете свой код, но оператор "+" на вашем языке работает так, как ожидалось. Это должно быть проверено разработчиком языка, а не вами. Однако эта функция не должна сильно усложняться перед тем, как добавить тест для нее.
Короче говоря, я думаю, что ваши наблюдения очень актуальны и указывают на прагматичный подход к тестированию. Слишком строгое соблюдение какого-либо строгого определения часто будет мешать, даже если сами определения могут быть необходимы для того, чтобы иметь возможность общаться с идеями, которые они передают. Как уже говорилось, целью является не метод, а результат; который для тестирования должен быть уверен в вашем коде.
1) Должны ли модульные тесты использоваться повсеместно, как бы ни была мала и проста эта функция?
Нет. Если в функции нет логики (если, while-циклы, добавления и т. Д.), Тестировать нечего.
Это означает, что функция добавления реализована следующим образом:
def add(a, b):
return a + b
Там нет ничего, чтобы проверить. Но если вы действительно хотите создать для него тест, то:
assert add(a, b) == a + b # Worst test ever!
худший тест, который когда-либо мог написать. Основная проблема заключается в том, что проверенная логика НЕ должна воспроизводиться в коде тестирования, потому что:
- Если там есть ошибка, она также будет воспроизведена.
- Вы больше не тестируете функцию, но это
a + b
работает одинаково в двух разных файлах.
Так что было бы больше смысла что-то вроде:
assert add(1, 2) == 3
Но еще раз, это всего лишь пример, и это add
Функция даже не должна быть проверена.
2) Как справиться с изменением реализаций?
Это зависит от того, что меняется. Имейте в виду, что:
- Вы тестируете API (грубо говоря, что для данного входа вы получаете определенный результат / эффект).
- Вы не повторяете производственный код в своем тестовом коде (как описано выше).
Таким образом, если вы не измените API своего производственного кода, тестовый код не будет затронут каким-либо образом.
3) Что нужно сделать, когда тест усложняется, чем тестируемый код?
Кричи на тех, кто написал эти тесты! (И переписать их).
Модульные тесты просты и не имеют никакой логики в них.
4a) Всегда ли лучше начинать с юнит-тестов или лучше начинать с системных тестов?
Если мы говорим о TDD, то даже не должно быть этой проблемы, потому что даже до написания одной маленькой крошечной функции хороший разработчик TDD написал бы для нее модульные тесты.
Если у вас уже есть работающий код без тестов, я бы сказал, что модульные тесты легче писать.
4б) Какие в начале разработки гораздо проще написать?
Модульные тесты! Поскольку у вас даже нет корня кода, как вы можете писать системные тесты?