Статьи

Принципы создания ремонтопригодных и эволюционирующих тестов

Иметь [автоматизированные] тесты юнит / интеграция / функционал /… замечательно, но им слишком легко стать помехой, делая любые изменения в системе болезненными и медленными — вплоть до того момента, когда вы их выбрасываете. Как избежать этого проклятия жестких тестов, слишком хрупких, слишком переплетенных, слишком связанных с деталями реализации?

Конечно, следование принципам чистого кода не только для производственного кода, но и для тестов поможет, но достаточно ли этого? Нет это не так.

Основываясь на обсуждении нашего недавнего курса с Кентом Беком, я думаю, что следующие три принципа, приведенные ниже, важны для того, чтобы разделить, легко развить тесты:

  • Тесты рассказывают историю
  • Настоящие модульные тесты + разъединенные интеграционные тесты более высокого уровня (-> Слои пирамиды автоматизации тестирования Майка Кона)
  • Более функциональный состав обработки

(Отказ от ответственности: все хорошие идеи здесь исходят от Кента Бека и моих одноклассников. Все заблуждения действительно мои.)

1. Тесты рассказывают историю

Если вы думаете о том, что ваши тесты рассказывают историю — отдельные методы тестирования, рассказывающие простые истории о небольших функциях, целые классы тестов о конкретных более масштабных функциях, весь набор тестов о вашем программном обеспечении, то вы в конечном итоге становитесь лучше, более разъединенными и более простыми. понимать тесты. Это означает, что когда кто-то читает тест, он / она чувствует, что читает историю.

Это на самом деле хорошо соответствует тому, что Гойко Адзич учит о приемочных тестах. то есть, что они должны быть « живой документацией » системы для использования бизнес-пользователями, и это является их самым большим преимуществом (а не тем, как обычно полагают, что у вас есть набор регрессионных тестов или что вы Можно показать, что система работает).

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

  • Это заставляет вас сосредоточиться на том, чтобы сказать, что должен делать код, а не на том, как он должен это делать. Таким образом, ваши тесты будут в большей степени отделены от реализации и, следовательно, более легко обслуживаемы и с большей вероятностью обнаружат дефект в способе реализации требования.
  • Повествовательный подход заставляет вас абстрагироваться от неважных деталей, f. ех. путем создания уровня абстракции между тестом и низкоуровневыми деталями того, что тестируется (уровень может состоять из чего-то простого, например, вспомогательного метода или чего-то более сложного)
  • Тесты будут легче понять. Как мы все знаем, код читается намного чаще, чем пишется, поэтому понятность очень важна. Если ваши тесты легко читать и понимать, они послужат хорошей документацией для тестируемого кода. Будущие поколения программистов, работающих над этим, полюбят вас.
  • Если вам сложно написать тест по-исторически, значит, что-то не так с вашим API, и вы должны изменить его.

Как вы должны писать тесты для чтения как истории?

  • Именование — нет: should_return_20_if_sk_or_ba — это мне ничего не говорит: что такое 20? что такое ба, ск? (Для любопытных: авиакомпаний, а именно SAS и British Airways) Да: should_give_discount_for_preferred_airlines — это говорит мне, что и почему сделано
  • Надлежащий уровень абстракции — Как уже упоминалось выше, чтобы получить истинную выгоду от ваших тестов и подготовить их к долгой жизни, вам необходимо поддерживать их на должном уровне абстракции. Переместите неважные детали из теста в вспомогательные методы, объекты (например, ObjectMother ) или setUp. Не скрывайте основную логику теста. (Конечно, если вам нужна обширная настройка или если у вас много низкоуровневого кода в тесте, что-то не так с вашим подходом к тестированию, дизайном тестируемого кода или с обоими. Сначала исправьте это.). Например, некоторые люди утверждают, что наличие цикла в тесте является слишком низкоуровневым — если вам это нужно, то, возможно, ваш API недостаточен, его пользователям может понадобиться цикл также, так почему бы просто не предоставить подходящий метод, который бы делал это для ты? Кстати, никто не говорит, что писать тесты на правильном уровне абстракции легко. Но это окупается.

(Довольно задуманный) пример:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
// GOOD ABSTRACTION LEVEL:
this.employee.setSickFrom(date(2011,DECEMBER,1)).to(date(2011,DECEMBER,31));
assertSalaryType(SalaryType.SICK, this.calculator.salaryFor(employee, DECEMBER));
 
// BAD ABSTRACTION LEVEL:
Calendar start = Calendar.getInstance();
start.set(Calendar.YEAR, 2011);
start.set(Calendar.MONTH, DECEMBER - 1); // Januar = 0
start.set(Calendar.DAY_OF_MONTH, 1);
Calendar end = Calendar.getInstance();
start.set(Calendar.YEAR, 2011);
start.set(Calendar.MONTH, DECEMBER - 1); // Januar = 0
start.set(Calendar.DAY_OF_MONTH, 31);
this.employee.setSickFrom(start).to(end);
 
Salary salary = this.calculator.salaryFor(employee, DECEMBER);
 
