Pull to refresh

Assert DSL на примере .Net

Reading time8 min
Views3.6K
Никто уже не отрицает полезность тестов в любой сколько-нибудь сложной системе. Без тестов очень быстро можно скатиться в хаос и проводить большую часть времени в отладчике, занимаясь поиском и отловом косвенных эффектов от изменений той или иной части приложения. Тесты важны, нужны и так далее по тексту.

По науке, тесты являются документированием системы. Грамотно написанные тесты дают понять, как работает система, как ведет себя, причем читаться все это должно как готовая спецификация на поведение системы. Т.е. в идеале должен получаться связный и понятный текст. Это идеал, к которому постепенно приближаются методы тестирования, начиная от юнит тестирования и наиболее явно проявляясь в поведенческом/приемочном тестировании, когда сами тесты уже пишутся на языке бизнеса (в этом моменте вспоминаем Fitnesse).

При написании тестов не стоит скупиться на строчки кода и классы, важно только их правильно структурировать. Я считаю, что может быть вполне нормальной ситуация, когда у вас тестовый класс состоит только из одного тестового метода – не надо этого стесняться, это гораздо лучше, чем классы на 20 экранов. HD экранов.

В общем, все должно быть направлено на максимальную ясность и четкость тестов, чтобы явно было видно все взаимосвязи. Чтобы можно было восстановить логику программы по одним лишь тестам. В дело читабельности пойдет не только Assert DSL (Domain Specific Language), но и именование файлов, подход Arrange Act Assert. Все это не новые подходы как оказывается, но широкой известности пока не получившие, судя по тому, что я вижу в окружающих меня проектах. Да и сам я натолкнулся на новые темы случайно, изучая исходные коды StructureMap.

Чтобы не томить, сразу расскажу какие основные шаги предлагаются для улучшения тестов:
  • Именовать тестовые файлы по основному методу, который тестируется.
  • Использовать DSL  для создания объектов, чтобы методы делать максимально лаконичными.
  • Стараться писать тесты в стиле «один тестовый метод – один assert».
  • Структурировать внутренности теста.
  • Создать и использовать Assert DSL.

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



В широком смысле это укладывается в парадигму Arrange Act Assert, которая говорит о том, что надо четко выделять подготовку к тесту, действие, проверку. В данном случае получится, что каждый тестовый класс будет описывать конкретную подготовку к проведению теста. В SetUp или в FixtureSetUp  будет в идеале задаваться Act и в тестах уже проверятся результат – Assert.

Лучше всего показать это на примере.

Пусть у нас есть класс Pirate, который является реализацией действий и возможностей пирата в игре. Пират может передвигаться по полю, забирать и оставлять золото, сражаться, плавать, убивать и умирать. Много чего он может и было бы неправильно все тесты пихать в один файл и разграничивать тесты регионами. Гораздо лучше сделать несколько тестовых классов, например:
  • PirateMovementTests
  • PirateAndGoldTests
  • PirateDefaultSettingsTests
  • PirateActionTests

Хм, у пирата не такие ветвистые и больше методы чтобы посвящать им по отдельному классу. Но зато у нас есть игровое поле, в котором методы с большей ответственностью есть. Допустим класс Field который отвечает за создание игрового поля и общий контроль передвижения. У него тесты будут такие:
  • WhenCreateField
  • WhenCreatePlayableField
  • WhenGenerateShips

После чего тесты внутри класса именовать по примеру:
  • MaxSizeShouldBeDefined
  • ShouldGenerateSeaOnBorder
  • ShouldGenerateShips

