TDD를 부숴주마 - setup과 구현 부산물

응큼한포도·2026년 2월 18일

TDD를 부숴주마

목록 보기
6/6

구현 부산물

assert문을 제외한 부분은 단위 테스트에서 setup이다.

이 부분은 구현 부산물이라서 의미론적으론 테스트에서 중요하지 않다.

하지만 테스트를 작성 할 때 신경써야하는 부분이 setup 과정이다.

흔히 말하는 flaky test의 경우 setup 과정의 문제이기 때문에 구현에 신경써야한다.

위 setup 과정은 깨지는 이유가 너무 다양하고 필자는 QA도 아니여서 글이 미숙할 수 있다.

그래서 얕고 주관이 많이 들어간 setup 방법을 설명하겠다.

flaky test

테스트에서 flaky test는 일관된 성공, 일관된 테스트의 실패가 아닌
어쩔 땐 성공, 어쩔 땐 테스트 실패로 불안정한 테스트를 말한다.

flaky test는 이런 특징 때문에 개발자를 개빡치게 만드는데
별 의미가 없는 곳에 디버깅과 프레임워크의 내부 구현에 신경써야되는등 시간을 죽일 뿐더러
환경이 달라지면 테스트가 깨지는 등 테스트가 계약 검증의 역할을 못하게 한다.

그래서 테스트 setup의 경우엔 비용을 좀 더 지불하더라도 안정성 있는 테스트를 작성하는게 심신에 좋다.

flaky test가 되는 이유는 너무도 다양하다. 사실 나도 모든 경우를 잘 알지 못한다.
하지만 대표적으로 문제가 되는 지점을 살펴보겠다.

DB IO

// DB (공유 스키마/잔여 데이터)
// setup에서 "기존 데이터가 비어있다"를 가정하면, 다른 테스트/이전 실행 잔여로 랜덤 깨짐
// 실제 DB랑 연결 안 되면 테스트 깨짐
repo.save(user);
assertEquals(1, repo.count()); // 다른 테스트가 먼저 넣어놨으면 2

네트워크 IO

// 네트워크(외부 API)
// 레이트리밋/타임아웃/503/DNS 같은 "환경 노이즈"가 곧 flaky
String body = http.get("https://api.somewhere.com");
assertTrue(body.contains("OK")); // 가끔 429/timeout 나면 끝

파일 IO

// 파일 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를 이용한다.

mock/stub/fake는 보통 IO 경계에서 쓴다.
왜냐하면 우리가 비지니스 클래스를 만들고 테스트하는데 이 동작들이 IO를 하는 경우가 많기 때문이다.

네트워크 통신과 DB와의 통신은 말할 것도 없고 시스템과 IO하는 과정도 빈번하게 일어난다.

이 지점은 계약 검증에 아무 의미도 없고 단위 테스트가 아니라 통합 테스트의 책임이며 무엇보다 테스트가 깨지기 쉬운 지점이다.

이렇게 별 의미없고 깨지기 쉽고 비용도 많이드는 부분은 mock/stub/fake 처리를 해준다.

stub

// 1) STUB: 관심 없음. 그냥 값만 주고 테스트 진행시키기
ShippingFeeClient shipping = req -> 3000; // 무조건 3000원 나온다고 쳐
OrderService sut = new OrderService(shipping /* ... */);
assertEquals(13000, sut.totalPrice(10000)); // 배송비 포함 로직만 확인

fake

// 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")); // 상태로 검증(결정적이라 잘 안 깨짐)

mock

// 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"); // 호출/인자 검증

opt-in

flaky test 중 비번하게 일어나는 부분이 바로 전역 상태와 프레임워크 때문에 깨지는 거다.

우리가 IO나 time, random 같은 경우를 올바른 테크닉으로 처리하더라도 테스트가 터지는데
대게 전역 상태와 프레임워크 때문에 터지는 경우이다.

이 경우 디버깅이 매우 어려워지는데
디버깅을 위해서 setup 전역 상태 초기화와 tear-down의 순서를 신경써야하고
공유하고 있는 모든 테스트의 협력 관계를 유심히 디버깅 해야한다.

프레임워크 때문에 깨지는 경우는 더 최악인데 spring test 같은 경우
디버깅을 위해서 프레임워크 내부의 구현을 공부해서 디버깅 해야된다.

그리고 깨지기도 정말 많이 깨져서 나는 스프링부트 테스트(@SpringBootTest)는 사용 안 하는걸 권한다.

잡설이 길었는데 이걸 해결하는게 opt-in 방식이다.

1. 테스트당 격리

이렇게 적지말고(공유 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"));
}

2. 명시적인 표시

터지기 쉬운 테스트를 다뤄야 할 땐 기본(unit) 스위트에 섞지 말고,
명시적으로 켰을 때만 돌게 만든다.

@Tag("optin")
@Test
void spring_boot_test_like_this() { }

이런식으로 프레임워크가 제공하는 기능을 이용하던 폴더를 분리하던
위험한 테스트는 따로 격리한다.

CI 과정에서 테스트를 통과를 해야하는데 그때마다 위험한 테스트가 계속 깨지면 신뢰성이 박살이라
위험한 테스트는 따로 필요할 때만 테스트 한다.

0개의 댓글