Юнит тестирование

Содержание

Преимущества

Цель модульного тестирования — изолировать отдельные части программы и показать, что по отдельности эти части работоспособны.

Этот тип тестирования обычно выполняется программистами.

Поощрение изменений

Модульное тестирование позже позволяет программистам проводить рефакторинг, будучи уверенными, что модуль по-прежнему работает корректно (регрессионное тестирование). Это поощряет программистов к изменениям кода, поскольку достаточно легко проверить, что код работает и после изменений.

Упрощение интеграции

Модульное тестирование помогает устранить сомнения по поводу отдельных модулей и может быть использовано для подхода к тестированию «снизу вверх»: сначала тестируются отдельные части программы, затем программа в целом.

Документирование кода

Модульные тесты можно рассматривать как «живой документ» для тестируемого класса. Клиенты, которые не знают, как использовать данный класс, могут использовать юнит-тест в качестве примера.

Отделение интерфейса от реализации

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

Ограничения

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

Тестирование программного обеспечения — комбинаторная задача. Например, каждое возможное значение булевской переменной потребует двух тестов: один на вариант TRUE, другой — на вариант FALSE. В результате на каждую строку исходного кода потребуется 3-5 строк тестового кода.

Для получения выгоды от модульного тестирования требуется строго следовать технологии тестирования во всём процессе разработки программного обеспечения. Нужно хранить не только записи обо всех проведённых тестах, но и обо всех изменениях исходного кода во всех модулях. С этой целью следует использовать систему контроля версий ПО. Таким образом, если более поздняя версия ПО не проходит тест, который был успешно пройден ранее, будет несложным сверить исходный код вариантов и устранить ошибку. Также необходимо убедиться в неизменном отслеживании и анализе неудачных тестов. Игнорирование этого требования приведёт к лавинообразному увеличению неудачных тестовых результатов.

Юнит-тестирование для чайников

Даже если вы никогда в жизни не думали, что занимаетесь тестированием, вы это делаете. Вы собираете свое приложение, нажимаете кнопку и проверяете, соответствует ли полученный результат вашим ожиданиям. Достаточно часто в приложении можно встретить формочки с кнопкой “Test it” или классы с названием TestController или MyServiceTestClient.

То что вы делаете, называется интеграционным тестированием. Современные приложения достаточно сложны и содержат множество зависимостей. Интеграционное тестирование проверяет, что несколько компонентов системы работают вместе правильно.
Оно выполняет свою задачу, но сложно для автоматизации. Как правило, тесты требуют, чтобы вся или почти вся система была развернута и сконфигурирована на машине, на которой они выполняются. Предположим, что вы разрабатываете web-приложение с UI и веб-сервисами. Минимальная комплектация, которая вам потребуется: браузер, веб-сервер, правильно настроенные веб-сервисы и база данных. На практике все еще сложнее. Разворачивать всё это на билд-сервере и всех машинах разработчиков?

Не нужно писать тесты, если

  • Вы делаете простой сайт-визитку из 5 статических html-страниц и с одной формой отправки письма. На этом заказчик, скорее всего, успокоится, ничего большего ему не нужно. Здесь нет никакой особенной логики, быстрее просто все проверить «руками»
  • Вы занимаетесь рекламным сайтом/простыми флеш-играми или баннерами – сложная верстка/анимация или большой объем статики. Никакой логики нет, только представление
  • Вы делаете проект для выставки. Срок – от двух недель до месяца, ваша система – комбинация железа и софта, в начале проекта не до конца известно, что именно должно получиться в конце. Софт будет работать 1-2 дня на выставке
  • Вы всегда пишете код без ошибок, обладаете идеальной памятью и даром предвидения. Ваш код настолько крут, что изменяет себя сам, вслед за требованиями клиента. Иногда код объясняет клиенту, что его требования — гов не нужно реализовывать

В первых трех случаях по объективным причинам (сжатые сроки, бюджеты, размытые цели или очень простые требования) вы не получите выигрыша от написания тестов.
Последний случай рассмотрим отдельно. Я знаю только одного такого человека, и если вы не узнали себя на фото ниже, то у меня для вас плохие новости.

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


В своей практике я много раз встречался с проектами старше года. Они делятся на три категории:

  • Без покрытия тестами. Обычно такие системы сопровождаются спагетти-кодом и уволившимися ведущими разработчиками. Никто в компании не знает, как именно все это работает. Да и что оно в конечном итоге должно делать, сотрудники представляют весьма отдаленно.
  • С тестами, которые никто не запускает и не поддерживает. Тесты в системе есть, но что они тестируют, и какой от них ожидается результат, неизвестно. Ситуация уже лучше. Присутствует какая-никакая архитектура, есть понимание, что такое слабая связанность. Можно отыскать некоторые документы. Скорее всего, в компании еще работает главный разработчик системы, который держит в голове особенности и хитросплетения кода.
  • С серьезным покрытием. Все тесты проходят. Если тесты в проекте действительно запускаются, то их много. Гораздо больше, чем в системах из предыдущей группы. И теперь каждый из них – атомарный: один тест проверяет только одну вещь. Тест является спецификацией метода класса, контрактом: какие входные параметры ожидает этот метод, и что остальные компоненты системы ждут от него на выходе. Таких систем гораздо меньше. В них присутствует актуальная спецификация. Текста немного: обычно пара страниц, с описанием основных фич, схем серверов и getting started guide’ом. В этом случае проект не зависит от людей. Разработчики могут приходить и уходить. Система надежно протестирована и сама рассказывает о себе путем тестов.

