assert문을 제외한 부분은 단위 테스트에서 setup이다.
이 부분은 구현 부산물이라서 의미론적으론 테스트에서 중요하지 않다.
하지만 테스트를 작성 할 때 신경써야하는 부분이 setup 과정이다.
흔히 말하는 flaky test의 경우 setup 과정의 문제이기 때문에 구현에 신경써야한다.
위 setup 과정은 깨지는 이유가 너무 다양하고 필자는 QA도 아니여서 글이 미숙할 수 있다.
그래서 얕고 주관이 많이 들어간 setup 방법을 설명하겠다.
테스트에서 flaky test는 일관된 성공, 일관된 테스트의 실패가 아닌
어쩔 땐 성공, 어쩔 땐 테스트 실패로 불안정한 테스트를 말한다.
flaky test는 이런 특징 때문에 개발자를 개빡치게 만드는데
별 의미가 없는 곳에 디버깅과 프레임워크의 내부 구현에 신경써야되는등 시간을 죽일 뿐더러
환경이 달라지면 테스트가 깨지는 등 테스트가 계약 검증의 역할을 못하게 한다.
그래서 테스트 setup의 경우엔 비용을 좀 더 지불하더라도 안정성 있는 테스트를 작성하는게 심신에 좋다.
flaky test가 되는 이유는 너무도 다양하다. 사실 나도 모든 경우를 잘 알지 못한다.
하지만 대표적으로 문제가 되는 지점을 살펴보겠다.
// DB (공유 스키마/잔여 데이터)
// setup에서 "기존 데이터가 비어있다"를 가정하면, 다른 테스트/이전 실행 잔여로 랜덤 깨짐
// 실제 DB랑 연결 안 되면 테스트 깨짐
repo.save(user);
assertEquals(1, repo.count()); // 다른 테스트가 먼저 넣어놨으면 2
// 네트워크(외부 API)
// 레이트리밋/타임아웃/503/DNS 같은 "환경 노이즈"가 곧 flaky
String body = http.get("https://api.somewhere.com");
assertTrue(body.contains("OK")); // 가끔 429/timeout 나면 끝
// 파일 IO(고정 경로)
// build/tmp 같은 공유 경로 쓰면 병렬/재시도에서 덮어쓰기/삭제로 랜덤 깨짐
Files.writeString(Paths.get("build/tmp/out.txt"), "x");
assertEquals("x", Files.readString(Paths.get("build/tmp/out.txt"))); // 다른 테스트가 바꾸면 깨짐
IO 같은 경우는 통신이 안 되면 테스트가 깨지니까 취약점이라고 생각하면 된다.
// 시간(now) / 날짜 경계
// now()를 그대로 쓰면 "자정/타임존/DST 경계"에서 랜덤 깨짐
LocalDate d = LocalDate.now();
assertEquals(LocalDate.of(2026, 2, 18), d); // 날짜 바뀌는 순간 깨짐
시간을 다루는 건 빡세서 주의 깊은 테크닉을 사용해야 한다.
// sleep 기반 동기화(비동기)
// sleep은 완료 조건이 아니라 희망사항이라 CI에서 자주 깨짐
asyncJob.start();
Thread.sleep(10);
assertTrue(asyncJob.isDone()); // 머신 느리면 false
// 전역 상태(System property / singleton / static cache)
// setup이 전역을 건드리면 다음 테스트에 독 묻혀서 순서 의존 발생
System.setProperty("MODE", "A");
// ... 원복 안 함
assertEquals("A", System.getProperty("MODE")); // 다음 테스트가 MODE=B로 바꾸면 랜덤
// 랜덤/UUID(Seed 미고정)
// 재현 불가 + 희귀 케이스가 가끔 튀어서 랜덤 실패
int n = new Random().nextInt(10);
assertNotEquals(7, n); // 10번 중 1번 깨짐
// 컬렉션 순서(HashMap/HashSet)
// 순서 보장 없는데 순서로 assert하면 JVM/버전/해시 시드에 따라 깨짐
List<String> keys =
new ArrayList<>(new HashMap<>(Map.of("a", 1, "b", 2)).keySet());
assertEquals(List.of("a", "b"), keys); // 순서 안 맞으면 깨짐
// 프레임워크 컨텍스트 공유(Spring 캐시/싱글톤 상태)
// 테스트가 같은 컨텍스트/빈을 공유하고 상태가 남으면 조합에 따라 랜덤 실패
service.enableFeatureX(); // 싱글톤 상태 변경
assertTrue(service.isFeatureXEnabled()); // 다음 테스트가 disable 하면 랜덤
이 녀석들은 이렇게 알아두면 된다.
"일단 된다고 쳐"
단위 테스트에서 중요하지 않은 부분인데 깨지기 쉬운 취약점이고 비용이 많이 든다면 대부분 mock/stub/fake를 이용한다.
mock/stub/fake는 보통 IO 경계에서 쓴다.
왜냐하면 우리가 비지니스 클래스를 만들고 테스트하는데 이 동작들이 IO를 하는 경우가 많기 때문이다.
네트워크 통신과 DB와의 통신은 말할 것도 없고 시스템과 IO하는 과정도 빈번하게 일어난다.
이 지점은 계약 검증에 아무 의미도 없고 단위 테스트가 아니라 통합 테스트의 책임이며 무엇보다 테스트가 깨지기 쉬운 지점이다.
이렇게 별 의미없고 깨지기 쉽고 비용도 많이드는 부분은 mock/stub/fake 처리를 해준다.
// 1) STUB: 관심 없음. 그냥 값만 주고 테스트 진행시키기
ShippingFeeClient shipping = req -> 3000; // 무조건 3000원 나온다고 쳐
OrderService sut = new OrderService(shipping /* ... */);
assertEquals(13000, sut.totalPrice(10000)); // 배송비 포함 로직만 확인
// 2) FAKE: 진짜처럼 동작하는 인메모리 구현(상태 기반으로 안정적)
class InMemoryUserRepository implements UserRepository {
private final Map<String, User> store = new HashMap<>();
@Override public void save(User user) { store.put(user.name(), user); }
@Override public boolean exists(String name) { return store.containsKey(name); }
}
UserRepository repo = new InMemoryUserRepository(); // Map으로 save/find 구현했다고 쳐
OrderService sut2 = new OrderService(repo /* ... */);
sut2.registerUser("kim");
assertTrue(repo.exists("kim")); // 상태로 검증(결정적이라 잘 안 깨짐)
// 3) MOCK: "호출했는지" 자체가 계약일 때만 씀(상호작용 검증)
EmailSender email = Mockito.mock(EmailSender.class);
OrderService sut3 = new OrderService(email /* ... */);
sut3.completeOrder("a@b.com");
Mockito.verify(email).send("a@b.com", "ORDER_COMPLETE"); // 호출/인자 검증
flaky test 중 비번하게 일어나는 부분이 바로 전역 상태와 프레임워크 때문에 깨지는 거다.
우리가 IO나 time, random 같은 경우를 올바른 테크닉으로 처리하더라도 테스트가 터지는데
대게 전역 상태와 프레임워크 때문에 터지는 경우이다.
이 경우 디버깅이 매우 어려워지는데
디버깅을 위해서 setup 전역 상태 초기화와 tear-down의 순서를 신경써야하고
공유하고 있는 모든 테스트의 협력 관계를 유심히 디버깅 해야한다.
프레임워크 때문에 깨지는 경우는 더 최악인데 spring test 같은 경우
디버깅을 위해서 프레임워크 내부의 구현을 공부해서 디버깅 해야된다.
그리고 깨지기도 정말 많이 깨져서 나는 스프링부트 테스트(@SpringBootTest)는 사용 안 하는걸 권한다.
잡설이 길었는데 이걸 해결하는게 opt-in 방식이다.
이렇게 적지말고(공유 fixture) 비용이 더 지불해도 전역 상태 대신 테스트당 하나씩 만들어라.
// BAD: 공유 fixture (테스트 순서/병렬에서 언젠가 터짐)
static final InMemoryUserRepository SHARED = new InMemoryUserRepository();
@Test
void bad_shared_fixture() {
SHARED.save("kim");
assertTrue(SHARED.exists("kim")); // 다른 테스트가 clear 하면 랜덤 깨짐
}
@Test
void unit_test_isolation_example() {
// 테스트마다 새로 만들어서(격리) 상태를 공유하지 않음
InMemoryUserRepository repo = new InMemoryUserRepository();
FakeClock clock = new FakeClock("2026-02-18T00:00:00Z");
UserService sut = new UserService(repo, clock);
sut.register("kim");
assertTrue(repo.exists("kim"));
}
터지기 쉬운 테스트를 다뤄야 할 땐 기본(unit) 스위트에 섞지 말고,
명시적으로 켰을 때만 돌게 만든다.
@Tag("optin")
@Test
void spring_boot_test_like_this() { }
이런식으로 프레임워크가 제공하는 기능을 이용하던 폴더를 분리하던
위험한 테스트는 따로 격리한다.
CI 과정에서 테스트를 통과를 해야하는데 그때마다 위험한 테스트가 계속 깨지면 신뢰성이 박살이라
위험한 테스트는 따로 필요할 때만 테스트 한다.