Всем привет! Я Юнес, SDET в Тинькофф. Помогаю автоматизаторам создавать более эффективные и надежные тесты, готовить тестовые данные и настраивать CI/CD-пайплайны. 

Расскажу о доступных способах оптимизации Spring-контекста для тестов. Будет здорово, если у вас есть знания о Spring Framework и опыт написания тестов: тогда мы будем на одной волне. Давайте разберемся в хитросплетениях аннотаций и конфигураций вместе под катом!

Оптимизация влияет на изолированность

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

Может показаться, что оптимизация контекста — это только про производительность и, если нет проблем с ресурсами или временем выполнения, она и не требуется. Но оптимизация влияет на еще один важный параметр тестов — изолированность. 

Рассмотрим гипотетический сценарий: есть проект с автотестами для сервиса достижений. В проекте есть информация о достижениях пользователя, которая хранится в базе данных Oracle. Сервис достижений обращается к базе данных с пользователями для получения информации, чтобы протестировать взаимодействие с другими сервисами, внутреннюю логику, операции с базами данных и так далее. Сам сервис имеет свою базу данных для выдачи достижений.

Схема взаимодействия сервисов
Схема взаимодействия сервисов

Задача: протестировать функцию в сервисе достижений «рассказать о достижении в соцсетях». Фича не связана с данными пользователя и использует только интеграцию “Social Service”. Запускаем все тесты с тегом “share-feature” и… получаем org.postgresql.util.PSQLException!

Причина ошибки не так важна. Это может быть и опечатка в адресе базы, и заблокированный пользователь, и проблемы с доступом. Главное, что мы понимаем: несмотря на то, что запускаемые тесты не имеют никакого отношения к базе с пользователями, Spring инициализирует бин DataSource, который устанавливает соединение с базой. 

Лишние бины в контексте приводят не только к повышенному потреблению ресурсов и увеличению времени выполнения, но и к непредсказуемому поведению, проблемам с отладкой и нестабильности. Неприятностей можно избежать, если оптимизировать Spring-контекст, желательно — сохранив нервы и позитивный настрой в процессе. 

Контекст в Spring — это основной интерфейс, который управляет бинами, внедряет зависимости (пресловутый Dependency Injection), обеспечивает доступ к ресурсам и делает много других полезных вещей.

Оптимизация ради оптимизации — не лучшая идея. Вот пример признаков, которые могут быть симптомами проблем с неоптимальным контекстом:

  • Медленное выполнение тестов. Если с ростом количества тестов время выполнения растет непропорционально, то одним из источников проблемы может быть неоптимальная конфигурация или чрезмерное использование бинов.

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

  • Нестабильные тесты. Если тесты периодически падают с исключениями вроде BeanCreationException, NoSuchBeanDefinitionException, UnsatisfiedDependencyException и другими, указывающими на проблемы со Spring, нужно приступать к оптимизации. 

  • Жесткие требования к тестовому окружению. Если вы даже один тест не можете запустить без всех ресурсов, доступов, баз данных, иначе он падает с “Failed to load ApplicationContext”, это может быть признаком проблем с контекстом.

Конфигурация в управлении контекстом

Классы конфигурации в Spring сигнализируют о том, что являются источником бинов. В конфигурациях можно задать зависимость между бинами. Чаще всего для создания конфигурации используются аннотации:

@Configuration — помечает класс как конфигурацию, то есть источник бинов. Аннотация — это must-have: в ней можно централизованно определить, какие бины будут помещены в контекст, задав для этого условия и правила. 

@TestConfiguration — конфигурация для тестов. Позволяет определить дополнительные или переопределить существующие бины для тестов. Важное отличие от Configuration — Spring исключает классы, помеченные аннотацией TestConfiguration из сканирования компонентов. Нужно явно подключить аннотацию, чтобы использовать тестовую конфигурацию, например через аннотацию Import.


@ContextConfiguration — определяет, как загружать и настраивать контекст для интеграционных тестов.


@ComponentScan — позволяет искать бины и использовать их в Spring-контексте. Аннотация так и подбивает либо использовать ее без аргументов, либо указать базовый пакет вроде “com.example”. Чаще всего это будет работать и ошибки не получится. Но такая практика ведет к проблемам в будущем, ведь ComponentScan цепляет все бины, встречающиеся в указанных пакетах. 

