| name | testing |
| description | Use when writing or modifying tests โ JUnit 5 + MockK + Kotlin coroutines test support, WebMvcTest slices, @SpringBootTest integration tests, Testcontainers for MySQL/Redis, fixture patterns under src/testFixtures, and Jacoco coverage conventions. Trigger on any file under src/test/ or src/testFixtures/, or when adding a new test for a new feature. |
Testing Guide
ํ
์คํธ ์์น
ํ
์คํธํด์ผ ํ๋ ๊ฒ
- ๋๋ฉ์ธ ๋น์ฆ๋์ค ๊ท์น ๋ฐ ๋ถ๋ณ ์กฐ๊ฑด
- ๋ณต์กํ ์กฐ๊ฑด ๋ถ๊ธฐ๊ฐ ์๋ ์๋น์ค ๋ก์ง
- ์ธ๋ถ ์์คํ
์ฐ๋ (Kakao OAuth, Apple OAuth, Google Places, FCM)
- ์์ธ ํ๋ฆ ๋ฐ ์๋ฌ ์๋ต ํ์ (
DpmApiResponse ๊ตฌ์กฐ ๊ฒ์ฆ)
ํ
์คํธํ์ง ์์๋ ๋๋ ๊ฒ
- ๋จ์ getter/setter
- DTO ๋งคํ (Request โ Command, Entity โ Response)
@ConfigurationProperties ๋ฐ์ธ๋ฉ ํด๋์ค
- JPA Entity ํ๋ ์ ์ธ
- ๋จ์
save() / findById() ์์ (๋น์ฆ๋์ค ๋ก์ง ์๋ CRUD)
Spring Context ์ฌ์ฌ์ฉ ์์น
Spring Boot ํ
์คํธ์์ Context ์์ฑ ๋น์ฉ์ ๋งค์ฐ ํฌ๋ค.
์ง์ @SpringBootTest, @ExtendWith๋ฅผ ์ ์ธํ์ง ๋ง๊ณ ์๋ ๋ฉํ ์ด๋
ธํ
์ด์
์ ์ฌ์ฉํ๋ค.
โ ๊ฐ ํ
์คํธ ํด๋์ค์ @SpringBootTest, @Import ์ง์ ์ ์ธ
โ
์ฌ์ ์ ์๋ @IntegrationTest, @UnitTest ๋ฉํ ์ด๋
ธํ
์ด์
์ฌ์ฉ
๋ชจ๋๋ณ ํ
์คํธ ์ ๋ต
| ๋ชจ๋ | ํ์ฉ ํ
์คํธ ์ข
๋ฅ | Spring Context | DB |
|---|
ssolv-domain | ์์ ๋จ์ ํ
์คํธ๋ง | ๊ธ์ง | ๋ถํ์ |
ssolv-global-utils | ์์ ๋จ์ ํ
์คํธ๋ง | ๊ธ์ง | ๋ถํ์ |
ssolv-api-common | ๋จ์ + testFixtures ์ ๊ณต | ๊ธ์ง | ๋ถํ์ |
ssolv-api-core | ๋จ์ + ํตํฉ ํ
์คํธ | ํ์ฉ | MySQL Testcontainers |
ssolv-api-place | ๋จ์ + ํตํฉ ํ
์คํธ | ํ์ฉ | MySQL Testcontainers |
ssolv-infrastructure | ๋จ์ + ์ธ๋ถ API mocking | ์ ํ์ ํ์ฉ | ๋ถํ์ |
ssolv-batch | ํตํฉ ํ
์คํธ | ํ์ฉ | MySQL Testcontainers |
๋ฉํ ์ด๋
ธํ
์ด์
ssolv-api-common/src/testFixtures์ ์ ์๋์ด ์๋ค.
@UnitTest โ Mockito ๋จ์ ํ
์คํธ
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@ExtendWith(MockitoExtension::class)
annotation class UnitTest
์ฌ์ฉ ๋์: Service, Controller (standalone MockMvc), ๋๋ฉ์ธ ๊ฐ์ฒด
@IntegrationTest โ Spring ํตํฉ ํ
์คํธ
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
@ActiveProfiles("test")
@ContextConfiguration(initializers = [TestContainerConfig::class])
@Import(TestSecurityConfig::class)
annotation class IntegrationTest
์ฌ์ฉ ๋์: ์ ์ฒด ํ๋ก์ฐ ๊ฒ์ฆ์ด ํ์ํ E2E ํ
์คํธ (NotificationIntegrationTest ๋ฑ)
@RepositoryTest โ JPA Repository ํ
์คํธ
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@DataJpaTest
@ActiveProfiles("test")
@ContextConfiguration(initializers = [TestContainerConfig::class])
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Transactional
annotation class RepositoryTest
์ฌ์ฉ ๋์: Kotlin-JDSL ์ปค์คํ
์ฟผ๋ฆฌ, ๋ณต์กํ ์กฐ์ธ/์ง๊ณ ์ฟผ๋ฆฌ ๊ฒ์ฆ
ํ
์คํธ ๋ ์ด์ด๋ณ ํจํด
Controller ๋จ์ ํ
์คํธ
suspend ์ปจํธ๋กค๋ฌ๋ asyncDispatch() 2๋จ๊ณ ํจํด์ ๋ฐ๋์ ์ฌ์ฉํ๋ค.
@UnitTest
class MeetingControllerTest {
@Mock private lateinit var createMeetingService: CreateMeetingService
@InjectMocks private lateinit var meetingController: MeetingController
private val testUserIdResolver = TestUserIdArgumentResolver()
private val objectMapper = ObjectMapper()
private val mockMvc: MockMvc by lazy {
MockMvcBuilders.standaloneSetup(meetingController)
.setControllerAdvice(GlobalExceptionHandler())
.setCustomArgumentResolvers(testUserIdResolver)
.build()
}
@Test
fun `๋ฏธํ
์์ฑ ์ฑ๊ณต - 200`() = runTest {
testUserIdResolver.setTestUserId(1L)
createMeetingService.stub {
onBlocking { invoke(any(), any()) }.doReturn(MeetingResponse(id = 1L, name = "ํ ๋ฏธํ
"))
}
val mvcResult = mockMvc.perform(
post("/api/v1/meetings")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(CreateMeetingRequest(name = "ํ ๋ฏธํ
")))
)
.andExpect(request().asyncStarted())
.andReturn()
mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.id").value(1L))
}
@Test
fun `@Valid ์๋ฐ - 400`() {
mockMvc.perform(
post("/api/v1/meetings")
.contentType(MediaType.APPLICATION_JSON)
.content("{}")
).andExpect(status().isBadRequest)
}
@Test
fun `๋น์ฆ๋์ค ์์ธ - 404`() = runTest {
createMeetingService.stub {
onBlocking { invoke(any(), any()) }.doThrow(MeetingException(ErrorCode.MEETING_NOT_FOUND))
}
val mvcResult = mockMvc.perform(
post("/api/v1/meetings")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(CreateMeetingRequest(name = "ํ ๋ฏธํ
")))
)
.andExpect(request().asyncStarted())
.andReturn()
mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().isNotFound)
.andExpect(jsonPath("$.error.code").value("M001"))
}
}
Service ๋จ์ ํ
์คํธ
@UnitTest + @Mock + @InjectMocks ์กฐํฉ.
TransactionTemplate์ฒ๋ผ ๋์์ ์ง์ ์ ์ดํด์ผ ํ๋ ์์กด์ฑ์ @BeforeEach์์ ์์ฑ์ ์ฃผ์
ํ๋ค.
@UnitTest
@DisplayName("[SURVEY] ์ค๋ฌธ ์์ฑ ์๋น์ค ํ
์คํธ")
class CreateSurveyServiceTest {
@Mock private lateinit var surveyJpaRepository: SurveyJpaRepository
@Mock private lateinit var meetingJpaRepository: MeetingJpaRepository
private lateinit var createSurveyService: CreateSurveyService
@BeforeEach
fun setUp() {
val transactionTemplate: TransactionTemplate = mock()
whenever(transactionTemplate.execute<Any>(any())).thenAnswer { invocation ->
val callback = invocation.getArgument<TransactionCallback<Any>>(0)
callback.doInTransaction(mock())
}
createSurveyService = CreateSurveyService(surveyJpaRepository, meetingJpaRepository, transactionTemplate)
}
@Test
@DisplayName("์ค๋ฌธ์ ์ฑ๊ณต์ ์ผ๋ก ์์ฑํ๋ค")
fun `์ค๋ฌธ์ ์ฑ๊ณต์ ์ผ๋ก ์์ฑํ๋ค`() = runTest {
val attendee = MeetingAttendeeFixture.createEntity(id = 10L)
whenever(meetingJpaRepository.findById(1L)).thenReturn(Optional.of(MeetingFixture.createEntity()))
whenever(surveyJpaRepository.existsByMeetingIdAndParticipantId(any(), any())).thenReturn(false)
createSurveyService.invoke(1L, 1L, SurveyRequestFixture.createRequest())
verify(surveyJpaRepository).save(any())
}
@Test
@DisplayName("์ด๋ฏธ ์ ์ถํ ๊ฒฝ์ฐ ์์ธ๊ฐ ๋ฐ์ํ๋ค")
fun `์ค๋ณต ์ ์ถ ์ ์์ธ๊ฐ ๋ฐ์ํ๋ค`() = runTest {
whenever(meetingJpaRepository.findById(1L)).thenReturn(Optional.of(MeetingFixture.createEntity()))
whenever(surveyJpaRepository.existsByMeetingIdAndParticipantId(any(), any())).thenReturn(true)
val exception = assertThrows<SurveyException> {
createSurveyService.invoke(1L, 1L, SurveyRequestFixture.createMinimalRequest())
}
assertEquals(ErrorCode.SURVEY_ALREADY_SUBMITTED, exception.errorCode)
}
}
Repository ํ
์คํธ
๋ณต์กํ Kotlin-JDSL ์ฟผ๋ฆฌ, ์ง๊ณ ์ฟผ๋ฆฌ๋ง ์์ฑํ๋ค. ๋จ์ save() / findById()๋ ํ
์คํธ ๋ถํ์.
@RepositoryTest
class MeetingJpaRepositoryTest {
@Autowired private lateinit var meetingJpaRepository: MeetingJpaRepository
@Autowired private lateinit var userJpaRepository: UserJpaRepository
@Test
fun `ํธ์คํธ ID๋ก ๋ฏธํ
๋ชฉ๋ก์ ์กฐํํ๋ค`() {
val user = userJpaRepository.save(UserFixture.createEntityWithoutId())
val station = stationJpaRepository.save(StationFixture.createEntityWithoutId())
meetingJpaRepository.save(MeetingFixture.createEntityWithoutId(hostUser = user, station = station))
val result = meetingJpaRepository.findByHostUserId(user.id!!)
assertThat(result).hasSize(1)
}
}
ํตํฉ ํ
์คํธ
์ ์ฒด ํ๋ก์ฐ ๊ฒ์ฆ์ด ํ์ํ ๊ฒฝ์ฐ์๋ง ์์ฑํ๋ค. ์ธ๋ถ ์๋น์ค๋ @MockkBean / @MockitoBean์ผ๋ก ๋์ฒดํ๋ค.
@IntegrationTest
@Transactional
class SomeIntegrationTest {
@Autowired private lateinit var someService: SomeService
@Autowired private lateinit var someJpaRepository: SomeJpaRepository
@MockkBean private lateinit var fcmClient: FcmClient
@Test
fun `์ ์ฒด ํ๋ก์ฐ ๊ฒ์ฆ`() = runBlocking {
val user = userRepository.save(UserFixture.createEntityWithoutId())
val result = someService.invoke(user.id!!)
val saved = someJpaRepository.findById(result.id).orElseThrow()
assertThat(saved.status).isEqualTo(ExpectedStatus)
}
}
Fixture ์์คํ
๊ณต์ฉ Fixture ์์น
ssolv-api-common/src/testFixtures/kotlin/.../fixture/์ ์ค์ํํ๋ค.
fixture/
โโโ UserFixture.kt
โโโ MeetingFixture.kt
โโโ MeetingAttendeeFixture.kt
โโโ SurveyFixture.kt
โโโ SurveyCategoryFixture.kt
โโโ StationFixture.kt
๋ฉ์๋ ๋ค์ด๋ฐ ๊ท์น
| ๋ฉ์๋ | ๋ฐํ ํ์
| id | ์ฉ๋ |
|---|
create() | ๋๋ฉ์ธ ๋ชจ๋ธ | ์์ | Repository ์ธํฐํ์ด์ค mock stub์ฉ |
createEntity() | JPA Entity | ์์ | DB ์กฐํ ๊ฒฐ๊ณผ ์๋ฎฌ๋ ์ด์
|
createEntityWithoutId() | JPA Entity | null | save() ์ ์ํ, ํตํฉ ํ
์คํธ DB ์ ์ฅ์ฉ |
| ํด๋์ค | create() | createEntity() | createEntityWithoutId() |
|---|
UserFixture | User ๋๋ฉ์ธ | O | O (+ createKakaoProfile(), createOAuthToken()) |
MeetingFixture | Meeting ๋๋ฉ์ธ | O | O |
MeetingAttendeeFixture | โ | O | O |
SurveyFixture | โ | O | O |
SurveyCategoryFixture | โ | O (level, parent ์ง์ ๊ฐ๋ฅ) | O |
StationFixture | โ | O | O |
val meeting = MeetingFixture.create(id = 1L, isClosed = false)
whenever(meetingRepository.findById(1L)).thenReturn(meeting)
val user = UserFixture.createEntity(id = 99L, nickname = "ํธ์คํธ")
whenever(userJpaRepository.findById(99L)).thenReturn(Optional.of(user))
val user = userRepository.save(UserFixture.createEntityWithoutId())
val meeting = meetingJpaRepository.save(MeetingFixture.createEntityWithoutId(hostUser = user))
๋ชจ๋ ๋ก์ปฌ Fixture
DTO, Request/Response์ฒ๋ผ ํน์ ๋ชจ๋์์๋ง ํ์ํ ํ
์คํธ ๋ฐ์ดํฐ๋ src/test/kotlin/.../fixture/์ ์์ฑํ๋ค.
object SurveyRequestFixture {
fun createRequest(selectedCategoryList: List<Long> = listOf(1L, 3L)): CreateSurveyRequest
fun createMinimalRequest(): CreateSurveyRequest
fun createEmptyRequest(): CreateSurveyRequest
}
์ ๋ชจ๋์ testFixtures ์ถ๊ฐ
dependencies {
testImplementation(testFixtures(project(":ssolv-api-common")))
}
์ฝ๋ฃจํด ํ
์คํธ
@Test
fun `๋จ์ ํ
์คํธ`() = runTest {
service.stub { onBlocking { method(any()) }.doReturn(value) }
val result = service.method(arg)
assertThat(result).isNotNull()
}
@Test
fun `ํตํฉ ํ
์คํธ`() = runBlocking {
val result = service.method(arg)
assertThat(result).isNotNull()
}
service.stub {
onBlocking { method(any()) }.doThrow(SomeException(ErrorCode.SOME_ERROR))
}
verify(repository).save(any())
verifyNoInteractions(externalClient)
์ปค๋ฒ๋ฆฌ์ง ์ ์ธ ๋์
Jacoco / SonarQube ๋ชจ๋ ์๋ ํจํด์ ์ ์ธํ๋ค.
| ํจํด | ์ด์ |
|---|
**/Q*.* | Kotlin-JDSL ์์ฑ ํ์ผ |
**/*Application*, **/*Config* | ์ค์ ํด๋์ค |
**/*Dto*, **/*Request*, **/*Response* | ๋ฐ์ดํฐ ์ ๋ฌ ๊ฐ์ฒด |
**/*Entity* | JPA ๋งคํ ํด๋์ค |
**/*Exception*, **/*ErrorCode* | ์์ธ ์ ์ ํด๋์ค |
์ปค๋ฒ๋ฆฌ์ง ๋ชฉํ
| ๋์ | ๋ชฉํ |
|---|
| Service ๋ ์ด์ด (๋ณต์ก ๋ก์ง) | 80% ์ด์ |
| Domain ๋ ์ด์ด | 90% ์ด์ |
| Controller ๋ ์ด์ด | ์ฃผ์ ํ๋ฆ ์ปค๋ฒ |
์คํ ๋ช
๋ น์ด
./gradlew test
./gradlew test jacocoTestReport --continue --stacktrace
./gradlew :ssolv-api-core:test
./gradlew :ssolv-api-place:test
./gradlew :ssolv-api-core:test --tests "org.depromeet.team3.survey.application.CreateSurveyServiceTest"
./gradlew :ssolv-api-core:test --info
ํตํฉ ํ
์คํธ(@IntegrationTest, @RepositoryTest)๋ ๋ก์ปฌ์ Docker๊ฐ ์คํ ์ค์ด์ด์ผ ํ๋ค.
์ ์๋ํฌ์ธํธ ์ฒดํฌ๋ฆฌ์คํธ
Controller (@UnitTest)
Service (@UnitTest)