Pull to refresh

Множественные Assertion’ы без прерываний в одном юнит-тесте на примере NUnit

Reading time4 min
Views18K


В практике юнит-тестирования часто возникает желание сделать несколько Assertion'ов в одном тест-методе. В теории же, такой подход критикуется с двух основных позиций. Во-первых, семантически, один тест должен проверять только один кейс, не разрастаться. Во-вторых, при падении одного из Assertion’ов в цепочке, выполнение теста прервется и мы увидим сообщение об ошибке лишь от него, а до всех остальных дело не дойдет, что не даст наиболее полной картины произошедшего. Первый аргумент безусловно резонен и при написании тестов его всегда следует держать в голове, но фанатичное следование этому принципу зачастую не представляется разумным (пример далее). Устранению же второй проблемы посвящен этот пост. Будет представлен небольшой класс, позволяющий просто и лаконично обеспечить исполнение нескольких Assertion’ов без прерывания выполнения метода и с выводом сообщения об ошибке каждого из них.

Итак, предположим, у нас есть класс Size, который, помимо прочего, принимает параметром конструктора значение в дюймах, а в себе содержит аксессоры для получения количества целых футов и оставшихся дюймов, т.е., передав на вход 16, мы получим 1 фут и 4 дюйма (в одном футе 12 дюймов).
public class Size
{
    public int Feet { get; private set; }
    public int RemainderInches { get; private set; }

    public Size(int totalInches)
    {
        // код конструктора
    }

    //...
}

Чтобы не растекаться тестами конструктора по древу и вместе с тем обеспечить годное покрытие хочется написать что-то вроде:
[Test]
public void ConstructorSuccess()
{
    var zeroSize = new Size(0);
    var inchesOnlySize = new Size(2);
    var mixedSize = new Size(15);
    var feetOnlySize = new Size(36);

    Assert.That(zeroSize.Feet == 0 && zeroSize.RemainderInches == 0, "Zero size");
    Assert.That(inchesOnlySize.Feet == 0 && inchesOnlySize.RemainderInches == 2, "Inches-only size");
    Assert.That(mixedSize.Feet == 1 && mixedSize.RemainderInches == 3, "Inches and feet size");
    Assert.That(feetOnlySize.Feet == 3 && feetOnlySize.RemainderInches == 0, "Feet-only size");
}

Disclaimer: Вместо одной проверки на истинность можно (и даже хорошо бы) использовать по две проверки на равенство, но в данном коротком примере это не принципиально, а код бы усложнило.

Ясно, что если выделять по методу на каждый такой Assertion, то наш тест-класс очень быстро обрастет огромным числом методов и в реальности, в результате, будем иметь сотни тестов, но толку не больше. Однако, в показанном подходе, как уже было сказано, при падении одного из Assertion’ов данных от остальных мы не увидим, т.к. выполнение метода остановится.

Приступим к устранению этого неудобства.

В NUnit падение теста происходит при возникновении любого непойманного Exception’a, а сам класс Assert при неудаче бросает AssertionException с полными сообщениями об ошибках. Таким образом, по сути, нам нужно обеспечить отлов исключений на протяжении тест-метода, накапливание их сообщений и вывод накопленного в конце. Естественно, что заниматься этим явно, прямо в коде самого теста — страшный ужас.

После некоторых размышлений, для этих целей был предложен класс-аккумулятор, использование которого внутри тест-метода из примера выше выглядит следующим образом:
var assertsAccumulator = new AssertsAccumulator();

assertsAccumulator.Accumulate(
    () => Assert.That(zeroSize.Feet == 0 && zeroSize.RemainderInches == 0, "Zero size"));
assertsAccumulator.Accumulate(
    () => Assert.That(inchesOnlySize.Feet == 0 && inchesOnlySize.RemainderInches == 2, "Inches-only size"));
assertsAccumulator.Accumulate(
    () => Assert.That(mixedSize.Feet == 1 && mixedSize.RemainderInches == 3, "Inches and feet size"));
assertsAccumulator.Accumulate(
    () => Assert.That(feetOnlySize.Feet == 3 && feetOnlySize.RemainderInches == 0, "Feet-only size"));

assertsAccumulator.Release();

Другой пример использования (надеюсь, код говорит сам за себя и понятен без комментариев):
Result<User> signInResult = authService.SignIn(TestUsername, TestPassword);

var assertsAccumulator = new AssertsAccumulator();
assertsAccumulator.Accumulate(() => Assert.That(signInResult.IsSuccess));
assertsAccumulator.Accumulate(() => Assert.That(signInResult.Value, Is.Not.Null));
assertsAccumulator.Accumulate(() => Assert.That(signInResult.Value.Username, Is.EqualTo(TestUsername)));
assertsAccumulator.Accumulate(() => Assert.That(signInResult.Value.Password, Is.EqualTo(HashedTestPassword)));
assertsAccumulator.Release();

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

Реализация AssertsAccumulator’a выглядит так:
public class AssertsAccumulator
{
    private StringBuilder Errors { get; set; }
    private bool AssertsPassed { get; set; }

    private String AccumulatedErrorMessage
    {
        get
        {
            return Errors.ToString();
        }
    }

    public AssertsAccumulator()
    {
        Errors = new StringBuilder();
        AssertsPassed = true;
    }

    private void RegisterError(string exceptionMessage)
    {
        AssertsPassed = false;
        Errors.AppendLine(exceptionMessage);
    }

    public void Accumulate(Action assert)
    {
        try
        {
            assert.Invoke();
        }
        catch (Exception exception)
        {
            RegisterError(exception.Message);
        }
    }

    public void Release()
    {
        if (!AssertsPassed)
        {
            throw new AssertionException(AccumulatedErrorMessage);
        }
    }
}

Как видно, наружу выставлены лишь два метода, Accumulate() и Release(), использование которых довольно прозрачно. Прием делегата методом Accumulate делает класс очень универсальным, можно передавать любые виды Assertion’ов (как и показано в примере с signInResult) и при необходимости класс можно очень легко адаптировать под любой другой тестовый фреймворк сменив только тип бросаемого Exception’a внутри Release().

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

В заключение хочется напомнить, что фанатичное следование какому-либо принципу редко является чем-то хорошим, и чрезмерно использование такого класса — не исключение. Нужно понимать, что использовать его можно только тогда, когда несколько Assertion’ов действительно проверяют одну семантическую изолированную операцию или сценарий и размещение их в одном тесте оправдано.
Tags:
Hubs:
+11
Comments26

Articles

Change theme settings