Написание тестов — важная часть создания качественного ПО, но в то же время кажется неинтересным и утомительным занятием. Попробуем улучшить этот процесс, объединив сразу несколько крутых технологий.
Если говорить про тестирование Java-приложений, то первым на ум приходит JUnit. Этот фреймворк часто используется в Test-Driven Development (TDD) подходе, при котором сначала пишутся тесты на ожидаемое поведение системы, а затем код, который обеспечивает указанное поведение. В качестве альтернативы JUnit выступает Spock Framework, использующий подход Behavior-Driven Development (BDD). Его суть заключается в том, чтобы создавать читаемые, продуктивные и понятные спецификации поведения системы.
BDD сценарии, в отличие от TDD, обычно более доступны для понимания и использования всеми участниками команды разработки, включая нетехнических участников команды, таких как бизнес-аналитики. BDD помогает фокусироваться на бизнес-требованиях и легче сопровождать код в долгосрочной перспективе.
Учитывая специфику Spock Framework, возникают следующие вопросы:
В чём состоит особенность написания BDD тестов в Spock?
Каким образом выполняется интеграция Spock и Spring Boot?
Как при тестировании Spring приложения выполнить конфигурацию контекста?
Можно ли тестировать persistence layer?
Как выглядит тестирование Java приложения с помощью языка Groovy?
Чтобы ответить на эти вопросы, далее в статье описан процесс интеграции Spring Boot и Spock Framework, а также приведены примеры тестирования в BDD подходе.
Теория
Основная структура спецификации (теста)
Тестирование в Spock устроено по принципу behavior-driven development (BDD). Это означает, что вместо написания тестов для отдельных компонентов будут разрабатываться спецификации и требования, основанные на работе системы.
В Spock используется система блоков given-when-then (и другие), что помогает семантически разделить код:
given: "an empty bank account"
// ...
when: "the account is credited $10"
// ...
then: "the account's balance is $10"
// ...
Так выглядит типичный тест с использованием Spock, написанный на языке Groovy:
class HashMapSpec extends Specification {
def "HashMap accepts null key"() {
given:
def map = new HashMap()
when:
map.put(null, "value")
then:
notThrown(NullPointerException)
}
}
Для сравнения, этот же тест на JUnit 5, написанный на Java:
public class HashMapTest {
@Test
public void testNullKey() {
HashMap map = new HashMap();
assertDoesNotThrow(() -> {
map.put(null, "value")
});
}
}
Инструменты Spock
Обычно системы состоят из сочетаний множества компонентов, взаимодействие которых со временем становится трудно отслеживать. В этом могут помочь инструменты: Mock, Stub и Spy. Они предоставляют удобный способ имитации и контроля вызовов зависимостей в тестах. Подробнее разобраться в них поможет эта статья.
Вкратце:
Stub - заглушка, не имеющая реализации, просто предоставляет данные;
Mock - добавляет к функционалу Stub возможность контролировать вызовы метода;
Spy - обертка для реального объекта, официальная документация не советует его использовать.
Практика
Совместимость версий
Spock Framework последней версии не будет совместим с версиями Spring Boot ниже 2.7. Лучший вариант, чтобы версии Spring и Spock были примерно одного времени выхода. Также будет требоваться версия Groovy, которая соответствует той, что указывается в конце версии Spock.
В качестве примера для сборки будет использоваться Gradle. Добавляем следующие зависимости в готовый Spring Boot проект (на момент написания статьи использовалась версия 2.7.12):
testImplementation 'org.codehaus.groovy:groovy-all:3.0.16'
testImplementation 'org.spockframework:spock-core:2.3-groovy-3.0'
testImplementation 'org.spockframework:spock-spring:2.3-groovy-3.0'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
Также нужно добавить следующую строку в разделе plugins:
id 'groovy'
Тесты в Spock пишутся на языке Groovy, что даёт несколько преимуществ. Например, строки в названиях функций (так будет проще описать поведение теста) или поддержка перегрузки операторов, что улучшает читаемость кода.
Свойства
В модуле test для удобства можно создать файл application.proprties, содержащий свойства, который будет использоваться вместо основного во время проведения тестов.
Иногда в целях тестирования приходится переопределять уже существующие бины, и чтобы это стало возможно, нужно прописать следующее свойство:
spring.main.allow-bean-definition-overriding=true
Добавляем H2 базу данных (если надо)
Чтобы во время тестов не затрагивать базу данных проекта, можно использовать in-memory database. Для этого добавим ещё одну зависимость:
testImplementation 'com.h2database:h2:2.1.214'
Также нужно добавить следующие свойства:
spring.datasource.url=jdbc:h2:mem:test;DB_CLOSE_ON_EXIT=FALSE;DB_CLOSE_DELAY=-1;
spring.datasource.username=test_usernamee
spring.datasource.password=test_password
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create
Зачастую на проекте используется ORM система, и существует уже готовая схема БД, на основе которой нужно проводить тестирование. Из-за этого могут возникнуть конфликты, которые можно решить, прописав свойства для свойства (да-да, всё верно).
Если у вас названия таблиц с маленькой буквы, то добавляем к первому свойству:
DATABASE_TO_UPPER=false;
Следующее свойство решает проблему инициализации схемы:
INIT=CREATE SCHEMA IF NOT EXISTS public;
Интеграция Spring Boot
В качестве примера протестируем сервис для работы с клиентами.
Если у тестируемого модуля сложная конфигурация, её можно вынести в отдельный groovy-класс вот таким образом:
@Configuration
class ClientServiceConfig {
private final mockFactory = new DetachedMockFactory()
@Bean
SearchIntegration searchIntegration() {
return mockFactory.Mock(SearchIntegration)
}
@Bean
RegionService regionService() {
return mockFactory.Stub(RegionService)
}
@Bean
ClientDataService clientDataService() {
return mockFactory.Stub(ClientDataService)
}
//...
}
Для запуска теста должны быть предоставлены зависимости тестируемых классов. Это можно сделать с помощью DetachedMockFactory выбрав Mock, Stub или Spy. Если для создания класса вы использовали заглушку Mock или Stub, то прописывать вложенные в них зависимости не нужно.
Затем полученную конфигурацию легко добавить в контекст к тестируемому объекту (через аннотацию @SpringBootTest или @ContextConfiguration). Классы, добавленные через @Autowired, должны присутствовать в контексте. Соответствующие конфигурации можно создавать для каждого модуля.
Таким образом, можно составить такую спецификацию сервиса для работы с клиентами:
@SpringBootTest(classes = [ClientServiceConfig.class, ClientService.class])
class ClientServiceSpec extends Specification {
@Autowired @Subject
ClientService clientService
@Autowired
ClientRepository clientRepository
Client validClient
def setup() {
validClient = new Client()
validClient.setInn("0123456789")
}
def cleanup() {
clientRepository.deleteAll()
}
def "should save valid client"() {
when:
def savedClient = clientService.save(validClient)
then:
savedClient.getId() != null
}
def "should not save invalid client"() {
expect:
clientService.save(new Client()) == null
}
def "getting client without inn should throw an exception"() {
when:
clientService.getByInn(null, true)
then:
def exception = thrown(ClientException)
println(exception.message)
}
}
Интеграционное тестирование
Ничего не мешает также использовать MockMvc, чтобы тестировать работу контроллеров без деплоя на сервер.
Вот пример тестирования эндпоинта аутентификации:
@SpringBootTest @AutoConfigureMockMvc
class AuthControllerSpecification extends Specification {
@Autowired
MockMvc mockMvc
@Autowired
ObjectMapper objectMapper
@SpringBean
JwtUtils jwtUtils = Mock()
def "should generate token for user with correct credentials"() {
given: "correct credentials"
def credentials = AuthRequest.builder()
.login("user@example.com")
.password("password")
.build()
when: "passed to: /account/auth"
def responseHeader = mockMvc.perform(post("/account/auth")
.content(objectMapper.writeValueAsString(credentials))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andReturn()
.response.getHeader(HttpHeaders.AUTHORIZATION)
then: "response header contains jwt token"
1 * jwtUtils.generateToken(_, _) >> "Authenticated!"
responseHeader == "Authenticated!"
}
}
Так как это простая конфигурация, то создать Mock, Stub или Spy можно с помощью аннотации @SpringBean.
Следующая строка отслеживает, чтобы вызов метода generateToken выполнился только один раз, а также переопределяет его возвращаемое значение:
1 * jwtUtils.generateToken(_, _) >> "Authenticated!"
С помощью этой техники можно изменить внутреннее поведение сервиса для создания различных тест-кейсов.
Заключение
Spock и Spring Boot при правильной настройке хорошо сочетаются вместе. Как раз на этом этапе часто возникают трудности, особенно у начинающих разработчиков. В статье мне хотелось просто и на примерах описать процесс настройки и тестирования такого сетапа.
Ссылки
Spock Framework Reference Documentation
Spring Module (spockframework.org)