테스트 QA

Stubs, Mocks, Fakes: 테스트 더블 오용이 팀의 생산성을 갉아먹는 이유와 방지 전략

강코의 코딩 일기 2026. 7. 8. 07:02
반응형

테스트 더블(Stubs, Mocks, Fakes)을 잘못 사용하면 팀의 테스트 독립성과 유지보수성이 저해됩니다. 테크리드를 위한 오용 사례와 효과적인 사용 전략을 알아보고, 견고한 테스트 코드를 구축하세요.

📑 목차

테스트 더블 오용: Stubs, Mocks, Fakes를 혼동하여 테스트 독립성을 잃는 안티패턴 - covid, testing, corona test, covid-19, corona, coronavirus, sars-cov-2, concept, quick test, pcr, pcr-test, covid test, covid, covid, covid, covid, covid, corona, corona, covid test, covid test, covid test

Image by analogicus on Pixabay

우리 팀의 테스트 코드, 정말 독립적인가요? 테스트 더블 오용의 서막

안녕하세요, 팀의 기술 방향을 이끌고 계신 테크리드/엔지니어링 매니저 여러분!

혹시 이런 경험 있으신가요? 분명히 특정 기능을 위한 테스트만 돌렸는데, 엉뚱한 곳에서 실패 알림이 뜨거나, 개발 환경에 따라 테스트 결과가 들쭉날쭉할 때 말이죠. 심지어 테스트를 통과했는데도 실제 배포 후 버그가 발견되어 당황스러웠던 적도 있을 겁니다. 이러한 문제들은 종종 테스트 더블(Test Double)을 잘못 사용했을 때 발생하는 테스트 독립성 상실의 신호일 수 있거든요.

테스트 더블은 외부 의존성으로부터 시스템을 격리하여 빠르고 안정적인 테스트를 가능하게 하는 강력한 도구인데요. 하지만 Stubs, Mocks, Fakes와 같은 다양한 형태를 명확히 이해하지 못하고 혼동해서 사용하게 되면, 오히려 테스트 코드를 복잡하게 만들고, 유지보수 비용을 증가시키며, 결국 팀의 생산성을 저해하는 안티패턴으로 이어지기 쉽습니다. 단순히 테스트 커버리지를 높이는 것을 넘어, 테스트 코드의 '품질'을 고민해야 하는 이유가 바로 여기에 있죠.

이번 글에서는 테크리드 관점에서 테스트 더블의 오용 사례들을 살펴보고, 각각의 특징을 명확히 이해하여 팀의 테스트 전략을 한 단계 더 발전시킬 수 있는 실질적인 방안들을 함께 고민해 보려고 합니다. 견고하고 독립적인 테스트 코드는 단순히 버그를 줄이는 것을 넘어, 개발 속도를 높이고 팀의 자신감을 불어넣는 핵심 요소라는 점을 기억하며 시작해 볼까요?

테스트 더블(Test Double)이란 무엇이며, 왜 사용할까요?

먼저, 테스트 더블이 무엇인지부터 간략하게 짚고 넘어갈게요. 테스트 더블은 말 그대로 '테스트를 위한 대역'이라는 뜻입니다. 영화 촬영 현장에서 스턴트맨이나 CG 캐릭터가 배우의 대역을 하는 것과 비슷하다고 생각하시면 이해하기 쉬울 거예요. 우리가 테스트하려는 시스템(System Under Test, SUT)이 의존하는 외부 컴포넌트(데이터베이스, 외부 API, 파일 시스템 등)를 실제처럼 작동하는 가짜 객체로 대체하는 것을 말하죠.