Проекты первого типа – крепкий орешек, с ними работать тяжелее всего. Обычно их рефакторинг по стоимости равен или превышает переписывание с нуля.

Почему есть проекты второго типа?

Коллеги из ScrumTrek уверяют, что всему виной темная сторона кода и властелин Дарт Автотестиус. Я убежден, что это очень близко к правде. Бездумное написание тестов не только не помогает, но вредит проекту. Если раньше у вас был один некачественный продукт, то написав тесты, не разобравшись в этой теме, вы получите два. И удвоенное время на сопровождение и поддержку.
Для того чтобы темная сторона кода не взяла верх, нужно придерживаться следующих основных правил.
Ваши тесты должны:

  • Быть достоверными
  • Не зависеть от окружения, на котором они выполняются
  • Легко поддерживаться
  • Легко читаться и быть простыми для понимания (даже новый разработчик должен понять что именно тестируется)
  • Соблюдать единую конвенцию именования
  • Запускаться регулярно в автоматическом режиме

Чтобы достичь выполнения этих пунктов, нужны терпение и воля. Но давайте по порядку.

Выберите логическое расположение тестов в вашей VCS

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

Выберите способ именования проектов с тестами

Одна из лучших практик: добавьте к каждому проекту его собственный тестовый проект.
У вас есть части системы <PROJECT_NAME>.Core, <PROJECT_NAME>.Bl и <PROJECT_NAME>.Web? Добавьте еще <PROJECT_NAME>.Core.Tests, <PROJECT_NAME>.Bl.Tests и <PROJECT_NAME>.Web.Tests.
У такого способа именования есть дополнительный сайд-эффект. Вы сможете использовать паттерн *.Tests.dll для запуска тестов на билд-сервере.

Используйте такой же способ именования для тестовых классов

У вас есть класс ProblemResolver? Добавьте в тестовый проект ProblemResolverTests. Каждый тестирующий класс должен тестировать только одну сущность. Иначе вы очень быстро скатитесь в унылое го во второй тип проектов (с тестами, которые никто не запускает).

Выберите «говорящий» способ именования методов тестирующих классов

