Написание минимального кода для прохождения юнит-теста - без читерства!


36

При выполнении TDD и написании модульного теста, как можно сопротивляться желанию «обмануть» при написании первой итерации кода «реализации», который вы тестируете?

Например:
давайте мне нужно вычислить факториал числа. Я начинаю с модульного теста (используя MSTest) что-то вроде:

[TestClass]
public class CalculateFactorialTests
{
    [TestMethod]
    public void CalculateFactorial_5_input_returns_120()
    {
        // Arrange
        var myMath = new MyMath();
        // Act
        long output = myMath.CalculateFactorial(5);
        // Assert
        Assert.AreEqual(120, output);
    }
}

Я запускаю этот код, и он терпит неудачу, так как CalculateFactorialметод даже не существует. Итак, я сейчас пишу первую итерацию кода для реализации тестируемого метода, записывая минимальный код, необходимый для прохождения теста.

Дело в том, что я постоянно испытываю желание написать следующее:

public class MyMath
{
    public long CalculateFactorial(long input)
    {
        return 120;
    }
}

Технически, это правильно, потому что это действительно минимальный код, необходимый для того, чтобы выполнить этот конкретный тестовый проход («зеленый»), хотя это явно «обман», так как он даже не пытается выполнять функцию вычисления факториала. Конечно, теперь часть рефакторинга становится упражнением в «написании правильной функциональности», а не настоящим рефакторингом реализации. Очевидно, что добавление дополнительных тестов с другими параметрами завершится неудачно и вызовет рефакторинг, но вы должны начать с этого одного теста.

Итак, мой вопрос: как вы получаете такой баланс между «написанием минимального кода для прохождения теста», сохраняя при этом его работоспособность и в духе того, чего вы на самом деле пытаетесь достичь?


4
Это человеческая вещь: вы должны сопротивляться желанию обмануть. Больше ничего нет. Вы можете добавить больше тестов и написать больше тестового кода, чем кода для тестирования, но если у вас нет такой роскоши, вам нужно будет просто сопротивляться. Есть много мест в коде, где мы должны сопротивляться желанию взломать или обмануть, потому что мы знаем, что, хотя это может сработать сегодня, это не сработает позже.
Дэн Розенстарк

7
Конечно, в TDD делать это наоборот - обманывать, то есть возвращать 120 - правильный путь. Мне довольно трудно заставить себя сделать это, а не мчаться вперед и начать писать факторный расчет.
Пол Мясник

2
Я бы посчитал это читом только потому, что он может пройти тест, но не добавляет никакой реальной функциональности или приближает вас к окончательному решению проблемы.
GrumpyMonkey

3
Если выясняется, что код клиентского кода только когда-либо передается в 5, возврат 120 - это не просто обман, а фактически законное решение.
Kramii восстановит монику

Я согласен с @PaulButcher - на самом деле, многие примеры модульного тестирования в текстах и ​​статьях используют этот подход.
HorusKol

Ответы:


45

Это совершенно законно. Красный, Зеленый, Рефакторинг.

Первый тест проходит.

Добавьте второй тест с новым входом.

Теперь быстро доберитесь до зеленого, вы можете добавить if-else, который отлично работает. Это проходит, но вы еще не сделали.

Третья часть Red, Green, Refactor является наиболее важной. Рефакторинг для устранения дублирования . Теперь у вас будет дублирование в вашем коде. Два оператора, возвращающие целые числа. И единственный способ устранить это дублирование - правильно закодировать функцию.

Я не говорю, что не пишите это правильно с первого раза. Я просто говорю, что это не обман, если нет.


12
Это только поднимает вопрос, почему бы просто не написать функцию правильно в первую очередь?
Роберт Харви

8
@ Роберт, факториальные числа тривиально просты. Настоящее преимущество TDD состоит в том, что когда вы пишете нетривиальные библиотеки, и написание теста сначала вынуждает вас разрабатывать API до реализации, что, по моему опыту, приводит к улучшению кода.

1
@ Роберт, это ты, кто обеспокоен решением проблемы, а не прохождением теста. Я говорю вам, что для нетривиальных задач лучше просто отложить сложный дизайн до тех пор, пока у вас не появятся тесты.