Тогда при просмотре тестов и результатов, можно читать их как When [game] Create Field [it] Should  Generate Sea On [field's] Border  – это почти чистый английский. С пиратами потребуется просто чуть больше написать в имени метода, т.е.
  • PirateShouldLostGoldIfHeKilled
  • ActionSurrenderShouldSendPirateOnShip

В случае, когда мы можем назвать тестовый класс со слова When, это чистый пример на Arrange Act Assert. Например:
  • Arrange – когда создается игровое поле. В инициализации тестового класса можно подготовить все для создания тестового игрового поля.
  • Act – создание игрового поля. Можно использовать как в самом тестовом методе, так и в инициализации тестового метода.
  • Arrange – проверка исполнения. В идеале тестовый метод может только из него и состоять.

Пример:
[TestFixture]
public class WhenCreateField {
    private Field field;
    private TestEmptyRules rule;

    [TestFixtureSetUp]
    public void ClassInit() {
        // Arrange, Act
        rule = new TestEmptyRules();
        field = rule.Field;
    }

    [Test]
    public void MaxSizeShouldBeDefined() {
        //Assert
        Position.MaxColumn.ShouldBeEqual(rule.Size);
        Position.MaxRow.ShouldBeEqual(rule.Size);
    }

    [Test]
    public void FieldShouldBeCreated() {
        //Assert
        field.ShouldBeNotNull();
    }

    [Test]
    public void ItShouldBeGrassByDefault() {
        //Assert
        field.GetPlayableArea()
            .ShouldContain()
            .OnlyCellsOf(CellType.Grass);
    }
    …
}

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

Один тест – одна проверка. Это  можно видеть по примерам выше. Сразу понятно что и как тестируется и что должно получиться в итоге. Часто велик соблазн дописать Assert в уже существующий тест – это у нас это называется «дописать зайчика», так вот «зайчики» потом могут сильно аукнуться, так как будут вносить смущения в умы и красть дополнительное время при поднятии тестов после рефакторинга.

Далее очень важна структурированность тестов. Сравните:
[Test]
public void HeMayKillFoes() {
    var airplaneCell = new AirplaneCell(4, 5);
    var player = Black.Pirate;
    var foe = Red.Pirate;

    airplaneCell.PirateComing(foe);

    airplaneCell.PirateComing(player);

    airplaneCell.Pirates.ShouldContain().Exact(player);
}

[Test]
public void HeMayKillFoes() {
    //Arrange
    var airplaneCell = new AirplaneCell(4, 5);
    var player = Black.Pirate;
    var foe = Red.Pirate;

    airplaneCell.PirateComing(foe);

    //Act
    airplaneCell.PirateComing(player);

    //Assert
    airplaneCell.Pirates.ShouldContain().Exact(player);
}

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

Еще пример:
 [Test]
 public void AtSecondTimeHeCanNotTransferToShip() {
     //Arrange
     var airplaneCell = new AirplaneCell(4, 5);
     var pirate = Black.Pirate;

     airplaneCell.PirateComing(pirate);

     //Act
     airplaneCell.Transfer();
     airplaneCell.PirateComing(pirate);
     airplaneCell.Transfer();

     //Assert
     pirate.State.ShouldBeEqual(PlayerState.Free);
 }

Выделены ключевые моменты и сразу понятен случай, для которого создается тест. При коридороном тестировании такого подхода выяснилось, что Act является самым спорным моментом в написании тестов. Разные люди часто по-разному видят, что надо включать в настройку теста, а что в тестируемое действие. Этот же момент упоминается и во всех статьях посвященных ААА, там же дается и ответ, к которому пришли и мы с коллегами: разграничивайте как считаете нужным и как договоритесь. Да, спасибо Кэп! Строгих правил нет.

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

Честно сказать, мне такой подход нравится гораздо больше, потому что первым порывом идет написать то свойство, которое надо протестировать. Потом уже понимаешь, что надо вписать нужный Assert, на которые я раньше вешал сокращения для InteliSense. Например для Assert.AreEqual / Assert.That( $actual$, Is.EqualTo($expected$))  было aae, т.е. набрал эту комбинацию, нажал Tab и уже есть шаблон в коде. Но это неудобно, это надо было настраивать ReSharper, помнить что сначала идет assert.

Гораздо удобнее использовать потенциал языка в плане написания методов расширений и использовать его. Таким образом у вас всегда будет подсказка, для всех разработчиков независимо от того, пользуются они ReSharper или же CodeAssist, или еще какой-либо системой.



На иллюстрации выше вы можете видеть, что код пишется на интуитивном, смысловом уровне. Первым порывом идет написать значение для тестирования, потом думаем как и с чем его сравнивать и последним шагом идет запись желаемого значения. Прошу обратить внимание, что IntelliSense подсказывает тип ожидаемого значения для проверки.



На этой иллюстрации показан стандартный подход к написанию проверки для nUnit. Надо помнить, что сначала идет служебный код Assert.That. При написании значения для проверки IntelliSense не всегда помогает. На иллюстрации показана реальная работа без прокручивания к какому-либо элементу в выпадающем списке. После записи значения для проверки опять же надо «вспомнить» служебное слово, написать тип проверки и при внесении ожидаемого значения IntelliSense бессилен.

Далее, сравните визуально два подхода:
[Test]
public void FieldShouldBeCreated() {
    //Assert
    field.ShouldBeNotNull();
}

[Test]
public void FieldShouldBeCreated() {
    //Assert
    Assert.IsNotNull(field);
}

И еще пример:
[Test]
public void MaxSizeShouldBeDefined() {
    // DSL
    Position.MaxColumn.ShouldBeEqual(rule.Size);
    Position.MaxRow.ShouldBeEqual(rule.Size);

// nUnit
    Assert.That(Position.MaxColumn, Is.EqualTo(rule.Size));
    Assert.That(Position.MaxRow, Is.EqualTo(rule.Size));

// MSTest
Assert.AreEqual(Position.MaxColumn, rule.Size);
    Assert.That(Position. MaxRow, rule.Size);
}

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

Еще одним плюсом написания методов расширений на проверку тестов может являться их логическая структура, созвучная с доменной моделью. Лучше всего это продемонстрировать опять же на примере:
[Test]
public void ItShouldBeGrassByDefault() {
    //Assert
    field.GetPlayableArea()
        .ShouldContain()
        .OnlyCellsOf(CellType.Grass);
}

[Test]
public void ItShouldBeGrassByDefault() {
    //Assert
    var playableArea = field.GetPlayableArea();
    Assert.IsTrue(playableArea.All(cells => cells.CellType == CellType.Grass));

    //or another case
    Assert.IsTrue(field.GetPlayableArea()
        .All(cells => cells.CellType == CellType.Grass));
}

Какой способ понятнее?

Я думаю, что не составит труда самим разработать свой тестовый DSL для конкретного использования. Но как пример (еще не отшлифованного использования):
public static CollectionAssertCases ShouldContain(this IEnumerable enumerable) {
    return new CollectionAssertCases(enumerable);
}

public class CollectionAssertCases {
    private readonly IEnumerable enumerable;

    public List AsList {
        get { return new List(enumerable); }
    }

    public CollectionAssertCases(IEnumerable enumerable) {
        this.enumerable = enumerable;
    }

    public void Elements(params T[] elements) {
        Assert.That(enumerable, Is.EquivalentTo(elements));
    }
}

public static void OnlyCellsOf(this CollectionAssertCases collection, CellType cellType) {
    Assert.IsTrue(collection.AsList.All(c => c.CellType == cellType));
}

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

Еще полезное применение можно найти при техническом тестировании кода. Например, я не хочу чтобы какое-либо свойство класса вдруг стало доступно для записи, и я могу написать на это тест легко.
[Test]
public void PiratesStateCanNotBeSetDirectly() {
    //Arrange
    var pirate = Black.Pirate;

    //Assert
    pirate.Property("State").ShouldBeReadonly();
}

Тест не загромождается ненужными методами и действиями при работе с рефлексией, но дает контроль над изменением API, если вы работаете в большой или неопытной команде, где не всегда удается договориться о том или ином использовании свойств. Работает так же как «защита от дурака». Согласитесь, что тест не вызовет особенно ярких эмоций даже у закостеневших в своем неприятии рефлексии коллег.

Опять же по итогам коридорного тестирования на коллегах, было обнаружено, что сила привычки великая вещь – сразу прочитать тесты и писать их несколько непривычно. Глаза ищут статический класс Assert и не находят, что вызывает легкое недоумение, при этом выяснилось, что слово Assert в комментариях в глаза почему-то не бросается. Я думаю, что это быстро пройдет, так как гибкость ума должна присутствовать у разработчиков все же.

В плюсы к описанному подходу можно отнести:
  • Процесс написания теста по ходу человеческой мысли;
  • Явное указание типа ожидаемых данных в подсказке при составлении Assert выражения;
  • Читабельность проверки работы теста;
  • Читабельность тестов в целом.

К минусам я бы отнес:
  • Порог вхождения. Новичкам надо будет объяснить, что существует в проекте DSL для проверок и правила его построения, для того, чтобы находить необходимые методы с помощью IntelliSense.

 
Tags:
Hubs:
Total votes 23: ↑20 and ↓3+17
Comments11

Articles