코드의 여백

[소프트웨어] 테스트 더블(Test Double) 알아보기

by rowing0328

Intro

소프트웨어 개발 과정에서 테스트는 필수적인 단계이며,

특히 단위 테스트(Unit Test)는 코드 품질을 보장하는 중요한 도구이다.

 

하지만 모든 테스트가 실제 객체나 외부 시스템에 의존하면,

테스트 작성과 실행이 복잡해지고 비효율적이 될 수 있다.

 

이 문제를 해결하기 위해 등장한 개념이 **테스트 더블(Test Double)**이다.

 

 

테스트 더블이란

테스트 더블(Test Double)은 소프트웨어 개발에서

테스트를 용이하게 하기 위해 실제 객체 대신 사용하는 가상의 객체를 의미한다.

 

이 용어는 영화 산업에서

위험한 장면을 대신 수행하는 스턴트 더블(Stunt Double)에서 유래되었다.

 

테스트 더블은 테스트 중인 시스템의 특정 부분이

준비되지 않았거나 테스트하기 어려운 상황에서 그 대안으로 사용된다.

 

 

테스트 더블의 종류

  • Dummy
    가장 기본적인 테스트 더블로, 인스턴스화된 객체는 필요하지만 기능은 필요하지 않을 때 사용된다.
    Dummy 객체는 단순히 메서드 호출에 필요한 파라미터를 채우기 위해 생성되며, 호출 시 정상적인 동작은 보장하지 않는다.
  • Fake
    복잡한 로직이나 객체 내부에서 필요로 하는 다른 외부 객체들의 동작을 단순화하여 구현한 객체이다.
    동작의 구현을 가지고 있지만 실제 프로덕션에는 적합하지 않는 객체이다.
  • Stub
    Dummy 객체에 최소한의 동작을 추가하여 실제로 동작하는 것처럼 만든 객체이다.
    Stub 객체는 테스트를 위해 프로그래밍된 내용에 대해서만 결과를 반환하도록 설계된 객체이다.
  • Test Spy
    Stub의 역할을 하면서 호출된 내용을 기록하는 객체이다.
    Spy 객체는 실제 객체와 Stub 모두로 활용 가능하며, 특정 메서드 호출 여부와 관련된 정보를 기록하고 확인할 수 있다.
  • Mock
    호출에 대한 기대를 명세하고, 이를 기반으로 동작하도록 프로그래밍된 객체이다.
    Mocks는 예상된 호출에 대한 명세를 정의하며, 테스트에서 이 명세가 충족되지 않을 경우 실패하도록 설계된다.

 

 

Test Double의 실제 사용 예시

 

Dummy

Dummy는 말 그대로 '가짜' 객체이다.

테스트에서 실제로 사용되지는 않지만, 요구사항을 충족하기 위해 필요한 객체를 의미한다.

주로 테스트 대상 코드에서 실행 로직에 영향을 주지 않는 파라미터나 객체를 대체하기 위해 사용된다.

 

예제 코드

import org.junit.jupiter.api.Test;

// DummyLogger는 Logger 인터페이스를 구현하지만, 실제 동작은 하지 않는다.
// EmailService에서 로깅 기능은 핵심이 아니므로 Dummy 객체로 대체
class DummyLogger implements Logger {
    
    @Override
    public void log(String message) {
        // 아무 작업도 수행하지 않는다.
    }
    
}

class EmailService {
    
    private Logger logger;
	
    // EmailService는 Logger에 의존하지만, 테스트 시에는 실제 구현체가 필요없다.
    public EmailService(Logger logger) {
        this.logger = logger;
    }

    public void sendEmail(String message) {
        // 로깅은 EmailService의 핵심 기능이 아니고, Dummy Logger를 사용해도 문제가 없다.
        logger.log(message);
        // 이메일 보내는 로직...
    }
    
}

public class EmailServiceTest {

    @Test
    void testSendEmailWithDummyLogger() {
        // Given -> Dummy Logger를 사용해 EmailService를 생성
        Logger dummyLogger = new DummyLogger();
        EmailService emailService = new EmailService(dummyLogger);

        // When -> 이메일 전송 메서드 호출
        emailService.sendEmail("Hello, World!");

        // Then -> EmailService의 핵심 로직(이메일 전송)이 제대로 동작하는지 확인
        // 로깅은 테스트의 관심사가 아니므로 Dummy Logger로 의존성을 해결
    }
    
}

 

