Модульные и интеграционные тесты серьёзно упрощают жизнь простого разработчика, позволяя выявить большую часть ошибок и проблем ещё на ранних стадиях разработки. Отдельного упоминания заслуживают фреймворки Spring Restdocs и Spring Cloud Contract, использование которых в интеграционных тестах позволяет сгенерировать сниппеты, заглушки и контракты тестируемых REST API.
Cucumber, Spring Restdocs и Spring Cloud Contract
Использование Spring Restdocs позволяет в ходе выполнения интеграционных тестов сгенерировать сниппеты и документацию для REST API, а совместно со Spring Cloud Contract ещё и заглушки и контракты, которые могут в дальнейшем использоваться при разработке клиентов.
Cucumber упрощает написание тестов при разработке на основе поведения или BDD (Behaviour Driven Development). При его использовании сценарии поведения тестируемого компонента можно описывать при помощи обычного языка с применением языка Gherkin, который регламентирует структуру сценария. Например:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
# language: ru Функция: получение списка контактов Сценарий: клиент запрашивает список контактов Дано в таблице есть контакты: | email | name | | jack.daniels@example.com | Jack Daniels | | jim.beam@example.com | Jim Beam | | john.dewar@example.com | John Dewar | Когда клиент выполнит запрос GET /contacts То будет возвращён ответ со статусом 200 Также тип содержимого будет application/json;charset=utf-8 А ответ будет содержать 3 элемента А также ответ будет выведен в лог А также запрос будет задокументирован |
Более подробно о Cucumber и Gherkin можно узнать из официальной документации. Также в мои планы входит написание подробной статьи на тему тестирования кода с Cucumber. Однако в рамках данной статьи я буду описывать процесс интеграции Cucumber и Spring Restdocs/Spring Cloud Contract.
Тестовый проект
Тестовым проектом будет простой REST-сервис с авторизацией и с хранением данных в БД. Зависимости проекта:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <!-- Spring Security Test --> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> <!-- Spring Cloud Contract --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-verifier</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-contract-wiremock</artifactId> <scope>test</scope> </dependency> <!-- Spring Restdocs --> <dependency> <groupId>org.springframework.restdocs</groupId> <artifactId>spring-restdocs-mockmvc</artifactId> <scope>test</scope> </dependency> <!-- Cucumber --> <dependency> <groupId>info.cukes</groupId> <artifactId>cucumber-junit</artifactId> <version>1.2.5</version> <scope>test</scope> </dependency> <dependency> <groupId>info.cukes</groupId> <artifactId>cucumber-spring</artifactId> <version>1.2.5</version> <scope>test</scope> </dependency> <dependency> <groupId>info.cukes</groupId> <artifactId>cucumber-java</artifactId> <version>1.2.5</version> <scope>test</scope> </dependency> </dependencies> |
Подготовка к написанию тестов
При разработке на основе поведения с использованием Cucumber, процесс разработки начинается с описания функциональности и сценариев её использования. В качестве примера используем указанное уже выше описание:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
# language: ru Функция: получение списка контактов Сценарий: клиент запрашивает список контактов Дано в таблице есть контакты: | email | name | | jack.daniels@example.com | Jack Daniels | | jim.beam@example.com | Jim Beam | | john.dewar@example.com | John Dewar | Когда клиент выполнит запрос GET /contacts То будет возвращён ответ со статусом 200 Также тип содержимого будет application/json;charset=utf-8 А ответ будет содержать 3 элемента А также ответ будет выведен в лог А также запрос будет задокументирован |
Теперь нам понадобится класс, который будет выполнять тесты на основе описанного сценария.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
package name.alexkosarev.sandbox.web.controllers.contacts; import cucumber.api.CucumberOptions; import cucumber.api.junit.Cucumber; import org.junit.runner.RunWith; @RunWith(Cucumber.class) @CucumberOptions( strict = true, glue = "name.alexkosarev.sandbox.web.controllers.contacts", features = "classpath:features/contacts/FindAll.feature") public class FindAllTest { } |
Аннотацией @RunWith мы указываем, что тест должен выполняться с Cucucmber.
Аннотацией @CucumberOptions мы настраиваем Cucumber:
- Свойство strict=true делает реализацию всех шагов обязательным
- Свойство glue указывает пакет в котором Cucumber будет рекурсивно искать реализации шагов
- Свойство features указывает путь, где находятся описания функциональностей. В качестве значения можно указать как директорию, так и конкретный файл как это сделано в нашем случае.
Если мы сейчас попробуем выполнить тест, то в ответ получим что-то вроде:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
Undefined scenarios: features/contacts/FindAll.feature:3 # Сценарий: клиент запрашивает список контактов 1 Scenarios (1 undefined) 5 Steps (5 undefined) 0m0,000s You can implement missing steps with the snippets below: @Дано("^в таблице есть контакты:$") public void в_таблице_есть_контакты(DataTable arg1) throws Throwable { // Write code here that turns the phrase above into concrete actions // For automatic transformation, change DataTable to one of // List<YourType>, List<List<E>>, List<Map<K,V>> or Map<K,V>. // E,K,V must be a scalar (String, Integer, Date, enum etc) throw new PendingException(); } @Когда("^клиент выполнит запрос GET /contacts$") public void клиент_выполнит_запрос_GET_contacts() throws Throwable { // Write code here that turns the phrase above into concrete actions throw new PendingException(); } @То("^будет возвращён ответ со статусом (\\d+)$") public void будет_возвращён_ответ_со_статусом(int arg1) throws Throwable { // Write code here that turns the phrase above into concrete actions throw new PendingException(); } @То("^тип содержимого будет application/json;charset=utf-(\\d+)$") public void тип_содержимого_будет_application_json_charset_utf(int arg1) throws Throwable { // Write code here that turns the phrase above into concrete actions throw new PendingException(); } @То("^ответ будет содержать (\\d+) элемента$") public void ответ_будет_содержать_элемента(int arg1) throws Throwable { // Write code here that turns the phrase above into concrete actions throw new PendingException(); } |
Как следует из лога, чтобы тесты выполнились, нам нужно реализовать указанные шаги. Но перед реализацией шагов нужно интегрировать поддержку Spring и его компонентов.
Интеграция Spring и Cucumber
Основная сложность в интеграции Spring и его компонентов в том, что Cucumber не поддерживает многие возможности JUnit вроде @Before, @After и @Rule, а также не даёт запустить тесты с @RunWith(SpringRunner.class), следовательно аннотации вроде @AutoConfigureRestDocs и @AutoConfigureMockMvc будут работать некорректно, либо не будут работать вообще.
Первое, что нужно сделать — создать класс, который будет инициализировать контекст Spring и который будут наследовать классы, содержащие реализацию шагов:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
@SpringBootTest @ContextConfiguration(classes = SandboxTestingApplication.class) public abstract class SpringCucumberIntegrationTest { @MockBean ContactRepository contactRepository; @Autowired private WebApplicationContext webApplicationContext; MockMvc mockMvc; private ManualRestDocumentation restDocumentation; public void setUp() { // конфигурация Spring Restdocs restDocumentation = new ManualRestDocumentation("target/generated-snippets"); // конфигурация MockMVC mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) .apply(springSecurity())// подключение Spring Security .apply(documentationConfiguration(restDocumentation)// подключение Spring Restdocs .snippets().withAdditionalDefaults(new WireMockSnippet()))// подключение Spring Cloud Contract и WireMock .build(); // инициализация контеста Spring Restdocs restDocumentation.beforeTest(FindAllTest.class, "setUp"); } public void tearDown() { // закрытие контекста Spring Restdocs restDocumentation.afterTest(); } } |
Аннотация @MockBean позволяет замокать компоненты контекста приложения Spring.
Реализация тестов
Теперь можно реализовать тестовые сниппеты. Для этого создадим новый класс:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
public class FindAllStepDefs extends SpringCucumberIntegrationTest { private ResultActions resultActions; @Before public void setUp() { super.setUp(); } @Дано("^в таблице есть контакты:$") public void в_таблице_есть_контакты(List<Contact> contacts) throws NoSuchMethodException { doReturn(contacts).when(contactRepository) .findAll(); } @Когда("^клиент выполнит запрос GET (.+)$") public void клиент_выполнит_запрос_GET(String requestPath) throws Exception { resultActions = mockMvc.perform(get(requestPath).with(user("tester"))); } @Тогда("^будет возвращён ответ со статусом (\\d+)$") public void будет_возвращён_ответ_со_статусом(int status) throws Exception { resultActions.andExpect(status().is(status)); } @То("^также ответ будет выведен в лог$") public void также_ответ_будет_выведен_в_лог() throws Exception { resultActions.andDo(print()); } @То("^ответ будет содержать (\\d+) элемента$") public void ответ_будет_содержать_элемента(int count) throws Exception { resultActions.andExpect(jsonPath("$.length()").value(3)); } @То("^тип содержимого будет (.+)$") public void тип_содержимого_будет(String contentType) throws Exception { resultActions.andExpect(content().contentType(contentType)); } @То("^также запрос будет задокументирован$") public void также_запрос_будет_задокументирован() throws Exception { resultActions.andDo(document("contacts/findAll", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), responseFields( fieldWithPath("[0].email").description("E-mail контакта"), fieldWithPath("[0].name").description("Имя контакта") ), dslContract() )); } @After public void tearDown() { super.tearDown(); } } |
Результат выполнения тестов будет:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
MockHttpServletRequest: HTTP Method = GET Request URI = /contacts Parameters = {} Headers = {} Handler: Type = name.alexkosarev.sandbox.web.controllers.ContactsController Method = public org.springframework.http.ResponseEntity<java.util.List<name.alexkosarev.sandbox.entities.Contact>> name.alexkosarev.sandbox.web.controllers.ContactsController.findAll() Async: Async started = false Async result = null Resolved Exception: Type = null ModelAndView: View name = null View = null Model = null FlashMap: Attributes = null MockHttpServletResponse: Status = 200 Error message = null Headers = {X-Content-Type-Options=[nosniff], X-XSS-Protection=[1; mode=block], Cache-Control=[no-cache, no-store, max-age=0, must-revalidate], Pragma=[no-cache], Expires=[0], X-Frame-Options=[DENY], Strict-Transport-Security=[max-age=31536000 ; includeSubDomains], Content-Type=[application/json;charset=UTF-8]} Content type = application/json;charset=UTF-8 Body = [{"email":"jack.daniels@example.com","name":"Jack Daniels"},{"email":"jim.beam@example.com","name":"Jim Beam"},{"email":"john.dewar@example.com","name":"John Dewar"}] Forwarded URL = null Redirected URL = null Cookies = [] <strong>1 Scenarios (1 passed) 7 Steps (7 passed) 0m0,341s</strong> Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.593 sec - in name.alexkosarev.sandbox.web.controllers.contacts.FindAllTest |
Вы можете заметить, что сам тестовый код ничем не отличается от того, что вы пишете в обычных интеграционных тестах Spring.
При стандартном подходе к тестированию весь тестовый код выглядел бы следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
@RunWith(SpringRunner.class) @SpringBootTest @AutoConfigureMockMvc @AutoConfigureRestDocs("target/generated-snippets") public class PossibleTest { @Autowired private MockMvc mockMvc; @MockBean private ContactRepository contactRepository; @Test public void test() { // given doReturn(Arrays.asList(new Contact("jack.daniels@example.com", "Jack Daniels"), new Contact("jim.beam@example.com", "Jim Beam"), new Contact("john.dewar@example.com", "John Dewar"))) .when(contactRepository).findAll(); // when mockMvc.perform(get("/contacts")) .andDo(print()) // then .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8)) .andExpect(jsonPath("$.length()").value(3)) // and then .andDo(document("contacts/findAll", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), responseFields( fieldWithPath("[0].email").description("E-mail контакта"), fieldWithPath("[0].name").description("Имя контакта") ), dslContract() )); } } |