В прошлый раз я рассказывал о том, что мне не нравится в 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:
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 может быть использовано для проверки ожидаемого результата, что может быть удобно. Но недостающие возможности легко моделируются через аргументы метода, так что это явно не станет проблемой при выборе тестового фреймворка.
>> "Во-первых, этот подход совсем не строготипизированный, что позволяет вернуть разнородные данные и отгрести во время исполнения."
ОтветитьУдалить+1 идея для написания анализатора под эту проблему
Угу. Уже завел репо на гитхабе:)
УдалитьА напишите в мс, что бы они в mstest параметризацию запилили :)
ОтветитьУдалитьВы так пишите, как будто я какой-нибудь Скотт Гатри, который распоряжается бюджетами на тысячи человек:)).
УдалитьЕдинственный вариант - это завести тикет на user voice и собрать приличное количество голосов. Но я почти уверен, что ответ будет однозначным: "мы не хоим инвестировать во что-то, что устарело уже давно и для чего есть куча вменяемых альтернатив".
Этот комментарий был удален автором.
ОтветитьУдалитьСпасибо за статью, TheoryData стал для меня открытием.
ОтветитьУдалитьХотя, перед прочтением статьи я был настроен скептически, думал, что всё знаю о параметризированных тестах xUnit, и в очередной раз убедился в том, что не самый умный. ;)
Всё-таки хорошая у нас профессия, каждый день можно получать удовольствие от новых знаний.
Да, я как-то TheoryData сам не сразу нашел:), хотя как нашел, понял, что штука очень полезная.
УдалитьХотел бы задать немного нелепый вопрос, о котором думаю еще со времени предыдущего поста (в нем было про отсутствие параметризированных тестов в 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, и в основном он сводился к мучительному подбору раннеров и адаптеров соответствующих версий.
Михаил, в случае параметризованного теста мы экономим 3 строки, плюс время, затраченное на вразумительное именование, которое станет весьма важным, когда число сценариев для одного теста станет больше 5.
УдалитьПо поводу отладки: вызов отладки также тривиален в случае с параметризованными тестами, поскольку в результате тестовый фреймворк просто генерирует два теста и они четко видны в Test Expolrer-е: посмотрите на скриншот со студии, который я привожу в посте. Там просто каждая строка для каждого параметризованного кейса и их можно отлаживать и запускать независимо.
Понял, спасибо!
УдалитьХочу еще раз поблагодарить за объяснения про параметризированные тесты. Пригодилось в проекте, в котором файлы данных для тестов шарятся с другими проектами.
УдалитьПравда выяснил заодно, что в NUnit обнаружение 60 тысяч тестов из через TestCaseSource тормозит просто адово (