Решил собрать воедино свой опыт по тестированию ПО, который скопился на данный момент. Это первая статья, она посвящена терминологии, понятиям и общим вопросам, касающихся того, как тестировать возвращаемое значение, изменение состояния и обращение к внешней стороне.
- Общая теория и соображения по вопросам тестирования
- Какие бывают тесты?
- Что мы тестируем?
- Структура теста
- Как именовать тесты?
- TDD
- Тестирование возвращаемого значения и изменения состояния
- Зависимости
- Установка зависимости через параметр конструктора
- Установка зависимости через свойство или метод
- Установка зависимости через фабричный класс
- Установка зависимости через локальный фабричный метод
- Тестирование обращения к внешней стороне (взаимодействия)
Общая теория и соображения по вопросам тестирования
В деле формирования общей картины по теме “Тестирование ПО” я в первую очередь ориентировался на книгу Роя Ошероува “Искусство автономного тестирования с примерами на C#”. Так что дальнейшее изложение можно воспринимать, в какой-то степени, как конспект этой книги. Если у Вас есть свои рекомендации по тому, что почитать на тему тестирования, напишите, пожалуйста, об этом в комментариях.
Какие бывают тесты?
Существует несколько разных классификаций тестов. Например такая:
- модульные тесты (unit-тесты);
- функциональные тесты;
- интеграционные тесты;
- нагрузочные тесты;
- тесты UI.
Вслед за Роем остановимся на терминах: автономные тесты, интеграционные тесты и оставим за скобками (т.е. не будем в рамках данной статьи касаться этого вопроса): тесты UI, нагрузочные и т.п.
Под автономным тестом можно понимать тест, который совмещает в себе unit-тест и функциональный тест. Особенности автономного теста, которые мне показались наиболее критичными:
- тест должен быстро выполняться;
- тест не контактирует с внешним миром, типа файловой системой, БД и прочим;
- тест проверяет некоторую единицу работы.
Интеграционный тест может работать долго, для него позволительно быть не изолированным от внешнего окружения.
Зафиксируем следующие связи: автономный – тестирует некоторую единицу работы, интеграционный – тестирует взаимодействие различных компонент без ограничений на используемые ресурсы.
Что мы тестируем?
Мы тестируем:
- возвращаемое значение;
- изменение состояния;
- обращение к внешней стороне.
Возвращаемое значение
Это наиболее простой вид функциональности, который нужно протестировать. По сути, здесь мы тестируем возвращаемое методом (или функцией) значение. В принципе, ничего сложно: создали нужный объект, вызвали требуемый метод на определенном наборе аргументов (или без них), и проверили возвращаемый им результат на предмет его совпадения с некоторым ожидаемым значением.
Изменение состояния
Здесь проверяем, что набор манипуляций, который мы сделали с объектом, привел к тому, что его состояние изменилось. Состояние определяется набором внутренних атрибутов (полей). Как правило, к ним мы напрямую доступ не имеем (а если имеем, то тут стоит задуматься, ибо это не очень хорошая практика), поэтому проверять изменение состояния приходится косвенным образом. Например, мы запустили исполнение через метод Run() и проверили, что все запустилось через свойство IsRunned или вызов метода с именем похожим на GetState().
Обращение к внешней стороне
Это наиболее интересный вид тестирования. Идея в том, что наша сущность может обращаться к внешней стороне, и нам необходимо протестировать, что это взаимодействие было выполнено ожидаемым образом. При этом внешнюю сторону мы никак не контролируем (например, если это какой-то веб-сервис). Тут есть два момента про которые стоит помнить, касающиеся подмены внешней стороны в тесте:
- либо мы делаем заключение по объекту, который обращается к внешней стороне, в этом случае она (подмена) называется заглушкой (stub);
- либо мы делаем заключение, анализируя изменения в сущности, к которой обращались, в этом случае подмененная “внешняя сторона” называется подставкой (mock).
Если вы пока не знаете, заглушка это или подставка, то называйте ее подделкой (fake). Более подробно про это будет рассказано далее.
Структура теста
Довольно часто рекомендуют проектировать тест по следующему паттерну:
- Arrange (Подготовка).
- Act (Действие).
- Assert (Проверка).
Так же можно встретить и такой подход:
- Given (Дано).
- When (Когда – деятельность, которая тестируется)
- Then (Тогда – проверяемые аспекты)
Как именовать тесты?
Как вариант, можно придерживаться следующих правил (в большей степени относится к C#):
- Если у нас есть проект с именем SimpleProject, то тесты лучше размещать в проекте с именем SimpleProject.Tests.
- В проекте SimpleProject есть набор классов, который нужно протестировать. Для этого в SimpleProject.Tests, повторяя ту же иерархию пространств имен, создаем классы с тестами, имена которых выглядят так: ClassNameTests.
- Имена тестов можно задавать по следующему шаблону:
UnitOfWork_Scenario_ExpectedBehavior, где
- UnitOfWork – имя единицы работы (например, имя метода или тестируемая функциональность).
- Scenario – сценарий тестирования.
- ExpectedBehavior – ожидаемое поведение.
Здесь есть смешение стилей именования CamelCase и snake_case, но благодаря читаемости, можно считать это допустимым (или нет, зависит от правил, которые у Вас приняты в команде).
TDD
Существует такой подход к разработке ПО как TDD (Test Driven Development). Суть в том, что мы сначала пишем тесты, потом код. Если вкратце, то работая в рамках данной методологии мы придерживаемся следующего правила: “Красный – зеленый – рефакторинг”. Т.е. пишем тест, который будет тестировать будущую функциональность, запускаем его (тест), он, естественно, не проходит (горит красным). Добавляем функциональность в проект, запускаем снова тесты, и делаем это до тех пор, пока тест не будет завершаться успешно, т.е. не станет зеленым. После этого проводим рефакторинг кода и тестов (убираем повторы и т.п.).
Идейно противоположной методологией (если так можно сказать) является следующая: вначале написали весь код, а тестим его потом. Вот так делать не нужно.
Как вариант, можно придерживается некоторого компромиссного решения, когда код и тесты пишутся параллельно, чаще код раньше, но очень маленькими порциями. Хотя может это и не совсем верно. Главное, чтобы код оставался тестопригодным.
Тестирование возвращаемого значения и изменения состояния
Выше мы говорили, что можно выделить три вида тестов. Первые два: тестирование возвращаемого значения и изменения состояния, как правило, не составляет труда реализовать. В самом простом случае вы создаете тест, который проверяет возвращаемое значение, либо производит манипуляцию с объектом и смотрит, что его состояние изменилось. Пример:
[Test] public void Sum_SumOfTwoNumbers_SumIsCorrect() { var expectedValue = 10; var sumValue = MyMathLib.Sum(3, 7); Assert.AreEqual(expectedValue, sumValue); }
Бывает так, что необходимо проверить сразу несколько случаев, тогда возможны два варианта, в первом: в коде тестового метода создается коллекция с входными и ожидаемыми значениями, после этого в цикле перебираются эти наборы, поставляются в тестируемую единицу и проверяются. Во втором используются специальные атрибуты (например, в NUnit – это TestCase), через который передаются нужные значения в тест. Лучше использовать второй подход, т.к. в этом случае будет создано несколько тестов с разными значениями аргументов. Проблема первого варианта ещё состоит в том, что если в процессе прохождения одного из кейсов возникнет ошибка, то все тесты, после него выполнены не будут. Вариант с TestCase’ами предполагает, что будут выполнены все тесты.
[TestCase(3, 7, 10)] [TestCase(10, -1, 10)] [TestCase(5, 0, 5)] public void Sum_SumOfTwoNumbers(double v1, double v2, double expectedValue) { sumValue = MyMathLib.Sum(v1, v2); Assert.AreEqual(expectedValue, sumValue); }
Более подробно о том, как создавать наборы тестов, обращайтесь к документации по framework’у тестирования, который вы используете.
Часто бывает так, что нашей единице тестирования нужно обратиться к внешней стороне (например, за данными), после этого она производит вычисления, а мы, в свою очередь, тестируем полученные результаты. В этом случае нам нужно как-то подменить реальный внешний компонент (базу данных, веб-сервис и т.п.) на некоторую заглушку, которая будет выдавать управляемый нами результат, чтобы протестировать логику. Для того, чтобы это можно было сделать, необходимо выполнить разрыв зависимости. Что это такое и как с этим работать расскажем в следующем блоке
Зависимости
Начнем с зависимостей. Редко классы существуют сами по себе и ни от кого не зависят. Эти зависимости объекты классов используют в своей логике, а наша задача протестировать непосредственно саму логику. Как это сделать? Ответ: необходимо разорвать зависимости. По сути, нам нужно сделать так, чтобы используемые сущности можно было подменять в тестах.
Первое что нужно сделать – это абстрагироваться от конкретных классов и объектов, заменив их на интерфейсы. Второе – подставить свою реализацию интерфейса в тесте. Для осуществления этой операции необходимо оставить в коде зазор(ы) (seams) – места, куда можно подставлять иную функциональность.
Это сразу приводит к следующим вопросам: а куда эту реализацию подставлять, и где размещать упомянутые выше зазоры?
Это можно делать через:
- конструктор;
- свойства;
- параметр метода;
- фабричный класс;
- локальный фабричный метод.
Разберем эти варианты на следующем примере: представьте, что мы разрабатываем систему мониторинга температуры, от которой требуется:
- выдавать информацию о том, было ли сегодня превышение температуры выше заданного порога;
- предоставлять минимальное и максимальное значения температуры за заданный интервал.
Эту информацию могут использовать сторонние системы для своих нужд, а мы должны для них ее подготовить.
Создадим два сервиса, первый AlarmTemperService – для отслеживания превышений и MinMaxTemperService для определения мин. и макс. значений температуры за заданный интервал. Для представления данных нам понадобится класс Temper, для получения данных из БД – реализация DataMapper’а с интерфейсом ITemperDataMapper.
interface ITemperDataMapper { List<Temper> GetDataAtRange(DateTime start, DateTime stop); } class Temper { public double Temperature { get; set; } public ValueQuality Quality { get; set; } public DateTime TimeStamp { get; set; } }
DataMapper – это шаблон проектирования, который предполагает создание прослойки между объектом и БД. Реализация в коде ITemperDataMapper’а нас, в данном случае, не очень интересует, главное, что мы выделили интерфейс.
Для наглядности, код будем сильно упрощать, не загромождая его лишними проверками и т.п.
TemperDataMapper ходит в БД, достает оттуда нужные данные, вытаскивает из них информацию о температуре и возвращает ее нам в виде списка объектов Temper.
Для примера приведем упрощенную реализацию сервиса AlarmTemperService:
class AlarmTemperService { readonly double _limit; readonly ITemperDataMapper _temperDataMapper; public AlarmTemperService(double limit) { _limit = limit; _temperDataMapper = new TemperDataMapper(); } public bool IsAlarm(DateTime start, DateTime stop) => _temperDataMapper.GetDataAtRange(start, stop) .Where(v => v.Quality == ValueQuality.GOOD) .Where(v => v.Temperature >= _limit) .Any(); }
Как вы можете видеть TemperDataMapper инициализируется внутри конструктора, и в дальнейшем, используется для получения данных.
Для того, чтобы протестировать этот сервис необходимо в тесте как-то подменить TemperDataMapper, чтобы он не работал с реальной БД, а выдавал для нас какие-нибудь демо данные. Сделаем это с использованием указанных выше способов.
Установка зависимости через параметр конструктора
Как мы уже отметили выше, нам нужно подменить реализацию интерфейса ITemperDataMapper на свою в классе AlarmTemperService. Это означает, что объект класса, реализующего ITemperDataMapper должен создаваться где-то снаружи и передаваться в AlarmTemperService. Реализуем передачу через аргумент конструктора, для этого выполним рефакторинг, в рамках которого добавим в конструктор аргумент ITemperDataMapper tdm.
class AlarmTemperService { // … public AlarmTemperService(double limit, ITemperDataMapper tdm) { _limit = limit; _temperDataMapper = tdm; } // … }
Теперь в тесте мы может написать примерно следующее:
//… [Test] public void IsAlarm_GetNullData_False() { // Arrange const double limitValue = 10; var ats = new AlarmTemperService(limitValue, MakeTemperDataMapper()); // Act var IsAlarm = ats.IsAlarm(new DateTime(2021, 9, 1), new DateTime(2021, 10, 1)); // Assert Assert.IsTrue(IsAlarm); } //…
Объект, реализующий ITemperDataMapper, мы создаем с помощью фабрики MakeTemperDataMapper(). Таким образом, у нас появилась возможность тестировать функциональность метода IsAlarm, без необходимости организовывать подключение к БД с возможностью самостоятельно определять, какие данные будут переданы при тех или иных значениях параметров start и stop.
Установка зависимости через свойство или метод
Другой вариант установки зависимости – это передать ее через метод или задать через свойство. Принципиальное отличие этого подхода от предыдущего состоит в том, что если мы передаем зависимость через параметры конструктора, то это делается в момент создания объекта, функционал которого мы тестируем. Если ли же мы задаем зависимость через свойство или метод, это можно сделать непосредственно перед тестированием функционала.
Реализация установки зависимости через свойства предполагает добавление специального свойства в класс AlarmTemperService:
class AlarmTemperService { // ... public ITemperDataMapper TemperDataMapper { get => _temperDataMapper; set => _temperDataMapper = value; } // ... }
Если вы выбрали второй вариант, то необходимо реализовать метод для задания ITemperDataMapper’а.
class AlarmTemperService { // ... public void SetTemperDataMapper(ITemperDataMapper tdm) => _temperDataMapper = tdm; // ... }
Пример теста с вариантом задания зависимости через свойство.
[Test] public void IsAlarm_GetDataWithAlarm_True() { // Arrange const double limitValue = 10; var ats = new AlarmTemperService(limitValue); ats.TemperDataMapper = MakeTemperDataMapper(); // Act var IsAlarm = ats.IsAlarm(new DateTime(2021, 9, 1), new DateTime(2021, 10, 1)); // Assert Assert.IsTrue(IsAlarm); }
Установка зависимости через фабричный класс
Два рассмотренные выше подхода предполагали передачу DataMapper’а в тестируемый класс. Работа с фабричным классом предполагает предварительную настройку фабрики, которая будет для нас поставлять нужный вариант реализации ITemperDataMapper.
Реализуем фабрику:
public static class TemperDataMapperFactory { private static Type temperDataMapperType; static TemperDataMapperFactory() => temperDataMapperType = typeof(TemperDataMapper); public static void SetTemperDataMapper(Type tdmType) { if (!tdmType.GetInterfaces().Contains(typeof(ITemperDataMapper))) { throw new ArgumentException("This type is not support ITemperDataMapper interface"); } temperDataMapperType = tdmType; } public static ITemperDataMapper Create() => (ITemperDataMapper)Activator.CreateInstance(temperDataMapperType); }
Объект, реализующий интерфейс ITemperDataMapper, создается с помощью метода Create(). Этот метод использует статическую переменную temperDataMapperType хранящую тип, экземпляр которого будет создаваться. При первом обращении к этому классу вызывается статический конструктор, в котором переменной temperDataMapperType присваивается значение – тип, определяющий класс TemperDataMapper – это тот, что работает с реальными данными. Т.е. по умолчанию все будет сделано автоматически, и самостоятельно ничего “подкручивать” не нужно.
Для задания класса, объекты которого будет создавать фабрика используется метод SetTemperDataMapper. Через него передается нужный тип, в самом методе предварительно делается проверка, что этот тип реализует интерфейс ITemperDataMapper.
Модифицируем код конструктора класса AlarmTemperService. Напоминаю, что изменению подлежит самый первый вариант, а не тот, в котором мы уже изменили конструктор или добавили свойство/метод.
class AlarmTemperService { // ... public AlarmTemperService(double limit) { this.limit = limit; temperDataMapper = TemperDataMapperFactory.Create(); } // ... }
Т.е. единственное, что сделали, это заменили вот эту строчку
temperDataMapper = new TemperDataMapper();
на эту:
temperDataMapper = TemperDataMapperFactory.Create();
Для того, чтобы в тесте класс AlarmTemperService использовал подставной DataMapper необходимо предварительно настроить фабрику. Это можно сделать в SetUp. Только не забудьте потом вернуть исходный вариант, если это критично.
[SetUpFixture] public void SetTemperDataMapper() => TemperDataMapperFactory.SetTemperDataMapper(MyTemperDataFactory); [Test] public void IsAlarm_GetDataWithAlarm_True() { // Arrange const double limitValue = 10; var ats = new AlarmTemperService(limitValue); // Act var IsAlarm = ats.IsAlarm(new DateTime(2021, 9, 1), new DateTime(2021, 10, 1)); // Assert Assert.IsTrue(IsAlarm); }
Установка зависимости через локальный фабричный метод
Это, наверное, из всех рассматриваемых техник самая “оригинальная”. Идея заключается в том, что в классе AlarmTemperService мы создаем метод, который в дальнейшем переопределим, этот метод возвращает объект, реализующий ITemperDataMapper, который поставляет данные для обработки. Ниже приведена часть класса, с внесенными изменениями (за основу берем первый вариант):
class AlarmTemperService { // ... public AlarmTemperService(double limit) { this.limit = limit; temperDataMapper = new TemperDataMapper(); } public virtual ITemperDataMapper GetTemperDataMapper() => temperDataMapper; public bool IsAlarm(DateTime start, DateTime stop) => GetTemperDataMapper().GetDataAtRange(start, stop) switch { null => false, var data => data.Where(v => v.Quality == ValueQuality.GOOD) .Where(v => v.Temperature >= limit) .Any(), }; // ... }
Теперь в тестовом проекте TemperWorker.Tests создадим класс наследник от AlarmTemperService. Добавим туда открытое свойство для установки своей реализации ITemperDataMapper и переопределим GetTemperDataMapper(), так, чтобы он возвращал экземпляр, задаваемый через свойство. Сделаем это. Добавим в проект TemperWorker.Tests класс TestableAlarmTemperService.
class TestableAlarmTemperService: AlarmTemperService { public TestableAlarmTemperService(double limit, ITemperDataMapper tdm) : base(limit) { DataMapper = tdm; } public ITemperDataMapper DataMapper { get; set; } public override ITemperDataMapper GetTemperDataMapper() => DataMapper; }
Тест использующий TestableAlarmTemperService с поддельным вариантом ITemperDataMapper будет выглядеть так:
public void IsAlarm_GetDataWithAlarm_True() { // Arrange const double limitValue = 10; var ats = new TestableAlarmTemperService(limitValue, MakeTemperDataMapper()); // Act var IsAlarm = ats.IsAlarm(new DateTime(2021, 9, 1), new DateTime(2021, 10, 1)); // Assert Assert.IsTrue(IsAlarm); }
Этот подход иногда ещё называют “выделить и переопределить”. Основное его преимущество в том, что мы минимально вмешиваемся в тестируемый класс, и для проверки логики создаем наследника, в котором уже добавляем элементы для подмены каких-то объектов на заглушки. Если изначально класс проектируется так, что с ним можно проводить манипуляции “выделить и переопределить” это может быть возможным вариантом для тестирования функциональности.
По сути это все подходы к рефакторингу вашего кода, с фокусом на различные места, где можно разорвать зависимость классов, реализующих сервисы, и TemperDataMapper.
Тестирование обращения к внешней стороне (взаимодействия)
При тестировании взаимодействия, как мы уже сказали, возможны два варианта, того, что мы тестируем: изменения внутри тестируемого объекта либо у того, к кому он обращается.
В первом случае поддельный объект называется заглушка (stub). Можно предложить такую диаграмму, чтобы понять, кто с кем взаимодействует, и что проверяет тест.
Наша единица тестирования обращается к внешней стороне, которую эмулирует заглушка, получает от нее некоторые данные (или просто подтверждение в той или иной форме о том, что обращение прошло успешно), и выполняет некоторую работу. Тестирующий код, проверяет результат этой работы.
Во втором случае, поддельный объект называется подставка (mock). Диаграмма для этого варианта будет такой.
Здесь тестируемый код обращается к подставке. Внутри этой подставки должны произойти некоторые изменения, которые проверяет тест. Например, если мы тестируем работу протокола, и для того чтобы понять, что все прошло в соответствии с его правилами, мы должны “заглянуть” в подставку, чтобы убедиться, что обращение к ней было произведено правильно. Другим сценарием, где это может пригодиться – это подписка на события. Т.е. подставка подписывается на события от тестируемой сущности, после этого генерируется событие, и мы проверяем, что подставка его получила и обработала.
Для реализации этих решений, также как и в случае с проверкой возвращаемого значения и изменения свойства, нужно добавить в код зазоры, чтобы можно было использовать подставки и заглушки.
Создание подставок и заглушек довольно часто является утомительным занятием, особенно, если реализация предполагает множество методов с большим количеством аргументов. Для решения этой задачи можно использовать изолирующие каркасы, которые позволяют генерировать экземпляры с заданным интерфейсом автоматически. Про изолирующие каркасы расскажем в одной из следующих статей.