[BlerOn] JUnit5 단위테스트
- BlerOn JUnit 5 단위테스트
BlerOn JUnit 5 단위테스트
BlerOn 백엔드(Spring Boot / DDD + Hexagonal + CQRS)에서 사용하는 JUnit 5 단위·슬라이스 테스트 전략을 정리한 문서다.
이 전략의 핵심은 @SpringBootTest 하나로 전체를 검증하지 않고, 계층별로 테스트를 분리하는 데 있다. 회원가입 API(MemberAppCommandService.registerMember)를 기준으로 Application Service · JPA Repository · Repository Impl 3계층에 각각 테스트를 두면 실행 속도와 실패 원인 추적이 모두 나아진다.
각 계층은 아래 세 가지를 항상 구분할 수 있어야 테스트가 유지보수 가능하다.
| 구분 | 계층 | 의미 |
|---|---|---|
| 비즈니스 | Application Service | Mock으로 의존성을 끊고 흐름·예외·이벤트만 검증 |
| 영속성 | JPA Repository | 쿼리 메서드와 Entity Lifecycle을 DB와 함께 검증 |
| 어댑터 | Repository Impl | 도메인 포트 구현체가 JPA를 올바르게 연결하는지 검증 |
그 구분을 테스트 클래스로 강제하는 것이 이 전략의 목적이다. H2에서 MariaDB로 전환하면서 겪은 시행착오와, @DataJpaTest 슬라이스 테스트를 안정적으로 구성하는 방법까지 함께 다룬다.
한눈에 보기
| 항목 | 내용 |
|---|---|
| 기준 기능 | 회원가입 (MemberAppCommandService.registerMember) |
| 기술 스택 | JUnit 5 · Mockito · Spring Boot Test · @DataJpaTest · MariaDB |
| Service 테스트 | Mockito 단위 테스트 — Spring 컨텍스트·DB 없음 |
| JPA / Impl 테스트 | @JpaIntegrationTest 슬라이스 — MariaDB 사용 |
| 네이밍 | should_{결과}_When_{조건}_Then_{기대} |
| 구조 | given → when → then · AssertJ · Mockito BDD |
| 민감 정보 | DB 접속 정보는 환경 변수로 주입, 문서·Git에 기록하지 않음 |
3계층 테스트 흐름
Application Service (Mockito) → JPA Repository (슬라이스) → Repository Impl (슬라이스 + @Import)
계층별 테스트 클래스
| 계층 | 테스트 클래스 | DB |
|---|---|---|
| Application Service | MemberAppCommandServiceImplTest |
❌ |
| JPA Repository | MemberJpaRepositoryTest |
✅ MariaDB |
| Repository Impl | MemberRepositoryImplTest |
✅ MariaDB |
1. 테스트 전략 개요
회원가입 API를 기준으로 3계층 테스트 전략을 수립했다.
| 계층 | 테스트 클래스 | 방식 | DB 사용 |
|---|---|---|---|
| Application Service | MemberAppCommandServiceImplTest |
Mockito 단위 테스트 | ❌ 없음 |
| JPA Repository | MemberJpaRepositoryTest |
@JpaIntegrationTest 슬라이스 |
✅ MariaDB |
| Repository Impl (어댑터) | MemberRepositoryImplTest |
@JpaIntegrationTest + @Import |
✅ MariaDB |
각 계층은 검증 목적이 다르기 때문에 분리한다. 한 테스트가 모든 것을 검증하려 하면 유지보수가 어려워진다.
공통 규칙
| 항목 | 규칙 |
|---|---|
| 네이밍 | should_{결과}_When_{조건}_Then_{기대} |
| 구조 | given → when → then |
| 표현 | AssertJ (assertThat, assertThatThrownBy) |
| Mockito | BDD 스타일 (given, verify, verifyNoInteractions) |
| DisplayName | 한글로 검증 의도 명시 |
민감 정보 주의
DB 호스트, 계정, 비밀번호 등 접속 정보는 문서·Git에 기록하지 않는다.
테스트 DB 설정은 application-test.yml에서 환경 변수로 주입한다.
spring:
datasource:
url: jdbc:mariadb://${TEST_DB_HOST}:${TEST_DB_PORT}/${TEST_DB_NAME}?characterEncoding=UTF-8&serverTimezone=Asia/Seoul
username: ${TEST_DB_USERNAME}
password: ${TEST_DB_PASSWORD}
2. 계층별 표준
2.1 Application Service — Mockito 단위 테스트
Spring 컨텍스트 없이 비즈니스 흐름만 검증한다.
@ExtendWith(MockitoExtension.class)
class MemberAppCommandServiceImplTest {
@Mock
private MemberRepository memberRepository;
@InjectMocks
private MemberAppCommandServiceImpl memberAppCommandService;
@Test
@DisplayName("일반 회원가입 성공 시 memberId와 memberMasterSeq를 반환한다")
void should_RegisterMember_When_ValidCommand_Then_Success() {
// given → when → then
}
}
| 항목 | 내용 |
|---|---|
| Spring 컨텍스트 | ❌ 로드하지 않음 |
| 의존성 | @Mock + @InjectMocks |
| 검증 범위 | 중복 검증, 예외 분기, 이벤트 발행 |
| DB | 사용하지 않음 |
2.2 JPA Repository — 슬라이스 테스트
Spring Data JPA 쿼리 메서드와 Entity Lifecycle 콜백을 검증한다.
@JpaIntegrationTest
class MemberJpaRepositoryTest {
@Autowired
private MemberJpaRepository memberJpaRepository;
@Autowired
private TestEntityManager entityManager;
}
| 항목 | 내용 |
|---|---|
| 어노테이션 | @JpaIntegrationTest (구체 클래스에 직접 선언) |
| 데이터 조작 | TestEntityManager 또는 Repository |
| 롤백 | @DataJpaTest 기본 @Transactional 롤백 |
| 테스트 데이터 | Fixture의 unique*() 메서드로 충돌 방지 |
2.3 Repository Impl — 어댑터 슬라이스 테스트
Domain Port 구현체의 매핑·HMAC 변환·어댑터 로직을 검증한다.
@JpaIntegrationTest
@Import({ MemberRepositoryImpl.class, MemberJpaMapper.class })
class MemberRepositoryImplTest {
@Autowired
private MemberRepositoryImpl memberRepository;
@MockBean
private AdminJpaRepository adminJpaRepository; // Impl 생성자 의존성
}
| 항목 | 내용 |
|---|---|
@Import |
테스트 대상 Impl + JpaMapper |
@MockBean |
Impl 생성자에 필요하지만 이번 테스트 범위 밖인 JpaRepository |
| 검증 범위 | Domain ↔ Entity 매핑, HMAC 변환, 어댑터 호출 |
3. 공통 설정
3.1 build.gradle
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.mariadb.jdbc:mariadb-java-client:3.3.3'
spring-boot-starter-test: JUnit 5, AssertJ, Mockito, Spring Test 포함mariadb-java-client: Repository 슬라이스 테스트 JDBC 드라이버
3.2 application-test.yml
Repository 슬라이스 테스트 전용 Spring Profile (test).
| 설정 | 목적 |
|---|---|
spring.test.database.replace: none |
@DataJpaTest가 H2로 datasource를 치환하지 않도록 함 |
ddl-auto: none |
기존 스키마 유지, DDL 실행 금지 |
MariaDBDialect |
Hibernate dialect 자동 감지 실패 방지 |
| Redis autoconfigure exclude | Repository 테스트에 Redis 불필요 |
| Hikari pool size 4 | 테스트용 최소 커넥션 풀 |
spring:
test:
database:
replace: none
datasource:
url: jdbc:mariadb://${TEST_DB_HOST:localhost}:${TEST_DB_PORT:3306}/${TEST_DB_NAME:bler}?characterEncoding=UTF-8&serverTimezone=Asia/Seoul
driver-class-name: org.mariadb.jdbc.Driver
username: ${TEST_DB_USERNAME}
password: ${TEST_DB_PASSWORD}
hikari:
maximum-pool-size: 4
minimum-idle: 1
connection-timeout: 20000
jpa:
hibernate:
ddl-auto: none
database-platform: org.hibernate.dialect.MariaDBDialect
properties:
hibernate:
ddl-auto: none
dialect: org.hibernate.dialect.MariaDBDialect
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
- org.redisson.spring.starter.RedissonAutoConfiguration
dev DB와 동일한 스키마를 사용하므로 VPN·사내망 접속이 필요할 수 있다.
테스트 데이터는@Transactional롤백 +uniqueMemberId()등으로 충돌을 방지한다.
3.3 @JpaIntegrationTest (공통 메타 어노테이션)
Repository 슬라이스 테스트에 공통으로 붙이는 커스텀 메타 어노테이션이다.
@DataJpaTest(properties = {
"spring.test.database.replace=none",
"spring.jpa.hibernate.ddl-auto=none",
"spring.jpa.properties.hibernate.ddl-auto=none",
"spring.jpa.database-platform=org.hibernate.dialect.MariaDBDialect"
})
@ActiveProfiles(value = "test", inheritProfiles = false)
@EntityScan(basePackageClasses = MemberMasterEntity.class)
@EnableJpaRepositories(
basePackages = "kr.co.bler.infrastructure.persistence.repository.member",
includeFilters = @ComponentScan.Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes = MemberJpaRepository.class
)
)
@Import({ ApplicationContextProvider, Aes256Util, Hmac256Util, EncryptedStringConverter })
public @interface JpaIntegrationTest {}
| 구성 요소 | 역할 |
|---|---|
@DataJpaTest |
JPA·Repository 관련 빈만 로드하는 슬라이스 테스트 |
@ActiveProfiles("test") |
application-test.yml 활성화 |
@EntityScan |
테스트에 필요한 Entity만 등록 |
@EnableJpaRepositories + includeFilters |
대상 Repository만 등록 (하위 패키지 제외) |
@Import |
@PrePersist HMAC, 암호화 Converter 등 Entity 콜백에 필요한 유틸 |
회원 외 Domain 테스트 추가 시
@JpaIntegrationTest를 그대로 쓰지 말고, Entity·Repository 범위에 맞는 별도 메타 어노테이션을 만드는 것을 권장한다.
3.4 JpaTestSupport (선택적 베이스 클래스)
@JpaIntegrationTest
public abstract class JpaTestSupport {}
IDE/Gradle에서 메타 어노테이션 상속이 누락될 수 있으므로, 구체 테스트 클래스에 @JpaIntegrationTest를 직접 선언한다.
3.5 MemberTestFixture (테스트 데이터 팩토리)
| 메서드 | 용도 |
|---|---|
uniqueMemberId() |
dev DB 충돌 방지용 고유 이메일 (junit-test-{uuid}@example.com) |
uniqueMobile() |
dev DB 충돌 방지용 고유 휴대폰 (0109XXXXXXX) |
learnerCommand() |
Service 테스트용 RegisterMemberCommand 기본값 |
memberEntity(...) |
JPA 테스트용 MemberMasterEntity 생성 |
savedMemberDomain(...) |
Service Mock 응답용 MemberDomain |
4. TestClass 별 용도
4.1 MemberAppCommandServiceImplTest
경로: application/service/member/MemberAppCommandServiceImplTest.java
| 테스트 | 검증 내용 |
|---|---|
should_RegisterMember_When_ValidCommand_Then_Success |
정상 가입 시 memberId, memberMasterSeq 반환 및 이벤트 발행 |
should_ThrowBizException_When_DuplicateEmail_Then_Fail |
이메일 중복 시 BizException |
should_ThrowBizException_When_DuplicateMobile_Then_Fail |
휴대폰 중복 시 BizException |
4.2 MemberJpaRepositoryTest
경로: infrastructure/persistence/repository/member/MemberJpaRepositoryTest.java
| 테스트 | 검증 내용 |
|---|---|
should_ReturnTrue_When_ActiveMemberExists_Then_ExistsByMemberIdAndDelYnFalse |
활성 회원 존재 → true |
should_ReturnFalse_When_DeletedMember_Then_ExistsByMemberIdAndDelYnFalse |
삭제 회원 → false |
should_SetHmacSearchFields_When_SaveMember_Then_PrePersistApplied |
저장 시 HMAC 검색 필드 자동 설정 |
4.3 MemberRepositoryImplTest
경로: infrastructure/persistence/repository/member/MemberRepositoryImplTest.java
| 테스트 | 검증 내용 |
|---|---|
should_ReturnTrue_When_MemberIdExists_Then_ExistsByEmail |
existsByEmail → JPA 쿼리 연동 |
should_ReturnTrue_When_MobileExists_Then_ExistsByMobile |
평문 mobile → HMAC 변환 후 조회 |
should_SaveMember_When_ValidDomain_Then_ReturnSavedDomain |
Domain 저장 → PK 생성 및 필드 매핑 |
5. 시행착오 — 오류와 해결
실제로 테스트 환경을 구성하면서 겪은 오류와 해결 과정이다.
5.1 H2 + ddl-auto 충돌 → MariaDB 전환
증상
Table 'KL_MEMBER_MASTER' not found (database is empty)
원인
- H2 인메모리 DB +
ddl-auto: create-drop조합 시도 @DataJpaTest기본 동작과 충돌하거나, 실제 스키마·제약조건과 불일치
해결
application-test.yml을 development MariaDB로 전환ddl-auto: none— 기존 테이블 구조 그대로 사용
5.2 Unable to determine Dialect without JDBC metadata
증상
Unable to determine Dialect without JDBC metadata
원인
@DataJpaTest기본값spring.test.database.replace=ANY가 datasource URL을 H2로 치환- MariaDB URL이 무시되어 Hibernate dialect 감지 실패
해결
application-test.yml:spring.test.database.replace: none@DataJpaTest(properties = {...})어노테이션에도 동일 property 인라인 명시spring.jpa.database-platform=org.hibernate.dialect.MariaDBDialect추가
5.3 메타 어노테이션 / 추상 클래스 설정 미적용
증상
- IDE에서 테스트 실행 시 yml 설정이 반영되지 않음
원인
- 메타 어노테이션 + 추상 베이스 클래스 조합에서 IDE Test Runner가 설정 상속을 누락
해결
@JpaIntegrationTest를 구체 테스트 클래스에 직접 선언- 핵심 property는
@DataJpaTest(properties = {...})에 인라인으로 중복 명시
5.4 Could not resolve join path 'CategoryEntity' (Repository 과다 스캔)
증상
Failed to load ApplicationContext
└─ lecturerJpaRepository 빈 생성 실패
└─ Could not resolve join path 'CategoryEntity'
원인
// ❌ 문제가 있던 설정
@EnableJpaRepositories(basePackageClasses = MemberJpaRepository.class)
@EntityScan(basePackageClasses = MemberMasterEntity.class)
basePackageClasses = MemberJpaRepository.class→member패키지 전체(하위lecturer,learner등) 스캔LecturerJpaRepositoryJPQL이CategoryEntityJOIN@EntityScan에는MemberMasterEntity만 등록 →CategoryEntity없음 → 쿼리 검증 실패
해결
// ✅ 수정 후 — 대상 Repository만 등록
@EnableJpaRepositories(
basePackages = "kr.co.bler.infrastructure.persistence.repository.member",
includeFilters = @ComponentScan.Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes = MemberJpaRepository.class
)
)
includeFilters가 있으면 Spring Data JPA는 필터에 맞는 Repository만 등록한다.
5.5 DB 연결 타임아웃 (네트워크)
증상
CannotCreateTransactionException: Could not open JPA EntityManager for transaction
└─ Connect timed out
원인
- 테스트 DB 서버에 네트워크 접근 불가 (VPN 미연결, 방화벽, 서버 다운)
해결
- VPN/사내망 연결 후 재실행
- PowerShell:
Test-NetConnection -ComputerName $env:TEST_DB_HOST -Port $env:TEST_DB_PORT
ApplicationContext 로드까지 성공하고 트랜잭션 시작 단계에서 실패하면, 코드 문제가 아니라 네트워크·접속 정보 문제이다.
5.6 Gradle GradleWorkerMain ClassNotFoundException
증상
Could not find or load main class worker.org.gradle.process.internal.worker.GradleWorkerMain
대응
- IDE(Cursor/IntelliJ) Test Runner로 개별 실행
./gradlew clean test또는 Gradle 캐시 정리 후 재시도
6. Service / JPA 레벨 테스트를 하는 이유
6.1 왜 3계층으로 나누는가?
Controller (Presentation)
↓
Application Service ← ① MemberAppCommandServiceImplTest (Mockito)
↓
Domain Repository (Port)
↓
Repository Impl ← ③ MemberRepositoryImplTest (@DataJpaTest + @Import)
↓
JPA Repository ← ② MemberJpaRepositoryTest (@DataJpaTest)
↓
MariaDB
@SpringBootTest로 전체를 올리면 느리고, 실패 시 원인 파악이 어렵다.
계층별로 나누면 빠르고, 실패 지점이 명확하다.
6.2 Service 레벨 테스트 (Mockito)
| 장점 | 한계 |
|---|---|
| Spring·DB 없이 밀리초 단위 실행 | SQL·Entity 매핑 미검증 |
| 중복 검증, 예외 분기, 이벤트 발행 집중 | HMAC 변환 미검증 |
| Repository·DB 장애와 무관 | → JPA/Impl 테스트로 보완 |
6.3 JPA Repository 레벨 테스트
| 장점 | 한계 |
|---|---|
existsByMemberIdAndDelYnFalse 등 쿼리 메서드 검증 |
Domain ↔ Entity 매핑 미검증 |
@PrePersist HMAC 검색 필드 등 Lifecycle 검증 |
|
@SpringBootTest 대비 경량 슬라이스 |
6.4 Repository Impl 레벨 테스트
| 장점 |
|---|
| Domain Port 어댑터가 JPA Repository를 올바르게 호출하는지 확인 |
평문 mobile → mobileFullSearch HMAC 변환 후 조회 검증 |
MemberDomain ↔ MemberMasterEntity 변환 및 PK 생성 확인 |
| Hexagonal Architecture Infrastructure Layer 격리 테스트 |
6.5 검증 매트릭스
| 검증 대상 | Service (Mockito) | JpaRepository | RepositoryImpl |
|---|---|---|---|
| 중복 이메일/휴대폰 예외 | ✅ | — | — |
| 가입 성공·이벤트 | ✅ | — | — |
| JPA 쿼리 메서드 | — | ✅ | — |
@PrePersist HMAC |
— | ✅ | — |
| Domain ↔ Entity 매핑 | — | — | ✅ |
| HMAC 변환 조회 | — | — | ✅ |
| DB 연결 필요 | ❌ | ✅ | ✅ |
| 실행 속도 | ⚡ 매우 빠름 | 🐢 보통 | 🐢 보통 |
7. 실행 방법
환경 변수 설정 (PowerShell 예시)
$env:TEST_DB_HOST = "<dev-db-host>"
$env:TEST_DB_PORT = "3306"
$env:TEST_DB_NAME = "bler"
$env:TEST_DB_USERNAME = "<username>"
$env:TEST_DB_PASSWORD = "<password>"
IDE (권장)
- Run Configuration에
TEST_DB_*환경 변수 설정 - VPN/사내망 연결 확인
- 테스트 클래스 또는 개별
@Test실행
Gradle
# Service (DB 불필요)
.\gradlew test --tests "kr.co.bler.application.service.member.MemberAppCommandServiceImplTest"
# Repository (DB + 네트워크 필요)
.\gradlew test --tests "kr.co.bler.infrastructure.persistence.repository.member.MemberJpaRepositoryTest"
.\gradlew test --tests "kr.co.bler.infrastructure.persistence.repository.member.MemberRepositoryImplTest"
8. 신규 기능 테스트 추가 가이드
Service 테스트
@ExtendWith(MockitoExtension.class)
class XxxAppServiceTest {
@Mock private XxxRepository xxxRepository;
@InjectMocks private XxxAppServiceImpl xxxAppService;
// given-when-then + should_When_Then 네이밍
}
Repository 슬라이스 테스트
- 테스트 클래스에
@JpaIntegrationTest직접 선언 @EntityScan/@EnableJpaRepositories범위를 테스트 대상에 맞게 최소화- Fixture에
unique*()메서드로 고유 테스트 데이터 생성 - dev DB 사용 시
@Transactional롤백 전제
체크리스트
- Service:
@ExtendWith(MockitoExtension.class),@Mock/@InjectMocks - Repository:
@JpaIntegrationTest직접 선언 @EnableJpaRepositories가 하위 패키지 Repository까지 스캔하지 않는지 확인@EntityScan에 JPQL이 참조하는 Entity가 모두 포함되는지 확인spring.test.database.replace=none(yml +@DataJpaTestproperties)ddl-auto: none- DB 접속 정보는 환경 변수로 관리
9. 파일 구조
src/test/
├── resources/
│ └── application-test.yml
├── java/kr/co/bler/
│ ├── application/service/member/
│ │ └── MemberAppCommandServiceImplTest.java # Service Mockito 단위 테스트
│ ├── infrastructure/persistence/repository/member/
│ │ ├── MemberJpaRepositoryTest.java # JPA Repository 슬라이스
│ │ └── MemberRepositoryImplTest.java # Repository Impl 슬라이스
│ └── support/
│ ├── JpaIntegrationTest.java # Repository 공통 메타 어노테이션
│ ├── JpaTestSupport.java
│ └── fixture/
│ └── MemberTestFixture.java
마치며
회원가입 API 하나를 기준으로 Service(Mockito) · JpaRepository · RepositoryImpl 3계층 테스트를 분리하면, 각 계층의 책임에 맞는 검증을 빠르게 수행할 수 있다.
특히 @DataJpaTest 슬라이스 테스트에서는 다음 세 가지가 핵심이다.
spring.test.database.replace=none— H2 치환 방지ddl-auto: none— dev DB 스키마 보호@EnableJpaRepositories스캔 범위 최소화 — 불필요한 Repository·Entity 로드 방지
같은 패턴으로 다른 Domain에도 테스트를 확장할 수 있다. Domain마다 @EntityScan / includeFilters 범위가 다르므로, 공통 메타 어노테이션을 무리하게 재사용하지 않는 것이 중요하다.