Если попадется конфигурация с другой аннотацией ComponentScan, Spring зарегистрирует ее и запустит новое сканирование. Такая цепная реакция перегрузит контекст ненужными бинами. Поэтому к выбору пакета в ComponentScan стоит подходить аккуратно.

Сканирование приведет к инициализации ненужных бинов:

@Configuration

@ComponentScan(basePackages = "com.example") // Предполагается, что 'com.example' это корневой пакет проекта

public class AchievementConfig{

…

}

Стоит выбирать специфичный пакет для сканирования:

@Configuration

@ComponentScan(basePackages = "com.example.achievement.service")

public class AchievementConfig{

…

}

@Import — для регистрации конкретного класса конфигурации в Spring-контексте. Один из частых вариантов использования — импорт нужного класса конфигурации.

@EnabelAutoConfiguration — включает автоконфигурацию Spring контекста. Те, кто работает со Spring Boot, знакомы с понятием автоконфигурации, когда Spring на основе зависимостей и пропертей регистрирует бины, которые могут вам пригодиться. Автоконфигурация автоматически отключается при использовании сегментации. 

Поскольку Spring не всегда успешно угадывает, какие бины нам нужны, а также тратит время на сканирование зависимостей, лучше не использовать @EnabelAutoConfiguration для тестов. Может сложиться ситуация, что вы хотите использовать автоконфигурацию, но вам нужно отключить часть бинов. Тогда вы можете либо использовать @EnabelAutoConfiguration со списком отключенных конфигураций:

@EnableAutoConfiguration(exclude = {
  DataSourceAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class, 
    HibernateJpaAutoConfiguration.class})


Либо настроить application.properties/application.yml:

spring.autoconfigure.exclude= \

org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration, \

org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration, \

org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
Пример

Проблема

Контекст для атвотестов поднимался за 120 секунд. Если что-то шло не так, приходилось вносить правки в код и перезапускать тесты, что делало отладку тестов настоящим кошмаром...

Диагностика

Проблема заключалась в неоптимальной конфигурации автотестов. Каждый тест загружал весь Spring Boot контекст приложения, что включало множество ненужных бинов и конфигураций. Это избыточное сканирование и инициализация значительно увеличивали время выполнения тестов.

Внесенные изменения

  1. Заменили @SpringBootTest на @ContextConfiguration: Это позволило загружать только необходимую конфигурацию для тестов.

  2. Создали специализированный конфигурационный класс TestConfig, который включал только те компоненты, которые действительно нужны для тестирования сервиса достижений.

  3. Использовали @ComponentScan с указанием конкретного пакета: Это ограничило сканирование только необходимым пакетом, содержащим AchievementService.

Код до изменений

@RunWith(SpringRunner.class)
@SpringBootTest
public class AchievementServiceTest {

    @Autowired
    private AchievementService achievementService;

    @Test
    public void testAchievement() {
        // Тестирование логики выдачи достижений
    }
}

Код после изменений

@RunWith(SpringRunner.class)
@ContextConfiguration(classes = {TestConfig.class})
public class AchievementServiceTest {

    @Autowired
    private AchievementService achievementService;

    @Test
    public void testAchievement() {
        // Тестирование логики выдачи достижений
    }
}

@Configuration
@ComponentScan(basePackageClasses = AchievementService.class)
public class TestConfig {
    // Конфигурация тестов, включающая только необходимые компоненты
}

Результат

После внесения изменений, среднее время выполнения одного теста сократилось до 30 секунд, что в 4 раза быстрее по сравнению с предыдущим временем выполнения. Это значительно ускорило процесс отладки.


Конфигурация в Spring — ключевой инструмент для определения, какие бины будут зарегистрированы и будут контролироваться на протяжении всего времени выполнения кода. При оптимизации важно, какие бины конфигурация задействует и нет ли лишних, как настроено сканирование и есть ли автоконфигурация. Когда мы говорим про оптимизацию конфигурации, здесь минимум усилий может дать отличный результат, если ранее не проводился рефакторинг. 

Сегментация 

Сегментация, или test slicing, — это механизм Spring Boot, который позволяет разделить контекст на слои, чтобы тестировать их в изоляции друг от друга. В тестовом классе нужно указать аннотацию сегмента, и Spring инициализирует ограниченный контекст, который включает бины, относящиеся к данному сегменту. Доступны аннотации:

@SpringBootTest — позволяет тестировать все сегменты приложения совместно, и нет исключений по загружаемым бинам.


@WebMvcTest — для тестов слоя Model-View-Controller. Включает бины, связанные с Controller.


@WebFluxTest — включает бины с Controller, но только для reactive-контекста.

@RestClientTest — для тестирования Rest-взаимодействия. Автоматически создает для тестов мок rest-сервиса.


@WebServiceClientTest, @WebServiceServerTest — для тестирования SOAP клиента или сервера.


@JsonTest — тесты, завязанные только на Json-сериализацию. Например, JsonComponent.


@DataJpaTest — для тестов, которые используют только JPA-компоненты. Задействует транзакции в тестах, откатывает действия после прогона каждого теста и задействует готовую среду, в которой используется база данных in-memory.


@JdbcTest — аналогично @DataJpaTest позволяет использовать транзакции, только для JDBC-компонент.

@JooqTest, @DataElasticsearchTest, @DataRedisTest, @DataNeo4jTest, @DataCassandraTest, @DataCouchbaseTest, @DataMongoTest, @DataR2dbcTest — для тестов, которые нацелены на работу с конкретной базой или системой управления БД.

@GraphQlTest — для тестов, которые выполняют GraphQL-запросы.

@DataLdapTest — для тестов, связанных с LDAP-протоколом.

Можно создать свою аннотацию, чтобы выделить нужный тип бинов, которые будут обязательно присутствовать в контексте вашего теста.

Пример

Проблема

Автотесты для сервиса достижений выполнялись долго, и тесты не были изолированы друг от друга, возникали ошибки связанные с БД, даже когда запускались тесты контроллера. Из-за этого время выполнения тестов увеличивалось, а также возникали проблемы с повторяемостью результатов тестирования.

Диагностика

Тесты использовали общую конфигурацию, загружали весь контекст Spring Boot приложения, что приводило к длительному времени инициализации и отсутствию изоляции между тестами.

Внесенные изменения

  1. Использовали @WebMvcTest: Это позволило тестировать только веб-слой, не загружая весь контекст приложения.

  2. Использовали @DataJpaTest: Это позволило тестировать только слой данных, не загружая другие части приложения.

  3. Использовали специализированные конфигурации для различных типов тестов, чтобы обеспечить их изоляцию и ускорить выполнение.

Код до изменений

@RunWith(SpringRunner.class)
@SpringBootTest
public class AchievementServiceIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void testGetAchievement() throws Exception {
        mockMvc.perform(get("/achievements/1"))
               .andExpect(status().isOk())
               .andExpect(content().contentType(MediaType.APPLICATION_JSON))
               .andExpect(jsonPath("$.id").value(1));
    }

    @Autowired
    private AchievementRepository achievementRepository;

    @Test
    public void testFindAchievementById() {
        Achievement achievement = achievementRepository.findById(1L).orElse(null);
        assertNotNull(achievement);
        assertEquals(1L, achievement.getId().longValue());
    }
}

Код после изменений

Web Layer Test

@RunWith(SpringRunner.class)
@WebMvcTest(AchievementController.class)
public class AchievementControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void testGetAchievement() throws Exception {
        mockMvc.perform(get("/achievements/1"))
               .andExpect(status().isOk())
               .andExpect(content().contentType(MediaType.APPLICATION_JSON))
               .andExpect(jsonPath("$.id").value(1));
    }
}

Data Layer Test

@RunWith(SpringRunner.class)
@DataJpaTest
public class AchievementRepositoryTest {

    @Autowired
    private AchievementRepository achievementRepository;

    @Test
    public void testFindAchievementById() {
        Achievement achievement = achievementRepository.findById(1L).orElse(null);
        assertNotNull(achievement);
        assertEquals(1L, achievement.getId().longValue());
    }
}

Результат

После внесения изменений, проблем с падением тестов стало значительно меньше благодаря изоляции. Количество flaky тестов уменьшилось на 20%. Скорость поднятия контеста также уменьшилась из-за того, что меньше бинов стало инициализироваться, но незначительно из-за того, что запускаемых контекстов стало больше.