1
@ Thorbjørn Равн Андерсен, нет, я не говорю, что у тебя может быть только одно возвращение. Есть веские причины для нескольких (например, защитные заявления). Проблема в том, что оба оператора возврата были «равны». Они сделали то же самое. Просто у них разные ценности. TDD не о жесткости, а о соблюдении определенного размера соотношения тест / код. Речь идет о создании уровня комфорта в вашей базе кода. Если вы можете написать неудачный тест, то функция, которая будет работать для будущих тестов этой функции, великолепна. Сделайте это, а затем напишите свои краевые тесты, гарантирующие, что ваша функция все еще работает.
CaffGeek

3
смысл не писать полную (хотя и простую) реализацию сразу, потому что у вас нет никакой гарантии, что ваши тесты МОГУТ даже провалиться. смысл того, чтобы тест не прошел до того, как он прошел, состоит в том, что у вас есть реальное доказательство того, что ваше изменение в коде соответствует тому, что вы сделали на нем. это единственная причина, по которой TDD настолько хорош для создания набора регрессионных тестов и полностью вытирает пол с подходом «тест после» в этом смысле.
Сара

25

Очевидно, что требуется понимание конечной цели и достижение алгоритма, который соответствует этой цели.

TDD не волшебная пуля для дизайна; вам все еще нужно знать, как решать проблемы с помощью кода, и вам все еще нужно знать, как это сделать на уровне выше нескольких строк кода, чтобы пройти тест.

Мне нравится идея TDD, потому что она поощряет хороший дизайн; это заставляет вас думать о том, как вы можете написать свой код, чтобы он был тестируемым, и в целом эта философия подтолкнет код к лучшему дизайну в целом. Но вы все равно должны знать, как разработать решение.

Я не одобряю редукционистские философии TDD, которые утверждают, что вы можете создать приложение, просто написав наименьшее количество кода для прохождения теста. Не думая об архитектуре, это не сработает, и ваш пример доказывает это.

Дядя Боб Мартин говорит это:

Если вы не занимаетесь разработкой, управляемой тестами, очень сложно назвать себя профессионалом. Джим Коплин позвал меня на ковер для этого. Ему не понравилось, что я это сказал. На самом деле, его позиция в настоящее время заключается в том, что Test Driven Development разрушает архитектуры, потому что люди пишут тесты, отказываясь от любых других мыслей, и разрывают свои архитектуры в безумном порыве, чтобы пройти тесты, и у него есть интересный момент, это интересный способ злоупотребить ритуалом и потерять намерение, стоящее за дисциплиной.

если вы не продумываете архитектуру, если вместо этого вы игнорируете архитектуру, проводите вместе тесты и заставляете их проходить, вы разрушаете то, что позволит зданию оставаться на ногах, потому что это концентрация на структура системы и четкие проектные решения, которые помогают системе сохранить ее структурную целостность.

Вы не можете просто бросить целую кучу тестов и заставить их проходить десятилетие за десятилетием за десятилетием и предполагать, что ваша система выживет. Мы не хотим превращаться в ад. Таким образом, хороший разработчик, ориентированный на тестирование, всегда осознает необходимость принятия архитектурных решений, всегда думая о большой картине.


Не совсем ответ на вопрос, но 1+
Никто

2
@ rmx: Хм, вопрос в том, как достичь баланса между «написанием минимального кода для прохождения теста», сохраняя его работоспособность и в духе того, чего вы на самом деле пытаетесь достичь? Мы читаем один и тот же вопрос?
Роберт Харви

Идеальным решением является алгоритм и не имеет ничего общего с архитектурой. Использование TDD не заставит вас изобретать алгоритмы. В какой-то момент вам нужно сделать шаги с точки зрения алгоритма / решения.
Joppe

Я согласен с @rmx. По сути, это не отвечает на мой конкретный вопрос, но дает повод задуматься о том, как TDD в целом вписывается в общую картину всего процесса разработки программного обеспечения. По этой причине +1.
CraigTP

Я думаю, что вы могли бы заменить «алгоритмы» - и другие термины - «архитектурой», и аргумент все еще сохраняется; это все из-за невозможности увидеть лес за деревьями. Если вы не собираетесь писать отдельный тест для каждого отдельного целочисленного ввода, TDD не сможет различить правильную факториальную реализацию и некоторое извращенное жесткое кодирование, которое работает для всех тестируемых случаев, но не для других. Проблема с TDD заключается в легкости, с которой «все тесты проходят» и «код хорош». В какой-то момент необходимо применить здравый смысл здравого смысла.
Джулия Хейворд

16

Очень хороший вопрос ... и я должен не согласиться почти со всеми, кроме @Robert.