TestLogin – не самое лучшее название метода. Что именно тестируется? Каковы входные параметры? Могут ли возникать ошибки и исключительные ситуации?
На мой взгляд, лучший способ именования методов такой: __.
Предположим, что у нас есть класс Calculator, а у него есть метод Sum, который (привет, Кэп!) должен складывать два числа.
В этом случае наш тестирующий класс будет выглядеть так:
сlass CalculatorTests { public void Sum_2Plus5_7Returned() { // … } }
Такая запись понятна без объяснений. Это спецификация к вашему коду.

Выберите тестовый фреймворк, который подходит вам

Вне зависимости от платформы не стоит писать велосипеды. Я видел много проектов, в которых автоматические тесты (в основном, не юнит, а приемочные) запускались из консольного приложения. Не надо этого делать, все уже сделано за вас.
Уделите чуть больше внимания обзору фреймворков. Например, многие .NET разработчики используют MsTest только потому, что он входит в поставку студии. Мне гораздо больше по душе NUnit. Он не создает лишних папок с результатами тестов и имеет поддержку параметризированного тестирования. Я могу так же легко запускать мои тесты на NUnit с помощью Решарпера. Кому-то понравится элегантность xUnit’а: конструктор вместо атрибутов инициализации, реализация IDisposable как TearDown.

Что тестировать, а что – нет?

Одни говорят о необходимости покрытия кода на 100%, другие считают это лишней тратой ресурсов.
Мне нравится такой подход: расчертите лист бумаги по оси X и Y, где X – алгоритмическая сложность, а Y – количество зависимостей. Ваш код можно разделить на 4 группы.

Рассмотрим сначала экстремальные случаи: простой код без зависимостей и сложный код с большим количеством зависимостей.

  1. Простой код без зависимостей. Скорее всего здесь и так все ясно. Его можно не тестировать.
  2. Сложный код с большим количеством зависимостей. Хм, если у вас есть такой код, тут пахнет God Object’ом и сильной связностью. Скорее всего, неплохо будет провести рефакторинг. Мы не станем покрывать этот код юнит-тестами, потому что перепишем его, а значит, у нас изменятся сигнатуры методов и появятся новые классы. Так зачем писать тесты, которые придется выбросить? Хочу оговориться, что для проведения такого рода рефакторинга нам все же нужно тестирование, но лучше воспользоваться более высокоуровневыми приемочными тестами. Мы рассмотрим этот случай отдельно.

Что у нас остается:

  1. Cложный код без зависимостей. Это некие алгоритмы или бизнес-логика. Отлично, это важные части системы, тестируем их.
  2. Не очень сложный код с зависимостями. Этот код связывает между собой разные компоненты. Тесты важны, чтобы уточнить, как именно должно происходить взаимодействие. Причина потери Mars Climate Orbiter 23 сентября 1999 года заключалась в программно-человеческой ошибке: одно подразделение проекта считало «в дюймах», а другое – «в метрах», и прояснили это уже после потери аппарата. Результат мог быть другим, если бы команды протестировали «швы» приложения.

Придерживайтесь единого стиля написания тела теста

Отлично зарекомендовал себя подход AAA (arrange, act, assert) . Вернемся к примеру с калькулятором:
class CalculatorTests { public void Sum_2Plus5_7Returned() { // arrange var calc = new Calculator(); // act var res = calc.Sum(2,5); // assert Assert.AreEqual(7, res); } }
Такая форма записи гораздо легче читается, чем
class CalculatorTests { public void Sum_2Plus5_7Returned() { Assert.AreEqual(7, new Calculator().sum(2,5)); } }
А значит, этот код проще поддерживать.

Тестируйте одну вещь за один раз

Каждый тест должен проверять только одну вещь. Если процесс слишком сложен (например, покупка в интернет магазине), разделите его на несколько частей и протестируйте их отдельно.
Если вы не будете придерживаться этого правила, ваши тесты станут нечитаемыми, и вскоре вам окажется очень сложно их поддерживать.

Борьба с зависимостями

До сих пор мы тестировали калькулятор. У него совсем нет зависимостей. В современных бизнес-приложениях количество таких классов, к сожалению, мало.
Рассмотрим такой пример.
public class AccountManagementController : BaseAdministrationController { #region Vars private readonly IOrderManager _orderManager; private readonly IAccountData _accountData; private readonly IUserManager _userManager; private readonly FilterParam _disabledAccountsFilter; #endregion public AccountManagementController() { _oms = OrderManagerFactory.GetOrderManager(); _accountData = _ orderManager.GetComponent<IAccountData>(); _userManager = UserManagerFactory.Get(); _disabledAccountsFilter = new FilterParam(«Enabled», Expression.Eq, true); } }
Фабрика в этом примере берет данные о конкретной реализации AccountData из файла конфигурации, что нас абсолютно не устраивает. Мы же не хотим поддерживать зоопарк файлов *.config. Более того, настоящие реализации могут зависеть от базы данных. Если мы продолжим в том же духе, то перестанем тестировать только методы контроллера и начнем вместе с ними тестировать другие компоненты системы. Как мы помним, это называется интеграционным тестированием.
Чтобы не тестировать все вместе, мы подсунем фальшивую реализацию (fake).
Перепишем наш класс так:
public class AccountManagementController : BaseAdministrationController { #region Vars private readonly IOrderManager _oms; private readonly IAccountData _accountData; private readonly IUserManager _userManager; private readonly FilterParam _disabledAccountsFilter; #endregion public AccountManagementController() { _oms = OrderManagerFactory.GetOrderManager(); _accountData = _oms.GetComponent<IAccountData>(); _userManager = UserManagerFactory.Get(); _disabledAccountsFilter = new FilterParam(«Enabled», Expression.Eq, true); } /// <summary> /// For testability /// </summary> /// <param name=»accountData»></param> /// <param name=»userManager»></param> public AccountManagementController( IAccountData accountData, IUserManager userManager) { _accountData = accountData; _userManager = userManager; _disabledAccountsFilter = new FilterParam(«Enabled», Expression.Eq, true); } }
Теперь у контроллера появилась новая точка входа, и мы можем передать туда другие реализации интерфейсов.

Fakes: stubs & mocks

Мы переписали класс и теперь можем подсунуть контроллеру другие реализации зависимостей, которые не станут лезть в базу, смотреть конфиги и т.д. Словом, будут делать только то, что от них требуется. Разделяем и властвуем. Настоящие реализации мы должны протестировать отдельно в своих собственных тестовых классах. Сейчас мы тестируем только контроллер.
Выделяют два типа подделок: стабы (stubs) и моки (mock).
Часто эти понятия путают. Разница в том, что стаб ничего не проверяет, а лишь имитирует заданное состояние. А мок – это объект, у которого есть ожидания. Например, что данный метод класса должен быть вызван определенное число раз. Иными словами, ваш тест никогда не сломается из-за «стаба», а вот из-за мока может.

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

Стаб


public void LogIn_ExisingUser_HashReturned() { // Arrange OrderProcessor = Mock.Of<IOrderProcessor>(); OrderData = Mock.Of<IOrderData>(); LayoutManager = Mock.Of<ILayoutManager>(); NewsProvider = Mock.Of<INewsProvider>(); Service = new IosService( UserManager, AccountData, OrderProcessor, OrderData, LayoutManager, NewsProvider); // Act var hash = Service.LogIn(«ValidUser», «Password»); // Assert Assert.That(!string.IsNullOrEmpty(hash)); }

Мок


public void Create_AddAccountToSpecificUser_AccountCreatedAndAddedToUser() { // Arrange var account = Mock.Of<AccountViewModel>(); // Act _controller.Create(1, account); // Assert _accountData.Verify(m => m.CreateAccount(It.IsAny<IAccount>()), Times.Exactly(1)); _accountData.Verify(m => m.AddAccountToUser(It.IsAny<int>(), It.IsAny<int>()), Times.Once()); }

Тестирование состояния и тестирование поведения

Почему важно понимать, казалось бы, незначительную разницу между моками и стабами? Давайте представим, что нам нужно протестировать автоматическую систему полива. Можно подойти к этой задаче двумя способами:

Тестирование состояния

Запускаем цикл (12 часов). И через 12 часов проверяем, хорошо ли политы растения, достаточно ли воды, каково состояние почвы и т.д.

Тестирование взаимодействия

Установим датчики, которые будут засекать, когда полив начался и закончился, и сколько воды поступило из системы.
Стабы используются при тестировании состояния, а моки – взаимодействия. Лучше использовать не более одного мока на тест. Иначе с высокой вероятностью вы нарушите принцип «тестировать только одну вещь». При этом в одном тесте может быть сколько угодно стабов или же мок и стабы.

Изоляционные фреймвоки

Мы могли бы реализовывать моки и стабы самостоятельно, но есть несколько причин, почему я не советую делать это:

  • Велосипеды уже написаны до нас
  • Многие интерфейсы не так просто реализовать с полпинка
  • Наши самописные подделки могут содержать ошибки
  • Это дополнительный код, который придется поддерживать

В примере выше я использовал фреймворк Moq для создания моков и стабов. Довольно распространен фреймворк Rhino Mocks. Оба фреймворка — бесплатные. На мой взгляд, они практически эквивалентны, но Moq субъективно удобнее.
На рынке есть также два коммерческих фреймворка: TypeMock Isolator и Microsoft Moles. На мой взгляд они обладают чрезмерными возможностями подменять невиртуальные и статические методы. Хотя при работе с унаследованным кодом это и может быть полезно, ниже я опишу, почему все-таки не советую заниматься подобными вещами.
Шоукейсы перечисленных изоляционных фреймворков можно посмотреть . А информацию по техническим аспектам работы с ними легко найти на Хабре.

Тестируемая архитектура

Вернемся к примеру с контроллером.
public AccountManagementController( IAccountData accountData, IUserManager userManager) { _accountData = accountData; _userManager = userManager; _disabledAccountsFilter = new FilterParam(«Enabled», Expression.Eq, true); }
Здесь мы отделались «малой кровью». К сожалению, не всегда все бывает так просто. Давайте рассмотрим основные случаи, как мы можем внедрить зависимости:

Инъекция в конструктор

Добавляем дополнительный конструктор или заменяем текущий (зависит от того, как вы создаете объекты в вашем приложении, используете ли IOC-контейнер). Этим подходом мы воспользовались в примере выше.

Инъекция в фабрику

Setter можно дополнительно «спрятать» от основного приложения, если выделить интерфейс IUserManagerFactory и работать в продакшн-коде по интерфейсной ссылке.
public class UserManagerFactory { private IUserManager _instance; /// <summary> /// Get UserManager instance /// </summary> /// <returns>IUserManager with configuration from the configuration file</returns> public IUserManager Get() { return _instance ?? Get(UserConfigurationSection.GetSection()); } private IUserManager Get(UserConfigurationSection config) { return _instance ?? (_instance = Create(config)); } /// <summary> /// For testing purposes only! /// </summary> /// <param name=»userManager»></param> public void Set(IUserManager userManager) { _instance = userManager; } }

Подмена фабрики

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

Переопределение локального фабричного метода

Если зависимости инстанцируются прямо в коде явным образом, то самый простой путь – выделить фабричный protected-метод CreateObjectName() и переопределить его в классе-наследнике. После этого тестируйте класс-наследник, а не ваш первоначально тестируемый класс.
Например, мы решили написать расширяемый калькулятор (со сложными действиями) и начали выделять новый слой абстракции.
public class Calculator { public double Multipy(double a, double b) { var multiplier = new Multiplier(); return multiplier.Execute(a, b); } } public interface IArithmetic { double Execute(double a, double b); } public class Multiplier : IArithmetic { public double Execute(double a, double b) { return a * b; } }
Мы не хотим тестировать класс Multiplier, для него будет отдельный тест. Перепишем код так:
public class Calculator { public double Multipy(double a, double b) { var multiplier = CreateMultiplier(); return multiplier.Execute(a, b); } protected virtual IArithmetic CreateMultiplier() { var multiplier = new Multiplier(); return multiplier; } } public class CalculatorUnderTest : Calculator { protected override IArithmetic CreateMultiplier() { return new FakeMultiplier(); } } public class FakeMultiplier : IArithmetic { public double Execute(double a, double b) { return 5; } }
Код намеренно упрощен, чтобы акцентировать внимание именно на иллюстрации способа. В реальной жизни вместо калькулятора, скорее всего, будут DataProvider’ы, UserManager’ы и другие сущности с гораздо более сложной логикой.

Тестируемая архитектура VS OOP

Многие разработчики начинают жаловаться, дескать «этот ваш тестируемый дизайн» нарушает инкапсуляцию, открывает слишком много. Я думаю, что существует только две причины, когда это может вас беспокоить:

Серьезные требования к безопасности

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

Производительность

Существует ряд задач, когда архитектурой приходится жертвовать в угоду производительности, и для кого-то это становится поводом отказаться от тестирования. В моей практике докинуть сервер/проапгрейдить железо всегда было дешевле, чем писать нетестируемый код. Если у вас есть критический участок, вероятно, стоит переписать его на более низком уровне. Ваше приложение на C#? Возможно, есть смысл собрать одну неуправляемую сборку на С++.
Вот несколько принципов, которые помогают писать тестируемый код:

  • Мыслите интерфейсами, а не классами, тогда вы всегда сможете легко подменять настоящие реализации подделками в тестовом коде
  • Избегайте прямого инстанцирования объектов внутри методов с логикой. Используйте фабрики или dependency injection. В этом случае использование IOC-контейнера в проекте может сильно упростить вам работу.
  • Избегайте прямого вызова статических методов
  • Избегайте конструкторов, которые содержат логику: вам сложно будет это протестировать.

Работа с унаследованным кодом

Под «унаследованным» мы будем понимать код без тестов. Качество такого кода может быть разным. Несколько советов, как можно покрыть его тестами.

Архитектура тестируема

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

Архитектура не тестируема

У нас есть жесткие связи, костыли и прочие радости жизни. Нам предстоит рефакторинг. Как правильно проводить комплексный рефакторинг – тема, выходящая далеко за рамки этой статьи.
Стоит выделить основное правило. Если вы не меняете интерфейсов – все просто, методика идентична. А вот если вы задумали большие перемены, следует составить граф зависимостей и разбить ваш код на отдельные более мелкие подсистемы (надеюсь, что это возможно). В идеале должно получиться примерно так: ядро, модуль #1, модуль #2 и т.д.
После этого выберите жертву. Только не начинайте с ядра. Возьмите сначала что-то поменьше: то, что вы способны отрефакторить за разумное время. Покрывайте эту подсистему интеграционными и/или приемочными тестами. А когда закончите, сможете покрыть эту часть юнит-тестами. Рано или поздно, шаг за шагом, вы должны преуспеть.
Будьте готовы, что сделать это быстро скорее всего не получится. Вам придется проявить волевые качества.

Поддержка тестов

Не относитесь к своим тестам как к второсортному коду. Многие начинающие разработчики ошибочно полагают, что DRY, KISS и все остальное – это для продакшна. А в тестах допустимо все. Это не верно. Тесты – такой-же код. Разница только в том, что у тестов другая цель – обеспечить качество вашего приложения. Все принципы, применямые в разработке продакшн-кода могут и должны применяться при написании тестов.
Есть всего три причины, почему тест перестал проходить:

  1. Ошибка в продакшн-коде: это баг, его нужно завести в баг-трекере и починить.
  2. Баг в тесте: видимо, продакшн-код изменился, а тест написан с ошибкой (например, тестирует слишком много или не то, что было нужно). Возможно, что раньше он проходил ошибочно. Разберитесь и почините тест.
  3. Смена требований. Если требования изменились слишком сильно – тест должен упасть. Это правильно и нормально. Вам нужно разобраться с новыми требованиями и исправить тест. Или удалить, если он больше не актуален.

Уделяйте внимание поддержке ваших тестов, чините их вовремя, удаляйте дубликаты, выделяйте базовые классы и развивайте API тестов. Можно завести шаблонные базовые тестовые классы, которые обязывают реализовать набор тестов (например CRUD). Если делать это регулярно, то вскоре это не будет занимать много времени.

Зачем нужны юнит-тесты

Многие разработчики говорят о юнит-тестах, но не всегда понятно, что они имеют в виду. Иногда неясно, чем они отличаются от других видов тестов, а порой совершенно непонятно их назначение.

Доказательство корректности кода

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

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

Отличие от других видов тестов

Все вышесказанное справедливо для любых тестов. Там даже не упомянуты юнит-тесты как таковые. Итак, в чем же их отличие?

Ответ кроется в названии: «юнит» означает, что мы тестируем не всю систему в целом, а небольшие ее части. Мы проводим тестирование с высокой гранулярностью.

Это основное отличие юнит-тестов от системных, когда тестированию подвергается вся система или подсистема, и от интеграционных, которые проверяют взаимодействие между модулями.

Основное преимущество независимого тестирования маленького участка кода состоит в том, что если тест провалится, ошибку будет легко обнаружить и исправить.

И все-таки, что такое юнит?

Часто встречается мнение, что юнит — это класс. Однако это не всегда верно. Например, в C++, где классы не обязательны.

«Юнит» можно определить как маленький, связный участок кода. Это вполне согласуется с основным принципом разработки и часто юнит — это некий класс. Но это также может быть набор функций или несколько маленьких классов, если весь функционал невозможно разместить в одном.

Юнит — это маленький самодостаточный участок кода, реализующий определенное поведение, который часто (но не всегда) является классом.

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

Отсутствие сцепления необходимо для написания юнит-тестов.

Другие применения юнит-тестов

Кроме доказательства корректности, у юнит-тестов есть еще несколько применений.

Тесты как документация

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

Я думаю, что если тесты легко использовать (а их должно быть легко использовать), то другой документации (к примеру, комментариев doxygen) не требуется.

Тем не менее, в этом обсуждении после поста про комментарии видно, что не все разделяют мое мнение на этот счет.

Разработка через тестирование

При разработке через тестирование (test-driven development, TDD) вы сначала пишете тесты, которые проверяют поведение вашего кода. При запуске они, конечно, провалятся (или даже не скомпилируются), поэтому ваша задача — написать код, который проходит эти тесты.

Основа философии разработки через тестирование — вы пишете только тот код, который нужен для прохождения тестов, ничего лишнего. Когда все тесты проходят, код нужно отрефакторить и почистить, а затем приступить к следующему участку.

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

И, поскольку TDD предполагает, что нет участков кода, не покрытых тестами, все поведение написанного кода будет документировано.

Возможность лучше разобраться в коде

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

  • они позволят вам убедиться, что вы правильно понимаете, как работает код;
  • они будут служить документацией для тех, кто будет читать код после вас;
  • если вы планируете рефакторинг, тесты помогут вам убедиться в корректности изменений.

В очередной раз Joel Spolsky, автор отличных книг из серии Joel on Software и одноименного блога JoelOnSoftware, написал потрясающую статью. В этот раз он рассуждает про Test Driven development и, как обычно, делает это без всякого уважения к авторитетам и современным тенденциям.

Должен признаться, что моё отношение к TDD в точности совпадает с тем, что он описал в этой статье. И это радует и успокаивает.

Попробую в этой статье раскрыть мое отношение к юнит тестам и TDD.

100% покрытие кода юнит тестами

Итак, поводом для статьи Джоеля стали письма от людей, которые призывали его добавить 13-ый пункт в его знаменитые 12 шагов, чтобы писать код лучше. Его призывали добавить пункт Юнит тесты, 100% вашего кода покрыто юнит тестами.

И ведь это сейчас практически индустриальный стандарт — почти все профессиональные книги пишут про то, что вы должны иметь 100% покрытие кода тестами, что писать новый код можно только используя TDD. Что если вы этого не делаете, то обречены вечно создавать дорогие, некачественные и плохо расширяемые программы.

Консультанты твердят то же самое. И только разработчики чувствуют себя ущербными, так как НЕ МОГУТ сделать 100% покрытие кода тестами. Они читают книги про TDD, слушают консультантов, тратят время, но 100% покрытия достичь все равно не могут. Вы, например, можете?

Все эти книги и консультанты концентрируются на идее, что требования и код будут меняться и тесты помогут изменять код почти безболезненно, улучшая качество программы. Многие даже пишут, что юнит тесты — это гарантия качества программы. Но на самом деле это не так. Юнит тесты не имеют ничего общего с качеством — это просто инструмент, позволяющий проверить, что вы не сломали ничего из прошлой функциональности. И это всё, что дают юнит тесты. При этом они далеко не бесплатны — обычно уходит чудовищно много времени на написание юнит тестов, особенно для старого (legacy) кода.

Любой юнит тест — это дополнительные расходы.

Еще, что идеологи TDD не указывают обычно — это то, что, если вы имеете 100% покрытие кода тестами, то изменение любого куска кода требует изменения и всех тестов для него. И изменение тестов со временем будет занимать все больше и больше времени. То есть, юнит тесты становятся со временем теми самыми кандалами, которые МЕШАЮТ вам делать рефакторинг или небольшие изменения — ведь для самого простого изменения интерфейса класса или функции вам придется изменить не только клиентов класса или функции, но и все тесты, которых может быть гораздо больше, чем клиентов (мест использования).

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

Почему же так происходит и что делать?

Авторы, пишущие книги по теории программирования и консультанты — это обычно не те люди, которые много занимаются программированием. Они зарабатывают на другом и их можно назвать теоретиками. Даже если у них был когда-то большой опыт программирования, то сейчас они пишут только небольшие тестовые программки, чтобы проверить свои теории и доказать их работоспособность. Но все мы знаем, как огромная разница между программой на 100 строк и на 1 000 000 строк. И экстраполировать на большие проекты методологии, прекрасно работающие на маленьких проектах — это нелепо. Но это то, чем сейчас занимаются все Agile и TDD консультанты и авторы книг.

Они экстраполируют. Они утверждают, что есть огромные проекты, покрытые на 100% юнит тестами, но видел ли кто-нибудь когда-нибудь такие проекты? Я — нет. Зато я видел множество других проектов, где встречались юнит тесты, но процент был далек от 100. Обычно он 0-5%. Причем 5% — это уже очень много и, уверен, достаточно для большинства проектов.

Никто из разработчиков не хочет тратить месяцы и годы для покрытия тестами кода, который работает стабильно, уже 5-10 лет не менялся и еще 10 лет не будет меняться. Но руководство нанимает консультантов и те убеждают, то без 100% покрытия тестами невозможно повысить качество продукта. Ставится такая задача, все работают в поте лица, а качество программы падает каждый день.

Почему?

  • Во-первых написание тестов для legacy кода вносит в него ошибки. От этого не уйти — приходится изменять код, а значит добавлять в него баги.
  • А во-вторых, повторюсь — юнит тесты не увеличивают качество кода! Они просто позволяют его в будущем дешевле изменять.

И это нам позволяет сделать главный вывод: 100% покрытие юнит тестами вредно. Даже так: сама идея о 100% покрытии тестами вредна! Покрывать надо только те куски кода, которые будут меняться, причём несерьезно, так как при серьезных изменениях придется переделывать и все тесты, а значит старые станут бесполезной обузой.

А много ли кода, подпадающего под это определение обычно в программе? Это и есть те самые 5-10%. Так что обычно достаточно покрыть тестами 5-10% кода для получения всех преимуществ юнит тестов. Если юнит тестов становится больше, то они перестают приносить пользу, но увеличивают расходы на поддержание их работоспособности.

Например, Michael Feathers в своей книге Working Effectively with Legacy Codeне призывает покрывать все 100% кода тестами.

Мало того, он даже не призывает всегда покрывать тестами код, который вы собираетесь изменять. Он призывает думать. Каждый раз делать осознанный выбор — нужны ли вам в данном месте юнит тесты или нет. Всем советую купить и прочитать эту его книгу. Эта книга — лучшее описание теории и практики написания юнит тестов, что я встречал. Причем именно с точки зрения практики — большая часть книги посвящена описанию стандартных приемов работы со старым кодом. А сам автор имеет огромный практический опыт по улучшению legacy кода.

Моё отношение к юнит тестам и TDD

Возможно у вас сложилось ощущение, что я противник TDD и юнит тестов. Но на самом деле — нет. Я обожаю юнит тесты и некое ощущение правильности, которое они дают мне. И я обожаю TDD, когда использую его на небольших проектах или обособленных небольших модулях.

Но я противник стопроцентного покрытия кода тестами и применения TDD для работы с legacy кодом.

Для каждой методологии есть свое применение. И нет никакой серебряной пули, помогающей всегда.

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

При разработке программ большую роль играет надёжность. Это не нуждается в доказательствах; скорее всего, каждый сам испытал на себе недостатки программ с багами. Кроме неприятностей для пользователей программы, баги мешают и самим разработчикам. Обнаружение бага означает, что на него необходимо как-то реагировать. Либо «фиксить», либо убеждать пользователей, что не надо, к примеру, нажимать определенную последовательность кнопочек или иконок. Для исправления бага требуется время, а время — единственный ресурс который у программиста есть. Вместо того, чтобы работать над новым проектом, придётся заниматься старым проектом.

Теперь представьте себе, что проект большой. В нем много кода, прошло несколько месяцев, с тех пор как программа сдана заказчику. Вы работаете над новым проектом для нового заказчика. И вот в этот самый момент, когда ваша голова занята новым сложнейшим и важнейшим проектом (поскольку каждый следующий проект важнее, чем предыдущий), к вам приходит баг-репорт. Вы откладываете в сторону все текущие дела и бросаетесь на исправление проблемы. Сначала вам приходится найти, а где же его исходники, поскольку все где-то в системе контроля версий и у вас на диске, заваленном кучей файлов… Затем вам придётся вспомнить, а что там где в коде, попытаться проанализировать, как информация из баг-репорта соотносится с кодом… Найдёте проблему, поправите. Хорошо, если проект небольшой. Вы сможете, не привлекая release-инженеров, сделать новую сборку проекта. Но для этого вам, возможно, придется разобраться с кучей новых для себя вещей, которые вы в течении проекта не делали: как собирать проект целиком, как менять у него версию, как писать release notes, как готовый проект выдавать заказчикам… Целая процедура…

Все вышеописанное отнимет в лучшем случае время у одного программиста, в худшем — у нескольких. В самом худшем случае это вообще будет не ваш баг, а баг вашего коллеги, который уже успел уволиться. Тогда объем работы, который придется выполнить для исправления будет ещё больше. Но и это еще не все. Вы отправите ваше исправление, а от заказчика может через пару дней прийти письмо примерно такого содержания: «Спасибо за исправления нашего бага, после установки новой версии, та функция которая была сломана, заработала… НО КАК НИ НЕПРИЯТНО ОБ ЭТОМ СООБЩАТЬ, СЛОМАЛИСЬ ПАРА ДРУГИХ…» На этом историю об исправлении бага завершаю. Думаю, суть ясна. У любого опытного программиста что-то подобное случалось, и не раз.

Юнит-тестирование (unit testing, модульное тестирование по-русски) это технология, цель которой уменьшить вероятность ошибок и побочных эффектов (когда при исправлении одного бага вносится другой баг).

В чем идея юнит-тестирования? Вместо того, чтобы тестировать продукт целиком, что может оказаться весьма сложной и нетривиальной процедурой, модульное тестирование направленно на тестирование каждого отдельного компонента, из которых состоит программа, в отдельности. Для Java отдельным компонентом, из которого состоит программа, является класс. Если каждый программист, проверит, что поведение его класса (модуля) соответствует задуманному, то и программа, состоящая из таких оттестированных классов, скорее всего, будет работать как задумано.

Что значит — «программист проверит»? Это не означает, что он сделает это вручную. Вручную это делается по-старинке. Программист просматривает код, пытается представить как код будет работать… Это человеческий фактор. Человеку свойственно ошибаться. Проверит — означает, что программист напишет небольшую программку для тестирования поведения своего юнита (класса).

Некоторое время назад (когда идеология модульного тестирования была не сильно развита), как правило, точка входа в программу всегда была одна. Например, в C-подобных языках это функция main(). Чтобы написать юнит-тест программисту пришлось бы написать еще один main(). Так как в обычной программе на С несколько main() быть не должно, программисту бы пришлось написать много программ, предназначенных только для тестирования. Это весьма трудоёмко. Сейчас, однако, и для компилируемых языков есть комплекты библиотека+инструменты, которые позволяют этот процесс автоматизировать и максимально упростить.

В Java в этом плане всё ещё проще. Скомпилированные классы устроены так, что любая другая программа на Java может их загрузить в память и выполнить. На этом принципе и построено юнит-тестирование в Java. В среды разработки и средства сборки Java-проектов были добавлены средства для старта не main()-классов, а любых тестовых классов. Такая интеграция со средствами разработки позволяет сделать процесс тестирования полностью автоматическим.

Ниже приведен пример юнит-теста. В данном случае тестируется стандартный класс — календарь. Получаются две переменные типа Calendar; к одной из переменных добавляется минута, и в завершении теста проверяется, что вернется разница в 60 секунд:

import org.junit.Assert; import org.junit.Test; import java.util.Calendar; import static junit.framework.Assert.assertEquals; public class TestCalendar { @Test public void test() { Calendar calendar1 = Calendar.getInstance(); Calendar calendar2 = Calendar.getInstance(); calendar2.add(Calendar.MINUTE, 1); long difference = calendar2.getTimeInMillis() — calendar1.getTimeInMillis(); assertEquals(60, difference); } }

Тест, как здесь видно, является обычным java-классом. Тестирование производится с помощью метода test(), помеченного аннотацией @Test. Тестовый метод не обязательно должен называться test(), главное, что он помечен аннотацией. В один тестовый класс можно поместить множество тестовых методов, помеченных аннотацией @Test. Проверка, что в результате получилось то, что нужно, производится с помощью вызова метода assertEquals(60, difference).

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

Обычно запуск теста можно простым способом осуществить из любой IDE. Например, в Intelij IDEA это делается просто нажатием правой кнопки мыши (на файле теста, на методе, на имени класса в тексте и прочее) и выбор пункта выпадающего меню. Тест можно выполнить запустить обычным образом или с отладкой.

Тест, приведенный в примере выше, не пройдет. В assertEquals() ожидается 60 (секунд), а результат будет в миллисекундах, т.е. 60000. В этом случае тестовая функция выбросит исключение.

Кроме выполнения из среды, тесты автоматически выполняются при сборке проекта, и если один или несколько тестов не проходят, то сборка завершается «неудачей». Запуск тестов можно отключить или сделать выборочным. Целью данного поста не является подробное описание юнит-тестов, это лишь краткое описание идеи. По юнит-тестам есть множество источников в интернете.

Хотелось бы добавить к вышеописанному, что для того, чтобы посмотреть какие части исходного кода покрыты тестами, а какие нет, существуют специальные программные средства. Рекомендую посмотреть пост Настройка Cobertura plug-in в maven для получения html-отчетов о покрытии исходного кода тестами

Другие статьи по Java…

Реклама

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

Ваш e-mail не будет опубликован. Обязательные поля помечены *