Явными преимуществами сегментации являются простота и удобство использования. Набор бинов уже предопределен, автоконфигурация сделает все за вас. Слои изолируются от остального контекста, что повышает контролируемость тестирования. Но аннотации недостаточно гибкие, если нужен детальный контроль над загружаемыми бинами. 

Не все тесты нуждаются в Spring-контексте. Можно оставить часть тестов без него, например юнит-тесты.

Условия инициализации бина

Spring предоставляет набор аннотаций для указания условий инициализации бина. Если условие выполняется, бин может быть зарегистрирован в контексте. Вот список условий, которые наиболее применимы для тестовых конфигураций и других бинов:

@ConditionalOnBean — если указанный бин есть в контексте.


@ConditionalOnClass — если указанный класс есть в classpath.


@ConditionalOnMissingBean — если бина нет в контексте.


@ConditionalOnMissingClass — если класса нет в classpath.

@ConditionalOnProperty — проверяет значение проперти в application.yml или application.properties.


@ConditionalOnResource — проверяет наличие файла в classpath. 


@ConditionalOnExpression — если логическое выражение возвращает true, бин инициализируется. Для составления выражений используется Spring Expression Language.

@ConditionalOnJava — бин инициализируется, если версия Java входит в перечень разрешенных.

Для одного бина можно использовать несколько условий. Я перечислил не все аннотации-условия, а наиболее подходящие для использования с тестами. Список всех актуальных аннотаций можно посмотреть на GitHub.

Создадим свою аннотацию-условие:

@Target({ElementType.TYPE, ElementType.METHOD})

@Retention(RetentionPolicy.RUNTIME)

@Conditional(OnDatabaseTypeCondition.class)

public @interface ConditionalOnDatabaseType {

	DatabaseType value();

}

Реализуем интерфейс Condition:

public class OnDatabaseTypeCondition implements Condition {

	@Override

	public boolean matches(@NotNull ConditionContext context, AnnotatedTypeMetadata metadata) {

    	Map<String, Object> attributes = metadata.getAnnotationAttributes(ConditionalOnDatabaseType.class.getName());

    	if (attributes != null) {

        	DatabaseType requiredDatabaseType = (DatabaseType) attributes.get("value");

        	String configuredDriverClassName = context.getEnvironment().getProperty("spring.datasource.driverClassName");

        	DatabaseType configuredDatabaseType = DatabaseType.fromDriverClassName(configuredDriverClassName);

        	return requiredDatabaseType.equals(configuredDatabaseType);

    	}

    	else {

        	return false;

    	}

	}

}
Пример

Проблема

У нас возникла проблема с длительным временем загрузки контекста Spring в тестах сервиса достижений. Есть несколько тяжеловесных бинов, которые нужны в небольшом количестве тестов, но они инициализируются всегда, что занимает +10-15 секунд к времени инициализации контекста.

Диагностика

Мы выделили несколько сценариев, когда мы точно используем эти бины. Изучив все варианты оптимизации, мы обнаружили, что использование условий удобнее всего, т.к. нам нужно точечное изменение, создание новой конфигурации в данном случае не оправдано.

Внесенные изменения

  1. Создание кастомного условия: Было создано кастомное условие OnTestPropertyCondition, которое проверяет наличие определённого свойства в конфигурации.

  2. Аннотация бинов: Мы использовали аннотацию @Conditional для бинов, которые не всегда нужны, и настроили их загрузку в зависимости от свойства test.condition.enabled.

  3. Настройка свойств: В тестах, где нужна указанная зависимость, было добавлено свойство test.condition.enabled=true, чтобы активировать необходимые бины только для нужных тестов.

Код до изменений

@Configuration
public class AchievementServiceTestConfig {

    @Bean
    public AchievementService achievementService() {
        return new AchievementService();
    }

    // Другие бины
}

Код после изменений

Кастомное условие

public class OnTestPropertyCondition implements Condition {

    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        String property = context.getEnvironment().getProperty("test.condition.enabled");
        return Boolean.parseBoolean(property);
    }
}

Конфигурация

@Configuration
public class AchievementServiceTestConfig {

    @Bean
    @Conditional(OnTestPropertyCondition.class)
    public AchievementService achievementService() {
        return new AchievementService();
    }

    // Другие бины
}