Письмо

return 120;

Выполнение одного прохода теста для факториальной функции - пустая трата времени . Это не «обман» и не следование буквально красно-зеленому рефактору. Это неправильно .

Вот почему:

  • Рассчитать Факториал это функция, а не «вернуть константу». «возврат 120» не является расчетом.
  • аргументы «рефактора» ошибочны; если у вас есть два случая испытания для 5 и 6, этот код все еще не так, потому что вы не вычисление факториала на все :

    if (input == 5) { return 120; } //input=5 case
    else { return 720; }   //input=6 case
    
  • если мы следуем аргументу 'refactor' буквально , тогда, когда у нас есть 5 тестовых случаев, мы вызовем YAGNI и реализуем функцию, используя таблицу поиска:

    if (factorialDictionary.Contains(input)) {
        return factorialDictionary[input]; 
    }
    throw new Exception("Input failure");
    

Ни один из них на самом деле ничего не рассчитывает, вы . И это не задача!


1
@rmx: нет, не пропустил это; «Рефакторинг для устранения дублирования» может быть удовлетворен с помощью таблицы поиска. Кстати, принцип, согласно которому модульные тесты кодируют требования, не является специфическим для BDD, это общий принцип Agile / XP. Если требовалось «ответить на вопрос« что такое фактор 5 », то« вернуть 120; » будет законным ;-)
Стивен А. Лоу

2
@Chad все, что является ненужной работой - просто напишите функцию в первый раз ;-)
Стивен А. Лоу

2
@ Стивен А. Да, по этой логике зачем писать какие-то тесты ?! «Просто напишите заявку в первый раз!» Смысл TDD, это небольшие, безопасные, постепенные изменения.
CaffGeek

1
@ Чед: соломенный.
Стивен А. Лоу

2
смысл не писать полную (хотя и простую) реализацию сразу, потому что у вас нет никакой гарантии, что ваши тесты МОГУТ даже провалиться. смысл того, чтобы тест не прошел до того, как он прошел, состоит в том, что у вас есть реальное доказательство того, что ваше изменение в коде соответствует тому, что вы сделали на нем. это единственная причина, по которой TDD настолько хорош для создания набора регрессионных тестов и полностью вытирает пол с подходом «тест после» в этом смысле. Вы никогда не пишете случайно тест, который не может провалиться. Кроме того, взгляните на дядя Бобс премьер фактор ката.
Сара

10

Когда вы написали только один модульный тест, однострочная реализация ( return 120;) является допустимой. Написание цикла, вычисляющего значение 120 - это было бы обманом!

Такие простые начальные тесты являются хорошим способом выявления крайних случаев и предотвращения разовых ошибок. Пять на самом деле не входное значение, с которого я бы начал.

Эмпирическое правило, которое может быть полезно здесь: ноль, один, много, много . Ноль и один являются важными крайними случаями для факториала. Их можно реализовать с помощью однострочников. Тестовый пример "много" (например, 5!) Заставит вас написать цикл. Тестовый набор "lot" (1000 !?) может заставить вас реализовать альтернативный алгоритм для обработки очень больших чисел.


2
Случай "-1" был бы интересен. Потому что он не очень четко определен, поэтому и тот, кто пишет тест, и тот, кто пишет код, должны сначала договориться о том, что должно произойти.
gnasher729

2
+1 за то, что указал factorial(5)на плохой первый тест. мы начинаем с простейших возможных случаев, и в каждой итерации мы делаем тесты немного более конкретными, призывая код стать немного более универсальным. это то, что дядя Боб называет предпосылкой приоритета трансформации ( blog.8thlight.com/uncle-bob/2013/05/27/… )
sara

5

Пока у вас есть только один тест, минимальный код, необходимый для прохождения теста, действительно return 120;, и вы можете легко сохранить его, если у вас больше нет тестов.

Это позволяет вам отложить дальнейшее проектирование до тех пор, пока вы на самом деле не напишите тесты, которые выполняют ДРУГИЕ возвращаемые значения этого метода.

Пожалуйста, помните, что этот тест является работоспособной версией вашей спецификации, и если все, что говорится в этой спецификации, это то, что f (6) = 120, то это идеально соответствует требованиям.


Шутки в сторону? По этой логике вам придется переписывать код каждый раз, когда кто-то придумывает новый ввод.
Роберт Харви

