spring-integration-testing
Skill18 starsChanged 4 months agoMITView on GitHub
Integration and end-to-end testing for Spring Boot Kotlin backends - real Postgres through Testcontainers with @ServiceConnection, Flyway-migrated schemas, @DataJpaTest adapter tests, module-level @SpringBootTest tests of services with real persistence, transactions and events, WireMock tests for HTTP service clients, and full-app E2E tests in :app with RestTestClient against a running server. Fakes replace only the boundaries that leave the system (third-party APIs, email, push). These are the most valuable tests in the codebase. Use this skill whenever writing tests that touch the database, a query, a migration, a transaction, an event listener, security rules, JSON serialization, an external HTTP API, or a whole HTTP flow. Trigger on phrases like "integration test", "E2E test", "end-to-end", "test the endpoint", "test the API", "test the repository", "test the query", "Testcontainers", "@SpringBootTest", "@DataJpaTest", "RestTestClient", "MockMvc", "WireMock", "test with a real database", "test the migration", "test the transaction", or "test the event listener".
In this repository67 other files it gives its agents.
- agent-onboardingSkill
- android-backgroundSkill
- android-cliSkill
- android-compose-architectureSkill
- android-compose-componentsSkill
- android-data-layerSkill
- android-di-koinSkill
- android-error-handlingSkill
- android-integration-testingSkill
- android-jacoco-setupSkill
- android-jvm-test-coverageSkill
- android-module-structureSkill
- android-navigationSkill
- android-presentation-mviSkill
- android-preview-figma-verifySkill
- android-ui-screenshot-verifySkill
- android-ui-testingSkill
- android-ui-verifySkill
- android-unit-testingSkill
- android-version-catalogSkill
- architecture-decision-recordSkill
- careful-planningSkill
- compose-adaptive-layoutsSkill
- figma-to-android-vectorSkill
- finalizeSkill
- git-commitSkill
- handoffSkill
- kmp-compose-architectureSkill
- kmp-compose-componentsSkill
- kmp-data-layerSkill
- kmp-di-koinSkill
- kmp-error-handlingSkill
- kmp-integration-testingSkill
- kmp-migration-analyzerSkill
- kmp-module-structureSkill
- kmp-navigationSkill
- kmp-presentation-mviSkill
- kmp-ui-testingSkill
- kmp-unit-testingSkill
- kotlin-coroutinesSkill
- kotlin-flowsSkill
- kotlin-mutation-testingSkill
- openspec-artifact-review-lightSkill
- openspec-artifact-reviewSkill
- project-breakdownSkill
- react-data-layerSkill
- react-error-handlingSkill
- react-integration-testingSkill
- react-presentation-mviSkill
- react-project-structureSkill
- react-routingSkill
- react-screen-architectureSkill
- react-securitySkill
- react-server-stateSkill
- react-ui-componentsSkill
- react-unit-testingSkill
- speckit-parallelize-agent-teamsSkill
- speckit-parallelize-subagentsSkill
- spring-api-layerSkill
- spring-di-configurationSkill
- The first 60 shown.
The file
We split this skill into the rules it gives an agent
9 rules. About 0 are common, 4 are distinctive, and 5 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 24
Only boundaries that leave the system are faked: third-party HTTP APIs, email, push notifications.
- Very specific: line 25
No Mockito, no @MockitoBean.
- Distinctive: line 47
Look up the latest WireMock version before adding it.
- Distinctive: line 76
Use the same Postgres major version as production.
- Distinctive: line 170
Test each adapter against the real database:
- Very specific: line 200
Test what the database decides: query filters and ordering, keyset pagination boundaries (ties on createdAt), unique constraints (expect …
- Very specific: line 240
Use these for what only a real transaction shows: rollback on failure, constraint violations surfacing as the right domain exception, …
- Distinctive: line 421
Every endpoint: the success status and body shape, as the client sees the JSON.
- Very specific: line 422
Every error code an endpoint can return, with its status.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Spring Integration and E2E Testing
The same adversarial methodology as spring-unit-testing applies: enumerate break vectors from the contract first, one distinct angle per test. This skill covers the tests that cross real boundaries. They catch what unit tests can't: wrong queries, missing migrations, transactions that don't roll back, events that never fire, security holes, broken JSON.
Test Types
| Type | Lives in | Spring context | Real | Faked |
| Persistence | module src/test | @DataJpaTest | Postgres, Flyway, entities, Spring Data, adapter | — |
| Module integration | module src/test | @SpringBootTest with the module's test app | Services, adapters, Postgres, transactions, internal events | Remote providers, Clock |
| HTTP client | module src/test | @SpringBootTest with the module's test app | HTTP service client, adapter, JSON | The provider (WireMock) |
| E2E | :app src/test | @SpringBootTest(RANDOM_PORT) | Everything: security, controllers, all modules, events, Postgres | Remote providers, Clock |
Rules for all of them:
- Real Postgres, never H2. H2 accepts SQL Postgres rejects and the other way round.
- The schema comes from the real Flyway migrations.
ddl-auto: validatestays on, so a test fails when an entity and its migration disagree. - Only boundaries that leave the system are faked: third-party HTTP APIs, email, push notifications. The fakes replace the domain interface (
FileStorage,EmailSender), never a class in the middle. - No Mockito, no
@MockitoBean. Fakes are registered as beans. - AssertK for assertions, Awaitility for waiting on async work. Never
Thread.sleep.
Dependencies
[versions]
wiremock = "3.13.1"
[libraries]
spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test" }
spring-boot-starter-data-jpa-test = { module = "org.springframework.boot:spring-boot-starter-data-jpa-test" }
spring-boot-starter-security-test = { module = "org.springframework.boot:spring-boot-starter-security-test" }
spring-boot-testcontainers = { module = "org.springframework.boot:spring-boot-testcontainers" }
testcontainers-postgresql = { module = "org.testcontainers:testcontainers-postgresql" }
testcontainers-junit-jupiter = { module = "org.testcontainers:testcontainers-junit-jupiter" }
awaitility-kotlin = { module = "org.awaitility:awaitility-kotlin" }
wiremock = { module = "org.wiremock:wiremock-standalone", version.ref = "wiremock" }Look up the latest WireMock version before adding it. The others are managed by the Spring Boot BOM.
Shared Test Infrastructure (:common test fixtures)
// common/build.gradle.kts
dependencies {
testFixturesApi(libs.spring.boot.testcontainers)
testFixturesApi(libs.testcontainers.postgresql)
testFixturesApi(libs.spring.boot.starter.test)
testFixturesApi(libs.awaitility.kotlin)
}Postgres container
@TestConfiguration(proxyBeanMethods = false)
class PostgresTestConfig {
@Bean
@ServiceConnection
fun postgresContainer(): PostgreSQLContainer = PostgreSQLContainer("postgres:17-alpine")
}@ServiceConnectionpoints the datasource at the container. No@DynamicPropertySourcefor the database URL.- Use the same Postgres major version as production.
- The container is a bean, so it lives as long as the cached Spring context. Every test class with the same configuration reuses one container and one context. Keep test configurations identical across classes so Spring can cache them.
Database cleaner
class DatabaseCleaner(
private val jdbcTemplate: JdbcTemplate,
) {
fun clean(vararg schemas: String) {
val tables = jdbcTemplate.queryForList(
"""
SELECT table_schema || '.' || table_name
FROM information_schema.tables
WHERE table_schema IN (${schemas.joinToString { "'$it'" }})
AND table_type = 'BASE TABLE'
AND table_name <> 'flyway_schema_history'
""",
String::class.java,
)
if (tables.isNotEmpty()) {
jdbcTemplate.execute("TRUNCATE ${tables.joinToString()} CASCADE")
}
}
}@SpringBootTest tests are not @Transactional. A test-managed transaction never commits, so @TransactionalEventListeners never fire and rollback behavior can't be observed. Clean the module's schema in @BeforeEach instead.
Module Test Application
@DataJpaTest and @SpringBootTest need a @SpringBootConfiguration. Each business module has one in its test sources, at the module's root package:
// chat/src/test/kotlin/com/<company>/<app>/chat/ChatTestApplication.kt
@SpringBootApplication
@ConfigurationPropertiesScan
@Import(PostgresTestConfig::class, ChatTestConfig::class)
class ChatTestApplication@TestConfiguration(proxyBeanMethods = false)
@EnableAsync
class ChatTestConfig {
@Bean
fun clock(): MutableClock = MutableClock()
@Bean
@Primary
fun fakeFileStorage(): FakeFileStorage = FakeFileStorage()
@Bean
fun databaseCleaner(jdbcTemplate: JdbcTemplate) = DatabaseCleaner(jdbcTemplate)
}# chat/src/test/resources/application.yml
spring:
flyway:
schemas: [chat_service]
locations: [classpath:db/migration/chat]
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
threads:
virtual:
enabled: true
http:
serviceclient:
supabase:
base-url: "http://localhost:1"
chirp:
chat:
supabase:
service-key: test-key
bucket: test-bucket
public-url: "http://localhost:1/public"- The module's test app only scans the module's own package, so module tests start fast and prove the module doesn't secretly depend on another one.
FakeFileStorageis the same in-memory fake the unit tests use. As a@Primarybean it wins whereverFileStorageis injected. The realSupabaseFileStorageis still created, which is why the test config gives it dummy properties pointing at an unreachable URL. Only the WireMock test talks to it directly.
Persistence Tests (@DataJpaTest)
Test each adapter against the real database:
@DataJpaTest
@Import(JpaChatRepository::class, JpaChatParticipantRepository::class)
class JpaChatRepositoryTest @Autowired constructor(
private val chatRepository: JpaChatRepository,
private val participantRepository: JpaChatParticipantRepository,
private val entityManager: TestEntityManager,
) {
@Test
fun `findByIdForParticipant hides the chat from non-members`() {
val alice = participantRepository.save(participant("alice"))
val bob = participantRepository.save(participant("bob"))
val carol = participantRepository.save(participant("carol"))
val chat = chatRepository.save(chat(creator = alice, participants = setOf(alice, bob)))
entityManager.flush()
entityManager.clear()
assertThat(chatRepository.findByIdForParticipant(chat.id, carol.userId)).isNull()
assertThat(chatRepository.findByIdForParticipant(chat.id, bob.userId)?.participants)
.isNotNull()
.containsExactlyInAnyOrder(alice, bob)
}
}@DataJpaTestrolls back after each test, which is fine here: nothing depends on commit.- Always
flush()andclear()between writing and reading. Otherwise the read is served from the persistence context and never runs your query. - Test what the database decides: query filters and ordering, keyset pagination boundaries (ties on
createdAt), unique constraints (expect the exception onflush()), cascades,@Versionconflicts, and that mappers don't hit unfetched lazy associations (clear()first, then map). - Constructor injection with
@Autowired constructoris the one place@Autowiredappears: JUnit creates the test instance, not Spring.
Module Integration Tests
Services with real adapters and a real transaction:
@SpringBootTest(classes = [ChatTestApplication::class])
@RecordApplicationEvents
class ChatServiceIntegrationTest @Autowired constructor(
private val chatService: ChatService,
private val chatRepository: ChatRepository,
private val participantRepository: ChatParticipantRepository,
private val databaseCleaner: DatabaseCleaner,
private val events: ApplicationEvents,
) {
@BeforeEach
fun setUp() {
databaseCleaner.clean("chat_service")
}
@Test
fun `a failing add leaves the chat unchanged in the database`() {
val alice = participantRepository.save(participant("alice"))
val chat = chatRepository.save(chat(creator = alice, participants = setOf(alice)))
assertFailure {
chatService.addParticipants(chat.id, requestUserId = alice.userId, userIds = setOf(UUID.randomUUID()))
}.isInstanceOf<ChatParticipantNotFoundException>()
assertThat(chatRepository.findById(chat.id)!!.participants).containsExactly(alice)
assertThat(events.stream(ChatParticipantsJoinedEvent::class.java).count()).isEqualTo(0L)
}
}Use these for what only a real transaction shows: rollback on failure, constraint violations surfacing as the right domain exception, events published only after commit, @Version conflicts between two concurrent updates, scheduled cleanup jobs.
Async event listeners
Cross-module listeners run @Async after commit. Publish the event inside a real transaction (inject TransactionTemplate and ApplicationEventPublisher), then wait for the result, don't sleep:
transactionTemplate.executeWithoutResult {
eventPublisher.publishEvent(UserEvent.Verified(userId = userId, email = "alice@example.com", username = "alice"))
}
await atMost Duration.ofSeconds(5) untilAsserted {
assertThat(participantRepository.findById(userId)).isNotNull()
}Also test that handling the same event twice leaves one row, not two.
HTTP Client Tests (WireMock)
@SpringBootTest(classes = [ChatTestApplication::class])
class SupabaseFileStorageTest @Autowired constructor(
private val fileStorage: SupabaseFileStorage,
) {
companion object {
@JvmField
@RegisterExtension
val wireMock: WireMockExtension = WireMockExtension.newInstance()
.options(wireMockConfig().dynamicPort())
.build()
@JvmStatic
@DynamicPropertySource
fun properties(registry: DynamicPropertyRegistry) {
registry.add("spring.http.serviceclient.supabase.base-url") { wireMock.baseUrl() }
registry.add("chirp.chat.supabase.service-key") { "test-key" }
}
}
@Test
fun `a 503 from Supabase becomes StorageUnavailableException`() {
wireMock.stubFor(post(urlPathMatching("/storage/v1/object/upload/sign/.*")).willReturn(serviceUnavailable()))
assertFailure { fileStorage.createUploadUrl("avatar.png", Duration.ofMinutes(5)) }
.isInstanceOf<StorageUnavailableException>()
}
@Test
fun `requests carry the service key`() {
wireMock.stubFor(delete(anyUrl()).willReturn(ok()))
fileStorage.delete("avatar.png")
wireMock.verify(deleteRequestedFor(anyUrl()).withHeader("Authorization", equalTo("Bearer test-key")))
}
}- This is the one place the real provider adapter runs. Include it in this test's context explicitly; keep it out of every other test.
- Cover: the exact path and method, auth headers, request body shape, response mapping, every error translation (
4xxthat matter,5xx, timeout viawithFixedDelaybeyond the read timeout, malformed body). - Copy real provider responses into the stubs. A hand-invented response tests your assumptions, not the integration.
E2E Tests (:app)
The whole app on a random port, called over real HTTP:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(PostgresTestConfig::class, E2eTestConfig::class)
abstract class E2eTest {
@LocalServerPort
private var port: Int = 0
@Autowired
private lateinit var databaseCleaner: DatabaseCleaner
@Autowired
protected lateinit var clock: MutableClock
@Autowired
protected lateinit var fakeEmailSender: FakeEmailSender
protected lateinit var client: RestTestClient
@BeforeEach
fun setUpE2e() {
databaseCleaner.clean("user_service", "chat_service", "notification_service")
client = RestTestClient.bindToServer().baseUrl("http://localhost:$port").build()
}
}@TestConfiguration(proxyBeanMethods = false)
class E2eTestConfig {
@Bean
@Primary
fun testClock(): MutableClock = MutableClock()
@Bean
@Primary
fun fakeFileStorage(): FakeFileStorage = FakeFileStorage()
@Bean
@Primary
fun fakeEmailSender(): FakeEmailSender = FakeEmailSender()
@Bean
fun databaseCleaner(jdbcTemplate: JdbcTemplate) = DatabaseCleaner(jdbcTemplate)
}- One abstract base class, so every E2E class shares one cached context and one container.
- Field injection in the base class is the accepted exception for test classes.
@Primaryfakes win over the real adapters for every boundary that leaves the system.
Auth helper
protected fun registerAndLogin(username: String): AuthenticatedUserDto {
val email = "$username@example.com"
client.post().uri("/api/auth/register")
.body(RegisterRequest(email = email, username = username, password = "Secret123!"))
.exchange()
.expectStatus().is2xxSuccessful
val token = fakeEmailSender.lastVerificationTokenFor(email)
client.get().uri("/api/auth/verify?token=$token").exchange().expectStatus().isOk
return client.post().uri("/api/auth/login")
.body(LoginRequest(email = email, password = "Secret123!"))
.exchange()
.expectStatus().isOk
.expectBody<AuthenticatedUserDto>()
.returnResult()
.responseBody!!
}Tokens come from the real login flow, so the test also covers security. Don't build authentication objects by hand.
Example
class ChatE2eTest : E2eTest() {
@Test
fun `a user who isn't in the chat gets 404 with CHAT_NOT_FOUND`() {
val alice = registerAndLogin("alice")
val bob = registerAndLogin("bob")
val mallory = registerAndLogin("mallory")
val chat = createChat(alice, otherUserIds = listOf(bob.user.id))
client.get().uri("/api/chats/${chat.id}")
.header(HttpHeaders.AUTHORIZATION, "Bearer ${mallory.accessToken}")
.exchange()
.expectStatus().isNotFound
.expectBody()
.jsonPath("$.code").isEqualTo("CHAT_NOT_FOUND")
}
@Test
fun `chat endpoints require a token`() {
client.get().uri("/api/chats")
.exchange()
.expectStatus().isUnauthorized
}
}What E2E tests must cover:
- Every endpoint: the success status and body shape, as the client sees the JSON.
- Every error
codean endpoint can return, with its status. 401without a token on every protected route;404/403for another user's resource.- Validation: one invalid body per endpoint returns
400 VALIDATION_FAILEDwith the field name. - Cross-module flows: an action in one module shows up in another (register → verify → user appears as a chat participant), using Awaitility for the async step.
- Time-dependent flows with
clock.advanceBy(...): tokens expire, refresh tokens rotate.
Running
| Scope | Command |
| One module (unit + integration) | ./gradlew :chat:test |
| E2E | ./gradlew :app:test |
| Everything | ./gradlew test |
Docker must be running for Testcontainers.
Anti-patterns
| Anti-pattern | What to do instead |
| H2 or an embedded database | Postgres via Testcontainers |
ddl-auto: create-drop in tests | Real Flyway migrations with ddl-auto: validate |
@Transactional on @SpringBootTest tests | Clean the schema in @BeforeEach; let transactions commit |
Reading back without flush() + clear() in @DataJpaTest | Flush and clear so the query really runs |
@MockitoBean / Mockito | @Primary fake beans of domain interfaces |
| Calling real third-party APIs | Fakes in E2E, WireMock for adapter tests |
Thread.sleep for async listeners | Awaitility untilAsserted |
A different @Import / property set per test class | One shared configuration per test type, so the context is cached |
Building a SecurityContext by hand in E2E tests | Real tokens from the login flow |
| Asserting only the status code of an error | Assert the ProblemDetail code too |
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
Reviews can't be read right now.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