설명

DummyLoggerLogger 인터페이스를 구현하지만, log 메서드는 비어 있다.

EmailService에서 로깅은 핵심 기능이 아니기 때문에 이런 식으로 설계한다.

 

EmailServiceTest에서는 로깅 동작을 검증할 필요가 없어서,

DummyLogger를 주입해 의존성을 해결하고 테스트를 진행한다.

 

Dummy 객체는 이렇게 테스트를 단순화하고,

불필요한 구현 없이 의존성을 처리하는데 자주 사용한다.

 

 

Fake

Fake는 실제 동작을 간단하게 모방하는 테스트 더블이다.

 

테스트 환경에서 복잡한 로직이나 외부 서비스의 실제 구현을 대체하는 데 사용된다.

주된 역할은 외부 인터페이스의 가벼운 구현을 제공해 테스트를 더 빠르고 예측 가능하도록 만드는 것이다.

 

이를 통해 데이터베이스 호출, 네트워크 요청, 파일 시스템 접근 등

비용이 많이 드는 작업을 효과적으로 모방할 수 있다.

 

예제 코드

@Entity
public class User {
    
    @Id
    private Long id;
    private String name;
    
    protected User() {}
    
    public User(Long id, String name) {
        this.id = id;
        this.name = name;
    }
    
    public Long getId() {
        return this.id;
    }
    
    public String getName() {
        return this.name;
    }
    
}

public interface UserRepository {
    
    void save(User user);
    User findById(long id);
    
}

public class FakeUserRepository implements UserRepository {
    
    private Collection<User> users = new ArrayList<>();
    
    @Override
    public void save(User user) {
        if (findById(user.getId()) == null) {
            user.add(user);
        }
    }
    
    @Override
    public User findById(long id) {
        for (User user : users) {
            if (user.getId() == id) {
                return user;
            }
        }
        return null;
    }
    
}

 

설명

테스트하려는 객체가 데이터베이스와 연관되어 있다고 가정해 보자.

이 경우 실제 데이터베이스를 연결해 테스트할 수도 있지만,

대신 가짜 데이터베이스 역할을 하는 FakeUserRepository를 만들어 테스트 객체에 주입할 수도 있다.

 

이렇게 하면 테스트 객체는 실제 데이터베이스에 의존하지 않으면서도 동일하게 동작하는 가짜 데이터베이스를 사용할 수 있다.

 

이처럼 실제 객체와 동일한 역할을 하도록 만들어 사용하는 객체를 Fake라고 한다.

 

 

Stub

Stub 객체는 테스트 코드에 간단한 반응을 제공하는 데 사용된다.

주로 테스트에 필요한 데이터를 제공하거나,

특정 메서드 호출이 예상대로 이루어지는지를 검증하는 역할을 한다.

 

Stub은 특정 상황에서 미리 정의된 응답을 반환하여 테스트가 예측 가능한 방식으로 실행되도록 돕는다.

특히 외부 시스템이나 복잡한 로직이 필요한 경우,

Stub을 사용해 단위 테스트에서 외부 의존성을 제거하고 테스트를 해당 단위에 집중할 수 있게 한다.

 

예제 코드

public class StubUserRepository implements UserRepository {
	// ...
    @Override
    public User findById(long id) {
        return new User(id, "Test User");
    }
    
}


설명

StubUserRepository는 findById() 메서드 호출 시,

언제나 동일한 id 값에 대해 이름이 Test User인 User 인스턴스를 반환한다.

 

테스트 환경에서 User 인스턴스의 name를 항상 Test User로 고정하고 싶다면,

이처럼 특정 동작을 정의한 객체(UserRepository의 구현체)를 만들어 사용할 수 있다.

 

다만, 이러한 방식에는 단점도 있다.

예를 들어, 테스트 요구 사항이 변경되어 findById() 메서드의 반환 값이 수정되어야 할 경우,

Stub 객체도 함께 수정해야 한다는 점이다.

 

참고로, 우리가 테스트에서 자주 사용하는 Mockito 프레임워크 역시 Stub과 유사한 역할을 한다.

 

이처럼 테스트를 위해 의도한 결과만 반환하도록 동작을 미리 정의한 객체를 Stub이라고 한다.

 

 

Spy

Spy 객체는 테스트 대상의 실제 구현을 사용하면서 호출 정보를 기록하는 데 사용된다.