6
@ Роберт, в НЕКОТОРЫХ моментах добавление нового регистра больше не приведет к простейшему возможному коду, после чего вы напишете новую реализацию. Поскольку у вас уже есть тесты, вы точно знаете, когда ваша новая реализация делает то же самое, что и старая.

1
@ Thorbjørn Равн Андерсен, именно самая важная часть Red-Green-Refactor, это рефакторинг.
CaffGeek

+1: Это общая идея, насколько я знаю, но что-то нужно сказать о выполнении подразумеваемого контракта (т. Е. Имя метода факториал ). Если вы когда-либо только специфицируете (то есть тестируете) f (6) = 120, вам нужно всего лишь «вернуть 120». Как только вы начнете добавлять тесты, чтобы убедиться, что f (x) == x * x-1 ... * xx-1: upperBound> = x> = 0, вы получите функцию, которая удовлетворяет факториальному уравнению.
Стивен Эверс

1
@SnOrfus, место для «подразумеваемых контрактов» находится в тестовых случаях. Если вы заключаете контракт на факториалы, вы ИСПЫТЫВАЕТЕ, если известны факториалы, а если нет известных факториалов. Их много. Не требуется много времени, чтобы преобразовать список из десяти первых факториалов в циклическое тестирование каждого числа вплоть до десятого факториала.

4

Если вы можете «обмануть» таким образом, это говорит о том, что ваши юнит-тесты некорректны.

Вместо того, чтобы тестировать метод факториала с одним значением, протестируйте его как диапазон значений. Здесь может помочь тестирование на основе данных.

Рассматривайте свои модульные тесты как проявление требований - они должны совместно определять поведение метода, который они тестируют. (Это называется поведенческим развитием - это будущее ;-))

Так что спросите себя - если бы кто-то изменил реализацию на что-то неправильное, ваши тесты все равно пройдут или они скажут «подождите минутку!»?

Принимая это во внимание, если ваш единственный тест был тем, который был в вашем вопросе, то технически соответствующая реализация верна. Проблема тогда рассматривается как плохо определенные требования.


Как указывал nanda, вы всегда можете добавить бесконечную серию caseоператоров в a switch, и вы не можете написать тест для каждого возможного ввода и вывода для примера OP.
Роберт Харви

Вы можете технически проверить значения от Int64.MinValueдо Int64.MaxValue. Выполнение этого заняло бы много времени, но оно явно определило бы требование без места для ошибки. С нынешней технологией это невыполнимо (я подозреваю, что это может стать более распространенным в будущем), и я согласен, что вы могли бы обмануть, но я думаю, что вопрос ОП не был практичным (никто не мог бы обмануть таким образом на практике), но теоретический.
Никто

@ rmx: Если бы вы могли это сделать, тесты были бы алгоритмом, и вам больше не нужно было бы писать алгоритм.
Роберт Харви

Это верно. Моя дипломная работа в университете на самом деле включает автоматическую генерацию реализации с использованием юнит-тестов в качестве руководства с генетическим алгоритмом в качестве помощи TDD - и это возможно только при твердых тестах. Разница в том, что привязка ваших требований к вашему коду, как правило, гораздо сложнее для чтения и понимания, чем один метод, который воплощает модульные тесты. Тогда возникает вопрос: если ваша реализация является проявлением ваших юнит-тестов, а ваши юнит-тесты являются проявлением ваших требований, почему бы просто не пропустить тестирование вообще? У меня нет ответа.
Никто

Кроме того, разве мы, как люди, можем так же ошибиться в модульных тестах, как и в коде реализации? Так зачем вообще юнит тест?
Никто

3

Просто напишите больше тестов. В конце концов, было бы короче, чтобы написать

public long CalculateFactorial(long input)
{
    return input <= 1 ? 1 : CalculateFactorial(input-1)*input;
}

чем

public long CalculateFactorial(long input)
{
    switch (input) {
       case 0: return 1;
       case 1: return 1;
       case 2: return 2;
       case 3: return 6;
       case 4: return 24;
       case 5: return 120;
    }
}

:-)


3
Почему бы просто не написать алгоритм правильно в первую очередь?
Роберт Харви

3
@Robert, это правильный алгоритм для вычисления Факториал числа от 0 до 5. Кроме того, что делает «правильно» означает? Это очень простой пример, но когда он становится более сложным, становится много градаций того, что означает «правильный». Достаточно ли корректна программа, требующая root-доступа? Правильно ли использовать XML вместо CSV? Ты не можешь ответить на это. Любой алгоритм является правильным, если он удовлетворяет некоторым бизнес-требованиям, которые сформулированы как тесты в TDD.
П Швед