그렇다면 왜 이런 대역이 필요할까요? 실제 객체를 사용하면 안 되는 걸까요?

  • 테스트 속도 향상: 실제 데이터베이스나 네트워크 통신은 시간이 오래 걸리죠. 테스트 더블을 사용하면 이런 느린 과정을 생략하고 매우 빠르게 테스트를 실행할 수 있습니다.
  • 테스트의 일관성 및 독립성 보장: 외부 시스템은 언제든 변경되거나 불안정할 수 있습니다. 테스트 더블을 사용하면 외부 환경에 관계없이 항상 동일한 결과를 보장할 수 있어 테스트의 독립성을 높여줍니다.
  • 복잡한 시나리오 테스트: 특정 에러 상황(네트워크 오류, 데이터 없음 등)을 실제 환경에서 재현하기는 매우 어렵습니다. 테스트 더블을 이용하면 이런 시나리오를 쉽게 설정하여 테스트할 수 있죠.
  • 개발 초기 단계 테스트 가능: 의존하는 컴포넌트가 아직 개발되지 않았거나 불안정할 때, 테스트 더블을 사용하여 SUT를 먼저 테스트할 수 있습니다.

결국 테스트 더블은 우리 팀이 단위 테스트(Unit Test)의 핵심 원칙인 '격리(Isolation)'를 지키면서, 빠르고 안정적으로 코드를 검증하고 피드백을 얻을 수 있도록 돕는 필수적인 기술이라고 할 수 있습니다. 하지만 이 강력한 도구를 제대로 사용하지 못하면 독이 될 수도 있다는 점을 명심해야 합니다.

Stubs, Mocks, Fakes: 이름은 달라도 다 똑같은 거 아니었나요? 핵심 차이점 파헤치기

테스트 더블에는 크게 Stubs, Mocks, Fakes, Spies, Dummies 등의 종류가 있습니다. 이 중에서 특히 Stubs, Mocks, Fakes는 가장 많이 사용되면서도 가장 많이 혼동되는 개념들이죠. 이들의 차이점을 명확히 아는 것이 테스트 독립성을 지키는 첫걸음입니다.

Stub: 미리 준비된 답변으로 독립성을 확보하다

Stub(스텁)테스트 중에 특정 호출에 대해 미리 정해진 값을 반환하도록 프로그래밍된 객체입니다. '미리 준비된 답변'을 제공하는 데 중점을 둡니다. SUT가 Stub 객체에게 어떤 값을 요청하면, Stub은 설정된 값을 돌려줄 뿐, 그 외의 다른 행위는 하지 않습니다. Stub은 주로 SUT가 어떤 데이터를 '소비'할 때 사용됩니다. 중요한 점은 Stub은 자신의 내부 상태 변화나 메서드 호출 여부를 검증하지 않는다는 것입니다. 오직 SUT가 필요로 하는 데이터를 제공하는 역할만 하죠.

주요 특징:

  • 상태 기반 테스트(State-based testing)에 주로 사용됩니다.
  • SUT가 의존하는 객체로부터 특정 값을 반환받아야 할 때 유용합니다.
  • 내부적으로 어떤 호출이 일어났는지 검증하지 않습니다.
  • 테스트의 입력(Input) 역할을 수행합니다.

예시: 사용자 정보를 조회하는 서비스가 있다고 해볼게요. 실제 데이터베이스 대신, 특정 ID를 요청하면 항상 고정된 사용자 객체를 반환하는 UserRepositoryStub을 만들 수 있습니다. 서비스는 이 Stub으로부터 사용자 정보를 받아 로직을 처리하겠죠.


// UserRepository 인터페이스
interface UserRepository {
    User findById(String id);
}

// UserRepositoryStub 구현
class UserRepositoryStub implements UserRepository {
    private Map<String, User> users = new HashMap<>();

    public UserRepositoryStub() {
        users.put("user123", new User("user123", "Alice"));
    }

    @Override
    public User findById(String id) {
        return users.getOrDefault(id, null); // 미리 설정된 값 반환
    }
}

// 테스트 코드
@Test
void getUserProfileWithStub() {
    UserRepository stub = new UserRepositoryStub();
    UserService userService = new UserService(stub); // Stub 주입

    User user = userService.getUserProfile("user123");
    assertNotNull(user);
    assertEquals("Alice", user.getName());
}
    