즉, 실제 객체의 동작을 감시하는 데 초점을 둔다.

 

Spy를 사용하면 메서드가 얼마나 자주 호출되었는지,

어떤 매개변수로 호출되었는지 등을 확인할 수 있다.

또한, 필요한 경우 실제 메서드의 반환 값을 변경하거나,

특정 메서드 호출을 가로챌 수도 있다.

 

예제 코드

public class MailingService {
	
    private int sendMailCount = 0;
    private Collection<Mail> mails = new ArrayList<>();
    
    public void sendMail(Mail mail) {
    	sendMailCount++;
        mails.add(mail);
    }
    
    public long getSendMailCount() {
    	return sendMailCount;
    }
    
}

 

설명

MailingService는 sendMail을 호출할 때마다 보낸 메일을 저장하고, 호출 횟수를 체크한다.

메일을 보낸 횟수를 물어보면, sendMailCount 변수에 저장된 값을 반환한다.

 

이처럼 자기 자신이 호출된 상황을 기록하고 확인할 수 있는 객체를 Spy라고 한다.

참고로, Mockito 프레임워크의 verify() 메서드도 같은 역할을 수행한다.

 

 

Mock

Mock 객체는 실제 객체를 모방한 객체로, 테스트 환경에서 실제 의존성을 대체하는 데 사용된다.

주로 외부 시스템과의 상호작용이나 서비스의 응답을 모방할 때 유용하다.

 

Mock 객체를 사용하면 특정 메서드 호출에 대한 기대 값을 설정할 수 있으며,

실제 로직을 수행하지 않기 때문에 테스트 속도를 높이고 복잡한 시나리오를 단순화할 수 있다.

 

예제 코드

@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
    
    @Mock
    private UserRepository userRepository;
    
    @Test
    void test() {
        when(userRepository.findById(anyLong())).thenReturn(new User(1, "Test User"));
        
        User actual = userService.findById(1);
        assertThat(actual.getId()).isEqualTo(1);
        assertThat(actual.getName()).isEqualTo("Test User");
    }
    
}

 

설명

위 예제에서는 User Service 인터페이스의 구현체가

findById() 메서드 호출 시 어떤 결과를 반환할지를 결정할 수 있다.

 

Mockito의 when 메서드에서 anyLong() 대신 특정 값을 사용하면,

특정 상황에 대한 테스트를 더 명확하게 설정할 수 있다.

 

참고로, Mockito는 대표적인 Mock 프레임워크로, 이러한 기능을 효과적으로 제공한다.

 

 

어떤 테스트 더블을 언제 사용하는가

테스트 더블은 테스트 과정에서

외부 의존성을 대체하는 유용한 도구다.

 

하지만 잘못 사용하면

오버 엔지니어링이나 테스트 복잡성을 높일 수 있다.

 

따라서 각 테스트 더블의 사용 시점을

명확히 이해하고 활용하는 것이 중요하다.

 

 

마무리

상황에 따라 적합한 도구를 선택해

테스트를 체계적이고 유연하게 작성하는 것이 중요하다는 것을 느꼈다.

 

단순히 의존성을 제거하는 데 그치지 않고,

유지보수성과 코드 검증을 통해

개발자가 스스로 실수를 최소화할 수 있다는 점이 특히 인상적이었다.

 

참고 자료 :

Java/Spring 테스트를 추가하고 싶은 개발자들의 오답노트

 

Java/Spring 테스트를 추가하고 싶은 개발자들의 오답노트 강의 | 김우근 - 인프런

김우근 | Spring에 테스트를 넣는 방법을 알려드립니다! 더 나아가 자연스러운 테스트를 할 수 있게 스프링 설계를 변경하는 방법을 배웁니다., 프로젝트 설계를 발전시키는 테스트의 본질을 짚

www.inflearn.com

 

단위 테스트의 기술 | 로이 오셔로브

 

단위 테스트의 기술 | 로이 오셔로브 - 교보문고

단위 테스트의 기술 | 기본 개념과 실전 예제, 리팩터링, 유지 보수성 향상, 조직 내 테스트 도입까지 한 권으로 끝내는 단위 테스트 완벽 가이드이 책의 최종 목표는 더 견고한 코드를 작성하고

product.kyobobook.co.kr

블로그의 정보

코드의 여백

rowing0328

활동하기