3
Следует отметить, что, поскольку тип вывода длинный, существует лишь небольшое количество входных значений (около 20), которые функция может обрабатывать правильно, поэтому большой оператор switch не обязательно является худшей реализацией - если скорость больше Важнее, чем размер кода, оператор switch может быть подходящим способом, в зависимости от ваших приоритетов.
user281377

3

Написание «читерских» тестов в порядке, для достаточно малых значений «ОК». Но помните - модульное тестирование завершается только тогда, когда все тесты пройдены, и новые тесты не могут быть написаны, если они не пройдут . Если вы действительно хотите иметь метод CalculateFactorial, который содержит кучу операторов if (или, что еще лучше, большой оператор switch / case :-), вы можете это сделать, и, поскольку вы имеете дело с числом с фиксированной точностью, необходим код реализовать это конечно (хотя, вероятно, довольно большой и некрасивый, и, возможно, ограниченный компилятором или системными ограничениями на максимальный размер кода процедуры). На данный момент, если вы действительнонастаивайте на том, что вся разработка должна вестись модульным тестом. Вы можете написать тест, который требует, чтобы код вычислял результат за промежуток времени, меньший, чем тот, который можно выполнить, следуя всем ветвям оператора if .

По сути, TDD может помочь вам написать код, который правильно реализует требования , но не может заставить вас писать хороший код. Это зависит от вас.

Поделитесь и наслаждайтесь.


+1 для «модульного тестирования завершено только тогда, когда все тесты пройдены, и никакие новые тесты не могут быть написаны безуспешно» Многие люди говорят, что законно возвращать константу, но не следуют «на короткий срок» или « если общие требования нужны только в тех конкретных случаях "
Thymine

1

Я на 100% согласен с предложением Роберта Харвиза: речь идет не только о прохождении тестов, но и об общей цели.

В качестве решения вашей основной задачи: «Проверено только на работу с заданным набором входных данных», я бы предложил использовать управляемые данными тесты, такие как теория xunit. Сила этой концепции заключается в том, что она позволяет легко создавать спецификации входов к выходам.

Для Factorials тест будет выглядеть так:

    [Theory]
    [InlineData(0, 1)]
    [InlineData( 1, 1 )]
    [InlineData( 2, 2 )]
    [InlineData( 3, 6 )]
    [InlineData( 4, 24 )]
    public void Test_Factorial(int input, int expected)
    {
        int result = Factorial( input );
        Assert.Equal( result, expected);
    }

Вы могли бы даже реализовать предоставление тестовых данных (которое возвращает IEnumerable<Tuple<xxx>>) и закодировать математический инвариант, такой как многократное деление на n приведет к n-1).

Я считаю, что это очень мощный способ тестирования.


1

Если вы все еще можете обмануть, то тестов недостаточно. Напишите больше тестов! Для вашего примера я постараюсь добавить тесты с вводом 1, -1, -1000, 0, 10, 200.

Тем не менее, если вы действительно привержены обману, вы можете написать бесконечное «если-тогда». В этом случае ничто не может помочь, кроме проверки кода. Вас скоро поймают на приемочном тесте ( написано другим человеком! )

Проблема модульных тестов заключается в том, что иногда программисты считают их ненужной работой. Правильный способ их увидеть - это инструмент для того, чтобы сделать результат своей работы правильным. Таким образом, если вы создаете if-then, вы неосознанно знаете, что есть другие случаи, которые нужно рассмотреть. Это означает, что вы должны написать еще один тест. И так далее, пока вы не поймете, что мошенничество не работает и лучше просто написать правильный путь. Если вы все еще чувствуете, что вы не закончены, вы не закончены.


1
Похоже, вы говорите, что простого написания кода, достаточного для прохождения теста (как утверждает TDD), недостаточно. Вы также должны помнить принципы разработки программного обеспечения. Я согласен с вами, кстати.
Роберт Харви

0

Я бы предположил, что ваш выбор теста не самый лучший тест.

Я бы начал с:

факториал (1) в качестве первого теста,

факториал (0) в качестве второго

факториал (-ве) как третий

а затем продолжить с нетривиальными случаями

и закончить делом переполнения.


Что такое -ve??
Роберт Харви

отрицательное значение.
Крис Кадмор
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.