본문으로 건너뛰기
cccskills
무료GitHub에 공개

spring-boot-testing-strategy

Provides expert knowledge of the testing pyramid, test design principles, MockK patterns, and integration testing conventions for Spring Boot + Kotlin projects.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

연동 도구PostgreSQL
설치 방법 보기

포함된 파일(1)

  • SKILL.md12.6 KB

SKILL.md(원문)

설치하기 전에 에이전트에게 전달되는 지시 내용을 확인할 수 있습니다.

Testing Strategy

Testing Pyramid

       /\
      /  \
     / E2E\       10% — full API, happy path + main error cases
    /------\
   /  Integ \     20% — components with real dependencies (DB, HTTP)
  /----------\
 /    Unit    \   70% — isolated, mocked dependencies, fast
/______________\

Unit tests are the foundation. They are fast, deterministic, and cheap to run. Integration tests validate that components work together. E2E tests validate that the system works end to end — keep them minimal.

If you find yourself writing more integration tests than unit tests, the code under test has too many responsibilities bundled together.

Test Naming Convention

Format: should [expected behavior] when [condition]

@Test
fun `should return user when found by id`() { ... }

@Test
fun `should return 404 when user does not exist`() { ... }

@Test
fun `should throw ConflictException when email already exists`() { ... }

@Test
fun `should not return deleted users in list`() { ... }

Group related tests with @Nested:

@ExtendWith(MockKExtension::class)
class UserServiceTest {

    @Nested
    inner class GetById {
        @Test
        fun `should return user when found`() { ... }

        @Test
        fun `should return NotFound when user does not exist`() { ... }
    }

    @Nested
    inner class Create {
        @Test
        fun `should create and return user when email is unique`() { ... }

        @Test
        fun `should throw ConflictException when email already exists`() { ... }
    }
}

Unit Test Structure (AAA)

Every test follows Arrange → Act → Assert. Use blank lines to separate each phase.

@Test
fun `should return user when found by id`() {
    // Arrange
    val userId = UUID.randomUUID()
    val user = User(id = userId, email = "user@example.com", name = "Test User")
    every { userRepository.findById(userId) } returns Optional.of(user)

    // Act
    val result = userService.getById(userId)

    // Assert
    assertThat(result).isInstanceOf(UserResult.Found::class.java)
    val found = result as UserResult.Found
    assertThat(found.user.id).isEqualTo(userId)
    verify(exactly = 1) { userRepository.findById(userId) }
}

No inline comments between phases once the structure is clear. The blank lines are enough.

What NOT to Test

Framework wiring. Spring handles dependency injection. Do not write tests that verify @Autowired works.

JPA mapping. Do not test that @Column(name = "email") maps to the right column. That is Hibernate's job.

Trivial getters and setters. A data class property has no logic. There is nothing to test.

Private methods directly. Private methods are implementation details. Test them through the public behavior that uses them. If a private method is complex enough to need its own test, it should be extracted into a separate class.

Implementation, not behavior. Tests that verify HOW something is done (mock call order, internal state) are brittle. Test WHAT the output or side effect is.

// bad — tests implementation detail
verify { userRepository.findById(any()) }
verify { cacheService.put(any(), any()) }
verify(ordering = Ordering.ORDERED) {   // this is too coupled to internals
    userRepository.findById(userId)
    cacheService.put(userId, user)
}

// good — tests observable behavior
assertThat(result).isEqualTo(expectedUser)

MockK Patterns

Basic Setup

@ExtendWith(MockKExtension::class)
class UserServiceTest {

    @MockK
    private lateinit var userRepository: UserRepository

    @MockK
    private lateinit var emailService: EmailService

    private lateinit var userService: UserService

    @BeforeEach
    fun setUp() {
        userService = UserService(userRepository, emailService)
    }
}

Stubbing

// Return value
every { userRepository.findById(userId) } returns Optional.of(user)

// Return null (for nullable return types)
every { userRepository.findByEmail(any()) } returns null

// Throw exception
every { userRepository.save(any()) } throws DataIntegrityViolationException("Duplicate")

// Return different values on successive calls
every { userRepository.findById(any()) } returnsMany listOf(Optional.of(user), Optional.empty())

// Answer with computation
every { userRepository.save(any()) } answers { firstArg() }

Verification

// Verify called exactly once with specific argument
verify(exactly = 1) { userRepository.findById(userId) }