Тест

@SpringBootTest
@TestPropertySource(properties = {"test.condition.enabled=true"})
public class AchievementServiceTest {

    @Autowired
    private AchievementService achievementService;

    @Test
    public void testAchievementService() {
        assertNotNull(achievementService);
        // дополнительные проверки
    }
}

Результат

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

Вариантов применения в тестах аннотаций-условий — масса. Можно использовать их для инициализации конфигурации, если активен определенный фича-флаг. Можно настроить создание моков-сервисов, если в контексте отсутствуют исходные сервисы. Мы ограничены только фантазией и сроками проекта. Используя условия, мы можем оптимизировать контекст, инициализируя те бины, что нужны для конкретного теста. 

Мокирование бинов

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


@MockBean — применяется к переменной или классу. Во время инициализации контекста Spring создаст моки-бины, которые имитируют его поведение в рамках наших тестов и заменят собой все бины того же типа в Spring-контексте. К сожалению, эта аннотация может ухудшить производительность в тестах из-за кэширования. 

Spring кэширует контекст между разными тестами. Когда запускается тест, Spring проверяет, был ли создан ранее контекст с аналогичными бинами и, если был, переиспользует его. Это очень полезно, особенно если контекст достаточно тяжеловесный. MockBean меняет конфигурацию контекста, из-за чего Spring воспринимает контекст как уникальный и вместо переиспользования для нескольких тестовых классов будет создаваться новый контекст для каждого из них.


@SpyBean — используется для создания объектов-шпионов, чтобы переопределять некоторое поведение бинов. Так же как и MockBean, SpyBean может создать проблемы, если выполняется кэширование контекста.


MockMvc — класс используется в связке с сегментацией. Он позволяет тестировать веб-слой так, словно он запущен на работающем сервере. Это эффективнее, нежели поднимать сервер Tomcat или Jetty для проверки работы тех же контроллеров. 

@Primary — указывает, что у бина приоритет. Если у нас есть несколько бинов одного типа, тогда нужно указать приоритетный бин, который будет использоваться при инъекции. В случае с тестами это может быть полезно для замены сервисов моками или стабами. Да, если у нас есть несколько бинов одного типа, у них обычно одинаковый приоритет. Чтобы избежать ошибок, мы указываем приоритетный бин, который будет использоваться при инъекции.

@TestConfiguration

public class TestConfig {

    @Bean

    @Primary

    public HeavyService heavyServiceMock() {

        return Mockito.mock(HeavyService.class);

    }

}

Профили

В Spring есть возможность привязать бины к различным окружениям. Например, мы можем создать два класса для настройки соединений с базой данных. Для тестирования используем базу H2, а для прода — Postgresql. Можно привязывать бины к нужному нам окружению и адаптировать к разным условиям.

@Profile — аннотация указывает на профили, в которых бин должен или не должен быть инициализирован.

@ActiveProfile — аннотация для тестов, которая говорит Spring активировать указанные профили при запуске теста. Это нужно для теста бинов, которые инициализируются только при определенном профиле. Можно тестировать, одновременно задействуя несколько профилей. 

Профиль нужен для определения поведения в зависимости от окружения. Перед созданием нового профиля определите список окружений, в которых будут запущены тесты. Настройте классы конфигурации под каждое из них, используя аннотацию Profile для указания окружения. Так разграничится, какие бины нужны в текущем окружении, а какие можно не инициализировать и не помещать в контекст. Это позволяет экономить время и ресурсы.

Профилями не стоит злоупотреблять — например, создавать профили под каждую базу: dev-db-h2, dev-db-mysql… Дело в том, что при запуске теста с новым профилем Spring создает новый контекст. Поэтому злоупотребление аннотациями ActiveProfile может замедлить выполнение тестов.

Пример

Проблема

Отсутствие разбивки бинов по окружениям привело к необходимости иметь доступ к базам данных на qa и dev контурах одновременно для стендов. Это усложняет управление конфигурациями и увеличивает риск ошибок. Также это создаёт проблему с безопасностью.

Диагностика

Все бины и конфигурации загружаются независимо от окружения, что приводит к избыточным подключениям и конфигурациям, которые не нужны для конкретного окружения.