assertNotNull(salary);
SalaryType salaryType = salary.getType();
assertNotNull(salaryType);
assertEquals(SalaryType.SICK, salaryType.getName());
  • Минимальная связь с деталями реализации. Чем больше ваш тестовый код похож на ваш код реализации, тем менее он полезен. Весь смысл тестов состоит в том, чтобы делать то же самое по-другому (и, как правило, гораздо проще), чем ваша реализация, чтобы вы с большей вероятностью обнаруживали в ней ошибки. Поэтому копирование и вставка кода из реализации и его незначительная корректировка — очень плохая вещь. Если вы не можете делать вещи проще (уверены, что не можете ?!), попробуйте сделать их хотя бы по-другому.
  • В основном использование общедоступного API — чтобы ваши тесты были как можно более разделенными и обслуживаемыми, без ущерба для эволюционности вашего кода, мне кажется разумным стараться как можно больше придерживаться открытого API тестируемого класса. Если вы хотите проверить некоторые подробности низкоуровневой реализации, чтобы убедиться, что вы все сделали правильно, создайте для нее еще один тестовый пример, чтобы при изменении реализации вы могли просто выбросить его, пока тест реализует «историю», реализованную код сможет жить счастливо. (См. Никогда не смешивайте публичные и частные юнит-тесты! )
  • TestCases per Fixture (отдельный класс тестирования для каждой из различных потребностей установки) — я надеюсь, что вы уже знаете, что нормально иметь больше тестов для одного бизнес-класса. На самом деле JUnit заставляет вас делать это, например, требуя, чтобы данные параметризованного теста находились на уровне класса. Если вы группируете тесты, которые требуют одинаковой настройки, вы можете переместить код установки в метод @Before и, таким образом, сделать сами тесты намного проще и удобнее для чтения. Конечно, всегда нетрудно найти правильный баланс между количеством тестовых случаев, креплений и методов тестирования.

(Примечание. Также в книге «Шаблоны xUnit» « Тесты как документация» указана как одна из основных целей тестирования.)

2. Истинные модульные тесты + разъединенные интеграционные тесты более высокого уровня

(-> Слои Майка Кона в пирамиде автоматизации тестирования )

Для обсуждения этого JUnit, возможно, не хороший пример — он очень особенный, потому что он использует себя для тестирования своего поведения, и тесты должны провалиться, даже если в тестовой среде есть дефект — но все же мы, возможно, сможем чему-то научиться из этого. Что меня удивило, так это большое количество интеграционных тестов *, около 35%. Рецепт долгоживущих, эволюционирующих тестов, кажется, заключается в следующем:

  • Настоящие модульные тесты , то есть тесты, которые проверяют только один класс и не зависят от его взаимодействия с другими классами. На такой тест не влияют изменения в этих коллаборациях (которые охватываются интеграционными тестами), и если изменяется сам тестируемый класс, тест, скорее всего, будет либо соответствовать изменениям, либо, если это крупномасштабное изменение вы просто выбрасываете его (скорее всего, вместе с тестируемым классом) и пишете новый тест для нового дизайна. (Я не утверждаю, что это легче реализовать, и для этого также требуется особый способ кодирования и структурирования программного обеспечения [см. № 3 ниже], но, безусловно, это может быть путь.)
  • Интеграционные тесты, которые проверяют совместную работу нескольких объектов — не обязательно целого приложения или подсистемы, практически любой части определяемой функциональности. Опять же, интеграционный тест должен рассказывать историю о том, что делает модуль — и эта история вряд ли изменится, даже если эволюционирует группа внутренних объектов. Таким образом, крайне важно тестировать на нужном уровне, где вас не слишком волнуют конкретные детали реализации, но вы не слишком далеки от кода, который хотите протестировать. Кент привел хороший пример того, как они реорганизовали способ, которым JUnit 4.5 управляет и выполняет отдельные этапы тестирования, заменяя вызовы вложенных методов объектами команд (что позволило ввести @Rule) — благодаря тому, что интеграционный тест находится на уровне «I» дать некоторый вклад в подсистему выполнения и ожидать определенного результата », они все еще работали. Если бы они находились на более низком уровне и зависели от того, что была серия вложенных вызовов методов, рефакторинг был бы намного сложнее.

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

3. Более функциональный состав обработки (т.е. убивать издевательства!)

Нужна ли вам среда для написания тестов Тогда вы можете делать это неоптимальным образом. (Отказ от ответственности: Конечно, есть разные, но одинаково хорошие подходы почти ко всему :-).) Когда Кент объяснял, как он сочиняет свои программы, он рисует картину, похожую на эту:

Что в этом интересного? Это не типичная сеть объектов, где один объект что-то делает и вызывает другой, чтобы * продолжить * обработку или выполнить ее часть. Он состоит из двух типов объектов: рабочие, которые получают входные данные и производят выходные данные, и интеграторы, которые делегируют работу отдельным рабочим и передают ее от одного к другому с минимальной собственной логикой. Рабочие очень функциональны и поэтому их легко тестировать с помощью настоящих модульных тестов (и, как уже говорилось, если требуется другая реализация, вы можете просто отбросить работника с его тестом и создать новый), в то время как интеграторы должны быть такими простыми, как: возможно (так что вероятность дефекта меньше) и покрываются интеграционными тестами. (Все прекрасно сочетается, не так ли?)

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

Вывод

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

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

Ссылки по теме

Ссылка: Принципы создания ремонтопригодных и эволюционируемых тестов от нашего партнера JCG Якуба Холи в блоге «Святая Ява» .

Статьи по Теме :