// Verify called with any argument
verify { emailService.sendWelcome(any()) }

// Verify never called
verify(exactly = 0) { emailService.sendWelcome(any()) }

// Verify called with specific argument
verify { emailService.sendWelcome(match { it.email == "user@example.com" }) }

Relaxed Mocks

// relaxed = true — returns default values (null, 0, false, empty list)
// Use only when you do not care about the return value of any method
val logger = mockk<Logger>(relaxed = true)

Do not use relaxed = true on mocks where you need to verify behavior — a relaxed mock does not fail on unexpected calls, which can hide bugs.

Coroutines

// Stubbing suspend functions
coEvery { userRepository.findById(userId) } returns user

// Verifying suspend functions
coVerify(exactly = 1) { userRepository.findById(userId) }

Argument Matchers

// Any value
every { service.process(any()) } returns result

// Specific type
every { service.process(any<UserRequest>()) } returns result

// Custom predicate
every { service.notify(match { it.email.endsWith("@example.com") }) } just Runs

// Capture for assertion
val slot = slot<User>()
every { userRepository.save(capture(slot)) } returns savedUser
// ... trigger the action ...
assertThat(slot.captured.email).isEqualTo("expected@example.com")

Integration Test Patterns

Controller Tests with MockMvc

@SpringBootTest
@AutoConfigureMockMvc
class UserControllerTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Autowired
    private lateinit var objectMapper: ObjectMapper

    @Test
    fun `should return 200 with user when found`() {
        mockMvc.perform(
            get("/api/v1/users/${existingUser.id}")
                .contentType(MediaType.APPLICATION_JSON)
        )
            .andExpect(status().isOk)
            .andExpect(jsonPath("$.email").value(existingUser.email))
            .andExpect(jsonPath("$.id").value(existingUser.id.toString()))
    }

    @Test
    fun `should return 400 when request body is invalid`() {
        val invalidRequest = mapOf("email" to "not-an-email")

        mockMvc.perform(
            post("/api/v1/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(invalidRequest))
        )
            .andExpect(status().isBadRequest)
            .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
    }

    @Test
    fun `should return 404 when user is not found`() {
        mockMvc.perform(get("/api/v1/users/${UUID.randomUUID()}"))
            .andExpect(status().isNotFound)
    }
}

@AutoConfigureMockMvc configures MockMvc without starting a real HTTP server. The test is faster than RANDOM_PORT but still tests the full Spring MVC stack.

Use RANDOM_PORT only when you need real HTTP (e.g., testing HTTP client behavior, WebSockets, or SSE).

Database Tests with Testcontainers

@SpringBootTest
@Testcontainers
class UserRepositoryIntegrationTest {

    companion object {
        @Container
        @JvmStatic
        val postgres = PostgreSQLContainer<Nothing>("postgres:16").apply {
            withDatabaseName("testdb")
            withUsername("test")
            withPassword("test")
        }

        @DynamicPropertySource
        @JvmStatic
        fun overrideProperties(registry: DynamicPropertyRegistry) {
            registry.add("spring.datasource.url", postgres::getJdbcUrl)
            registry.add("spring.datasource.username", postgres::getUsername)
            registry.add("spring.datasource.password", postgres::getPassword)
        }
    }

    @Autowired
    private lateinit var userRepository: UserRepository

    @Test
    fun `should enforce unique email at database level`() {
        userRepository.save(User(email = "unique@example.com", name = "First"))
        assertThrows<DataIntegrityViolationException> {
            userRepository.save(User(email = "unique@example.com", name = "Second"))
        }
    }
}

Share the container across test classes using a base class with @Container on a static field. Testcontainers reuses the same container if the configuration is identical.

Seed Data with @Sql

@SpringBootTest
@AutoConfigureMockMvc
@Sql("/test-data/users.sql")   // runs before each test in this class
class UserControllerTest { ... }

// Or per-test
@Test
@Sql("/test-data/users-with-orders.sql")
fun `should return orders when user has orders`() { ... }

SQL files go in src/test/resources/test-data/. Use descriptive names that explain what state they create, not what feature they serve.

Test Data Factories

Never hardcode UUIDs or share mutable state between tests.

object UserTestFactory {

    fun create(
        id: UUID = UUID.randomUUID(),
        email: String = "user-${UUID.randomUUID()}@example.com",
        name: String = "Test User",
        role: UserRole = UserRole.USER
    ): User = User(
        id = id,
        email = email,
        name = name,
        role = role
    )