Mock: 행위 기반 테스트의 감시자

Mock(목)특정 메서드가 특정 인자와 함께 호출되었는지 여부, 호출 횟수 등을 검증하는 데 사용되는 객체입니다. Stub이 '무엇을 반환할지'에 중점을 둔다면, Mock은 '어떻게 호출되었는지'에 중점을 둡니다. Mock은 주로 SUT가 의존하는 객체에게 어떤 명령을 '내릴' 때 사용되며, 이러한 '행위(Behavior)'를 검증합니다. Mock 객체는 테스트 코드 내에서 기대하는 행위를 미리 정의(expectations)하고, 테스트 실행 후 이 행위가 실제로 발생했는지 검증(verification)하는 과정을 거칩니다.

주요 특징:

  • 행위 기반 테스트(Behavior-based testing)에 주로 사용됩니다.
  • SUT가 의존하는 객체에게 특정 행위를 수행했는지 검증해야 할 때 유용합니다.
  • 테스트의 결과(Output) 중 하나인 '부수 효과(Side effect)'를 검증합니다.
  • Mockito 같은 라이브러리를 통해 쉽게 생성하고 사용할 수 있습니다.

예시: 상품 주문 후 결제 시스템에 결제를 요청하는 서비스가 있다고 해봅시다. PaymentGateway 인터페이스의 processPayment 메서드가 올바른 금액과 함께 호출되었는지를 Mock을 통해 검증할 수 있습니다.


// PaymentGateway 인터페이스
interface PaymentGateway {
    boolean processPayment(String orderId, double amount);
}

// 테스트 코드 (Mockito 사용)
@Test
void placeOrderProcessesPayment() {
    PaymentGateway mockPaymentGateway = mock(PaymentGateway.class); // Mock 객체 생성
    OrderService orderService = new OrderService(mockPaymentGateway);

    // Mock의 행위 설정 (선택 사항, 여기서는 기본 동작으로 충분)
    when(mockPaymentGateway.processPayment(anyString(), anyDouble())).thenReturn(true);

    orderService.placeOrder("order123", 100.0);

    // Mock 검증: processPayment 메서드가 "order123"과 100.0으로 한 번 호출되었는지 확인
    verify(mockPaymentGateway, times(1)).processPayment("order123", 100.0);
}
    

Fake: 진짜처럼 보이지만 가벼운 대역

Fake(페이크)는 실제 객체와 동일한 인터페이스를 가지지만, 프로덕션 코드에는 적합하지 않은, 단순화된 내부 구현을 가진 객체입니다. Stub이나 Mock보다 더 많은 '진짜' 로직을 가지고 있지만, 실제 시스템의 복잡성을 줄여서 가볍게 만든 버전이라고 생각하면 됩니다. 예를 들어, 실제 데이터베이스 대신 메모리 기반의 데이터베이스를 사용하거나, 실제 파일 시스템 대신 가상의 파일 시스템을 사용하는 경우가 Fake에 해당합니다.

주요 특징:

  • 실제 객체의 간소화된 동작 가능한 구현체입니다.
  • Stub이나 Mock으로는 구현하기 어려운 복잡한 상호작용이나 상태 변화를 테스트할 때 유용합니다.
  • 통합 테스트(Integration Test)컴포넌트 테스트(Component Test)에서 자주 사용됩니다.
  • 실제 환경과 최대한 유사하게 동작해야 하지만, 성능이나 리소스 사용량 면에서 훨씬 가볍습니다.

예시: 인메모리 데이터베이스를 사용하는 InMemoryUserRepository는 Fake의 좋은 예시입니다. 실제 DB 연결 없이도 사용자 CRUD 작업을 수행할 수 있죠.


// UserRepository 인터페이스는 Stub 예시와 동일

// InMemoryUserRepository 구현 (Fake)
class InMemoryUserRepository implements UserRepository {
    private Map<String, User> users = new HashMap<>();

    public InMemoryUserRepository() {
        users.put("user123", new User("user123", "Alice"));
        users.put("user456", new User("user456", "Bob"));
    }

