Visual Studio 2015

Создавайте более качественное программное обеспечение с помощью Smart Unit Tests

Пратар Лакшман

Продукты и технологии:

Visual Studio 2015 Preview

В статье рассматриваются:

  • генерация набора тестовых случаев (test cases);
  • ограниченное исследование (bounded exploration);
  • параметризованное модульное тестирование.

Циклы выпуска ПО постоянно сокращаются. Времена, когда группы разработки ПО могли строго следовать этапам создания спецификации, реализации и тестирования согласно модели водопада (waterfall model), давно минули. Разрабатывать высококачественное ПО в таком неспокойном мире весьма трудно, поэтому растет спрос на переоценку существующих методологий разработки.

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

Эту ситуацию можно улучшить. Для определения задуманного поведения программной системы требуется общая среда, которую можно использовать при проектировании, реализации и тестировании, причем она должна быть простой для дальнейшего развития. Спецификация должна относиться непосредственно к коду, а среду следует классифицировать как избыточный набор тестов. Методы, основанные на специальном инструментарии и поддерживаемые Smart Unit Tests, могут помочь в достижении этой цели.

Smart Unit Tests

Smart Unit Tests — функциональность Visual Studio 2015 Preview (рис. 1) — интеллектуальный помощник в разработке ПО, облегчающий группам разработчиков находить ошибки на ранних этапах и сокращающий затраты на сопровождение тестов. Эта функциональность основана на предыдущей работе Microsoft Research под названием «Pex». Ее ядро использует анализ кода по стратегии «белого ящика» (white-box code analyses) и удовлетворение ограничений (constraint solving) для синтеза точных тестовых входных значений, которые обеспечивают отработку всех путей в тестируемом коде, их сохранения в виде компактного набора традиционных модульных тестов с высокой степенью охвата кода тестами (coverage) и автоматического развития набора тестов по мере эволюции кода.

Smart Unit Tests полностью интегрированы в Visual Studio 2015 Preview
Рис. 1. Smart Unit Tests полностью интегрированы в Visual Studio 2015 Preview

Более того, и это настоятельно рекомендуется, атрибуты корректности (correctness properties), указанные в коде как контрольные выражения (assertions), можно использовать для более эффективного управления генерацией тестовых случаев.

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

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

Генерация набора тестовых данных

В принципе, методы программного анализа группируются между следующими двумя крайностями.

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

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

int Complicated(int x, int y)
{
  if (x == Obfuscate(y))
    throw new RareException();
  return 0;
}
int Obfuscate(int y)
{
  return (100 + y) * 567 % 2347;
}

Методы статического анализа довольно консервативны, поэтому нелинейные целочисленные арифметические операции в Obfuscate заставляют большинство методов статического анализа выдавать предупреждение о потенциальной ошибке в Complicated. Кроме того, у методов случайного тестирования (random testing techniques) очень мало шансов найти такую пару значений x и y, которые приводят к исключению.

Smart Unit Tests реализует метод анализа, который располагается между этими двумя крайностями. Подобно методам статического анализа она доказывает, что некое свойство справедливо для большинства возможных путей. И аналогично методам динамического анализа она сообщает только о реальных ошибках — никаких ложно положительных результатов.

Генерация тестового случая включает:

  • динамическое распознавание всех ветвлений (явных и неявных) в тестируемом коде;
  • синтез точных тестовых входных значений для отработки этих ветвлений;
  • запись вывода тестируемого кода для упомянутых ранее входных значений;
  • их сохранение как компактного набора тестов с высокой степенью охвата кода.

На рис. 2 показано, как это работает при использовании оснащения кода средствами мониторинга и протоколирования в период выполнения (далее для краткости — просто оснащение), а также перечислены необходимые этапы.

  1. Тестируемый код сначала оснащается и в него помещаются обратные вызовы, которые позволят механизму тестирования отслеживать выполнение. Затем код выполняется, используя простейшее релевантное конкретное входное значение (на основе типа параметра). Это представляет начальный тестовый случай.
  2. Механизм тестирования отслеживает выполнение, вычисляет охват кода для каждого тестового случая и следит за тем, как входное значение проходит по коду. Если все пути охвачены, процесс прекращается; все исключительные поведения считаются ветвлениями — такими же, как явные ветвления в коде. Если охвачены пока не все пути, механизм тестирования выбирает тестовый случай, достигающий той точки в программе, от которой начинается неохваченное ветвление, и определяет, как условие ветвления зависит от входного значения.
  3. Механизм тестирования конструирует систему ограничений, представляющую условие, при котором управление достигает этой точки в программе, а затем продолжилось бы по ранее неохваченному ветвлению. Далее запрашивается механизм удовлетворения ограничений (constraint solver) для синтеза нового конкретного входного значения на основе этого ограничения.
  4. Если механизм удовлетворения ограничений может определить конкретное входное значение для данного ограничения, тестируемый код выполняется, используя это новое значение.
  5. Если степень охвата кода увеличивается, генерируется тестовый случай.

Взгляд на генерацию тестового случая изнутри
Рис. 2. Взгляд на генерацию тестового случая изнутри

