В практике юнит-тестирования часто возникает желание сделать несколько 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’ов действительно проверяют одну семантическую изолированную операцию или сценарий и размещение их в одном тесте оправдано.
Автор: Falco