    @Override
    public User findById(String id) {
        System.out.println("Finding user " + id + " from in-memory DB.");
        return users.get(id);
    }
    // 다른 CRUD 메서드들도 실제 DB처럼 구현 가능 (예: save, delete 등)
}

// 테스트 코드
@Test
void getUserProfileWithFake() {
    UserRepository fakeRepo = new InMemoryUserRepository(); // Fake 주입
    UserService userService = new UserService(fakeRepo);

    User user = userService.getUserProfile("user456");
    assertNotNull(user);
    assertEquals("Bob", user.getName());

    User nonExistentUser = userService.getUserProfile("user999");
    assertNull(nonExistentUser);
}
    

이 세 가지 주요 테스트 더블의 차이점을 표로 정리해 보면 더욱 명확해질 겁니다.

구분 Stub Mock Fake
주요 목적 SUT에 미리 정해진 데이터 제공 SUT와 의존성 간의 특정 행위 검증 실제 객체의 간소화된 작동 구현체 제공
검증 대상 SUT의 상태(State) 변화 SUT가 의존성에게 요청한 행위(Behavior) SUT의 상태 변화 및 실제에 가까운 상호작용
주요 사용처 단위 테스트 (데이터 입력) 단위 테스트 (외부 호출 검증) 통합 테스트, 컴포넌트 테스트 (경량화된 의존성)
구현 복잡도 낮음 (단순 값 반환) 보통 (프레임워크 사용 시 간편) 높음 (실제 로직의 간소화된 구현)
테스트 독립성 높음 높음 상대적으로 낮음 (더 많은 로직 포함)

이 차이점을 명확히 인지하는 것이 테스트 더블 오용을 막는 가장 중요한 시작점입니다. 이제 이 개념들을 혼동했을 때 발생하는 흔한 안티패턴들을 살펴보겠습니다.

안티패턴 1: '모든 더블은 Mock'이라는 오해와 그 대가

가장 흔한 오해 중 하나는 모든 테스트 더블을 'Mock'으로 통칭하거나, Mocking 라이브러리(예: Mockito, Jest)를 사용해서 모든 의존성을 Mock 객체로 만드는 것입니다. 분명 SUT가 어떤 데이터를 받아서 내부 로직을 처리하는 단순한 경우인데도, 굳이 Mock 객체를 생성하고 verify 구문을 통해 해당 객체의 특정 메서드가 호출되었는지 검증하는 식이죠.


// 예시: 사용자 이름을 받아서 인사말을 생성하는 서비스
// 실제로는 UserRepository로부터 사용자 이름을 조회해야 함

// 잘못된 Mock 사용 예시
@Test
void greetUserWithMock_OverSpecification() {
    UserRepository mockUserRepository = mock(UserRepository.class); // Mock 생성
    UserService userService = new UserService(mockUserRepository);

    // Stub처럼 행동하도록 설정
    when(mockUserRepository.findById("alice")).thenReturn(new User("alice", "Alice"));

    String greeting = userService.greetUser("alice");

    // 불필요한 verify (Stub처럼 사용되었음에도 불구하고)
    verify(mockUserRepository, times(1)).findById("alice"); // 이 부분이 과도한 검증

    assertEquals("Hello, Alice!", greeting);
}
    

이 코드는 UserServiceUserRepositoryfindById 메서드를 호출했는지 '검증'하고 있습니다. 하지만 greetUser 메서드의 핵심 로직은 '사용자 이름을 가져와서 인사말을 만드는 것'이지, 'findById를 호출하는 것' 자체가 목적이 아니거든요. findById 호출은 greetUser 로직의 구현 세부 사항(implementation detail)에 불과합니다.