Testing Engine Механизм тестирования
Code Under Test Тестируемый код
Instrumentation Framework Инфраструктура оснащения
Constraint Solver Механизм удовлетворения ограничений
(1) Instrument .NET code at runtime using CLR Profiler. Monitor data/control flow. (1) Оснащаем .NET-код в период выполнения, используя CLR Profiler. Отслеживаем поток данных/управления
(2) Listen to monitoring callbacks. Symbolic execution along concrete paths. (2) Слушаем обратные вызовы мониторинга. Символическое выполнение по конкретным путям
(3) Compute precise inputs. (3) Вычисляем точные входные значения
(4) Run with computed test inputs. (4) Выполняем, используя вычисленные тестовые входные значения
(5) Emit test if it increases coverage. (5) Генерируем тест, если это увеличивает степень охвата кода

Этапы 2–5 повторяются, пока не будут охвачены все ветвления или пока не будут превышены предварительно сконфигурированные границы исследования.

Этот процесс называют исследованием (exploration). В рамках исследования тестируемый код может «прогоняться» несколько раз. Некоторые из этих прогонов увеличивают охват, и только прогоны, которые увеличивают охват, приводят к генерации тестовых случаев. Таким образом, все генерируемые тесты отрабатывают выполнимые пути (feasible paths).

Ограниченное исследование

Если тестируемый код не содержит циклов или неограниченной рекурсии, исследование обычно быстро останавливается, поскольку анализу подлежит лишь малое конечное количество путей выполнения. Однако наиболее интересные программы содержат циклы или неограниченную рекурсию. В таких случаях количество путей выполнения практически бесконечно, и, в целом, невозможно решить, достигается ли некое выражение. Иначе говоря, исследование продолжалось бы вечно, чтобы проанализировать все пути выполнения программы. Поскольку генерация тестов включает реальное выполнение тестируемого кода, возникает вопрос: как же защититься от такого неконтролируемого исследования? И здесь ключевую роль играет ограниченное исследование (bounded exploration). Оно гарантирует, что исследование остановится по истечении приемлемого периода времени. При этом используется несколько конфигурируемых границ исследования.

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

Важный аспект в любом эффективном подходе к тестированию на основе инструментария — быстрая обратная связь, поэтому все эти границы были заранее сконфигурированы для поддержки быстрого интерактивного использования.

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

Параметризованное модульное тестирование

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

  • Контракты API (API Contracts) Указывают поведение операций индивидуальных API с точки зрения реализации. Их цель — гарантировать отказоустойчивость в том плане, что операции не будут рушиться, а инварианты данных (data invariants) будут сохраняться. Распространенная проблема с контрактами API — их узкое видение операций индивидуальных API, что затрудняет описание общесистемных протоколов.
  • Модульные тесты (Unit Tests) Выполняют типичные сценарии использования с точки зрения клиента API. Их цель — гарантировать функциональную корректность в том плане, что несколько операций взаимодействуют, как ожидается. Распространенная проблема с модульными тестами состоит в том, что они отделены от деталей реализации API.

Smart Unit Tests делает возможным параметризованное модульное тестирование (parameterized unit testing), которое объединяет оба метода. Эта методология, поддерживаемая механизмом генерации тестового ввода, сочетает интересы клиента и реализации. Атрибуты функциональной корректности (параметризованные модульные тесты) проверяются в большинстве случаев реализации (генерация тестового ввода).

Параметризованный модульный тест (parameterized unit test, PUT) — простое обобщение модульного теста за счет использования параметров. PUT заявляет о поведении кода для целого набора возможных входных значений, а не для одного типичного входного значения. Он выражает допущения по тестовому вводу, выполняет последовательность операций и контролирует атрибуты, которые должны находиться в конечном состоянии, т. е. он выступает в роли спецификации. Такая спецификация не требует и не вводит никакого нового языка или артефакта. PUT пишется на уровне API, реализуемых программным продуктом, и на языке программирования этого продукта. Проектировщики могут использовать их для выражения ожидаемого поведения API программного продукта, разработчики — для того, чтобы способствовать автоматизированному тестированию, а тестеры — для автоматической генерации углубленных тестов. Например, следующий PUT проверяет, что после добавления элемента в непустой список этот элемент действительно содержится в списке:

void TestAdd(ArrayList list, object element)
{
  PexAssume.IsNotNull(list);
  list.Add(element);
  PexAssert.IsTrue(list.Contains(element));
}

PUT разделяют следующие две задачи:

  1. спецификацию атрибутов корректности тестируемого кода для всех возможных аргументов теста;
  2. реально «закрытые» тестовые случаи с конкретными аргументами.

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

Применение

Группы разработки ПО могли уже привыкнуть к другим методологиям, и было бы странным ожидать от них немедленного принятия новой методологии. На самом деле Smart Unit Tests — это вовсе не замена какой-либо существующей практики тестирования, а дополнение к ней. Адаптация скорее всего будет проходить постепенно — сначала с применением автоматической генерации тестов по умолчанию и средств сопровождения, а потом с переходом к написанию спецификаций в коде.

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

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

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

