Andrey on .NET | MSTest V2 – Часть 2: Данные для тестов

MSTest V2 – Часть 2: Данные для тестов

.NET logoЧасто одни и те же тесты необходимо выполнить для различного набора данных. Например, проверка валидатора, который гарантирует что длина строки укладывается в заданный интервал. По сути, это не менее четырех тестов, которые отличаются только самой строкой. Конечно, можно просто скопировать тест несколько раз, изменяя строку. Или можно вынести общий код в отдельный метод. Но легче всего воспользоваться возможностями MSTest V2.

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

[TestMethod]
public void LengthValidator_5Chars()
{
    // Arrange
    const string target = "12345";
    var validator = new StringLengthValidator();

    // Act
    bool result = validator.Validate(target);

    // Assert
    Assert.IsTrue(result);
}

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

public void LengthValidator_Common1(string? target, bool expectedResult) { … }

Тесты с параметрами

MS Test V2 позволяет создавать тесты, которые принимают параметры и могут быть запущенны несколько раз c различными значениями. Такие методы отмечаются атрибутом [DataTestMethod] вместо обычного [TestMethod].

[DataRow]

[DataRow] это самый простой способ передать в тест значения, заданные в виде параметров данного атрибута. Каждое его указание у метода – это одно выполнение данного теста с указанными параметрами.

При запуске теста сопоставление значений [DataRow] и параметров происходит по позициям. При этом в атрибуте значения сохраняются как object и перед передачей в тест приводятся к типу соответствующего параметра метода. Как следствие, число типа int может быть передано в параметр типа float.

Ограничение данного подхода заключается в свойстве самих атрибутов – они могут принимать только значения следующих типов: числа, string, char, bool, object, Type, перечисления (enum) и одномерные массивы из указанных типов.

Перепишем исходный пример с использованием [DataRow] и вместо группы методов получим всего один:

[DataTestMethod]
[DataRow(null, true)]
[DataRow("", false)]
[DataRow("1234", false)]
[DataRow("12345", true)]
[DataRow("1234567890", true)]
[DataRow("12345678901", false)]
public void LengthValidator_WithDataRow(string? target, bool expectedResult)
{
    // Arrange
    var validator = new StringLengthValidator();

    // Act
    bool result = validator.Validate(target);

    // Assert
    Assert.AreEqual(expectedResult, result, $"Failed string: '{target ?? "null"}'");
}

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

[DynamicData]

Но что делать, если недостаточно типов, которые можно передать через параметры атрибута? Воспользоваться другим атрибутом – [DynamicData]. Он позволяет указать статическое свойство или статический метод, которые будут предоставлять значения и имеют следующее объявление:

private static IEnumerable<object?[]> ИмяСвойства { get { … } }
private static IEnumerable<object?[]> ИмяМетода() { … }

При этом имя и модификатор доступа могут быть любыми.

У [DynamicData] есть два параметра. Первый это имя. Второй указывает на то, принадлежит ли имя свойству (DynamicDataSourceType.Property) или методу (DynamicDataSourceType.Method). По умолчанию считается что используется свойство.

Возвращаемое перечисление содержит массив значений типа object. Как и в случае с [DataRow], они сопоставляются с параметрами теста по позициям и приводятся к указанному у конкретного параметра типу перед передачей в тест. Но теперь, в отличии от [DataRow], нет ограничений на типы создаваемых объектов. Например, можно возвращать экземпляры классов.

Перепишем исходный пример с использованием метода, предоставляющего данные:

[DataTestMethod]
[DynamicData(nameof(GetTestStrings), DynamicDataSourceType.Method)]
public void LengthValidator_WithDynamicData(string? target, bool expectedResult)
{
    // Arrange
    var validator = new StringLengthValidator();

    // Act
    bool result = validator.Validate(target);

    // Assert
    Assert.AreEqual(expectedResult, result, $"Failed string: '{target ?? "null"}'");
}

private static IEnumerable<object?[]> GetTestStrings()
{
    yield return new object?[] { null, true };
    yield return new object?[] { string.Empty, false };
    yield return new object?[] { "1234", false };
    yield return new object?[] { "12345", true };
    yield return new object?[] { "1234567890", true };
    yield return new object?[] { "12345678901", false };
}

