TDD и полное покрытие тестами, где необходимы экспоненциальные тесты

18

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

  1. Точное совпадение по имени
  2. Все слова поискового запроса по имени или синониму результата
  3. Несколько слов поискового запроса по названию или синониму результата (% по убыванию)
  4. Все слова поискового запроса в описании
  5. Несколько слов поискового запроса в описании (% по убыванию)
  6. Дата последнего изменения по убыванию

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

  1. 32
  2. 16
  3. 8 (Вторичный тай-брейк, основанный на% по убыванию)
  4. 4
  5. 2 (Вторичный тай-брейк, основанный на% по убыванию)
  6. 1

В духе TDD я решил начать с моих юнит-тестов. Чтобы иметь контрольный пример для каждого уникального сценария, было бы как минимум 63 уникальных тестовых случая, не рассматривая дополнительные тестовые примеры для логики вторичного прерывателя связей по правилам 3 и 5. Это кажется властным.

Фактические тесты будут на самом деле меньше, хотя. Основываясь на самих реальных правилах, определенные правила гарантируют, что нижние правила всегда будут истинными (например, когда «все слова поискового запроса появляются в описании», тогда правило «некоторые слова поискового запроса появляются в описании» всегда будет истинным). Тем не менее, стоит ли усилий при написании каждого из этих тестов? Это тот уровень тестирования, который обычно требуется, когда речь идет о 100% тестовом покрытии в TDD? Если нет, то какова будет приемлемая альтернативная стратегия тестирования?

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

Ответы:

17

Ваш вопрос подразумевает, что TDD имеет какое-то отношение к «написанию всех тестовых случаев в первую очередь». ИМХО, это не "в духе TDD", на самом деле это против . Помните , что TDD означает «тест ведомого развитие», так что вам нужна только те тестовые случаи , которые на самом деле «диск» ваша реализация, не больше. И до тех пор, пока ваша реализация не разработана таким образом, чтобы число блоков кода росло экспоненциально с каждым новым требованием, вам также не понадобится экспоненциальное количество тестовых случаев. В вашем примере цикл TDD, вероятно, будет выглядеть так:

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

Затем начните со 2-го требования:

  • «Все слова поискового запроса по имени или синоним результата» должны иметь более низкий балл, чем «Точное совпадение по имени», но более высокий балл, чем все остальное.
  • Теперь создайте тестовые примеры для этого нового требования, как и выше, один за другим, и реализуйте следующую часть вашего кода после каждого нового теста. Не забудьте провести рефакторинг, ваш код, а также ваши тестовые случаи.

Здесь есть одна загвоздка : когда вы добавляете тестовые случаи для номера требования / категории «n», вам нужно будет только добавить тесты, чтобы убедиться, что оценка категории «n-1» выше, чем оценка для категории «n» , Вам не нужно будет добавлять какие-либо тестовые примеры для каждой другой комбинации категорий 1, ..., n-1, так как тесты, которые вы написали ранее, убедятся, что результаты этих категорий будут по-прежнему в правильном порядке.

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

Док Браун
источник
1
Мне очень нравится этот ответ. Это дает четкую и лаконичную стратегию модульного тестирования для решения этой проблемы с учетом TDD. Вы разбиваете это довольно хорошо.
maple_shaft
@maple_shaft: спасибо, и мне очень нравится твой вопрос. Я хотел бы добавить, что, полагаю, даже при вашем подходе к проектированию всех тестовых примеров вначале классического метода построения классов эквивалентности для тестов может быть достаточно для уменьшения экспоненциального роста (но я до сих пор этого не решал).
Док Браун
13

Подумайте о написании класса, который просматривает заранее определенный список условий и умножает текущий счет на 2 для каждой успешной проверки.

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

Затем вы можете написать класс для каждого условия и есть только 2 теста для каждого случая.

Я не совсем понимаю ваш вариант использования, но, надеюсь, этот пример поможет.