    fun createRequest(
        email: String = "user-${UUID.randomUUID()}@example.com",
        name: String = "Test User"
    ): CreateUserRequest = CreateUserRequest(
        email = email,
        name = name
    )

    fun createAdmin(): User = create(role = UserRole.ADMIN)

    fun createList(count: Int): List<User> = (1..count).map { create() }
}

Usage in tests:

val user = UserTestFactory.create(email = "specific@example.com")
val admin = UserTestFactory.createAdmin()
val users = UserTestFactory.createList(5)

Default values use UUID.randomUUID() to ensure tests do not share state through hardcoded IDs or emails.

When Tests Are Required

New behavior. Every new feature requires at minimum one unit test and one integration test covering the happy path. Edge cases and error paths require additional unit tests.

Bug fix. Write a regression test that reproduces the bug before fixing it. The test must fail before the fix and pass after. This prevents the bug from reappearing silently.

// Example: bug where findByEmail returned deleted users
@Test
fun `should not return deleted user — regression for bug #42`() {
    val user = userRepository.save(UserTestFactory.create(email = "gone@example.com"))
    userRepository.delete(user)
    val result = userRepository.findByEmail("gone@example.com")
    assertThat(result).isNull()
}

Refactor. Do not write new tests. The existing tests must pass unchanged. If they do not, the refactor changed behavior — that is a bug.

High-risk paths. Security, payment processing, data migrations, and permission checks require contract tests in addition to unit and integration tests. These paths have severe consequences if broken.

CI Integration

Tests must run in CI on every pull request. The pipeline must fail if any test fails.

Recommended test execution order:

  1. Unit tests (fastest — fail fast)
  2. Integration tests with H2 (@DataJpaTest)
  3. Integration tests with Testcontainers (slowest — run last)

Separate test tasks in Gradle:

// build.gradle.kts
tasks.register<Test>("integrationTest") {
    description = "Runs integration tests"
    group = "verification"
    useJUnitPlatform {
        includeTags("integration")
    }
    shouldRunAfter(tasks.test)
}

Tag integration tests:

@Tag("integration")
@SpringBootTest
class UserRepositoryIntegrationTest { ... }

Do not mix unit and integration test tags in the same class. A test class is either one or the other.

리뷰

아직 리뷰가 없습니다. 사용해 본 소감을 남겨 주세요.

댓글

로그인 없이 쓸 수 있습니다. 사용 후기, 질문, 작동하지 않은 환경 등을 편하게 남겨 주세요.

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

보내면 바로 공개됩니다. 이메일 주소나 전화번호 같은 개인 정보는 쓰지 마세요. URL은 2개까지입니다.

0 / 1,000자

이 양식은 reCAPTCHA로 보호되며 Google 개인정보처리방침 및 서비스 약관이 적용됩니다.

같은 저장소의 스킬

Use when adding monitoring, metrics, logging, or tracing to Spring AI or LLM integration code. Covers token tracking, latency measurement, cost estimation, and prompt/response logging. Use when user mentions AI monitoring, token costs, or LLM observability.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

lgzarturo/springboot-course32026년 10월 17일 업데이트

android

무료

Provides expert knowledge for building modern native Android apps using Kotlin, Jetpack Compose, Material Design 3, MVI architecture, Hilt dependency injection, and Media3/ExoPlayer.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

lgzarturo/springboot-course32026년 10월 17일 업데이트

Design, implement, review, and audit Android Jetpack Compose UI/UX for phone interfaces. Use when a request concerns concrete Compose layout, components, interaction, accessibility, or visual behavior. Do not activate for framework or dependency presence alone; non-UI Android work is excluded.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

lgzarturo/springboot-course32026년 10월 17일 업데이트

Provides expert knowledge for designing, implementing, and managing REST API versioning strategies with deprecation workflows.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

lgzarturo/springboot-course32026년 10월 17일 업데이트

astro

무료

Provides expert knowledge for building Astro 5+ sites with Islands Architecture, Content Collections, TypeScript, and performance-first rendering strategies.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

lgzarturo/springboot-course32026년 10월 17일 업데이트

Audits token storage mechanisms to prevent XSS-based JWT theft.

한국어 요약은 준비 중입니다. 원문 설명을 표시하고 있습니다.

lgzarturo/springboot-course32026년 10월 17일 업데이트

lgzarturo의 스킬 모두 보기

이 스킬의 문제 신고