Ловушки интеграционного тестирования в Spring Boot
Некоторые экземпляры из коллекции проблем, с которыми часто сталкивается разработчик в мире тестирования микросервисов.
Вступление — пирамида тестирования, микросервисы и ипотека
В современном IT микросервисная архитектура стала нормой. У неё много преимуществ, включая гибкость и масштабируемость, но полное тестирование продуктов, созданных по её принципам, является сложной и затратной задачей. Стоимость этого тестирования возрастает по мере того, как мы переходим от модульных тестов отдельных сервисов к более обширным приёмочным тестам и далее. В результате современные подходы к разработке накладывают на инженеров дополнительные обязательства по обеспечению высокого качества каждого из сервисов системы, а цена, пропущенного на более низкой ступени пирамиды, тестирования дефекта многократно возрастает. Подробнее о пирамиде можно прочесть, например, здесь, главный принцип: в основании — дешёвые модульные тесты, их много, чем выше поднимаемся, тем выше и дороже тесты, соответственно, тем их меньше. На практике это означает, что наличие качественных и надёжных модульных и интеграционных тестов для каждого сервиса жизненно необходимо для успешного развития проекта, иначе придётся столкнуться с большими сложностями при развитии. Ведь интеграционные тесты в процессе разработки ПО подобны строительному инспектору, проверяющему качество и целостность здания после завершения строительства. Как инспектор, интеграционные тесты проверяют, что все компоненты и элементы программного обеспечения корректно соединены и функционируют вместе, как и должно быть в хорошо построенном доме.
И кстати про недвижимость: меня зовут Александр Таношкин, я ведущий инженер-программист в компании Циан с 2018 года, моя работа связана с маркетплейсом ипотечных предложений от банков - сервисом Циан.Ипотека. Мы предоставляем клиентам возможность выбора наилучших ипотечных условий от различных банков. Архитектура продукта построена на микросервисах, мы работаем с такими технологиями, как Java/Kotlin/Spring, PostgreSQL, Kafka, Kubernetes и Яндекс.Облако. С релиза первой версии нашего продукта прошло уже немало времени, и за это время мы собрали внушительную коллекцию «ловушек» интеграционного тестирования — проблем, обычно выражающихся в непредсказуемых и неочевидных падениях тестов, расследование которых может быть увлекательно, но крайне затратно. В статье я поделюсь некоторыми экземплярами коллекции и предложу практические рекомендации, как их избежать, чтобы сосредоточиться на главной задаче — обеспечении качества.
Иллюстрируя ловушки тестирования, я написал микросервис-пример (CRUD API + асинхронная логика), использующий довольно распространённый сейчас стек Gradle/Spring Boot/Kotlin/Postgres/Kafka, при этом описанные здесь проблемы и способы их решения релевантны для других технологий, отличаться будут детали реализации. Сервис-пример состоит из типовых для подобного ПО слоёв — контроллеры, валидаторы, бизнес-логика, репозитории и прочее. В статье для краткости приведены лишь фрагменты тестового кода, иллюстрирующие ту или иную ловушку, полную версию кода можно найти здесь.
Для тестирования используются возможности Spring, Junit 5 и testcontainers. Большим плюсом этого стека является автоматизированный запуск реалистичного окружения: хорошо известная аннотация @SpringBootTest позволяет проверять приложение в целом (поднимается контекст, объединяющий все компоненты, что позволяет проверять приложение в наиболее реалистичных условиях), однако, это далеко не единственное преимущество: используя принцип convention over configuration, мы получаем ещё и эксклюзивный backing service — например, полноценную БД — за счет нескольких аннотаций: во фрагменте ниже мы декларативно запрашиваем контейнер с БД postgres и просим Spring Boot настроить конфигурацию соединения с этой БД.
@Container
@ServiceConnection
var postgresContainer = PostgreSQLContainer(DockerImageName.parse("postgres:13"))
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class DirtyDbPitfall
В некоторых тестах, чтобы воспроизвести ту или иную проблему, я перенастроил стандартный порядок запуска. Не рекомендуется так поступать в настоящем проекте без крайней необходимости:
@TestMethodOrder(MethodOrderer.MethodName::class)
Для запуска примеров из проекта вам потребуется Java 21, Docker-окружение и следующие команды:
Запуск всех тестов проекта
./gradlew clean test
Гранулярный запуск (отдельные классы или методы)
./gradlew clean test --tests '*DirtyDbPitfall.createApiTest'
BUILD SUCCESSFUL in 21s
./gradlew clean test --tests '*DirtyDbPitfall.removeApiTest'
BUILD SUCCESSFUL in 21s
./gradlew clean test --tests '*DirtyDbPitfall'
DirtyDbPitfall > Removal API test FAILED
org.opentest4j.AssertionFailedError at DirtyDbPitfall.kt:79
BUILD FAILED in 21s
Симптомы, по которым можно опознать ловушки тестирования, практически всегда будут выражаться в моргающих тестах, либо в ситуации, когда два теста, будучи запущенными по отдельности — зелёные, при запуске вместе — краснеют.
Приступим к осмотру коллекции.
В конце статьи я собрал таблицу с краткой выжимкой по каждой ловушке, способах её обхода и применяемых инструментах.
Коллекция ловушек
Грязная база (в примере DirtyDbPitfall.kt)
Суть ловушки: несколько тестов, составляющих один набор (например, несколько @Test-методов в рамках Test-класса), проверяют функциональность, связанную с чем-то внешним, имеющим состояние (самое очевидное — БД), при этом состояние не очищается перед запуском каждого теста.
Как такое может случиться? Например, два теста появились в одном классе по результатам вливания двух веток в основную. Первоначально каждый из них работал корректно как при индивидуальном прогоне, так и при запуске всех тестов проекта, однако после слияния они стали несовместимыми из-за взаимных «претензий» на состояние, например, базы данных.
Пример
Один из тестов проверяет API создания/чтения: в начале вызываются POST-запросы для создания ресурса, после чего происходит чтение списка:
@Test
@DisplayName("Creation API test")
fun createApiTest() {
// given
val wantedEntitiesCount = 4
// when
val creationResponses = (1..4).map {
restClient.post().uri("/persons").body(mapOf("name" to "John Doe")).retrieve().toBodilessEntity()
}
assertThat(creationResponses).allMatch { it.statusCode == HttpStatusCode.valueOf(200) }
// then
val businessEntities = restClient.get().uri("/persons").retrieve().body<List<Person>>()
assertThat(businessEntities?.size).isEqualTo(wantedEntitiesCount)
}
Другой тест тоже использует API создания нового ресурса, но затем его удаляет и проверяет, что в результате удаления ресурсов не осталось:
@Test
@DisplayName("Removal API test")
fun removeApiTest() {
// given
val creationResponse =
restClient.post().uri("/persons").body(mapOf("name" to "John Doe")).retrieve().toEntity<Person>()
assertThat(creationResponse.statusCode).isEqualTo(HttpStatusCode.valueOf(200))
val createdId = creationResponse.body?.id ?: fail { "Created person ID could not be null" }
// when
val removalResponse =
restClient.delete().uri("/persons/{createdId}", createdId).retrieve().toBodilessEntity()
assertThat(removalResponse.statusCode).isEqualTo(HttpStatusCode.valueOf(200))
// then
val businessEntities = restClient.get().uri("/persons").retrieve().body<List<Person>>()
assertThat(businessEntities?.size).isEqualTo(0)
}
Если мы запустим оба теста, сборка упадет со следующей ошибкой:
expected: 0
but was: 4
at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62)
at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502)
at tano.testingpitfalls.dirtydb.DirtyDbPitfall.removeApiTest(DirtyDbPitfall.kt:79)
Причина — тесты используют реальную БД, запущенную в контейнере и не чистят её перед запуском, результат — в БД остаются данные, созданные в результате предыдущих запусков тестов (API создания), а получаем мы красный тест при проверке API удаления.
Обход
Чистим БД, используя хуки JUnit (аннотация @BeforeEach). При этом логика очистки может быть инкапсулирована в отдельном тестовом компоненте (в примере он называется SystemUnderTest). Это позволяет при потенциальном будущем усложнении системы (например, если возникнет задача реализации soft delete) сохранить сам код тестового сценария от замутнения деталями реализации очистки. Даже если тесту в соответствии со сценарием нужно определённое состояние, лучше не полагаться на результаты работы других тестов сценария, а явно привести систему в нужное состояние (с помощью тех же хуков JUnit, либо явно — при подготовке к выполнению сценария в самом тестовом методе).
@BeforeEach
fun setUp() {
systemUnderTest.cleanDb()
}
Вариации на тему этой проблемы
«Загрязняться» может не только БД, но и любые ресурсы, переживающие по времени жизни один тест. Общий принцип тут тот же — очищать (если нет возможности сделать это через программный интерфейс, можно пойти по пути пересоздания контейнера).
Нежданный планировщик (UnexpectedSchedulerPitfall.kt)
Суть ловушки: в сервисе существует планировщик, запускающий операции по расписанию или с фиксированной частотой. В рамках теста, проверяющего нечто непосредственно не связанное с работой планировщика, мы сталкиваемся с побочными эффектами работы функций, инициируемых планировщиком (например, удалением данных, которые мы собирались в тесте проверить).
Как такое может случиться? Например, планировщик появился после реализации тех тестов, на которые он в результате стал влиять, другой вариант — у него изменились параметры запуска..
Пример
В приведённом проекте есть класс LeadRemovalSchedulerService, он инициирует удаление пользователей с определённым статусом, настроен (для демонстрационных целей) на очень частый (раз в 10 миллисекунд) запуск:
class LeadRemovalSchedulerService(
private val personService: PersonService,
) {
private val logger = KotlinLogging.logger {}
@Scheduled(fixedRate = 10)
fun removeLeads() {
val count = personService.removeLeads()
if (count > 0) {
logger.info { "Removed $count leads" }
}
}
}
Интеграционный тест создаёт запись через API и собирается её модифицировать:
@Test
fun testModification() {
// given
val creationResponse =
restClient.post().uri("/persons").body(mapOf("name" to "John Doe")).retrieve().toEntity<Person>()
Assertions.assertThat(creationResponse.statusCode).isEqualTo(HttpStatusCode.valueOf(200))
val createdId = creationResponse.body?.id ?: fail { "Created person ID could not be null" }
// when
val modificationResponse =
restClient.patch().uri("/persons/{createdId}", createdId).body(mapOf("status" to "CLIENT")).retrieve()
.toBodilessEntity()
// then
Assertions.assertThat(modificationResponse.statusCode).isEqualTo(HttpStatusCode.valueOf(200))
}
Но падает, потому что пока он делал запрос, планировщик удалил данные из БД:
org.springframework.web.client.HttpServerErrorException$InternalServerError: 500 Internal Server Error: "{"timestamp":"2024-03-14T06:41:38.650+00:00","status":500,"error":"Internal Server Error","path":"/persons/1"}"
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: tano.testingpitfalls.domain.IllegalInputDataException: No person with ID 1 found] with root cause
tano.testingpitfalls.domain.IllegalInputDataException: No person with ID 1 found
at tano.testingpitfalls.service.PersonService.modifyPersonStatus(PersonService.kt:30)
Обход
Работа планировщика должна быть предсказуемой. Для этого можно ввести параметр, позволяющий его отключать (как минимум, для целей теста):
@Configuration
@ConditionalOnProperty(prefix = "scheduler", name = ["disabled"], havingValue = "false", matchIfMissing = true)
@EnableScheduling
class SchedulerConfiguration
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = ["scheduler.disabled=true"]
)
Если необходимо проверить работу самого планировщика, на это нужно написать отдельный тест, при этом параметры (частота, расписание) самого планировщика также следует выносить в конфигурацию и проверять отдельно от прочих тестовых сценариев.
Который час?! (ClockPitfall.kt)
Суть ловушки: в сервисе есть функциональность, работа которой зависит от текущей даты и/или времени, при этом на уровне теста это никак не контролируется, из-за чего работоспособность теста зависит от того, когда он запускается.
Как такое может случиться? Самый вероятный сценарий — появление функциональности, работающей со временем после реализации некоего базового сценария и тестов для него.
Пример
В проекте-примере PATH API (@PatchMapping("/{personId}") позволяет модифицировать поле status у person, в соответствующем сервисе (PersonService) это вызывает дополнительный побочный эффект — отправка приветствия. Результат этого шага мы фиксируем в БД:
@Transactional
fun modifyPersonStatus(personId: Long, newPersonStatus: PersonStatus): PersonEntity {
val personForModification = personEntityRepository.findByIdOrNull(id = personId) ?: throw IllegalInputDataException("No person with ID $personId found")
personForModification.status = newPersonStatus
if (newPersonStatus == PersonStatus.CLIENT) {
val greetingSent = greetingService.greet(name = personForModification.name)
personForModification.greetingSent = greetingSent
}
return personForModification
}
Интеграционный тест проверяет работу соответствующего API, проверяя значение поля greetingMessageSent:
@Test
fun testGreetings() {
// given
val creationResponse =
restClient.post().uri("/persons").body(mapOf("name" to "John Doe")).retrieve().toEntity<Person>()
assertThat(creationResponse.statusCode).isEqualTo(HttpStatusCode.valueOf(200))
val createdId = creationResponse.body?.id ?: fail { "Created person ID could not be null" }
// when
val modificationResponse =
restClient.patch().uri("/persons/{createdId}", createdId).body(mapOf("status" to "CLIENT")).retrieve()
.toEntity<Person>()
assertThat(modificationResponse.statusCode).isEqualTo(HttpStatusCode.valueOf(200))
// then
assertThat(modificationResponse.body?.greetingMessageSent).isEqualTo(true)
}
Чтобы понять, где здесь ловушка, посмотрим на метод greet GreetingServiceа: логика здесь простая — если пользователь стал нашим клиентом, нужно его поздравить, отослав сообщение:
fun greet(name: String): Boolean {
if (clock.workingHours()) {
// We will skip the sending logic itself for the sake of simplicity
logger.info { "Greeting message for $name was sent successfully" }
return true
} else {
logger.info { "Greeting message for $name was not sent because it's not working hours" }
return false
}
}
Самое важное здесь — вызов clock.workingHours(), это метод-расширение, позволяющий определить, рабочее сейчас время или нет:
fun Clock.workingHours(): Boolean {
val hour = this.instant().atZone(this.zone).hour
return hour in 9..18
}
Обратите внимание: в нашем примере уже определён и настроен bean clock (ClockConfiguration), который и используется в GreetingService для принятия решения — беспокоить пользователя или нет:
@Bean
fun clock(): Clock {
// here in order to reproduce the clock pitfall we create fixed clock with nighttime
// however in real life we should use system default clock
val nightTime = ZonedDateTime.now().withHour(2).withMinute(0).withSecond(0).withNano(0)
return Clock.fixed(nightTime.toInstant(), ZoneId.systemDefault())
}
При этом для воспроизводимости ловушки часы настроены всегда выдавать ночное время. В реальности можно столкнуться с ситуациями, когда компонента clock вовсе нет в контексте, либо он определён и настроен в соответствии с TZ, принятой на проекте, или же в UTC.
Обход
Настройка часов должна стать подготовкой работы тестового сценария: мы можем переопределить компонент clock для тестовой конфигурации и настроить его для нужд конкретного теста (TestClockConfiguration.kt):
@Bean
@Primary
fun workingHoursClock(): Clock {
val workingTime = ZonedDateTime.now().withHour(10).withMinute(0).withSecond(0).withNano(0)
return Clock.fixed(workingTime.toInstant(), ZoneId.systemDefault())
}
Здесь часы настроены выдавать всегда рабочее время. Нужно задействовать конфигурацию в тестах, и проблема решена:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@ActiveProfiles("test")
@Import(TestClockConfiguration::class)
class ClockPitfall
Не дождались… (DidNotWaitPitfall.kt)
Суть ловушки: в сервисе есть асинхронный обработчик, подписанный на события, поступающие из других сервисов системой через брокера сообщений (RMQ/Kafka). В интеграционных тестах мы проверяем обработчик, программно отправляя сообщения в брокер (как будто они поступили от внешнего сервиса). Так как обработчик событий асинхронный, мы не можем делать assertion сразу, нужно подождать.
Как такое может случиться? Легко представить себе ситуацию, когда первоначально асинхронный обработчик успевает отработать даже в ситуации синхронного assertion-а в рамках теста (допустим, если проверке финального состояния предшествовали дополнительные действия), но позже перестал, и тесты стали падать/«моргать».
Пример
В проекте-примере есть сервис LoyaltyProgramEnteredHandler:
@Service(EVENT_TYPE_LOYALTY_PROGRAM_ENTERED)
class LoyaltyProgramEnteredHandler(
private val personService: PersonService
): EventHandler {
private val logger = KotlinLogging.logger {}
override fun handleEvent(event: Event) {
val personId = event.personId
val welcomeBonuses = personService.accrueWelcomeBonuses(personId = personId)
logger.info { "Successfully handled event $event, added $welcomeBonuses" }
}
}
Вызывается он функциональным компонентом-consumer-ом:
@Bean
fun processEvents() = Consumer<Event> {
eventDispatcher.processEvent(event = it)
}
Который, в свою очередь, принимает сообщения из topic-а Kafka (использована библиотека Spring Cloud Stream + binder для Kafka):
cloud:
function:
definition: processEvents
stream:
bindings:
processEvents-in-0:
group: ${spring.application.name}
destination: events
В тестовой конфигурации также настроен дополнительный binding, который позволяет сообщения отправлять из сценария:
...
spring:
cloud:
function:
definition: processEvents
stream:
bindings:
...
events-out-0:
destination: events
В самом тесте (DidNotWaiPitfall.waitingPitfall) мы проверяем побочный эффект от деятельности обработчика.
В начале сохраняем и приводим в нужный вид некие данные, проверяем начальное состояние поля, изменения которого ожидаем по результатам вызова обработчика:
val creationResponse =
restClient.post().uri("/persons").body(mapOf("name" to "John Doe")).retrieve().toEntity<Person>()
assertThat(creationResponse.statusCode).isEqualTo(HttpStatusCode.valueOf(200))
val createdId = creationResponse.body?.id ?: fail { "Created person ID could not be null" }
val modificationResponse =
restClient.patch().uri("/persons/{createdId}", createdId).body(mapOf("status" to "CLIENT")).retrieve()
.toEntity<Person>()
assertThat(modificationResponse.statusCode).isEqualTo(HttpStatusCode.valueOf(200))
assertThat(modificationResponse.body?.bonusBalance).isEqualTo(0)
Далее отправляем сообщение (якобы от внешнего сервиса) в Kafka, используя binding, о котором я писал выше:
fun sendEvent(eventType: String, userId: Long) {
streamBridge.send("events-out-0", Event(personId = userId, type = eventType))
После чего проверяем побочный эффект, вызывая GET API:
val resultingPerson = restClient.get().uri("/persons/{createdId}", createdId).retrieve().toEntity<Person>()
assertThat(resultingPerson.body?.bonusBalance).isEqualTo(100)
В результате тест наш падает со следующей ошибкой:
Expected :100L
Actual :0L
org.opentest4j.AssertionFailedError:
expected: 100L
but was: 0L
Обход
Один из способов выхода из этой ситуации — не получив с первого раза желаемый результат в рамках assertion-а, просто подождать, а потом повторить попытку, пока исход проверки не будет устраивать.
Для этого не нужно писать много boilerplate-кода, достаточно задействовать библиотеку вроде awaitility:
await untilAsserted {
val resultingPerson = restClient.get().uri("/persons/{createdId}", createdId).retrieve().toEntity<Person>()
assertThat(resultingPerson.body?.bonusBalance).isEqualTo(100)
}
Здесь с довольно разумными значениями по умолчанию повторяется проверка данных, возвращаемых GET API до «позеленения» условия, или же истечения тайм-аута.
Непредсказуемый компонент (UnpredictablePitfall.kt)
Суть ловушки: в сервисе есть компонент, генерирующий непредсказуемые значения, в тесте же хочется его поведение предопределить. В каком-то смысле предыдущий пример с часами также можно отнести к этой категории, но здесь для обхода будет использован иной инструментарий, поэтому выделил отдельно.
Как такое может случиться? Возможно, в результате эволюции теста возникла необходимость проверять поле, связанное напрямую с «непредсказуемым».
Пример
В integration-testing-pitfalls есть функциональность подтверждения контакта пользователя:
@Transactional
fun confirmEmail(personId: Long, email: String): EmailInformation {
val foundPerson = personEntityRepository.findByIdOrNull(id = personId) ?: throw IllegalInputDataException("No person with ID $personId found")
return foundPerson.emails.getOrElse(email) {
val confirmationId = confirmationIdGenerator.generateConfirmationId()
val emailInformation = EmailInformation(
email = email,
confirmationId = confirmationId,
isVerified = false
)
foundPerson.emails[email] = emailInformation
// We will skip the confirmation logic for now
return emailInformation
}
}
Здесь для нас интерес представляет генерация confirmation ID:
fun generateConfirmationId() = UUID.randomUUID().toString()
В тесте контроллера мы хотим проверить в том числе поле confirmationCode, что невозможно без переопределения поведения генератора:
@Test
fun testUnpredictable() {
// given
val creationResponse =
restClient.post().uri("/persons").body(mapOf("name" to "John Doe")).retrieve().toEntity<Person>()
Assertions.assertThat(creationResponse.statusCode).isEqualTo(HttpStatusCode.valueOf(200))
val createdId = creationResponse.body?.id ?: fail { "Created person ID could not be null" }
val email = "john@domain.com"
val wantedConfirmationId = "CONFIRMATION_ID"
// when
val emailResponse =
restClient.post().uri("/persons/{createdId}/email", createdId).body(mapOf("email" to email)).retrieve()
.toEntity<EmailInfo>()
// then
Assertions.assertThat(emailResponse.body?.isVerified).isEqualTo(false)
Assertions.assertThat(emailResponse.body?.email).isEqualTo(email)
Assertions.assertThat(emailResponse.body?.confirmationCode).isEqualTo(wantedConfirmationId)
}
В отличие от примера с часами в этот раз воспользуемся возможностью тестовой библиотеки и определим в контексте bean, воспользовавшись следующей конструкцией:
@MockkBean
private lateinit var confirmationIdGenerator: ConfirmationIdGenerator
Плюсом такого подхода является возможность использовать всю мощь mock-библиотеки для динамического переопределения поведения нашего компонента:
every { confirmationIdGenerator.generateConfirmationId() } returns wantedConfirmationId
Выводы
На каждом уровне пирамиды тестирования необходимо использовать подходящие и доступные технологии и инструменты автоматизации, а также придерживаться лучших практик индустрии. Только такой комплексный подход обеспечивает достижение высокого качества и создание надёжной основы для развития проекта. При проведении интеграционного тестирования на пути разработчика могут встать сложности-ловушки, о которых я и рассказал вам в этой статье. Ниже привожу таблицу с кратким описанием ловушек и способов их обхода:
Ловушка | Суть | Как обходить | Инструменты |
Грязная база | Несколько тестов проверяют функции сервиса, относящиеся к работе с состоянием, при этом по результатам выполнения тестов состояние не очищается | Использовать механизмы тестового фреймворка для очистки состояния перед выполнением теста | @BeforeEach |
Нежданный планировщик | Тесты через API взаимодействуют с конкретной сущностью, при этом в процесс вклинивается планировщик и меняет атрибуты сущности, а то и вовсе её удаляет, из-за чего тесты падают | Разделять тестирования планировщика и основной функциональности сервиса: добавить в сервис возможность отключать планировщик на уровне конфигурации, саму же работу планировщика тестировать отдельно, вызывая его явно | @ActiveProfiles("test") |
Который час?! | Тесты проверяют API, реализация которого содержит алгоритм, зависящий от текущего времени/даты | Переопределить компонент, представляющий часы, и настроить его работу, обеспечив предсказуемость тестов | @TestConfiguration |
Не дождались… | Есть асинхронный обработчик, мы результаты его работы пытаемся проверить в тесте, не зная точно, завершился его вызов или нет | Используя декларативные средства, организовать петлю ожидания с тайм-аутом | testImplementation("org.awaitility:awaitility-kotlin:4.2.1") |
Непредсказуемый компонент | В системе есть компонент (например, генератор случайных чисел), результат работы которого хотелось бы проверять, меняя его поведение динамически в разных тестах | Использовать mock-библиотеку для помещения в контекст компонента, позволяющего через API динамически изменять поведение | @MockkBean |
Что ещё почитать/посмотреть по теме
Надеюсь, информация была полезной и интересной. Приглашаю вас в комментарии — обсудим непростые ситуации с интеграционными тестами. Спасибо за вашу поддержку!