Внесенные изменения

  1. Создание профилей для разных окружений (dev и qa): Было определно два Spring профиля (qa и dev) для управления конфигурацией бинов в зависимости от окружения.

  2. Определение специфичных конфигураций для каждого окружения: Разделение конфигурационных файлов и бинов для dev и qa окружений.

Код до изменений

@Configuration
public class DatabaseConfig {

    @Autowired
    private DevDatabaseProperties devDatabaseProperties;
    
    @Autowired
    private QaDatabaseProperties qaDatabaseProperties;
    
    @Bean
    public DataSource qaDataSource() {
        return DataSourceBuilder.create()
                .url(qaDatabaseProperties.getUrl())
                .username(qaDatabaseProperties.getUsername())
                .password(qaDatabaseProperties.getPassword())
                .driverClassName(qaDatabaseProperties.getDriverClassName())
                .build();
    }

    @Bean
    public DataSource devDataSource() {
        return DataSourceBuilder.create()
                .url(devDatabaseProperties.getUrl())
                .username(devDatabaseProperties.getUsername())
                .password(devDatabaseProperties.getPassword())
                .driverClassName(devDatabaseProperties.getDriverClassName())
                .build();
    }
}

application.yml

dev:
  datasource:
    url: jdbc:h2:mem:devdb
    driver-class-name: org.h2.Driver
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
qa:
  datasource:
    url: jdbc:h2:mem:qadb
    driver-class-name: org.h2.Driver
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}

Тест

@RunWith(SpringRunner.class)
@SpringBootTest
public class AchievementServiceDevTest {

    @Autowired
    private AchievementDevService achievementService;

    @Test
    public void testAchievement() {
        // Тестирование логики выдачи достижений на dev окружении
    }
}

@RunWith(SpringRunner.class)
@SpringBootTest
public class AchievementServiceQaTest {

    @Autowired
    private AchievementQaService achievementService;

    @Test
    public void testAchievement() {
        // Тестирование логики выдачи достижений на dev окружении
    }
}

Код после изменений

@Configuration
@Profile("dev")
public class DevDatabaseConfig {

    @Autowired
    private DatabaseProperties properties;
    

    @Bean
    public DataSource dataSource() {
        return DataSourceBuilder.create()
                .url(properties.getUrl())
                .username(properties.getUsername())
                .password(properties.getPassword())
                .driverClassName(properties.getDriverClassName())
                .build();
    }
    
}

application-dev.yml

spring:
  datasource:
    url: jdbc:h2:mem:devdb
    driver-class-name: org.h2.Driver
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}

application-qa.yml

spring:
  datasource:
    url: jdbc:h2:mem:qadb
    driver-class-name: org.h2.Driver
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}

Тест

@RunWith(SpringRunner.class)
@SpringBootTest
@ActiveProfiles("dev")
public class AchievementServiceDevTest {

    @Autowired
    private AchievementService achievementService;

    @Test
    public void testAchievementDev() {
        // Тестирование логики выдачи достижений на dev окружении
    }
}

@RunWith(SpringRunner.class)
@SpringBootTest
@ActiveProfiles("qa")
public class AchievementServiceQaTest {

    @Autowired
    private AchievementService achievementService;

    @Test
    public void testAchievementQa() {
        // Тестирование логики выдачи достижений на qa окружении
    }
}

Результат

Данные изменения избавили команду от многих проблем. Лучшая изоляция позволяет не обращать внимание на доступность БД из другого контура. Бины, которые нужны в qa окружении не инициализируются в dev окружении и наоборот, что экономит немного ресурсов и времени.

Главная задача профилей — обеспечить гибкую настройку контекста в зависимости от окружения. А еще они положительно влияют на изолированность тестов и производительность, поскольку в тестовой среде инициализируются только бины и конфигурации, нужные нам для тестирования. 

Мы стараемся избегать операций создания или пересоздания контекста, потому что Spring-контекст выполняет большое количество различных операций при запуске. Он проходит пять этапов инициализации, во время которых парсит конфигурацию, сканирует пакеты, регистрирует и создает бины, настраивает их. Все это требует ресурсов и времени. И чем больше бинов используется, тем дольше это происходит.

Дополнительная настройка контекста

Вот список аннотаций, которые влияют на работу контекста, но не подошли под остальные категории.


