среда, 4 мая 2016 г.

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

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

Если вы не в курсе, что такое параметризованные юнит-тесты, и для чего они нужны, то рекомендую почитать мой пост Параметризованные юнит тесты.

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

Ну а теперь к примерам. Чтобы сделать в Xunit тест параметризованным, вместо атрибута Fact метод нужно пометить атрибутом Theory и указать источник данных.

Самый простой способ – с помощью InlineDataAttribute:

[Theory]
[
InlineData("s1", "S1", true
)]
[
InlineData("s1", "S2", false)]
public void TestCaseInsensitiveEquality(string s1, string s2, bool
areEqual)
{
   
if
(areEqual)
       
Assert.Equal(s1, s2, ignoreCase: true
);
   
else
        Assert.NotEqual(s1, s2, new CaseInsensitiveStringComparer());
}

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

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

Тут есть несколько вариантов: можно использовать класс-генератор или фабричный метод. В первом случае нужно использовать ClassDataAttribute с помощью которого нужно указать класс, реализующий IEnumerable<object[]>, или же воспользоваться MemberDataAttribute, которому можно передать имя статическокго метода, возвращающего IEnumerable<object[]>.

[Theory]
[
MemberData(nameof(GetEqualityTestCases))]
public void TestEquality(string s1, string s2, bool
areEqual)
{
   
if
(areEqual)
       
Assert.Equal(s1, s2, ignoreCase: true
);
   
else
        Assert.NotEqual(s1, s2, new CaseInsensitiveStringComparer
());
}

private static IEnumerable<object
[]> GetEqualityTestCases()
{
   
yield return new object[] { "21", "s1", true };
}

(Я привожу пример лишь для MemberDataAttribute, поскольку нахожу этот вариант более простым и понятным. Как-то я не вижу смысла использовать ClassDataAttribute, тем более что в случае MemberDataAttribute всегда можно использовать свойство MemberType и вынести фабричный метод за пределы тестового класса.)

Очень важно, чтобы фабричный метод был статическим. Входные данные параметризованных тестов являются статической информацией и не должны основываться на состоянии тестируемого кода (что было бы возможно, если бы фабричный метод мог бы быть экземплярным). Поэтому, при попытке сделать метод экземплярным вы получите ошибку: Could not find public static member (property, field, or method) named 'GetEqualityTestCases' on Xunit4Fun.Samples.MemberDataSample. (Любопытно, что сообщение об ошибке намекает, что метод должен быть открытым, хотя, на самом деле, это не так).

Если все фабричные методы вменяемы и сконфигурированы правильно, то при наличии xunit.runner.visualstudio, вы должны увидеть параметризованные тесты в окне Test Explorer:

clip_image002

TheoryData и TheoryData<T>

Помимо использование методов, в атрибуте MemberDataAttribute можно указать и свойство, которое должно возвращать список object[], каждый элемент которого будет соответствовать аргументам метода.

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

[Theory]
[
MemberData(nameof(ParseTestCases))]
public void ParseShouldBeSuccessful(string
input)
{
   
int
dummy;
   
bool result = int.TryParse(input, out
dummy);
   
Assert.
True(result);
}

private static IEnumerable<object
[]> ParseTestCases()
{
   
yield return new object[] { "42"
};
   
yield return new object[] { "-1" };
}

В этом случае, вместо массива объектов можно использовать легковесную оболочку для входных данных параметризованного теста – класс TheoryData<T> (там их целое семейство, начиная от необобщенного класса TheoryData, заканчивая классом TheoryData<T1, …, T5>):

[Theory]
[
MemberData(nameof(ParseInput))]
public void Parse(string s, bool
shouldSucceed)
{
   
int
dummy;
   
bool result = int.TryParse(s, out
dummy);
   
Assert.
Equal(shouldSucceed, result);
}

private static TheoryData<string, bool
> ParseInput()
{
   
return new TheoryData<string, bool
>()
    {
       
// Можно использовать синтаксис инициализации коллекций!
        { "42", true
},
        {
"42c", false },
    };
}

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

Заключение

Параметризованные тесты в Xunit покрывают ключевые сценарии и вполне себе удобны, хотя NUnit и несколько более функционален в этом плане.

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

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

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

  1. >> "Во-первых, этот подход совсем не строготипизированный, что позволяет вернуть разнородные данные и отгрести во время исполнения."
    +1 идея для написания анализатора под эту проблему

    ОтветитьУдалить
    Ответы
    1. Угу. Уже завел репо на гитхабе:)

      Удалить
  2. А напишите в мс, что бы они в mstest параметризацию запилили :)

    ОтветитьУдалить
    Ответы
    1. Вы так пишите, как будто я какой-нибудь Скотт Гатри, который распоряжается бюджетами на тысячи человек:)).

      Единственный вариант - это завести тикет на user voice и собрать приличное количество голосов. Но я почти уверен, что ответ будет однозначным: "мы не хоим инвестировать во что-то, что устарело уже давно и для чего есть куча вменяемых альтернатив".

      Удалить
  3. Этот комментарий был удален автором.

    ОтветитьУдалить
  4. Спасибо за статью, TheoryData стал для меня открытием.
    Хотя, перед прочтением статьи я был настроен скептически, думал, что всё знаю о параметризированных тестах xUnit, и в очередной раз убедился в том, что не самый умный. ;)

    Всё-таки хорошая у нас профессия, каждый день можно получать удовольствие от новых знаний.

    ОтветитьУдалить
    Ответы
    1. Да, я как-то TheoryData сам не сразу нашел:), хотя как нашел, понял, что штука очень полезная.

      Удалить
  5. Хотел бы задать немного нелепый вопрос, о котором думаю еще со времени предыдущего поста (в нем было про отсутствие параметризированных тестов в ms test).

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

    [InlineData("s1", "S1", true)]
    [InlineData("s1", "S2", false)]
    public void TestCaseInsensitiveEquality(string s1, string s2, bool areEqual)

    по сравнению с

    [TestMethod]
    public void TestSameStringDifferentCase()
    {
    TestCaseInsensitiveEquality("s1", "S1", true);
    }
    [TestMethod]
    public void TestDifferentStringDifferentCase()
    {
    TestCaseInsensitiveEquality("s1", "S2", false);
    }

    Мне представляется, что читабельность во втором случае не ухудшилась, а возможно и улучшилась.

    А как происходит отладка теста с конкретным набором параметров? В случае с явным описанием - вызов отладчика тривиален.

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

    ОтветитьУдалить
    Ответы
    1. Михаил, в случае параметризованного теста мы экономим 3 строки, плюс время, затраченное на вразумительное именование, которое станет весьма важным, когда число сценариев для одного теста станет больше 5.

      По поводу отладки: вызов отладки также тривиален в случае с параметризованными тестами, поскольку в результате тестовый фреймворк просто генерирует два теста и они четко видны в Test Expolrer-е: посмотрите на скриншот со студии, который я привожу в посте. Там просто каждая строка для каждого параметризованного кейса и их можно отлаживать и запускать независимо.

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

      Правда выяснил заодно, что в NUnit обнаружение 60 тысяч тестов из через TestCaseSource тормозит просто адово (

      Удалить