init: 클린 기반 auth 서버 설계
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
# Fixture / Factory 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. factory는 새 유효 객체를 매번 반환한다
|
||||
|
||||
```java
|
||||
public final class UserFactory {
|
||||
|
||||
private UserFactory() {
|
||||
}
|
||||
|
||||
public static User user() {
|
||||
return new User(
|
||||
"user-" + UUID.randomUUID() + "@test.com",
|
||||
"ACTIVE"
|
||||
);
|
||||
}
|
||||
|
||||
public static User user(UnaryOperator<UserBuilder> customizer) {
|
||||
UserBuilder builder = UserBuilder.defaultUser();
|
||||
return customizer.apply(builder).build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 매 호출마다 새 객체를 만든다
|
||||
- 기본값은 유효한 상태다
|
||||
- 테스트는 필요한 값만 override할 수 있다
|
||||
|
||||
JUnit은 기본적으로 테스트 메서드마다 새 테스트 인스턴스를 만들어 격리를 보장하려고 하므로, 테스트 데이터 helper도 같은 방향으로 fresh object를 주는 것이 자연스럽다.
|
||||
|
||||
### 예시 2. fixture 이름이 시나리오를 설명한다
|
||||
|
||||
```java
|
||||
public final class UserFixture {
|
||||
|
||||
private UserFixture() {
|
||||
}
|
||||
|
||||
public static User activeUser() {
|
||||
return UserFactory.user();
|
||||
}
|
||||
|
||||
public static User deletedUser() {
|
||||
return UserFactory.user(builder -> builder.deletedAt(OffsetDateTime.now()));
|
||||
}
|
||||
|
||||
public static User invalidEmailUser() {
|
||||
return UserFactory.user(builder -> builder.email("not-an-email"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- fixture 이름만 봐도 상태 의미가 드러난다
|
||||
- invalid 상태도 명시적으로 분리된다
|
||||
- factory와 fixture의 역할이 나뉜다
|
||||
|
||||
이런 분리는 공식 어노테이션이 강제하는 것은 아니지만, Spring 테스트 지원이 fixture 준비를 쉽게 해 주는 목적과 잘 맞는 실무 패턴이다.
|
||||
|
||||
### 예시 3. repository test에서는 persisted fixture를 분리한다
|
||||
|
||||
```java
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PersistedUserFactory {
|
||||
|
||||
private final EntityManager em;
|
||||
|
||||
public User persistedUser() {
|
||||
User user = UserFactory.user();
|
||||
em.persist(user);
|
||||
em.flush();
|
||||
return user;
|
||||
}
|
||||
|
||||
public User persistedUserAndClear() {
|
||||
User user = UserFactory.user();
|
||||
em.persist(user);
|
||||
em.flush();
|
||||
em.clear();
|
||||
return user;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- DB에 반영된 fixture와 메모리 상태 fixture를 구분한다
|
||||
- repository test에서 영속성 상태를 더 명확히 다룰 수 있다
|
||||
- 표준 `EntityManager`만 사용하므로 `@DataJpaTest`와 `@SpringBootTest` 양쪽 컨텍스트에서 동일하게 동작한다
|
||||
|
||||
Spring Boot는 `@DataJpaTest` 슬라이스에서 `TestEntityManager`를 `persist`/`flush`/`find` 같은 common testing task를 위한 대안 `EntityManager`로 제공한다고 설명한다. 다만 `TestEntityManager`는 `@DataJpaTest` 컨텍스트에서만 자동 구성되므로, `@SpringBootTest`에서도 재사용할 helper에는 표준 `EntityManager`를 주입하는 것이 안전하다.
|
||||
|
||||
### 예시 4. 핵심 차이는 테스트 본문에 남긴다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void findByEmail_returnsMatchingUser() {
|
||||
User saved = userRepository.save(
|
||||
UserFactory.user(builder -> builder.email("target@test.com"))
|
||||
);
|
||||
|
||||
Optional<User> result = userRepository.findByEmail("target@test.com");
|
||||
|
||||
assertThat(result).contains(saved);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 반복 필드는 factory가 채우지만, 핵심 조건인 email은 테스트 본문에 드러난다
|
||||
- 테스트를 읽는 사람이 왜 이 테스트가 중요한지 바로 이해할 수 있다
|
||||
- fixture/factory가 assertion의 핵심을 숨기지 않는다
|
||||
|
||||
Spring 테스트 문서는 DI와 테스트 지원이 테스트를 더 쉽게 만들 수 있다고 설명하지만, 그 목적은 테스트 의미를 감추는 것이 아니다.
|
||||
|
||||
### 예시 5. Spring context 테스트에서도 fixture helper는 test support로 분리한다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class UserCommandServiceTest {
|
||||
|
||||
@Autowired
|
||||
private UserCommandService userCommandService;
|
||||
|
||||
@Autowired
|
||||
private PersistedUserFactory persistedUserFactory;
|
||||
|
||||
@Test
|
||||
void deactivateUser_marksUserInactive() {
|
||||
User user = persistedUserFactory.persistedUser();
|
||||
|
||||
userCommandService.deactivate(user.getId());
|
||||
|
||||
// assertion ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- fixture 준비가 재사용 가능하다
|
||||
- 그래도 테스트의 핵심 동작은 서비스 호출과 assertion에 남아 있다
|
||||
- Spring DI는 helper 주입에만 쓰고, fixture 자체를 production 로직처럼 다루지 않는다
|
||||
|
||||
Spring Framework는 테스트 인스턴스에 field, setter, constructor injection을 지원한다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. mutable shared fixture를 static으로 재사용한다
|
||||
|
||||
```java
|
||||
public final class SharedFixtures {
|
||||
public static final User USER = new User("a@test.com", "ACTIVE");
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 한 테스트의 변경이 다른 테스트에 영향을 줄 수 있다
|
||||
- JUnit의 기본 per-method 격리 철학과 맞지 않는다
|
||||
- 테스트 순서 의존과 flaky test를 만들기 쉽다
|
||||
|
||||
JUnit은 기본 lifecycle이 테스트 간 mutable state 부작용을 피하기 위한 `PER_METHOD`라고 설명한다.
|
||||
|
||||
### 예시 2. boolean 나열형 factory로 의미를 숨긴다
|
||||
|
||||
```java
|
||||
public static User user(boolean deleted, boolean admin, boolean locked, boolean invalidEmail) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 호출부에서 각 boolean이 무엇을 뜻하는지 바로 알기 어렵다
|
||||
- 상태 의미가 시나리오 이름으로 드러나지 않는다
|
||||
- 잘못된 조합도 쉽게 생긴다
|
||||
|
||||
이런 형태는 fixture/factory가 테스트 가독성을 높여야 한다는 목적에 어긋난다. 프로젝트에서는 명시적 이름의 fixture나 builder override를 선호한다.
|
||||
|
||||
### 예시 3. invalid 상태를 기본 factory에 섞는다
|
||||
|
||||
```java
|
||||
public static User user() {
|
||||
return new User(null, "ACTIVE");
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 기본 factory가 유효하지 않은 객체를 만든다
|
||||
- 여러 테스트가 뜻하지 않게 invalid 상태를 끌고 들어온다
|
||||
- invalid 검증 테스트와 정상 경로 테스트가 섞인다
|
||||
|
||||
factory 기본값은 특별한 이유가 없으면 유효한 객체여야 테스트 의도가 분명해진다. 이는 프로젝트 fixture/factory 기본 규칙이다.
|
||||
|
||||
### 예시 4. repository test에서 DB round-trip 의미를 helper가 완전히 숨긴다
|
||||
|
||||
```java
|
||||
public User persistedUser() {
|
||||
User user = UserFactory.user();
|
||||
em.persist(user);
|
||||
em.flush();
|
||||
em.clear();
|
||||
return em.find(User.class, user.getId());
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 언제 `flush`/`clear`가 일어나는지 테스트 본문에서 보이지 않는다
|
||||
- 어떤 테스트는 `flush`까지만 필요하고, 어떤 테스트는 `clear`가 핵심인데 모두 같은 helper 뒤에 숨는다
|
||||
- repository semantics를 읽기 어렵게 만든다
|
||||
|
||||
`TestEntityManager`는 helper를 제공하지만, 그 목적은 테스트를 보조하는 것이지 중요한 JPA 의미를 완전히 숨기는 것이 아니다.
|
||||
|
||||
### 예시 5. fixture/factory를 production source에 넣는다
|
||||
|
||||
```java
|
||||
src/main/java/com/example/user/UserFixture.java
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 테스트 지원 코드가 production code와 경계를 잃는다
|
||||
- 실제 애플리케이션 책임과 테스트 전용 책임이 섞인다
|
||||
- 유지보수 시 production API처럼 오해되기 쉽다
|
||||
|
||||
Spring 테스트 문서는 테스트 인스턴스에 대한 DI를 지원하지만, 테스트 준비 코드를 production source에 두라고 요구하지는 않는다. 프로젝트에서는 test support를 test source에 두는 것이 기본이다.
|
||||
|
||||
### 예시 6. PER_CLASS lifecycle에 기대어 상태를 공유한다
|
||||
|
||||
```java
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class UserRepositoryTest {
|
||||
|
||||
private final List<User> users = new ArrayList<>();
|
||||
|
||||
@Test
|
||||
void test1() {
|
||||
users.add(UserFactory.user());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test2() {
|
||||
assertThat(users).hasSize(1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 테스트 간 상태가 공유된다
|
||||
- 순서와 실행 방식에 따라 쉽게 깨질 수 있다
|
||||
- fixture 편의 때문에 lifecycle을 바꾼 나쁜 예다
|
||||
|
||||
JUnit은 `PER_CLASS`를 쓰면 instance state를 직접 reset해야 할 수 있고, 기본 lifecycle을 일관되지 않게 바꾸면 fragile build가 될 수 있다고 경고한다.
|
||||
@@ -0,0 +1,316 @@
|
||||
# Mock 사용 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 순수 단위 테스트에서는 MockitoExtension과 @Mock를 사용한다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserNotifierTest {
|
||||
|
||||
@Mock
|
||||
private MailSender mailSender;
|
||||
|
||||
private UserNotifier userNotifier;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userNotifier = new UserNotifier(mailSender);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendWelcomeMail_delegatesToMailSender() {
|
||||
userNotifier.sendWelcomeMail("a@test.com");
|
||||
|
||||
verify(mailSender).send("a@test.com");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- Spring 컨텍스트 없이 외부 협력자만 대체한다
|
||||
- 테스트 대상 생성이 명시적이라 의존성이 잘 드러난다
|
||||
- interaction verification도 외부 경계에만 한정된다
|
||||
|
||||
Mockito는 `MockitoExtension`이 JUnit Jupiter용 확장이고 strict stubbings를 처리한다고 설명한다. `@InjectMocks`는 편의 기능이지만, 프로젝트 기본값은 명시적 생성자 조립을 우선한다.
|
||||
|
||||
### 예시 2. @InjectMocks는 보조 편의 수단으로 제한적으로 사용한다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserServiceTest {
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@InjectMocks
|
||||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void findUser_returnsRepositoryResult() {
|
||||
User user = new User(1L, "a@test.com");
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
|
||||
|
||||
Optional<User> result = userService.findUser(1L);
|
||||
|
||||
assertThat(result).contains(user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 대상이 단순하고 의존성도 적을 때 보일러플레이트를 줄일 수 있다
|
||||
- `@InjectMocks`를 “자동 wiring 마법”이 아니라 편의 기능으로만 사용한다
|
||||
- 테스트의 핵심 stub과 assertion은 여전히 본문에 남아 있다
|
||||
|
||||
Mockito는 `@InjectMocks`가 constructor/property/setter injection 순서로 mock 주입을 시도한다고 설명한다.
|
||||
|
||||
### 예시 3. Spring 컨텍스트 테스트에서 bean 하나만 대체할 때는 @MockitoBean을 사용한다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class PaymentFacadeTest {
|
||||
|
||||
@MockitoBean
|
||||
private PaymentGatewayClient paymentGatewayClient;
|
||||
|
||||
@Autowired
|
||||
private PaymentFacade paymentFacade;
|
||||
|
||||
@Test
|
||||
void approve_usesGatewayClient() {
|
||||
when(paymentGatewayClient.approve(any())).thenReturn(new GatewayResult(true));
|
||||
|
||||
boolean result = paymentFacade.approve(1L);
|
||||
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- full context가 필요한 테스트에서 특정 bean만 override한다
|
||||
- 신규 기준에 맞는 `@MockitoBean`을 사용한다
|
||||
- 외부 연동 경계만 mock으로 대체한다
|
||||
|
||||
Spring Framework는 `@MockitoBean`이 테스트 `ApplicationContext`의 bean을 Mockito mock으로 override한다고 설명하고, Spring Boot도 이를 공식 테스트 기능으로 안내한다.
|
||||
|
||||
### 예시 4. spy가 꼭 필요하면 doReturn(...).when(spy)...를 사용한다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LegacyUserServiceTest {
|
||||
|
||||
@Test
|
||||
void spy_stubsWithoutCallingRealMethod() {
|
||||
LegacyUserService spy = spy(new LegacyUserService());
|
||||
|
||||
doReturn("stubbed").when(spy).loadExternalValue();
|
||||
|
||||
String result = spy.read();
|
||||
|
||||
assertThat(result).isEqualTo("stubbed");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- spy가 필요한 예외 상황에서도 실제 메서드 부작용을 피한다
|
||||
- Mockito가 권장하는 spy stubbing 방식과 맞다
|
||||
- partial mock을 최소 범위로 제한한다
|
||||
|
||||
Mockito는 spy를 carefully and occasionally 사용하라고 설명하고, spy stubbing에는 `doReturn` 계열을 고려하라고 설명한다.
|
||||
|
||||
### 예시 5. verifyNoMoreInteractions()는 정말 의미가 있을 때만 쓴다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuditPublisherTest {
|
||||
|
||||
@Mock
|
||||
private EventBus eventBus;
|
||||
|
||||
@Test
|
||||
void publishExactlyOneAuditEvent() {
|
||||
AuditPublisher publisher = new AuditPublisher(eventBus);
|
||||
|
||||
publisher.publish("LOGIN");
|
||||
|
||||
verify(eventBus).publish("LOGIN");
|
||||
verifyNoMoreInteractions(eventBus);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- “정확히 한 번만 발행되어야 한다”는 의미가 테스트 요구와 직접 연결된다
|
||||
- 습관적 사용이 아니라 비즈니스 의미가 있을 때만 사용한다
|
||||
- interaction assertion이 과도하지 않다
|
||||
|
||||
Mockito는 `verifyNoMoreInteractions()`를 every test method에 사용하는 것을 권장하지 않지만, interaction testing toolkit의 일부로는 유용하다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 테스트 대상 자체를 mock한다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserServiceTest {
|
||||
|
||||
@Mock
|
||||
private UserService userService;
|
||||
|
||||
@Test
|
||||
void findUser() {
|
||||
when(userService.findUser(1L)).thenReturn(Optional.of(new User(1L, "a@test.com")));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 검증하려는 대상을 아예 가짜로 바꿔 버린다
|
||||
- 테스트가 대상 로직을 전혀 실행하지 않는다
|
||||
- mock은 협력자 경계에만 써야 한다
|
||||
|
||||
Mockito mock은 협력 객체를 대체하는 도구이지, 테스트 대상을 없애는 도구가 아니다. Spring의 `@MockitoBean`도 마찬가지로 bean override 용도다.
|
||||
|
||||
### 예시 2. repository test에서 repository를 mock한다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserRepositoryTest {
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void findByEmail() {
|
||||
when(userRepository.findByEmail("a@test.com"))
|
||||
.thenReturn(Optional.of(new User(1L, "a@test.com")));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- repository 경계의 실제 DB 의미, query, 매핑을 전혀 검증하지 않는다
|
||||
- 이런 테스트는 repository test가 아니라 service 단위 테스트의 협력자 stub에 가깝다
|
||||
- repository 자체 검증은 실제 repository/DB로 해야 한다
|
||||
|
||||
repository test의 목적은 영속성 경계 검증이므로 mock repository는 목적과 맞지 않는다.
|
||||
|
||||
### 예시 3. 모든 테스트에 verifyNoMoreInteractions()를 습관적으로 붙인다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserNotifierTest {
|
||||
|
||||
@Mock
|
||||
private MailSender mailSender;
|
||||
|
||||
@Test
|
||||
void sendWelcomeMail() {
|
||||
UserNotifier notifier = new UserNotifier(mailSender);
|
||||
|
||||
notifier.sendWelcomeMail("a@test.com");
|
||||
|
||||
verify(mailSender).send("a@test.com");
|
||||
verifyNoMoreInteractions(mailSender);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 추가 상호작용 금지가 이 테스트의 핵심 의미가 아닐 수도 있다
|
||||
- 테스트가 불필요하게 취약해진다
|
||||
- Mockito도 이 메서드를 every test method에 쓰는 것은 권장하지 않는다
|
||||
|
||||
Mockito는 `verifyNoMoreInteractions()`를 모든 테스트마다 쓰는 것을 권장하지 않는다고 설명한다.
|
||||
|
||||
### 예시 4. Spring 신규 테스트에서 @MockBean을 기본값으로 쓴다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class PaymentFacadeTest {
|
||||
|
||||
@MockBean
|
||||
private PaymentGatewayClient paymentGatewayClient;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 현재 Spring 기준에서는 신규 코드 기본값이 아니다
|
||||
- Spring Boot는 `@MockBean`이 3.4.0부터 deprecated 되었고 `@MockitoBean`을 쓰라고 안내한다
|
||||
- 장기적으로 제거 예정 API에 새 테스트를 얹는 셈이다
|
||||
|
||||
Spring Boot API 문서는 `@MockBean`이 3.4.0부터 4.0.0 제거 예정으로 deprecated 되었고 `MockitoBean`을 대안으로 제시한다.
|
||||
|
||||
### 예시 5. spy에서 when(spy.method())로 실제 메서드를 먼저 호출한다
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LegacyUserServiceTest {
|
||||
|
||||
@Test
|
||||
void badSpyUsage() {
|
||||
LegacyUserService spy = spy(new LegacyUserService());
|
||||
|
||||
when(spy.loadExternalValue()).thenReturn("stubbed");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- stub 과정에서 실제 메서드가 호출될 수 있다
|
||||
- 부작용이나 예외를 일으킬 수 있다
|
||||
- spy에서는 `doReturn(...).when(spy)...`가 더 안전하다
|
||||
|
||||
Mockito는 spy stubbing에서 `when(...)`가 부적절할 수 있고, `doReturn` 계열을 고려하라고 설명한다.
|
||||
|
||||
### 예시 6. static mock을 길게 열어 두고 일반 테스트처럼 사용한다
|
||||
|
||||
```java
|
||||
@Test
|
||||
void badStaticMockUsage() {
|
||||
MockedStatic<ClockUtil> mocked = mockStatic(ClockUtil.class);
|
||||
mocked.when(ClockUtil::now).thenReturn(Instant.parse("2026-01-01T00:00:00Z"));
|
||||
|
||||
// 여러 로직 수행
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- scope가 길고 close가 명확하지 않다
|
||||
- static mock은 생성된 thread에만 영향을 주고 동시 사용에도 안전하지 않다
|
||||
- try-with-resources로 짧게 감싸는 편이 맞다
|
||||
|
||||
Mockito는 `MockedStatic`이 생성된 thread에만 영향을 주며 concurrent use에 안전하지 않다고 설명한다.
|
||||
|
||||
### 예시 7. non-singleton bean을 무심코 @MockitoBean으로 바꾼다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class ScopedBeanTest {
|
||||
|
||||
@MockitoBean
|
||||
private RequestScopedClient requestScopedClient;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- Spring은 non-singleton bean을 mock/spy하면 singleton처럼 취급될 수 있다고 설명한다
|
||||
- scope 의미가 깨질 수 있다
|
||||
- 이런 경우는 테스트 구조 자체를 다시 설계하는 편이 더 안전하다
|
||||
|
||||
Spring Framework는 non-singleton bean을 `@MockitoBean`/`@MockitoSpyBean`으로 override하면 singleton처럼 다뤄질 수 있다고 설명한다.
|
||||
@@ -0,0 +1,297 @@
|
||||
# Repository Test 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 기본 repository test는 @DataJpaTest로 시작한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void findByEmail_returnsUser() {
|
||||
// given
|
||||
User user = new User("a@test.com", "active");
|
||||
userRepository.save(user);
|
||||
|
||||
// when
|
||||
Optional<User> result = userRepository.findByEmail("a@test.com");
|
||||
|
||||
// then
|
||||
assertThat(result).isPresent();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- JPA slice만 좁게 로딩한다
|
||||
- repository 자체를 테스트 대상으로 유지한다
|
||||
- full application context를 불필요하게 띄우지 않는다
|
||||
|
||||
Spring Boot는 `@DataJpaTest`가 JPA components에 초점을 맞추고, 엔티티와 repository를 스캔한다고 설명한다.
|
||||
|
||||
### 예시 2. 제약 위반은 flush()까지 가서 검증한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryConstraintTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void save_duplicateEmail_throwsExceptionOnFlush() {
|
||||
userRepository.save(new User("dup@test.com", "active"));
|
||||
userRepository.save(new User("dup@test.com", "active"));
|
||||
|
||||
assertThatThrownBy(() -> userRepository.flush())
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- unique constraint 위반이 실제 DB 동기화 시점에 드러난다는 점을 반영한다
|
||||
- `save()`만 보고 통과한 테스트를 방지한다
|
||||
- 영속성 경계의 실제 실패 시점을 검증한다
|
||||
|
||||
Hibernate의 영속성 컨텍스트는 1차 캐시로 동작하므로, DB 의미를 보려면 `flush`가 중요하다. `TestEntityManager`도 `persistAndFlush` 같은 helper를 제공한다.
|
||||
|
||||
### 예시 3. 실제 재조회 의미를 검증할 때는 flush() 후 clear()를 사용한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryReloadTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private TestEntityManager em;
|
||||
|
||||
@Test
|
||||
void findActiveUsers_excludesDeletedRows() {
|
||||
userRepository.save(new User("a@test.com", "active", null));
|
||||
userRepository.save(new User("b@test.com", "active", OffsetDateTime.now()));
|
||||
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<User> users = userRepository.findAllByDeletedAtIsNullOrderByIdDesc();
|
||||
|
||||
assertThat(users).extracting(User::getEmail)
|
||||
.containsExactly("a@test.com");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 1차 캐시가 아니라 실제 DB round-trip 이후 결과를 검증한다
|
||||
- soft delete predicate 같은 조회 semantics를 더 신뢰도 높게 확인한다
|
||||
- `TestEntityManager`를 보조 도구로만 사용한다
|
||||
|
||||
Hibernate는 persistence context가 transaction-scoped first-level cache로 동작한다고 설명하고, `TestEntityManager`는 test에서 `persist`/`flush`/`find` helper를 제공한다고 설명한다.
|
||||
|
||||
### 예시 4. PostgreSQL 특화 query는 실제 DB 계열에서 검증한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
class PostgresUserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void findRecentlyCreatedUsers_worksWithPostgresSpecificQuery() {
|
||||
// given / when / then
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 임베디드 DB 대체를 끄고 실제 DB 계열을 사용하도록 의도를 드러낸다
|
||||
- PostgreSQL native query나 dialect 의존 쿼리를 더 신뢰도 높게 검증할 수 있다
|
||||
- repository test의 범위는 유지하면서 DB 의미를 맞춘다
|
||||
|
||||
Spring Boot는 `@DataJpaTest`가 기본적으로 임베디드 DB를 구성할 수 있고, 실제 DB를 선호하면 `@AutoConfigureTestDatabase`로 제어할 수 있다고 설명한다.
|
||||
|
||||
### 예시 5. commit이 정말 필요한 경우에만 예외적으로 commit 테스트를 쓴다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
@Commit
|
||||
class UserRepositoryCommitTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void savesDataThatMustBeObservedAfterCommit() {
|
||||
userRepository.save(new User("commit@test.com", "active"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 기본 rollback 규칙을 알고, 필요한 경우에만 예외를 사용한다
|
||||
- commit 이후에만 보이는 DB 효과를 검증하는 목적이 분명하다
|
||||
- rollback이 기본, commit은 예외라는 기준을 지킨다
|
||||
|
||||
Spring 테스트는 transactional test를 기본 rollback하고, `@Commit`/`@Rollback`으로 이를 바꿀 수 있다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. repository만 보는데 @SpringBootTest를 기본값으로 쓴다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class UserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- repository 경계만 볼 테스트에 full application context를 띄운다
|
||||
- 테스트 범위와 비용이 과도하다
|
||||
- `@DataJpaTest`가 더 적합한 기본값이다
|
||||
|
||||
Spring Boot는 `@DataJpaTest`를 JPA slice test로 제공하고, 일반 `@Component`는 로드하지 않는다고 설명한다.
|
||||
|
||||
### 예시 2. 제약 테스트를 flush 없이 작성한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryConstraintTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void save_duplicateEmail_throwsException() {
|
||||
userRepository.save(new User("dup@test.com", "active"));
|
||||
assertThatThrownBy(() ->
|
||||
userRepository.save(new User("dup@test.com", "active"))
|
||||
).isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 실제 DB 제약 위반이 아직 드러나지 않을 수 있다
|
||||
- 영속성 컨텍스트 안에서 테스트가 가짜로 통과하거나 실패 시점이 늦어질 수 있다
|
||||
- 이런 검증은 보통 `flush` 시점까지 가야 한다
|
||||
|
||||
Hibernate의 persistence context는 1차 캐시로 동작하므로, DB 의미를 드러내려면 `flush`가 중요하다.
|
||||
|
||||
### 예시 3. 재조회 검증인데 clear() 없이 같은 엔티티만 본다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryReloadTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void findById_readsUpdatedState() {
|
||||
User user = userRepository.save(new User("a@test.com", "active"));
|
||||
user.changeStatus("inactive");
|
||||
|
||||
User found = userRepository.findById(user.getId()).orElseThrow();
|
||||
|
||||
assertThat(found.getStatus()).isEqualTo("inactive");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 같은 persistence context 안의 같은 엔티티 인스턴스를 다시 본 것일 수 있다
|
||||
- 실제 DB에서 다시 읽은 결과인지 보장되지 않는다
|
||||
- 조회 semantics 검증으로는 신뢰도가 낮다
|
||||
|
||||
Hibernate는 `Session`/`EntityManager`가 first-level cache를 유지한다고 설명한다.
|
||||
|
||||
### 예시 4. PostgreSQL 특화 native query를 임베디드 DB만으로 신뢰한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class NativePostgresQueryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void works() {
|
||||
userRepository.runPostgresSpecificQuery();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 테스트 DB가 운영 DB 의미를 충분히 재현하지 못할 수 있다
|
||||
- dialect 차이, 함수, JSONB, partial index 전제, locking clause 같은 부분은 놓치기 쉽다
|
||||
- DB 특화 query는 실제 DB 계열 검증이 더 적합하다
|
||||
|
||||
Spring Boot는 `@DataJpaTest`가 임베디드 DB를 기본 구성할 수 있고, 실제 DB를 쓰려면 `@AutoConfigureTestDatabase`로 제어할 수 있다고 설명한다.
|
||||
|
||||
### 예시 5. repository test 안에서 service 정책까지 같이 검증한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 테스트 범위가 repository 경계를 벗어난다
|
||||
- slice 목적과 맞지 않는다
|
||||
- service 정책 검증은 상위 통합 테스트 책임이다
|
||||
|
||||
`@DataJpaTest`는 JPA components에만 초점을 맞추고 일반 컴포넌트를 로드하지 않는 slice다.
|
||||
|
||||
### 예시 6. repository test에서 repository 대신 EntityManager만 직접 사용한다
|
||||
|
||||
```java
|
||||
@DataJpaTest
|
||||
class UserRepositoryTest {
|
||||
|
||||
@PersistenceContext
|
||||
private EntityManager em;
|
||||
|
||||
@Test
|
||||
void test() {
|
||||
em.persist(new User("a@test.com", "active"));
|
||||
em.createQuery("select u from User u", User.class).getResultList();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- repository를 검증하겠다면서 repository를 거의 사용하지 않는다
|
||||
- 결국 repository contract가 아니라 JPA API 자체를 테스트하게 된다
|
||||
- `EntityManager`는 보조 도구로만 쓰는 편이 적절하다
|
||||
|
||||
Spring Boot는 `TestEntityManager`를 repository/JPA test의 보조 도구로 제공한다고 설명한다. 중심은 repository여야 한다.
|
||||
@@ -0,0 +1,254 @@
|
||||
# @SpringBootTest 사용 예시
|
||||
|
||||
## 좋은 예시
|
||||
|
||||
### 예시 1. 애플리케이션 기동 smoke test는 NONE으로 충분하다
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
class ApplicationContextSmokeTest {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 실제 서버가 필요 없는 full-context 기동 확인이다
|
||||
- Boot 방식으로 애플리케이션이 정상 조립되는지 확인한다
|
||||
- non-web full-context 테스트에 가장 좁은 환경을 선택했다
|
||||
|
||||
Spring Boot는 `@SpringBootTest`가 `SpringApplication`으로 컨텍스트를 만들고, `NONE`은 웹 환경 없이 `ApplicationContext`만 로드한다고 설명한다.
|
||||
|
||||
### 예시 2. 여러 레이어가 함께 필요한 서비스 통합 테스트는 NONE을 사용한다
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
class OrderCommandServiceIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private OrderCommandService orderCommandService;
|
||||
|
||||
@Autowired
|
||||
private OrderRepository orderRepository;
|
||||
|
||||
@Test
|
||||
void confirmOrder_changesState() {
|
||||
// given
|
||||
|
||||
// when
|
||||
orderCommandService.confirm(1L);
|
||||
|
||||
// then
|
||||
assertThat(orderRepository.findById(1L)).isPresent();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 서비스, 리포지토리, 트랜잭션, 설정 조합까지 함께 검증한다
|
||||
- 실제 웹 서버는 필요 없으므로 `NONE`으로 범위를 제한했다
|
||||
- full context가 필요한 이유가 분명하다
|
||||
|
||||
`@SpringBootTest`는 Boot features가 필요한 full application context 테스트에 적합하고, `NONE`은 웹 환경을 만들지 않는다고 Boot 문서가 설명한다.
|
||||
|
||||
### 예시 3. 실제 서버는 필요 없지만 MVC/보안/직렬화까지 함께 보고 싶으면 MOCK + MockMvc를 쓴다
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
|
||||
@AutoConfigureMockMvc
|
||||
class UserApiIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void getUser_returns200() throws Exception {
|
||||
mockMvc.perform(get("/users/1"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 전체 컨텍스트는 유지하면서 실제 내장 서버는 띄우지 않는다
|
||||
- MVC 설정, 보안 필터, Jackson, 예외 처리 등을 함께 볼 수 있다
|
||||
- mock 기반 웹 통합 테스트 목적과 잘 맞는다
|
||||
|
||||
Spring Boot는 `MOCK`이 내장 서버를 시작하지 않는 mock web environment이고, `@AutoConfigureMockMvc`와 함께 사용할 수 있다고 설명한다.
|
||||
|
||||
### 예시 4. 실제 HTTP round-trip이 필요하면 RANDOM_PORT를 사용한다
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
class UserHttpIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Test
|
||||
void getUser_returns200() {
|
||||
var response = restTemplate.getForEntity("/users/1", String.class);
|
||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- 실제 내장 서버와 실제 HTTP 경로를 검증한다
|
||||
- 고정 포트 충돌 없이 자동화 테스트에 적합하다
|
||||
- mock 환경으로는 검증하기 어려운 실제 서버 동작을 본다
|
||||
|
||||
Spring Boot는 `RANDOM_PORT`가 실제 `WebServerApplicationContext`를 만들고 임의 포트에 서버를 시작한다고 설명한다.
|
||||
|
||||
### 예시 5. full context가 필요하지만 테스트 편의 기능도 원하면 @AutoConfigure…를 조합한다
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
|
||||
@AutoConfigureMockMvc
|
||||
class UserAdminFlowTest {
|
||||
// full context + MockMvc 편의 빈 사용
|
||||
}
|
||||
```
|
||||
|
||||
**좋은 이유:**
|
||||
|
||||
- slice로 자를 수는 없지만 테스트 편의 빈은 활용한다
|
||||
- `@SpringBootTest`와 `@AutoConfigure…`의 역할이 분명하다
|
||||
- Boot가 공식적으로 허용한 조합이다
|
||||
|
||||
Spring Boot는 `@AutoConfigure…` 계열을 `@SpringBootTest`와 함께 사용할 수 있다고 설명한다.
|
||||
|
||||
## 나쁜 예시
|
||||
|
||||
### 예시 1. 순수 단위 테스트에 @SpringBootTest를 붙인다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class MoneyCalculatorTest {
|
||||
|
||||
@Test
|
||||
void add() {
|
||||
MoneyCalculator calculator = new MoneyCalculator();
|
||||
assertThat(calculator.add(1, 2)).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- Spring 컨테이너가 전혀 필요 없다
|
||||
- full context 로딩 비용만 추가한다
|
||||
- 이런 테스트는 순수 JUnit 단위 테스트가 맞다
|
||||
|
||||
Spring 문서는 IoC 덕분에 단위 테스트와 통합 테스트를 구분해서 설계할 수 있다고 설명하고, Boot는 `@SpringBootTest`를 Boot features가 필요할 때 쓰는 어노테이션으로 설명한다.
|
||||
|
||||
### 예시 2. repository만 검증하려고 @SpringBootTest를 기본값으로 쓴다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
class UserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Test
|
||||
void findByEmail() {
|
||||
assertThat(userRepository.findByEmail("a@test.com")).isPresent();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- JPA repository만 볼 테스트에 전체 애플리케이션을 띄운다
|
||||
- 데이터 접근 slice로 충분한 범위를 과도하게 넓힌다
|
||||
- 테스트 목적 대비 로딩 비용이 크다
|
||||
|
||||
Spring Boot는 `@DataJpaTest` 같은 데이터 slice를 별도로 제공한다고 설명한다.
|
||||
|
||||
### 예시 3. MVC 계층 검증인데 full context를 기본값으로 쓴다
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class UserControllerTest {
|
||||
// request mapping, validation, status code만 검증
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 테스트 관심사가 MVC 계층에 머무르면 `@WebMvcTest`가 더 정확하다
|
||||
- full context를 기본값으로 잡으면 테스트 범위와 책임이 흐려진다
|
||||
- slice로 충분한 대상을 과도하게 넓힌다
|
||||
|
||||
Spring Boot는 Spring MVC controller 테스트에 `@WebMvcTest`를 제공한다고 설명한다.
|
||||
|
||||
### 예시 4. RANDOM_PORT 테스트에서 @Transactional이면 서버 변경도 롤백된다고 기대한다
|
||||
|
||||
```java
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@Transactional
|
||||
class UserHttpRollbackTest {
|
||||
|
||||
@Test
|
||||
void createUser() {
|
||||
// HTTP 호출 후 테스트 종료되면 DB도 원복될 것이라고 기대
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 실제 서버와 테스트 메서드는 별도 스레드/별도 트랜잭션이다
|
||||
- 테스트 메서드 롤백이 서버 쪽 트랜잭션에는 적용되지 않는다
|
||||
- 데이터 정리 전략을 별도로 가져가야 한다
|
||||
|
||||
Spring Boot는 `RANDOM_PORT`/`DEFINED_PORT`에서 서버와 클라이언트가 별도 스레드에서 실행되므로 서버 쪽 트랜잭션은 테스트 롤백으로 되돌아가지 않는다고 명시한다.
|
||||
|
||||
### 예시 5. 여러 slice annotation을 동시에 섞는다
|
||||
|
||||
```java
|
||||
@WebMvcTest
|
||||
@DataJpaTest
|
||||
class MixedSliceTest {
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- Spring Boot가 지원하지 않는 조합이다
|
||||
- 테스트 범위가 애매하고 자동 구성도 예측하기 어렵다
|
||||
- slice가 여러 개 필요하면 하나를 고르고 나머지는 수동으로 추가해야 한다
|
||||
|
||||
Spring Boot는 여러 `@…Test` slice annotation을 한 테스트에 함께 사용하는 것은 지원하지 않는다고 설명한다.
|
||||
|
||||
### 예시 6. 작은 차이마다 다른 @SpringBootTest 구성을 만들어 컨텍스트 캐시를 깨뜨린다
|
||||
|
||||
```java
|
||||
@SpringBootTest(properties = "feature.a=true")
|
||||
class ATest {
|
||||
}
|
||||
|
||||
@SpringBootTest(properties = "feature.a=false")
|
||||
class BTest {
|
||||
}
|
||||
|
||||
@SpringBootTest(properties = "feature.b=true")
|
||||
class CTest {
|
||||
}
|
||||
```
|
||||
|
||||
**나쁜 이유:**
|
||||
|
||||
- 목적이 비슷한 테스트인데 서로 다른 컨텍스트를 계속 만든다
|
||||
- static context cache 재사용이 줄어든다
|
||||
- 전체 테스트 시간이 불필요하게 늘 수 있다
|
||||
|
||||
Spring TestContext Framework는 `ApplicationContext`를 static cache에 저장해 재사용한다고 설명한다. 컨텍스트 구성이 달라질수록 재사용 이점이 줄어든다.
|
||||
Reference in New Issue
Block a user