이러한 오용이 팀에 미치는 영향은 다음과 같습니다:

  • 과도한 명세(Over-specification) 및 취약한 테스트: 테스트 코드가 SUT의 내부 구현에 너무 밀접하게 결합됩니다. SUT의 내부 로직을 리팩토링하여 findById 호출 방식이 조금만 달라져도 (예: 캐시 계층을 추가하여 직접 DB를 호출하지 않게 되거나, 다른 메서드를 호출하게 되거나) 테스트가 실패하게 됩니다. 이는 리팩토링을 어렵게 만들고, 팀이 코드 개선에 주저하게 만듭니다.
  • 테스트 코드의 가독성 저하: 단순한 데이터 제공이 필요한 상황에서 Mock을 사용하면, when().thenReturn()verify() 구문이 불필요하게 늘어나 테스트 코드의 의도를 파악하기 어렵게 만듭니다. "이 테스트는 뭘 검증하려는 거지?"라는 의문이 생기게 되죠.
  • 유지보수 비용 증가: SUT의 구현이 변경될 때마다 수많은 Mock 관련 verify 구문을 수정해야 할 수 있습니다. 이는 장기적으로 팀의 유지보수 비용을 크게 증가시킵니다.
  • 잘못된 자신감: Mock으로 모든 행위를 검증했기 때문에 "테스트는 완벽하다!"라고 생각할 수 있지만, 실제 SUT의 비즈니스 로직은 제대로 검증되지 않았을 가능성도 있습니다. Mock은 인터랙션만 검증할 뿐, 실제 데이터 처리 로직의 정확성은 보장하지 않거든요.

테크리드라면, 팀원들이 불필요한 Mock 검증으로 인해 리팩토링을 두려워하거나, 테스트 코드 수정에 시간을 과도하게 낭비하고 있지는 않은지 주의 깊게 살펴봐야 합니다. "SUT가 의존성 객체에게 무엇을 반환받는가?"가 중요하면 Stub을, "SUT가 의존성 객체에게 어떤 명령을 내리는가?"가 중요하면 Mock을 사용하는 것이 올바른 접근입니다.

테스트 더블 오용: Stubs, Mocks, Fakes를 혼동하여 테스트 독립성을 잃는 안티패턴 - test, virus, coronavirus, self-test, covid-19, infection, lock down, hygiene, transmission, shutdown, pandemic, test, test, test, test, test, virus, virus, virus, virus, coronavirus, coronavirus, coronavirus, coronavirus, covid-19, covid-19, covid-19, covid-19, pandemic, pandemic

Image by Alexandra_Koch on Pixabay

안티패턴 2: Fakes의 과도한 사용과 테스트 경계의 모호함

Fake 객체는 실제 객체처럼 동작하면서도 가벼운 구현체를 제공한다는 점에서 매력적입니다. 하지만 Fake를 과도하게 사용하거나, 불필요하게 복잡한 Fake를 만들었을 때도 문제가 발생할 수 있습니다.

흔한 실수:

  • 단순 단위 테스트에 복잡한 Fake 사용: SUT가 단지 한두 가지 메서드 호출 결과만 필요로 하는 단순한 단위 테스트인데도, 실제 DB의 모든 기능을 흉내 내는 복잡한 인메모리 DB Fake를 사용하는 경우가 있습니다. 이는 Stub으로도 충분했을 상황에 불필요하게 많은 코드를 작성하게 만들죠.
  • Fake의 과도한 로직: Fake 객체 자체가 너무 많은 비즈니스 로직을 포함하게 되면, Fake를 테스트하기 위한 테스트 코드가 또 필요해지는 아이러니한 상황이 발생할 수 있습니다. Fake는 '간소화된 구현'이어야지, '또 다른 프로덕션 코드'가 되어서는 안 됩니다.
  • 테스트 경계의 모호함: Fake를 사용하다 보면 단위 테스트와 통합 테스트의 경계가 모호해질 수 있습니다. Fake는 실제 시스템의 일부를 흉내 내기 때문에, 완벽한 격리를 제공하지 못할 수 있습니다. 예를 들어, 인메모리 DB Fake는 실제 DB의 모든 제약 조건이나 성능 특성을 완벽하게 반영하지 못할 수 있거든요.