@Lazy — аннотация информирует Spring о том, что для данного бина нужна ленивая инициализация, то есть Spring инициализирует его только тогда, когда он будет вызван в коде. В тестах это помогает ускорить инициализацию контекста и снизить количество потребляемых ресурсов. Ею стоит помечать тяжелые бины, а также редко используемые компоненты. 

В отличие от бинов с другим профилем или бинов с невыполненным условием инициализации, ленивый бин присутствует в контексте и не вызывает UnsatisfiedDependencyException при попытке обращения к нему.

@DirtiesContext — информирует Spring о том, что контекст требуется перезапустить до или после выполнения указанного метода или класса. Она используется в тестах, чтобы возвращать исходное состояние бинам, очищать кэш, пересоздавать соединение с базой данных и так далее. Проблема в том, что пересоздание контекста — ресурсоемкая операция, которая замедляет выполнение тестов. Стоит минимизировать или даже исключить использование DirtiesContext и стремиться к изолированности тестов, транзакционности операций с базой данных и эффективной подготовке тестовых данных.

Кэширование

Кэш — высокоскоростное хранилище для временного хранения копий данных, к которым часто требуется доступ. Использование механизма кэширования повышает производительность тестов благодаря тому, что вместо выполнения одной и той же операции снова и снова мы берем результат выполнения операции из кэша. Spring предоставляет нам широкий набор инструментов для кэширования данных. 

@EnableCaching — чтобы аннотации @CacheEvict, @CachePut, @Cacheable и @CacheConfig работали, нужно указать Spring, что мы хотим использовать кэширование в нашем коде. Для включения кэширования нужно добавить аннотацию в конфигурационном классе и настроить менеджер кэша (актуально, если вы не используете Spring Boot).

@Cachable — применяется к методу и указывает, что результат выполнения метода нужно кэшировать. Если метод вызван, Spring проверяет, не было ли сохраненного результата функции с такими входными данными. Если в кэше есть такой результат, то он возвращается, если нет — метод выполняется, а результат кэшируется. Для Cachable можно настроить условия кэширования, имя кэша, синхронизацию и так далее.


@CacheEvict — аннотация для удаления данных из кэша. К примеру, есть база данных с информацией о постах, которые публикует пользователь. Кэшируем запросы о получении списка всех постов от пользователя. Если нужно удалить пользователя со всем списком постов, нужно будет удалить эти данные из кэша, иначе тесты будут получать неактуальную информацию и появятся false negative, с которыми нужно будет разбираться. Для этого на метод ставится аннотация CacheEvict с указанием имени кэша.

@CachePut — аннотация для обновления данных в кэше. По аналогии с CacheEvict, если вы обновляете данные в базе или на стороннем сервисе, нужно обновить данные и в кэше.

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

@Service

public class AchievementService {

	@Cacheable(value = "achievements", key = "#achievementId")

	public Achievement getAchievementById(Long achievementId) {

    	// код метода

	}

	@CachePut(value = "achievements", key = "#achievement.id")

	public Achievement updateAchievement(Achievement achievement) {

    	// код метода

    	return achievement;

	}

	@CacheEvict(value = "achievements", key = "#achievementId")

	public void deleteAchievement(Long achievementId) {

    	// код метода

	}

}

Стало так:

@Service

@CacheConfig(cacheNames = "achievements")

public class AchievementService {

	@Cacheable(key = "#achievementId")

	public Achievement getAchievementById(Long achievementId) {

    	// код метода

	}

	@CachePut(key = "#achievement.id")

	public Achievement updateAchievement(Achievement achievement) {

    	// код метода

    	return achievement;

	}

	@CacheEvict(key = "#achievementId")

	public void deleteAchievement(Long achievementId) {

    	// код метода

	}

}

@Caching — аннотация, которая позволяет указать несколько действий с кэшем для одного метода:

@Caching(

    evict = { @CacheEvict("achievements"), @CacheEvict(value = "achievementIds", allEntries = true) },

    put = { @CachePut(value = "achievements", key = "#achievement.id") }

)

public Achievement saveAchievement(Achievement achievement) { /* код метода */ }