Тестирование на основе спецификации Группы разработки ПО могут использовать PUT как спецификацию, управляющую генерацией избыточных тестовых случаев для выявления нарушений тестовых контрольных выражений. Освободившись от большей части ручной работы, необходимой для написания тестовых случаев, которые достигают высокой степени охвата кода тестами, группы могут сосредоточиться на задачах, не автоматизируемых Smart Unit Tests, например на написании более интересных сценариев в виде PUT и разработке интеграционных тестов, которые выходят за рамки PUT.

Автоматическое нахождение ошибок в коде Контрольные выражения (assertions), указывающие атрибуты корректности, можно определять несколькими способами: как выражения assert, как контракты кода и т. д. Приятно отметить, что все они компилируются с учетом ветвлений: выражение if с ветвлениями then и else представляет результат проверяемого предиката. Поскольку Smart Unit Tests вычисляет входные значения, с помощью которых отрабатываются все ветвления, эта функциональность становится и эффективным инструментом для нахождения ошибок в коде: любой ввод, способный запустить ветвление else, представляет ошибку в тестируемом коде. Таким образом, все ошибки, о которых вы получаете сообщения, действительно являются ошибками.

Сокращение усилий в сопровождении тестовых случаев При наличии PUT нужно поддерживать гораздо меньшее количество тестовых случаев. Там, где индивидуальные закрытые тестовые случаи пишутся вручную, при эволюции тестируемого кода может возникнуть масса проблем. Вам придется адаптировать код всех тестов по отдельности, что может потребовать значительных затрат. Но при написании PUT в сопровождении нуждаются лишь PUT. Кроме того, Smart Unit Tests может автоматически заново генерировать индивидуальные тестовые случаи.

Проблемы

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

  • Язык В принципе, механизм тестирования может анализировать произвольные .NET-программы, написанные на любом .NET-языке. Но код тестов генерируется только на C#.
  • Отсутствие детерминизма Механизм тестирования предполагает, что тестируемый код является детерминированным. Если нет, он отсечет недетерминированные пути выполнения или будет крутиться в циклах, пока не будут достигнуты границы исследования.
  • Параллельная обработка Механизм тестирования не обрабатывает многопоточные программы.
  • Неоснащенный неуправляемый или .NET-код Механизм тестирования не понимает неуправляемый код, т. е. x86-инструкции, вызываемые через Platform Invoke (P/Invoke) в Microsoft .NET Framework. Механизм тестирования не знает, как транслировать такие вызовы в ограничения, которые можно было бы удовлетворить с помощью соответствующего функционала. И даже в случае .NET-кода механизм способен анализировать только оснащенный им код.
  • Арифметика с плавающей точкой Механизм тестирования использует функционал автоматического удовлетворения ограничений, чтобы определять, какие значения релевантны для данного тестового случая и тестируемого кода. Однако возможности этого функционала ограничены. В частности, он не способен делать точные выводы в случае арифметических операций с плавающей точкой.

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

Написание хороших параметризованных модульных тестов Это может быть непростой задачей. Вы должны ответить на два основных вопроса.

  • Охват кода Каковы хорошие сценарии (последовательности вызовов методов) для проверки тестируемого кода?
  • Верификация Каковы хорошие контрольные выражения, которые можно легко поместить в код без реализации алгоритма заново?

PUT полезен, только если он дает ответы на оба эти вопроса.

  • Без достаточного охвата (т. е. сценарий слишком узок для охвата всего тестируемого кода) ценность PUT весьма ограниченна.
  • Без достаточной верификации вычисленных результатов (т. е. в PUT не хватает контрольных выражений) нельзя проверить, что код все делает правильно. В таком случае PUT лишь проверяет, что тестируемый код не рушится или не дает ошибок в период выполнения.

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

Заключение

Функциональность Smart Unit Tests в Visual Studio 2015 Preview позволяет указывать предполагаемое поведение ПО в терминах его исходного кода и использует автоматизированный анализ по стратегии «белого ящика» в сочетании с функционалом удовлетворения ограничений для генерации и поддержки компактного набора релевантных тестов с высокой степенью охвата вашего .NET-кода. Преимущества PUT в том, что проектировщики могут использовать их для выражения ожидаемого поведения API программного продукта, разработчики — для того, чтобы способствовать автоматизированному тестированию, а тестеры — для автоматической генерации углубленных тестов.

Циклы выпуска ПО постоянно сокращаются. Разрабатывать высококачественное ПО в таком неспокойном мире весьма трудно, поэтому растет спрос на переоценку существующих методологий разработки. Такие средства, как Smart Unit Tests, могут помочь группам разработки ПО облегчить достижение качественных результатов при постоянном сокращении циклов выпуска.


Пратар Лакшман (Pratap Lakshman) — старший менеджер программ в группе Visual Studio (Microsoft Developer Division), где в настоящее время работает над средствами тестирования.

Выражаю благодарность за рецензирование статьи эксперту Microsoft Николаю Тиллманну (Nikolai Tillmann).