вторник, 28 августа 2012 г.

Параметризованные юнит тесты

DISCLAIMER: все примеры в этой статье написаны с использованием NUnit, однако все их можно применять и с другими тестовыми фреймворками, как xUnit и MbUnit.

Юнит тесты – это отличная штука; они помогают навести порядок в дизайне приложения, являются незаменимым средством при рефакторинге, позволяют делать процесс разработки более управляемым и т.п. Но как и любой другой инструмент юнит тесты тоже можно использовать неправильно. Если с плохо поддерживаемым кодом все мы хоть как-то привыкли бороться, поскольку сталкиваемся с ним постоянно, но когда на каждую строку г#%@&-кода приходится еще пара строк таких же по качеству тестов, то положение становится каким-то совсем уж невеселым.

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

Именно поэтому требования 100% покрытия кода тестами мне кажется не просто неразумными, а даже опасными. Конечно, любое ветвление в коде (в виде if-ов) должно находить свое отражение в интерфейсе абстракции, но на практике оказывается, что в каждом классе есть достаточное количество подробностей, хрупких по своей природе, тестировать которые не стоит.

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

Еще одним способом повышения сопровождаемости кода является использование параметризованных юнит тестов, доступных в таких фреймворках как NUnit, MbUnit, xUnit и других.

Параметризованные тесты

Практически любой юнит тест явно или неявно содержит три основных этапа: Arrange (инициализация тестируемого класса), Act («управляющее воздействие» на тестируемый класс) и Assert (проверка результата). Некоторые этапы могут быть вырожденными или явно отсутствовать в коде (например, секция Act может находиться в методе инициализации теста, а секция Assert может быть спрятана за методом Verify мок-объекта).

По большому счету, основная цель теста – это удостовериться в том, что при заданном входном воздействии тестируемая система переходит в предполагаемое состояние. Поскольку для многих классов/модулей существует множество входных воздействий, то в реальных условиях даже для одного тестируемого метода может существовать пяток разных тестов вида: Test_SomeOperation_Failed_On_Null_Input или Test_SomeOperation_Returns_42_On_Valid_String. Такой подход вполне оправдан, поскольку обеспечивает покрытие основных граничных условий (corner case) и обеспечивает вменяемое покрытие кода тестами. Однако даже при наличии хороших имен такие тесты тяжело читать и сложно понять, какие граничные условия покрыты, а какие нет.

Решить подобную проблему могут параметризованные юнит тесты (parameterized unit tests), доступные в большинстве тестовых фреймворках. Каждый фреймворк использует свои атрибуты для задания параметров и может поддерживать разные варианты тестирования. Так, в NUnit используется атрибут TestCaseAttribute, в xUnit – InlineDataAttribute, в MbUnit – RowAttribute (все эти фреймворки поддерживают ряд других атрибутов для задания параметризованных тестов, здесь перечислен лишь один из них). MSTest, к сожалению, не поддерживает параметризованные тесты (pex – не всчет).

ПРИМЕЧАНИЕ
Отсутствие параметризованных тестов является настолько важным для меня, что только по этой причине мы в команде отказались от MS Test-ов в пользу NUnit.

Давайте в качестве примера рассмотрим следующий пример. Предположим, у нас есть класс, представляющий собой интервал (класс Interval): [0, 7), [-1, +Inf) и т.д. Понятно, что такой класс содержит большое количество граничных условий сам по себе для проверки корректности диапазона, ну а если добавить ему, например, метод Contains для определения того, попадает ли указанная точка в диапазон, то количество тестов возрастет еще как минимум вдвое.

Использование параметризованных тестов для такого класса не только уменьшит в разы объем тестового кода, но и значительно повысит читабельность; более того, в этом случае параметризованные тесты могут служить отличным источником документации. Вот пример такого теста на NUnit (на других фреймворках код будет аналогичным):

[TestCase("(-Inf,+Inf)", Result = true)]
[TestCase("[0,1.5)", Result = true)]
[TestCase("(0,0)", Result = false)]
[TestCase("[0,-1.12)", Result = false)]
[TestCase("(Inf,-Inf]", Result = false)]
[TestCase(null, Result = false, ExpectedException = typeof(ArgumentNullException))]
public bool Test_Interval_Validity(string range)
{
    try
    {
        Interval.Parse(range);
        return true;
    }
    catch (FormatException)
    {
        return false;
    }
}

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

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

[TestCaseSource("GetTestIntervals")]
public bool Test_Interval_Contains(string range, double value)
{
    var interval = Interval.Parse(range);
    return interval.Contains(value);
}

public IEnumerable<TestCaseData> GetTestIntervals()
{
    yield return new TestCaseData("[1,3]", 2).Returns(true);
    yield return new TestCaseData("[1,1]", 1).Returns(true);
    yield return new TestCaseData("[1,3)", 3).Returns(false);
    yield return new TestCaseData(null, 0).Throws(typeof(ArgumentNullException));
    yield return new TestCaseData("(0,+Inf)", double.PositiveInfinity).Returns(false);
}

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

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

ПРИМЕЧАНИЕ
Помимо приведенных здесь атрибутов TestCase и TestCaseSource, предназначенных для указания тестовых данных, большинство тестовых фреймворков поддерживают атрибуты для задания диапазона значений и получения комбинации значений аргументов. Так, например, для NUnit стоит обратить внимание на атрибуты Combinatorial и Values, а также на пару атрибутов Datapoints и Theory.

Data-Driven тесты

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

Давайте рассмотрим реализацию «в лоб»:

