Spring Framework часто приводят как пример Cloud Native фреймворка, созданного для работы в облаке, разработки Twelve-Factor приложений, микросервисов, и одного из самых стабильных, но в то же время инновационных продуктов. Но в этой статье я бы хотел остановиться на еще одной сильной стороне Spring: это его поддержка разработки через тестирование (TDD-руемость?). Не смотря на TDD-руемость, я часто замечал, что в проектах на Spring либо игнорируются некоторые best practices для тестирования, либо изобретаются свои велосипеды, либо вообще не пишутся тесты потому что они "медленные" или "ненадежные". И вот именно о том, как писать быстрые и надежные тесты для приложений на Spring Framework и вести разработку через тестирование я и расскажу. Так что если вы используете Spring (или хотите начать), понимаете что такое вообще тесты (или хотите понять), или думаете что contextLoads
это и есть необходимый и достаточный уровень интеграционного тестирования — то будет интересно!
"TDD-руемость" характеристика очень неоднозначная, и плохо измеримая, но все же у Spring есть много всего что by design помогает писать интеграционные и юнит тесты с минимумом усилий. Например:
- Интеграционное тестирование — можно легко запустить приложение, замокать компоненты, переопределить параметры, и т.п.
- Фокусное интеграционное тестирование — только доступ к данным, только веб и т.д.
- Поддержка из коробки — in-memory баз данных, очереди сообщений, аутентификации и авторизации в тестах
- Тестирование через контракты (Spring Cloud Contract)
- Поддержка тестирования Web UI, используя HtmlUnit
- Гибкость конфигурации приложения — профили, тестовые конфигурации, компоненты и т.п.
- И еще много всего
Для начала, маленькое, но необходимое, введение про TDD и тестирование вообще.
Test Driven Development
В основе TDD лежит очень простая идея — пишем тесты до того, как пишем код. В теории звучит пугающе, но через какое-то время приходит понимание практик и приемов, и вариант написание тестов после вызывает ощутимый дискомфорт. Одна из ключевых практик это итеративность, т.е. делать все маленькими, сфокусированными итерациями, каждая из которых описывается как Red-Green-Refactor.
В красной фазе — пишем падающий тест, при чем очень важно, чтобы он падал с ясной, понятной причиной и описанием и чтобы сам тест был законченым и проходил, когда написан код. Тест должен проверять поведение, а не реализацию, т.е. следовать подходу "черного ящика", далее поясню почему.
В зеленой фазе пишем минимально необходимый код чтобы пройти тест. Иногда бывает интересно попрактиковаться и доводить до асбсурда (хотя лучше не увлекаться) и когда функция возвращает boolean в зависимости от состояния системы, первым "проходом" может быть просто return true
.
В фазе рефакторинга, к которой можно приступать только когда все тесты зеленые, рефакторим код и приводим его в надлежащее состояние. Необязательно даже для куска кода, который мы написали, поэтому и начинать рефакторинг важно на стабильной системе. Подход "черного ящика" как раз поможет выполнять рефакторинг, меняя реализацию, но не трогая поведение.
Я буду еще говорить о разных аспектах TDD в будущем, в конце-концов это идея серии статей, поэтому сейчас особо не буду останавливаться на деталях. Но заранее отвечая на стандартную критику TDD, упомяну пару мифов, которые я слышу часто.
- "TDD это про 100% покрытие кода, а это не дает гарантий" — разработка через тестирование отношения к 100%-му покрытию не имеет вообще. Во многих командах, где я работал, эту метрику даже не измеряли, и относили к разряду vanity метрик. И да, 100% test coverage не значит ничего.
- "TDD работает только для простых функцих, настоящее приложение с БД и сложным состоянием с ним не сделаешь" — очень популярное оправдание, обычно дополняемое "У нас такое сложное приложение, что мы вообще тесты не пишем, никак нельзя". Я видел работающий TDD подход на абсолютно разных приложениях — web (с SPA и без), мобильных, API, микросервисов, монолитов, сложнейших банковских систем, облачных платформ, фреймворков, ритейл платформ, написанных на разных языках и технологиях. Так что популярный миф "Мы уникальные, у нас все по-другому" это чаще всего оправдание не вкладывать силы и средства в тестирование, а не реальная причина (хотя реальные причины тоже могут быть).
- "С TDD все равно будут баги" — конечно, как и в любом другом софте. TDD это вообще не про баги или их отсутствие, это инструмент разработки. Как отладка. Как IDE. Как документация. Ни один из этих инструментов не гарантирует отстутствие багов, они лишь помогают справляться с возрастающей сложностью системы.
Главная цель TDD и вообще тестирования — дать команде уверенность, что система работает стабильно. Поэтому никакая из практик тестирования не определяет, сколько и каких тестов писать. Пишите, сколько считаете нужным, сколько вам нужно, чтобы быть уверенным, что прямо сейчас код можно поставить в продакшен и он будет работать. Есть люди, которые считают быстрые интеграционные тесты, как ультимативный "черный ящик" необходимыми и достаточными, а юнит-тесты — опциональными. Кто-то говорит, что e2e тесты при возможности быстрого отката к предыдущей версии и наличии canary-релизов не так критичны. Сколько команд — столько и подходов, важно найти свой.
Одна из моих целей — это отойти в рассказе о TDD от формата "разработка через тестирование функции, которая складывает два числа", и посмотреть на реальное приложение, своего рода выпаренную до минимального приложения практику тестирования, собранную на реальных проектах. В качестве такого полуреального примера я буду использовать небольшое веб-приложение, которое сам придумал, для абстрактной фабрики пекарни-булочной — Cake Factory. Я планирую писать небольшие статьи, фокусируясь каждый раз на отдельном куске функциональности приложения и показывать, через TDD можно проектировать API, внутреннюю структуру приложения и поддерживать постоянный рефакторинг.
Примерный план для серии статей, как я его вижу на данный момент, это:
- Walking skeleton — каркас приложения, на котором можно запустить Red-Green-Refactor цикл
- UI тестирование и Behaviour Driven Design
- Тестирование доступа к данным (Spring Data)
- Тестирование авторизации и аутентификации (Spring Security)
- Реактивный стек (WebFlux + Project Reactor)
- Взаимодействие (микро) сервисов и контракты (Spring Cloud)
- Тестирование очереди сообщений (Spring Cloud)
Эта вводная статья будет про пункты 1 и 2 — я создам каркас приложения и базовый UI тест, используя подход BDD — или behaviour-driven development. Каждая статья будет начинаться с user story, но для экономии времени про "продуктовую" часть я говорить не буду. User story будет написана по-английски, скоро станет понятно, почему. Все примеры кода можно найти на GitHub, так что я не буду разбирать весь код, только важные части.
User story — это описание фичи приложения на естественном языке, которые обычно пишутся от лица пользователя системы.
User story 1: Пользователь видит страницу приветствия
As Alice, a new user
I want to see a welcome page when visiting Cake Factory web-site
So that I know when Cake Factory is about to launch
Acceptance criteria:
Scenario: a user visiting the web-site visit before the launch date
Given I’m a new user
When I visit Cake Factory web-site
Then I see a message 'Thanks for your interest'
And I see a message 'The web-site is coming soon…'
Потребуются знания: что такое Behaviour-Driven Development и Cucumber, основ Spring Boot Testing.
Первая user story совсем базовая, но цель пока не в сложности, а в создании walking skeleton — минимального приложения, чтобы запустить TDD цикл.
После создания нового проекта на Spring Initializr с Web и Mustache модулями, для начала мне понадобится еще несколько изменений в build.gradle
:
- добавить HtmlUnit
testImplementation('net.sourceforge.htmlunit:htmlunit')
. Версию указывать не нужно, Spring Boot dependency management плагин для Gradle автоматически выберет нужную и совместимую версию - мигрировать проект с JUnit 4 на JUnit 5 (потому, что 2018-й на дворе)
- добавить зависимости на Cucumber — библиотеку, которую я буду использовать для написания BDD спецификаций
- удалить созданный по-умолчанию
CakeFactoryApplicationTests
с неизбежнымcontextLoads
По большому счету, это уже базовый "скелет" приложения, уже можно писать первый тест.
Первый тест
Теперь мне как раз пригодится user-story, написанная на английском. Лучший триггер для запуска очередной итерации TDD — это acceptance criteria написанные в таком виде, что их можно с минимум телодвижений превратить в исполняемую спецификацию.
В идеале, user story должны быть написаны так, чтобы их можно было просто скопировать в BDD спецификацию и запустить. Это далеко не всегда просто и не всегда возможно, но это должно быть целью product owner-a и всей команды, пусть и не всегда достижимой.
Итак, моя первая фича.
Feature: Welcome page
Scenario: a user visiting the web-site visit before the launch date
Given a new user, Alice
When she visits Cake Factory web-site
Then she sees a message 'Thank you for your interest'
And she sees a message 'The web-site is coming in December!'
Если сгененрировать описания шагов (очень помогает плагин Intellij IDEA для поддержки Gherkin) и запустить тест, то он, разумеется, будет зеленым — он пока ничего не тестирует. И здесь наступает важная фаза работы над тестом — нужно написать тест, как будто основной код написан.
Часто у тех, кто начинает осванивать TDD здесь наступает ступор — сложно уложить в голове алгоритмы и логику чего-то, что еще не существует. И поэтому очень важно иметь как можно более маленькие и сфокусированные итерации, начиная от user-story и спускаясь на интеграционный и юнит-уровень. Важно фокусироваться на одном тесте за раз и стараться мокать и игнорировать зависимости, которые пока не важны. Я иногда замечал, как люди легко уходят в сторону — создают интерфейс или класс для зависимости, тут же генерят для него пустой тестовый класс, там добавляется еще одна зависимость, создается еще один интерфейс и так далее.
Если story будет "надо бы при сейве рефрешить статус" ее очень сложно автоматизировать и формализовать. В моем же примере, каждый шаг четко можно уложить в последовательность шагов, которые можно описать кодом. Понятно, что это самый простой пример и он мало что демонстрирует, но надеюсь что дальше, с возрастанием сложности, будет интереснее.
Red
Итак, для мой первой фичи я создал следующие описания шагов:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class WelcomePage {
private WebClient webClient;
private HtmlPage page;
@LocalServerPort
private int port;
private String baseUrl;
@Before
public void setUp() {
webClient = new WebClient();
baseUrl = "http://localhost:" + port;
}
@Given("a new user, Alice")
public void aNewUser() {
// nothing here, every user is new by default
}
@When("she visits Cake Factory web-site")
public void sheVisitsCakeFactoryWebSite() throws IOException {
page = webClient.getPage(baseUrl);
}
@Then("she sees a message {string}")
public void sheSeesAMessageThanksForYourInterest(String expectedMessage) {
assertThat(page.getBody().asText()).contains(expectedMessage);
}
}
Пара моментов, на которые следует обратить внимание:
- запуск фичей выполняется другим файлом,
Features.java
используяRunWith
аннотацию из JUnit 4, Cucumber не поддерживает версию 5, увы @SpringBootTest
аннотация добавлена на описание шагов, ее оттуда подхватываетcucumber-spring
и конфигурирует тестовый контекст (т.е. запускает приложение)- Spring приложение для теста запускается с
webEnvironment = RANDOM_PORT
и этот случайный порт передается в тест используя@LocalServerPort
, Spring найдет эту аннотацию и установит значение поля в порт сервера
И тест, ожидаемо, падает с ошибкой 404 for http://localhost:51517
.
Ошибки, с которыми падает тест, невероятно важны, особенно когда речь идет о юнит или интеграционных тестах и эти ошибки — часть API. Если тест падает сNullPointerException
это не слишком хорошо, а вотBaseUrl configuration property is not set
— гораздо лучше.
Green
Чтобы сделать тест зеленым я добавил базовый контроллер и view с минимальным HTML:
@Controller
public class IndexController {
@GetMapping
public String index() {
return "index";
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Cake Factory</title>
</head>
<body>
<h1>Thank you for your interest</h1>
<h2>The web-site is coming in December!</h2>
</body>
</html>
Тест зеленый, приложение работает, хоть и выполнено в традицих сурового инженерного дизайна.
На реальном проекте и в сбалансированной команде я бы, конечно, сел вместе с дизайнером и мы бы превратили голый HTML во что-то гораздо более прекрасное. Но в рамках статьи чуда не произойдет, царевна так и останется лягушкой.
Вопрос "какую часть в TDD занимает дизайн" не такой простой. Одна из практик, которую я нашел полезной — сначала вообще даже не смотреть на UI (даже не запускать приложение, чтобы сберечь нервы), написать тест, сделать его зеленым — и потом, имея стабильное основание, работать над фронт-ендом, постоянно перезапуская тесты.
Refactor
В первой итерации никакого рефакторинга особо нет, но хотя я последние 10 минут потратил выбирая шаблон для Bulma, которые можно засчитать за рефакторинг!
В заключение
Пока в приложении нет ни работы с безопасностью, ни с БД, ни API — то тесты и TDD выглядят довольно просто. Да и в общем-то из пирамиды тестирования я затронул только самую верхушку, UI тест. Но в этом, отчасти, и секрет lean подхода — делать все небольшими итерациями, один компонент за раз. Это помогает фокусироваться на тестах, делать их простыми, и контролировать качество кода. Надеюсь, что в следующих статьях будет больше интересного.
Ссылки
- Проект на GitHub
- Concourse CI
P.S. Название статьи не такое безумное, как может показаться в начале, думаю многие уже догадались. "How to build a pyramid in your boot" отсылка к пирамиде тестирования (расскажу про нее дальше) и Spring Boot, где boot в британском английском значит еще и "багажник".