public class ScoreBuilder
{
    private ISingleScorableCondition[] _conditions;
    public ScoreBuilder (ISingleScorableCondition[] conditions)
    {
        _conditions = conditions;
    }

    public int GetScore(string toBeScored)
    {
        foreach (var condition in _conditions)
        {
            if (_conditions.Test(toBeScored))
            {
                // score this somehow
            }
        }
    }
}

public class ExactMatchOnNameCondition : ISingleScorableCondition
{
    private IDataSource _dataSource;
    public ExactMatchOnNameCondition(IDataSource dataSource)
    {
        _dataSource = dataSource;
    }

    public bool Test(string toBeTested)
    {
        return _dataSource.Contains(toBeTested);
    }
}

// etc

Вы заметите, что ваши 2 ^ условия испытаний быстро сводятся к 4+ (2 * условия). 20 гораздо менее властен, чем 64. И если вы добавите еще один позже, вам не нужно менять ЛЮБОЙ из существующих классов (принцип открытого-закрытого), поэтому вам не нужно писать 64 новых теста, вы просто добавить еще один класс с двумя новыми тестами и добавить его в свой класс ScoreBuilder.

прецизионный самописец
источник
Интересный подход. Все время мой разум никогда не рассматривал ООП-подход, так как я застрял в уме одного компонента компаратора. Я действительно не искал совет алгоритма, но это очень полезно независимо.
maple_shaft
4
@maple_shaft: Нет, но вы искали совет TDD, и этот вид алгоритмов идеально подходит для решения вопроса о том, стоит ли это усилий, за счет значительного сокращения усилий. Снижение сложности является ключом к TDD.
pdr
+1, отличный ответ. Хотя я считаю, что даже без такого сложного решения число тестовых случаев не должно расти в геометрической прогрессии (см. Мой ответ ниже).
Док Браун
Я не принял ваш ответ, потому что я чувствовал, что другой ответ лучше отвечает на реальный вопрос, но мне настолько понравился ваш дизайн, что я воплощаю его так, как вы предлагали. Это снижает сложность и делает ее более расширяемой в долгосрочной перспективе.
maple_shaft
4

Тем не менее, стоит ли усилий при написании каждого из этих тестов?

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

Второй тест будет того стоить, потому что он охватывает другой путь в коде, вероятно, проверяя другое отношение приоритетов.

63-й тест, вероятно, не стоит того, потому что вы уверены, что на 99,99% он покрыт логикой вашего кода или другого теста.

Это тот уровень тестирования, который обычно требуется, когда речь идет о 100% тестовом покрытии в TDD?

Насколько я понимаю, покрытие на 100% означает, что все пути кода выполнены. Это не означает, что вы выполняете все комбинации своих правил, но все пути, по которым ваш код может идти вниз (как вы отмечаете, некоторые комбинации не могут существовать в коде). Но так как вы делаете TDD, пока нет «кода» для проверки путей. Буква процесса сказала бы сделать все 63+.

Лично я считаю 100% покрытие несбыточной мечтой. Кроме того, это непрагматично. Модульные тесты существуют, чтобы служить вам, а не наоборот. По мере того, как вы проводите больше тестов, вы получаете все меньшую отдачу от выгоды (вероятность того, что тест предотвратит ошибку + уверенность в правильности кода). В зависимости от того, что делает ваш код, определяет, где на этой скользящей шкале вы прекращаете делать тесты. Если ваш код работает на ядерном реакторе, то, возможно, все 63+ тестов того стоят. Если ваш код организует ваш музыкальный архив, то вы, вероятно, можете сэкономить гораздо меньше.