В Spring кэшируются не только повтояемые и ресурсоёмких операции, но и контексты. Если для первого нужно явно указывать аннотации вроде Cachable, то с контекстами немного сложнее. Как только Spring загружает ApplicationContext (или WebApplicationContext) для теста, этот контекст кэшируется и повторно используется для всех последующих тестов, которые объявляют ту же уникальную конфигурацию контекста в том же наборе тестов. Как упоминалось ранее, аннотации вроде MockBean и SpyBean меняют конфигурацию контекста, что не позволяет её переиспользовать. Также любое изменение метаданных конфигурации (активные профили, подключенные классы конфигурации, родительский контекст и пр.) создаёт новый контекст, а не переиспользует старый. Поэтому, где это возможно, стоит не использовать аннотации, создающие уникальные контексты и искать баланс между изоляцией и модульностью и производительностью.

За время работы со Spring я вывел для себя два момента, как не сломать механизм кэширования и обернуть его в свою пользу:

  1. Запускать тесты, которые используют одинаковый контекст, в одном процессе. Контекст хранится в статической переменной в JVM, поэтому кэширование фактически не будет работать при запуске тестов в разных процессах. В Spring сводят кэширование к нулю Fork Mode в Maven или maxParallelForks в Gradle, которые запускают несколько процессов параллельно.

  1. Кэширование требует ресурсов, не стоит использовать его для всего подряд, это сделает только хуже. Не кэширую данные в тестах, если они часто меняются, зависят от контекста, узкоспециализированные или часто меняются. А еще слишком дорого кэшировать примитивы и легковычисляемые данные.

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

Анализируем результат

Перед оптимизацией контекста стоит понять его текущее состояние. Это будет точка, от которой будем отталкиваться. Нам важны следующие метрики.

Время инициализации контекста

Что означает: сколько времени нужно Spring, чтобы выполнить полную инициализацию контекста, начиная от парсинга конфигураций и заканчивая настройкой созданных бинов.

Зачем нужна: сведение к минимуму времени запуска ускоряет выполнение тестов, способствуя ускорению циклов разработки и обратной связи.

Чем измерять:
— Брать замеры от Spring. Spring при старте замеряет время старта контекста и выводит в виде сообщения “Started ExampleTest in 4.822 seconds (JVM running for 5.224)”. Нужно только установить уровень логирования не ниже INFO.

— Отлавливать события старта и завершения инициализации Spring-контекста с помощью ApplicationListener и считать разницу.

Потребление памяти

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

Зачем нужна: для предотвращения проблем с памятью при запуске в окружении с ограниченными ресурсами.

Чем измерять: профилировщик — VisualVM, JProfiler и так далее.


Сканируемые бины для тестов

Что означает: какие бины Spring подтягивает для тестов.

Зачем нужна: для правильной конфигурации ComponentScan — чтобы не загружать ненужные бины.

Чем измерять: Spring выводит информацию о работе ComponenScan в логи, нужно выставить уровень логирования DEBUG и искать в логах информацию о ClassPathBeanDefinitionScanner через Ctrl+F.

В DEBUG-логах можно найти информацию о работе автоконфигурации, она выводится в CONDITIONS EVALUATION REPORT. Помимо этого, можно понаблюдать за работой кэша Spring (если вы кэшируете данные), включив логирование в application.properties: logging.level.org.springframework.test.context.cache=DEBUG.

После сбора метрик можно приступать к рефакторингу. После рефакторинга нужно повторно собрать метрики и сравнить. Также стоит повторно запустить тесты и убедиться, что изменения не повлияли на результаты — по крайней мере, в худшую сторону.

Выводы

Оптимизация контекста Spring для тестов заключается не только в повышении скорости выполнения тестов или экономии ресурсов. Речь идет также об обеспечении надежности, удобстве обслуживания и масштабируемости процесса тестирования, а также увеличении изолированности тестов. Надеюсь, приведенные в статье рекомендации помогут кому-нибудь улучшить тесты и сделать процесс тестирования эффективнее и стабильнее. Если вы знаете способы оптимизации контекста, не приведенные в статье, — буду рад, если поделитесь в комментариях :)

Комментарии (1)


  1. MSid
    25.05.2024 19:05

    У нас используется полный контекст в тестах и, чтоб ускорить его поднятие при запуске не всех тестов, а только части, мы используем ленивую инициализацию всех бинов по умолчанию. Однако некоторые бины при таком подходе нужно разметить обязательными к инициализации через @Lazy(false)