이러한 오용이 팀에 미치는 영향은 다음과 같습니다:

  • 느려지는 테스트 스위트: Fake는 Stub이나 Mock보다 더 많은 로직을 포함하므로, 테스트 실행 시간이 길어질 수 있습니다. 수백, 수천 개의 테스트가 있는 대규모 프로젝트에서는 이로 인해 CI/CD 파이프라인이 지연되고, 개발자의 피드백 루프가 길어져 생산성이 저하됩니다.
  • 테스트 코드의 복잡성 증가 및 유지보수 어려움: 복잡한 Fake는 그 자체로 또 하나의 서브 시스템이 되어, 유지보수와 디버깅이 어려워집니다. Fake 코드에 버그가 있다면, SUT의 버그인지 Fake의 버그인지 파악하기 어려워지죠.
  • 잘못된 보안감: Fake가 실제 시스템을 완벽하게 모방한다고 착각하게 만들어, 실제 통합 테스트의 중요성을 간과하게 만들 수 있습니다. Fake는 어디까지나 '가짜'이므로, 실제 시스템과의 미묘한 차이에서 발생하는 버그를 잡아내지 못할 수 있습니다.

테크리드는 팀원들이 Fake를 사용할 때 그 필요성을 명확히 인지하고 있는지, 그리고 Fake가 지나치게 복잡해지지는 않는지 주기적으로 검토해야 합니다. "정말 이 정도의 실제 로직이 필요한가?"라는 질문을 던져보고, Stub이나 Mock으로 대체할 수 있다면 그렇게 유도하는 것이 좋습니다.

안티패턴 3: 테스트 더블을 통한 '구현 강요'와 설계 품질 저하

이 안티패턴은 테스트 더블의 사용이 코드의 설계 자체에 부정적인 영향을 미치는 경우를 말합니다. 때로는 테스트를 쉽게 만들기 위해, 프로덕션 코드의 설계 원칙을 희생하는 선택을 하게 되는데요. 이는 장기적으로 팀의 기술 부채를 증가시키고 소프트웨어의 유연성을 떨어뜨립니다.

흔한 실수:

  • 낮은 응집도/높은 결합도 유발: 테스트를 위해 특정 메서드를 public으로 바꾸거나, 불필요한 의존성을 주입받도록 설계하는 경우가 있습니다. 이는 객체 지향 설계 원칙인 높은 응집도(High Cohesion)낮은 결합도(Low Coupling)를 위반하게 됩니다.
  • 테스트만을 위한 인터페이스/클래스 생성: 특정 클래스를 Mock하기 위해 필요하지 않은 인터페이스를 생성하거나, 단일 책임 원칙(Single Responsibility Principle)을 위반하면서까지 테스트하기 쉬운 형태로 클래스를 쪼개는 경우도 발생합니다.
  • 'Mockable'한 설계 강요: 모든 컴포넌트를 Mock하기 쉽게 만들려는 강박에 사로잡혀, 자연스러운 객체 간의 상호작용을 깨뜨리거나 불필요한 추상화를 도입하기도 합니다. 예를 들어, 값 객체(Value Object)나 도메인 모델 자체를 Mock하려 시도하는 것이 대표적입니다.

이러한 오용이 팀에 미치는 영향은 다음과 같습니다:

  • 설계 품질 저하 및 기술 부채 증가: 테스트 용이성을 위해 설계 원칙을 희생하면, 코드가 이해하기 어렵고 변경하기 어려워집니다. 이는 장기적으로 기술 부채로 쌓여 팀의 개발 속도를 늦추고, 새로운 기능을 추가하거나 기존 기능을 수정하는 데 더 많은 비용을 초래합니다.
  • 유연성 감소: Mock을 만들기 쉬운 구조에 갇히게 되면, 나중에 더 나은 설계 패턴이나 라이브러리로 전환하기가 어려워집니다. 테스트 코드 자체가 프로덕션 코드의 발목을 잡는 상황이 되는 것이죠.
  • '테스트를 위한 테스트' 악순환: 테스트를 쉽게 하려고 코드를 비틀고, 그 비틀어진 코드를 테스트하기 위해 또 다른 테스트 더블을 복잡하게 만드는 악순환에 빠질 수 있습니다. 핵심 비즈니스 로직을 검증하는 것이 아니라, 테스트 코드 자체의 구조를 위해 에너지를 낭비하게 됩니다.