Telastyn
источник
«покрытие» обычно относится к покрытию кода (выполняется каждая строка кода) или покрытию ветви (каждая ветвь выполняется по меньшей мере один раз в любом возможном направлении). Для обоих типов покрытия нет необходимости в 64 различных тестовых случаях. По крайней мере, не с серьезной реализацией, которая не содержит отдельных частей кода для каждого из 64 случаев. Так что 100% покрытие вполне возможно.
Док Браун
@DocBrown - конечно, в этом случае - другие вещи сложнее / невозможно проверить; рассмотрите пути исключения из памяти. Разве все 64 не потребовались бы в «письме» TDD, чтобы принудить поведение проверяться в неведении о реализации?
Теластин
Ну, мой комментарий был связан с вопросом, и ваш ответ создает впечатление, что в случае ФП может быть трудно получить 100% охват . Сомневаюсь. И я согласен с вами, что можно строить случаи, когда 100% охват труднее достичь, но об этом не спрашивали.
Док Браун
4

Я бы сказал, что это идеальный случай для TDD.

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

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

Вонко вменяемый
источник
1

Я не фанат строгой интерпретации 100% покрытия тестами как написания спецификаций для каждого отдельного метода или тестирования каждой перестановки кода. Это фанатично ведет к созданию управляемых тестами проектов ваших классов, которые неправильно инкапсулируют бизнес-логику и дают тесты / спецификации, которые, как правило, не имеют смысла с точки зрения описания поддерживаемой бизнес-логики. Вместо этого я сосредотачиваюсь на структурировании тестов во многом так же, как сами бизнес-правила, и стараюсь использовать каждую условную ветвь кода с тестами с явным ожиданием, что тестеры будут легко поняты тестировщиком как обычные сценарии использования и фактически описывают бизнес-правила, которые были реализованы.

Помня об этой идее, я бы тщательно проверил 6 факторов ранжирования, которые вы перечислили в отрыве друг от друга, а затем 2 или 3 теста стиля интеграции, которые гарантируют, что вы свернете свои результаты до ожидаемых общих значений ранжирования. Например, в случае № 1 «Точное совпадение по имени» у меня было бы по крайней мере два модульных теста, чтобы проверить, когда он точен, а когда нет, и что два сценария возвращают ожидаемый результат. Если он чувствителен к регистру, то также случай проверки «Точного соответствия» и «Точного соответствия» и, возможно, других вариантов ввода, таких как знаки препинания, лишние пробелы и т. Д., Также возвращает ожидаемые оценки.

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

Предполагая, что случаи # 2 / # 3 и # 4 / # 5 обобщены для одних и тех же базовых методов, но, передавая различные поля, вам нужно написать только один набор модульных тестов для базовых методов и написать простые дополнительные модульные тесты для проверки конкретного поля (название, имя, описание и т. д.) и оценки при назначенном факторинге, так что это дополнительно снижает избыточность ваших общих усилий по тестированию.

При таком подходе описанный выше подход, вероятно, даст 3 или 4 модульных теста для случая # 1, возможно, будет учтено 10 спецификаций для некоторых / всех w / синонимов - плюс 4 спецификации для правильной оценки случаев # 2 - # 5 и 2 до 3-х спецификаций на конечную дату упорядоченного ранжирования, затем от 3 до 4 тестов уровня интеграции, которые измеряют все 6 случаев, объединенные вероятным образом (пока забудьте о непонятных крайних случаях, если вы четко не видите проблему в своем коде, которую необходимо выполнить, чтобы гарантировать это условие выполняется) или убедитесь, что не будет нарушено / нарушено в результате последующих изменений. Это дает около 25 или около того спецификаций для выполнения 100% написанного кода (даже если вы напрямую не вызывали 100% написанных методов).

Майкл Лэнг
источник
1

Я никогда не был фанатом 100% тестового покрытия. По моему опыту, если что-то достаточно просто для тестирования только с одним или двумя контрольными случаями, то это достаточно просто, чтобы редко проваливаться. Когда это терпит неудачу, это обычно из-за архитектурных изменений, которые в любом случае потребовали бы тестовых изменений.

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

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

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

Карл Билефельдт
источник