일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
- 윈도우 Oracle
- 서평단
- ORA-00922
- ORA-12899
- Oracle 18c HR schema
- 오라클 캐릭터셋 변경
- Oracle Express Edition
- 오라클 캐릭터셋 조회
- oracle
- Oracle 18c HR
- oracle 18c
- 오라클 캐릭터셋 확인
- Oracle 테이블 대소문자
- 무료 오라클 데이터베이스
- Oracle 윈도우 설치
- 비전공자를 위한 데이터베이스 입문
- Oracle 테이블 띄어쓰기
- Orace 18c
- Oracle 초기 사용자
- Oracle 사용자명 입력
- Oracle 18c 설치
- Oracle 사용자명
- 무료 오라클 설치
- ora-01722
- Today
- Total
The Nirsa Way
[Spring Boot] Kotlin + Mockk 단위 테스트 (GWT 패턴, Given-When-Then, every, just Runs, verify, stub) 본문
[Spring Boot] Kotlin + Mockk 단위 테스트 (GWT 패턴, Given-When-Then, every, just Runs, verify, stub)
KoreaNirsa 2025. 8. 6. 12:36
[Spring Boot] 코틀린 Mockk 기반 단위 테스트
스프링 부트 환경에서 보통 많이 쓰이는 목킹 라이브러리는 Mockito와 Mockk을 사용하게 됩니다. 대부분 자바 진형에서는 Mockito를 많이 사용하지만 Mockk는 final 클래스, 확장 함수, 코루틴 테스트 등 코틀린에 친화적인 목킹 라이브러리로써 코틀린 진형에서 테스트 코드를 작성할 때 많이 사용됩니다.
Mockk에서 자주 사용되는 어노테이션은 다음과 같습니다.
- @Mockk : Mock 객체를 생성할 필드에 사용
- @InjectMockks : 테스트 대상 객체 생성 및 Mockk 객체 주입
- @ExtendWith(MockkExtension::class) : JUnit5 환경에서 Mockk 활성화
// 예시 코드
@ExtendWith(MockKExtension::class)
class AuthServiceTest {
@MockK
lateinit var memberRepository: MemberRepository
@InjectMockKs
lateinit var authService: AuthServiceImpl
}
우선 mockk를 사용하기 위해 build.gradle에 의존성을 추가 해주세요.
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("io.mockk:mockk:1.14.5")
테스트 코드 분석하기
예시로 사용될 테스트 코드는 현재 진행중인 사이드 프로젝트의 코드 일부 입니다. 전체적인 코드가 어떻게 생성되는지만 확인하고 상황에 맞춰 사용하시면 될 것 같습니다.
아래의 예시 코드는 정상 입력 시 회원과 약관 내용이 저장되고 올바른 ID가 반환되는 테스트 코드입니다. GWT(Given-When-Then) 패턴을 사용하였습니다.
Given-When-Then은 테스트 조건 설정 - 실제 동작 수행 - 결과 검증의 패턴을 가집니다.
@ExtendWith(MockKExtension::class)
class AuthServiceImplTest {
@MockK
lateinit var memberRepository: MemberRepository
@MockK
lateinit var termsRepository: TermsRepository
@MockK
lateinit var memberTermsRepository: MemberTermsRepository
@MockK
lateinit var passwordEncoder: PasswordEncoder
@InjectMockKs
lateinit var authService: AuthServiceImpl
private val sampleEmail = "user@example.com"
@BeforeEach
fun setup() {
clearAllMocks()
}
// 정상 입력 시 회원과 약관이 저장되고 올바른 ID가 반환되는지 검증하는 테스트
@Test
fun signup_withValidInput_savesMemberAndTerms() {
// Given: 이메일 미존재, 암호화 결과, 저장된 회원 설정
every { memberRepository.findByEmail(sampleEmail) } returns null
every { passwordEncoder.encode("Password123!") } returns "encodedPwd"
val savedMember = Member(
memberId = 1L,
email = sampleEmail,
name = "홍길동",
nickname = "길동이",
birthDate = LocalDate.now(),
gender = Gender.M,
password = "encodedPwd"
)
every { memberRepository.save(any<Member>()) } returns savedMember
// TermsRepository stub: 모든 TermsType에 대해 동작하도록 설정
every { termsRepository.findTopByTypeOrderByVersionDesc(any<TermsType>()) } returns Terms(
termsId = 1L,
type = TermsType.SERVICE,
version = "v1",
content = "Sample content",
isRequired = true,
createdAt = LocalDateTime.now()
)
// MemberTermsRepository stub: saveAll 호출 처리
every { memberTermsRepository.saveAll(any<List<MemberTerms>>()) } answers { firstArg<List<MemberTerms>>() }
val memberDto = ReqSignupMemberDTO(
email = sampleEmail,
emailVerified = "Y",
name = "홍길동",
nickname = "길동이",
birthDate = LocalDate.now().toString(),
gender = "M",
password = "Password123!"
)
val termsDto = ReqSignupTermsDTO(
agreeTerms = true,
agreePrivacy = true,
agreeLocation = true,
agreePaymentPolicy = true,
agreeMarketing = false,
agreePersonalized = false
)
val wrapper = ReqSignupWrapper(memberDto, termsDto)
// When: 회원가입 호출
val resultId = authService.signup(wrapper)
// Then: 반환된 ID와 저장 로직 검증
assertEquals(1L, resultId)
verify { memberRepository.save(match { it.email == sampleEmail && it.password == "encodedPwd" }) }
verify { termsRepository.findTopByTypeOrderByVersionDesc(any<TermsType>()) }
verify { memberTermsRepository.saveAll(any<List<MemberTerms>>()) } // saveAll 호출 검증
}
}
Given : 테스트 조건 설정
우선 Given에 대해 먼저 알아볼텐데, Given은 테스트를 위한 조건을 준비하는 단계로써 필요한 정보들을 담은 객체를 생성합니다. Mockk에서 Given 관련된 함수는 아래와 같습니다.
- every { ... } returnes ... : 특정 메서드 호출 시 값을 리턴하도록 설정
- every { ... } answers { ... } : 동적 응답 설정
- every { ... } thorws SomeException() : 예외 발생 시뮬레이션
- just Runs : 반환 값이 없는 메서드에 사용
또한 아래의 코드를 살펴보기 전 Stub 이라는 개념을 알고 가셔야 합니다. Stub은 테스트 대상이 의존중인 외부 객체(Repository)등의 동작을 흉내내어 미리 정해진 값을 반환하게 하는 과정입니다.
아래의 코드는 회원 가입을 하기 전 정보를 저장하고 전제 조건을 준비하는 단계입니다.
// Given: 이메일 미존재, 암호화 결과, 저장된 회원 설정
every { memberRepository.findByEmail(sampleEmail) } returns null
every { passwordEncoder.encode("Password123!") } returns "encodedPwd"
val savedMember = Member(
memberId = 1L,
email = sampleEmail,
name = "홍길동",
nickname = "길동이",
birthDate = LocalDate.now(),
gender = Gender.M,
password = "encodedPwd"
)
every { memberRepository.save(any<Member>()) } returns savedMember
// TermsRepository stub: 모든 TermsType에 대해 동작하도록 설정
every { termsRepository.findTopByTypeOrderByVersionDesc(any<TermsType>()) } returns Terms(
termsId = 1L,
type = TermsType.SERVICE,
version = "v1",
content = "Sample content",
isRequired = true,
createdAt = LocalDateTime.now()
)
// MemberTermsRepository stub: saveAll 호출 처리
every { memberTermsRepository.saveAll(any<List<MemberTerms>>()) } answers { firstArg<List<MemberTerms>>() }
val memberDto = ReqSignupMemberDTO(
email = sampleEmail,
emailVerified = "Y",
name = "홍길동",
nickname = "길동이",
birthDate = LocalDate.now().toString(),
gender = "M",
password = "Password123!"
)
val termsDto = ReqSignupTermsDTO(
agreeTerms = true,
agreePrivacy = true,
agreeLocation = true,
agreePaymentPolicy = true,
agreeMarketing = false,
agreePersonalized = false
)
val wrapper = ReqSignupWrapper(memberDto, termsDto)
memberRepository.findByEmail(sampleEmail)을 호출했을 때 returns null을 하여 중복되는 이메일이 존재하지 않고, 사용자가 입력한 비밀번호("Password123!")를 암호화 했다는 가정하에 진행합니다. (암호화된 비밀번호는 "encodedPwd")
이후 Member 객체를 생성하고 memberRepository.save를 stub 처리 합니다. 이 과정은 추후 When에서 실제 서비스의 회원가입 메서드를 호출했을 때, 실제 로직 안에 있는 save()를 호출하는 과정에서 실제 DB에 저장이 아닌 아래에서 미리 정의한 savedMember 객체를 반환하도록 설정하는 것 입니다.
즉, 간단히는 이후 로직에서 memberRepository.save()가 실행되면 실제 DB에 저장이 아니라, 미리 만들어둔 savedMember 객체를 반환합니다.
// Given: 이메일 미존재, 암호화 결과, 저장된 회원 설정
every { memberRepository.findByEmail(sampleEmail) } returns null
every { passwordEncoder.encode("Password123!") } returns "encodedPwd"
val savedMember = Member(
memberId = 1L,
email = sampleEmail,
name = "홍길동",
nickname = "길동이",
birthDate = LocalDate.now(),
gender = Gender.M,
password = "encodedPwd"
)
every { memberRepository.save(any<Member>()) } returns savedMember
그 다음에 있는 코드들도 stub 처리된 내용들 입니다. termsRepository.findTopByTypeOrderByVersionDesc() 메서드가 호출되면 만들어둔 Terms 객체를 반환, memberTermsRepository.saveAll()이 호출되면 첫번 째 인자(firstArg)로 들어온 리스트(List<MemberTerms>)를 그대로 반환하라는 의미 입니다.
// TermsRepository stub: 모든 TermsType에 대해 동작하도록 설정
every { termsRepository.findTopByTypeOrderByVersionDesc(any<TermsType>()) } returns Terms(
termsId = 1L,
type = TermsType.SERVICE,
version = "v1",
content = "Sample content",
isRequired = true,
createdAt = LocalDateTime.now()
)
// MemberTermsRepository stub: saveAll 호출 처리
every { memberTermsRepository.saveAll(any<List<MemberTerms>>()) } answers { firstArg<List<MemberTerms>>() }
실제 사용중인 Service 계층의 코드 일부입니다. save() 메서드가 호출되면 위에서 stub 처리한 savedMember 객체가 반환되고 findTopByTypeOrderByVersionDesc() 메서드를 호출할 땐 stub 처리한 Terms가 반환되며 마지막으로 saveAll() 메서드를 호출하면 첫 번째 인자(memberTermsList)를 그대로 반환하는 구조가 되는 겁니다.
@Transactional
override fun signup(
reqSignupWrapper : ReqSignupWrapper
) : Long {
... 중략
val encodedPassword = passwordEncoder.encode(requestMember.password)
val member = requestMember.toMemberEntity(encodedPassword)
val savedMember = memberRepository.save(member)
... 중략
val latestTermsMap: Map<TermsType, Terms> = TermsType.entries.associateWith { type ->
termsRepository.findTopByTypeOrderByVersionDesc(type)
?: throw AuthException(ResponseCode.TERMS_NOT_FOUND)
}
... 중략
memberTermsRepository.saveAll(memberTermsList)
return savedMember.memberId!!
}
마지막으로 아래의 코드는 직접 요청 객체들을 만들고 저장하는 부분입니다.
val memberDto = ReqSignupMemberDTO(
email = sampleEmail,
emailVerified = "Y",
name = "홍길동",
nickname = "길동이",
birthDate = LocalDate.now().toString(),
gender = "M",
password = "Password123!"
)
val termsDto = ReqSignupTermsDTO(
agreeTerms = true,
agreePrivacy = true,
agreeLocation = true,
agreePaymentPolicy = true,
agreeMarketing = false,
agreePersonalized = false
)
val wrapper = ReqSignupWrapper(memberDto, termsDto)
참고로 저의 사이드 프로젝트에서 회원 가입에 대한 요청은 아래와 같은 형태(ReqSignupWrapper)로 만들어져서 사용중입니다.
@Schema(description = "회원가입 요청 Wrapper")
data class ReqSignupWrapper(
@field:Valid val member: ReqSignupMemberDTO,
@field:Valid val terms: ReqSignupTermsDTO
)
When : 실제 동작 수행
When은 실제 동작을 수행하는 단계 입니다. 현재 회원가입 테스트 코드에서는 실제 서비스 계층의 signup() 메서드를 호출하게 됩니다. signup() 메서드가 호출되며 외부 객체(Repository)를 호출할 경우 stub 처리한 객체들이 반환되게 됩니다.
val resultId = authService.signup(wrapper)
Then : 결과 검증
마지막으로 결과를 검증하는 단계입니다.
verify는 테스트할 대상 코드에서 특정 메서드가 실제로 호출 되었는지 검증을 수행합니다. Mockk은 테스트 대상 코드가 When에서 실행되는 동안 Call History를 저장하여 verify를 호출할 때 해당 이력을 바탕으로 검증을 수행합니다.
해당 코드의 전체적인 흐름은 아래와 같습니다.
- assertEquals(1L, resultId) : 최종 반환 값(resultId)이 예상대로 1L이 맞는지 확인
- save()가 호출되었는지 검증 및 match로 입력 값 검증(Member 객체가 이메일과 암호화된 비밀번호를 제대로 저장했는지)
- findTopByTypeOrderByVersionDesc()가 제대로 호출 되었는지 검증
- saveAll()이 제대로 호출 되었는지 검증
assertEquals(1L, resultId)
verify { memberRepository.save(match { it.email == sampleEmail && it.password == "encodedPwd" }) }
verify { termsRepository.findTopByTypeOrderByVersionDesc(any<TermsType>()) }
verify { memberTermsRepository.saveAll(any<List<MemberTerms>>()) }