테크리드로서 팀원들에게 '테스트 용이성'과 '좋은 설계'는 상충하는 것이 아니라 상호 보완적인 관계임을 강조해야 합니다. 좋은 설계는 자연스럽게 테스트하기 쉬운 코드를 만듭니다. 의존성 주입(Dependency Injection), 인터페이스 기반 프로그래밍, 단일 책임 원칙 등은 테스트 더블을 효과적으로 활용하는 동시에 코드의 품질을 높이는 데 기여합니다. 테스트 더블은 도구일 뿐, 목적이 되어서는 안 됩니다.

테스트 더블 오용: Stubs, Mocks, Fakes를 혼동하여 테스트 독립성을 잃는 안티패턴 - test tube, covid-19, mask, face mask, medical, pandemic, hospital, quarantine, coronavirus, test tube, covid-19, covid-19, covid-19, mask, medical, medical, medical, hospital, hospital, hospital, hospital, hospital, coronavirus, coronavirus

Image by fernandozhiminaicela on Pixabay

우리 팀의 테스트 독립성을 지키는 현명한 테스트 더블 활용 전략

그렇다면 우리 팀은 어떻게 테스트 더블을 현명하게 사용하여, 앞서 언급한 안티패턴들을 피하고 견고한 테스트 코드를 구축할 수 있을까요? 테크리드로서 다음 전략들을 팀에 적용해 보세요.

명확한 역할 정의: Stubs는 상태, Mocks는 행위

가장 중요한 것은 각 테스트 더블의 역할을 명확히 이해하고 구분하여 사용하는 것입니다. 팀원들에게 "Stubs는 SUT가 외부로부터 '받는' 데이터를 제공하는 데 집중하고, Mocks는 SUT가 외부로 '호출하는' 행위를 검증하는 데 집중한다"는 원칙을 일관되게 교육하고 강조해야 합니다. 불필요한 verify 호출은 과감히 제거하고, 테스트의 가독성과 유지보수성을 높이는 데 주력해야 합니다.

  • SUT가 어떤 값을 필요로 할 때: Stub을 사용하세요. when(dependency.someMethod()).thenReturn(value);
  • SUT가 특정 메서드를 호출했는지 확인해야 할 때: Mock을 사용하세요. verify(dependency, times(1)).someMethod(args);
  • 실제 객체와 유사한 복잡한 상태/행위가 필요하지만, 실제 시스템은 너무 무거울 때: Fake를 고려하되, Fake의 복잡성이 지나치지 않도록 주의하세요.

의존성 주입(DI)과 인터페이스 활용으로 유연성 확보

테스트 더블을 효과적으로 사용하기 위한 기반은 바로 좋은 설계입니다. 특히 의존성 주입(Dependency Injection)인터페이스 기반 프로그래밍은 테스트 용이성을 극대화하는 핵심 기술입니다.

  • 인터페이스 활용: 구체 클래스보다는 인터페이스에 의존하도록 설계하여, 런타임에 실제 구현체 대신 테스트 더블을 쉽게 주입할 수 있도록 합니다. 예를 들어, UserRepository 인터페이스를 만들고, 프로덕션에서는 JdbcUserRepository를, 테스트에서는 UserRepositoryStub이나 InMemoryUserRepository를 주입하는 식이죠.
  • DI 컨테이너 사용: Spring 같은 DI 프레임워크를 활용하면 의존성 관리가 훨씬 용이해집니다. 테스트 환경에서는 특정 빈(Bean)을 테스트 더블로 대체하도록 설정할 수 있어, 복잡한 설정 없이도 테스트를 실행할 수 있습니다.