Чтобы избежать ошибки при указании имени свойства или метода, а также для удобства возможного рефакторинга, лучше использовать выражение nameof(…).

ITestDataSource

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

В этом случае можно создать свой атрибут, который реализует ITestDataSource (собственно, рассмотренные выше два атрибута это и делают):

public interface ITestDataSource
{
    IEnumerable<object?[]> GetData(MethodInfo methodInfo);
    string GetDisplayName(MethodInfo methodInfo, object?[] data);
}

GetData(…) отвечает за создание перечисления с тестовыми данными, которые будут по позициям сопоставлены с параметрами теста, приведены к соответствующим типам и переданы в тест. Параметр MethodInfo methodInfo предоставляет метаданные запускаемого метода с кодом теста.

Второй метод, GetDisplayName(…), отвечает за создание уникального имени теста, которое будет использоваться в отчетах. Здесь также присутствует параметр MethodInfo methodInfo, с помощью которого можно получить имя запускаемого метода.

Реализуем исходный пример теста, но уже с использованием своего атрибута:

[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public class CheckStringLengthAttribute : Attribute, ITestDataSource
{
    private readonly int _minLength;
    private readonly int _maxLength;

    public CheckStringLengthAttribute(int minLength, int maxLength)
    {
        _minLength = minLength;
        _maxLength = maxLength;
    }

    public IEnumerable<object?[]> GetData(MethodInfo methodInfo)
    {
        yield return new object?[] { null, true };
        yield return new object?[] { string.Empty, false };
        yield return new object?[] { new string('*', _minLength - 1), false };
        yield return new object?[] { new string('*', _minLength), true };
        yield return new object?[] { new string('*', _maxLength), true };
        yield return new object?[] { new string('*', _maxLength + 1), false };
    }

    public string GetDisplayName(MethodInfo methodInfo, object?[] data)
    {
        string suffix = data[0] is string testStr
            ? testStr.Length.ToString()
            : "NULL";

        return $"{methodInfo.Name}_StringLength{suffix}";
    }
}

Конструктор атрибута требует два значения, определяющие минимальную и максимальную длину строки. Уникальное имя теста в GetDisplayName(…) создается путем объединения исходного имени и длины тестовой строки.

В результате тест будет выглядеть следующим образом:

[DataTestMethod]
[CheckStringLength(5, 10)]
public void LengthValidator_WithITestDataSource(string? target, bool expectedResult)
{
    // Arrange
    var validator = new StringLengthValidator();

    // Act
    bool result = validator.Validate(target);

    // Assert
    Assert.AreEqual(expectedResult, result, $"Failed string: '{target ?? "null"}'");
}

Совместное использование атрибутов

Все три рассмотренные выше атрибута могут быть указаны несколько раз для одного и того же теста. Как правило это:

  1. [DataRow] – с различными наборами значений.
  2. [DynamicData] – с разными методами и свойствами, предоставляющими данные.
  3. реализация ITestDataSource – с разными параметры атрибута, что должно приводить к созданию различных наборов данных.

Кроме того, указанные атрибуты можно сочетать. Например, пусть метод IEnumerable<object?[]>  GetIntValues(…) создает некую заданную последовательность целых чисел. При помощи [DynamicData] они используется в нескольких тестах. Однако, в одном из них нужно проверять еще и значение null. В этом случае можно просто дополнительно указать DataRow]:

[DynamicData(nameof(GetIntValues), DynamicDataSourceType.Method)]
public void Test1(int val, bool expectedResult) { … }

[DynamicData(nameof(GetIntValues), DynamicDataSourceType.Method)]
[DataRow(null, true)]
public void Test2(int? val, bool expectedResult) { … }

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

Комментарии (2) -

Обе части довольно интересные и полезные. Опять же присоединюсь к самому первому комментарию - хотелось бы увидеть сравнение MSTest с остальными коллегами по цеху или более подробно узнать о причинах такого выбора.
Также интересно было узнать об используемых инструментах, IDE, плагинах и тп (не только по части unit-тестов).
Спасибо, что не забрасываете блог и Интересности - регулярно заглядываю.
P.S. Еще немного режет глаз русский текст в коде.

Rustamer Спасибо за отзыв.
Насчет сравнения MSTest c остальными - есть такая мысль (появилась как раз после комментария в первой части). Так что, возможно, будет такая статья.

Добавить комментарий