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

Если говорить про тестирование 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)

Видео туториал про Spock Framework

Testing with Spring and Spock

MockMvc :: Spring Framework