[TestCaseSource("GetValidateUserTestData")]
public bool Test_ValidateUserById(int userId)
{
    // Arrange
    Console.WriteLine("Validating user '{0}'", userId);

    // Act
    return UsersService.ValidateUserById(userId);
}

public static IEnumerable<TestCaseData> GetValidateUserTestData()
{
    yield return new TestCaseData(GetValidUserId()).Returns(true);
    yield return new TestCaseData(GetInvalidUserId()).Returns(false);
}

К сожалению данный подход не будет работать; проблема заключается в том, что NUnit вычисляет параметры тестов до запуска самих тесто в блоке try/catch, подавляя все исключения. Так что если любой из методов получения тестовых данных, используемый в GetValidateUserTestData упадет с исключением, то при запуске теста вы получите одно невменяемое сообщение: «System.Reflection.TargetParameterCountException : Parameter count mismatch.», без какой-либо информации о том, что же пошло не так.

Решение состоит в том, чтобы сделать вычисление аргументов теста ленивым образом, например, с помощью передачи Func<int> вместо int:

[TestCaseSource("GetValidateUserTestData")]
public bool Test_ValidateUserById(Func<int> getUserId)
{
    // Arrange
    int userId = getUserId();
    Console.WriteLine("Validating user '{0}'", userId);

    // Act
    return UsersService.ValidateUserById(userId);
}

private static TestCaseData CreateTestCaseData(Func<int> getUser)
{
    return new TestCaseData(getUser);
}

public static IEnumerable<TestCaseData> GetValidateUserTestData()
{
    yield return CreateTestCaseData(() => GetValidUserId()).Returns(true);
    yield return CreateTestCaseData(() => GetInvalidUserId()).Returns(false);
}

ПРИМЕЧАНИЕ
Поведение в NUnit усложняется еще и тем, что вызов метода GetValidateUserTestData() осуществляется даже до вызова инициализации всего набора тестов (т.е. до вызова метода, помеченного атрибутом TestFixtureSetUp). Однако это поведение является фичей, а не багом. По словам разработчиков, они собираются добавить dynamically generated test data в версии 3.

Параметризованные тесты vs. велосипеды

Судя по моему недавнему ответу на rsdn, некоторые аспекты параметризованных тестов остались недопонятыми. Так, вопрос заключается в том, почему бы вместо параметризованных тестов не использовать простым велосипедом и не вызывать тестируемый код с разными параметрами из одного тестового метода? Чем не параметризованные юнит-тесты?

// test case
{
   
Func<string, bool
> test = range =>
    {
       
try
        {
           
Interval
.Parse(range);
           
return true
;
        }
       
catch (FormatException
)
        {
           
return false
;
        };
    };
       
    check(test(
"(-Inf,+Inf)") == true
);
    check(test(
"[0,1.5)") == true
);
    check(test(
"(0,0)") == false
);
    check(test(
"[0,-1.12)") == false
);
    check(test(
"(Inf,-Inf]") == false
);
    check_exception(() => test(
null), typeof(ArgumentNullException));
}

Разница здесь в том, что с точки зрения тестового фреймворка “велосипедное” решение будет выглядеть в виде одного юнит-теста, с невозможностью запуска или отладки отдельного тест-кейса. К тому же, сразу возникают вопросы насчет того, что делать, когда один из тест кейсов упадет? Нужно ли сразу останавливать весь “композитный” тест или нужно запустить все тест-кейсы и потом сгенерировать, например, AggregateException?

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

А также, они позволяют увидеть, какой конкретно тест-кейс упал:

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

Заключение

Читая замечательную книгу “The Art Of Unit Testing” я был несколько удивлен небольшому вниманию, которое уделил ее автор параметризованным юнит тестам. Опыт показывает, что разумная параметризация тестов способна очень сильно повысить как покрытие кода, так и сопровождаемость юнит тестов, две задачи, которые обычно являются взаимоисключающими.

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

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

Дополнительные ссылки

6 комментариев:

  1. Resharper поддерживает параметризованный энит тесты ?

    ОтветитьУдалить
  2. Да, полностью. Более того, он знает об атрибуте TestCaseSource и подсвечивает как ошибку при указании несуществующего метода. Ну и запускаются они тоже нормально из него.

    ОтветитьУдалить
  3. это обнадеживает. мы правда используем консольную приблуду для запуска тестов параллельно (запускаем тесты по классам). ее тоже придется проверять как она работает.

    ОтветитьУдалить
  4. По поводу инструментов для запуска тестов, очень рекомендую nCrunch. Наша команда активно его использует в разработке и мы просто счастливы. Получаем выполнение обновленных тестов еще до того, как сохранили код. Ну и по производительности абсолютно не грузит проц, тем более что выполнение идет во время работы непосредственно с кодом, а не отладки. Еще плюс - явно показывает покрытие кода тестами.

    ОтветитьУдалить
  5. @mihasic: Да, я на NCrunch только мельком смотрел, но идея с Continious Testing очень клевая.

    В 12-й студии появилось жалкое подобие Continious Testing в виде автоматического запуска тестов после билда, но до NCrunch-а ей далеко: нет ни покрытия, ни эвристик типа запускать прежде всего упавшие тесты и т.п.

    ОтветитьУдалить
  6. Ну, мы и сидим на 12 начиная с бэты. А здесь как часто бывает - идет подмена понятий. Там нет такого реального CT как в nCrunch (специально глянул видео, красиво все на презентациях). Тесты запускаются вручную, либо при билде.
    Аналогом nCrunch, может быть Mighty-Moose (который Грэг Янг недавно объявил бесплатным). Пока я не пробовал.
    P.S. У студии Code Coverage показывает непокрытые места более детально.

    ОтветитьУдалить