'테스트 커버리지'보다 '테스트 품질'에 집중하기

높은 테스트 커버리지는 좋지만, 그것이 곧 높은 테스트 품질을 의미하지는 않습니다. 측정 지표에 매몰되어 불필요하거나 취약한 테스트를 양산하는 것은 오히려 팀의 생산성을 떨어뜨립니다. 테크리드로서 팀원들에게 다음을 강조해야 합니다.

  • 테스트의 목적 명확화: 각 테스트가 어떤 비즈니스 로직이나 행위를 검증하는지 명확히 이해하고 작성해야 합니다. "이 테스트가 실패하면 어떤 버그가 발생했음을 의미하는가?"를 자문해 보세요.
  • 최소한의 필요한 더블 사용: 테스트 더블은 꼭 필요한 상황에만 사용하고, 그 중에서도 가장 단순한 형태(Stub -> Mock -> Fake 순서로 고려)부터 시작하는 것이 좋습니다.
  • 테스트 코드 리뷰: 정기적인 코드 리뷰를 통해 테스트 코드의 품질, 가독성, 그리고 테스트 더블의 적절한 사용 여부를 함께 점검하는 문화를 만드세요.

만약 팀원들이 테스트를 작성하는 데 어려움을 겪거나, 테스트 더블을 오용하는 경향이 보인다면, 테스트 더블 사용 가이드라인을 문서화하고, 주기적인 내부 세미나나 워크숍을 통해 지식을 공유하는 것도 좋은 방법입니다. 페어 프로그래밍을 통해 숙련된 개발자가 초보 개발자에게 올바른 테스트 작성법을 전수하는 것도 효과적입니다.

견고한 테스트 코드를 향한 여정: 팀의 생산성을 높이는 핵심

테스트 더블은 현대 소프트웨어 개발에서 떼려야 뗄 수 없는 중요한 도구입니다. 하지만 그 강력함 뒤에는 오용했을 때 발생할 수 있는 잠재적인 위험도 숨어있죠. Stubs, Mocks, Fakes의 미묘한 차이를 이해하고, 각각의 목적에 맞게 사용하는 것은 단순히 테스트 코드의 오류를 줄이는 것을 넘어, 팀의 개발 문화와 생산성에 지대한 영향을 미칩니다.

테스트 코드가 견고하고 독립적일 때, 팀은 자신감을 가지고 코드를 리팩토링하고, 새로운 기능을 빠르게 추가할 수 있습니다. 이는 개발 속도를 높이고, 버그 발생률을 낮추며, 궁극적으로 사용자에게 더 나은 품질의 소프트웨어를 제공하는 선순환을 만들어냅니다. 테크리드로서 이러한 테스트 더블의 올바른 사용을 팀 전체에 전파하고, 지속적으로 좋은 테스트 코드 문화를 만들어 나가는 데 노력을 기울여야 합니다.

여러분 팀의 테스트 코드는 어떠신가요? 혹시 비슷한 고민을 하고 계시거나, 테스트 더블 오용으로 인해 발생했던 재미있는 에피소드가 있다면 댓글로 자유롭게 의견을 나눠주세요! 함께 더 나은 개발 문화를 만들어나가는 데 큰 도움이 될 겁니다.

📌 함께 읽으면 좋은 글

  • [모바일 앱 개발] 새로운 모바일 앱, 어떤 수익 모델을 택해야 할까? 인앱 구독과 일회성 구매 사이의 갈림길
  • [오픈소스] 오픈소스 첫 기여, 내 PR이 계속 반려되는 이유와 해결 전략은?
  • [데이터 엔지니어링] Airflow DAG 실행 시간, 이렇게 줄였습니다: 실전 최적화 경험기

이 글이 도움이 되셨다면 공감(♥)댓글로 응원해 주세요!
궁금한 점이나 다루었으면 하는 주제가 있다면 댓글로 남겨주세요.

반응형