본문 바로가기
테스트

테스트 대역 5가지

by 이상한나라의개발자 2024. 7. 17.

테스트 대역 5가지 유형

유형 설명
Dummy 아무런 동작을 하지 않습니다.
Stub 지정한 값만 반환합니다.
Fake 자체적인 로직이 있습니다.
Mock 아무런 동작을 하지 않습니다. 대신 어떤 행동이 호출됐는지 기록합니다.
Spy 실제 객체와 똑같이 행동합니다. 그리고 모든 행동 호출을 기록합니다.

 

Dummy

Dummy(더미)는 테스트 대역 중에서도 가장 간단하고 뚜렷한 목적을 지닌 대역입니다. Dummy의 역할은 아무런 동작을 하지 않는 것입니다. Dummy 객체는 오롯이 코드가 정상적으로 돌아가게 하기 위한 역할만 합니다. 그리고 특정 행동이 일어나지 않게 만드는데 사용됩니다.

@Service
@RequiredArgsConstructor
public class UserService {
    
    private final UserRepository userRepository;
    private final VerificationEmailSender verificationEmailSender;
    
    @Transactional
    public User register(UserCreateDto userCreateDto) {
        User user = User.builder()
                .email(userCreateDto.getEmail())
                .nickname(userCreateDto.getNickname())
                .status(UserStatus.PENDING)
                .verificationCode(UUID.randomUUID().toString())
                .build();


        userRepository.save(user);
        verificationEmailSender.send(user);
        
        return user;
    }
}

 

위 코드는 회원가입 코드입니다. 데이터베이스에 저장할 사용자 정보를 만들고, 이때 상태는 "가입 보류" 상태로 지정합니다. 왜냐하면 저장하려는 사용자 정보가 아직 이메일 인증이 안된 상태이기 때문입니다. 그 다음 데이터를 저장하고 바로 가입 인증 메일을 발송합니다.

 

위에서 UserRepository와 VerificationEmailSender은 인터페이스 입니다. UserRepository는 도메인을 반환하는 인터페이스이며 JpaRepository를 상속하지 않습니다. 즉, JPA와의 의존성을 제거한 상태입니다.

 

UserService.register 메서드에 메일을 발송하는 코드가 있다는 사실에 주목해봅시다. 그리고 이 코드의 테스트를 만들어 실해하면 어떤 일이 벌어질까요?

 

해당 메서드에 대한 테스트를 잘못 구성했다가는 테스트를 실행할 때마다 실제 메일이 발송되는 대참사가 일어날 것입니다. 이를 개선하기 위해 VerificationEmailSender의 대역으로 다음과 같은 클래스를 만들어 봅시다.

public class DummyVerificationEmailSender implements VerificationEmailSender {
    
    @Override
    public void send(User user) {
        // do nothing
    }
}

 

DummyVerificationEmailSender 컴포넌트는 대역처럼 동작하기 위해 VerificationEmailSender 인터페이스를 상속 받습니다.

DummyVerificationEmailSender 컴포넌트는 아무런 동작을 하지 않습니다. 이렇게 만들어진 대역은 어떻게 사용할 수 있을까요?

class UserServiceTest {
    
    @Test
    void 이메일_회원가입을_하면_가입_보류_상태가_된다() {
        
        // given (준비)
        UserCreateDto userCreateDto = UserCreateDto.builder()
                .email("test@email.com")
                .nickname("tester")
                .build();

        // when (실행)
        UserService userService = UserService.builder()
                .verificationEmailSender(new DummyVerificationEmailSender())
                .userRepository(userRepository)
                .build();
        User user = userService.register(userCreateDto);
        
        // then (단언 assert)
        assertEquals(UserStatus.PENDING, user.getStatus());
    }
}

 

UserService를 생성할 때 메일을 보내는 컴포넌트 객체인 verificationEmailSender 자리에 방금 만든 Dummy 객체를 사용하도록 주입합니다. 이렇게 주입하는 객체에 테스트 대역을 넣어 사용할 수 있습니다. 그리고 그 효과는 매우 강력합니다. 이제 테스트는 UserService.register 메서드를 실행해도 메일을 보내지 않습니다. DummyVerificationEmailSender 클래스의 send 메서드는 아무런 동작도 하지 않기 때문입니다. 그래서 이때 실제 구현체가 들어가야 할 verificationEmailSender 자리에 대신 들어간 dummyVerificationEmailSender 객체를 테스트 대역이라고 합니다. 즉, 테스트 대역은 말 그대로 실제 객체를 대신하는 객체 입니다. 

 

Stub

Stub 이라는 단어는 번역하면 "부본", "잛은 부분" 이라는 뜻입니다. 이는 원본과 비슷하게 만들어 참고로 보관하는 서류를 뜻하는 말입니다. 즉, 부본은 흔이 이야기 하는 원본, 사본, 등본과 같은 문서 종류의 하나입니다. 쉽게 이야기 하면 사본과 같은 개념이라고 이해하시면 됩니다. 개발 세계에서 Stub도 마찬가지입니다. 개발 세계의 Stub은 원본을 따라한 부분과 마찬가지로 실제 객체의 응답을 최대한 비슷하게 따라하는 대역입니다. 여기에서 "응답을 최대한 비슷하게 따라한다" 라는 점이 중요합니다. 그래서 Stub은 응답을 원본과 똑같이 반환하는데 집중합니다. 즉, 원본의 응답을 복제해 똑같은 응답으로 미리 준비하고 이를 바로 반환합니다.

 

정리하면 Stub은 미리 준비된 값을 반환하는 대역 객체를 가르킵니다. 이 테스트 대역은 Dummy처럼 실제 구현 코드를 실행하지 않는다는 점에서는 유사하지만 Dummy보다는 조금더 발전된 형태입니다. Dummy는 아무런 동작도 하지 않지만 Stub은 개발자가 의도한 미리 준비된 값을 반환하기 때문입니다. 

 

테스트를 작성하다 보면 어떤 객체의 메서드 호출 결과가 뻔한 것에 비해 동작이 지나치게 복잡한 경우가 있습니다. 예를 들면, 외부 API 요청을 보내는 작업을 생각해 봅시다. API 요청은 네트워크 자원을 사용하는 엄청난 고연산 작업입니다. 하지만 다음과 같은 API가 있고 테스트에서 이를 호출하고 있다면 어떨까요?

[요청]
GET https://another-server/ping

[응답]
status: 200
body: {
	"content": "pong"
}

 

서버는 위 요청을 받으면 별다른 동작 없이 응답을 반환합니다. 그래서 이 API 요청 결과는 pong 응답으로 성공하거나, 다른 이유로 실패한는 것 중 하나입니다. 그러니 테스트할 때마다 실제로 API를 호출하는 것이 자원 낭비가 될 수 있습니다. 그러니 실제로 API 요청하는 것이 아니라 응답이 {"content":"pong"}으로 온다고 가정하고 테스트를 작성하는 편이 나을 수 있습니다. ( 네트워크가 제대로 동자하는지만..)

 

또 하나 예를 들어 봅시다. JPA 같은 저장소에서 findById를 이용해 값을 불러오는 상황도 마찬가지입니다. 저장소에서 데이터를 불러오는 작업은 디스크 I/O, 네트워크 호출이 발생할 수 있는 고연산 작업이고 그 결과는 테스트 환경에 따라 쉽게 예상할 수 있습니다. 그래서 디스크 I/O를 발생시키지 않고 처음부터 값을 제대로 불러오는 것을 가정해 테스트를 작성하는 편이 나을 수 있습니다.

 

Stub은 바로 이럴 때 사용합니다. Stub을 사용해서 미리 준비된 값을 반환해서 고연산 작업이 실제로 실행되지 않게 하는 것이죠

 

코드를 확장해 봅시다. 이메일 인증을 받고 있으니 이메일 중복 체크 로직을 넣었다고 가정해 보는 것입니다.

@Builder
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final VerificationEmailSender verificationEmailSender;

    @Transactional
    public User register(UserCreateDto userCreateDto) {
        
        // 이메일이 일치하는 사용자가 조회되면 예외를 발생시킨다.
        if (userRepository.findByEmail(userCreateDto.getEmail()).isPresent()) {
            throw new IllegalArgumentException("이미 가입된 이메일입니다.");
        }

        User user = User.builder()
                .email(userCreateDto.getEmail())
                .nickname(userCreateDto.getNickname())
                .status(UserStatus.PENDING)
                .verificationCode(UUID.randomUUID().toString())
                .build();


        userRepository.save(user);
        verificationEmailSender.send(user);

        return user;
    }
}

 

findByEmail 메서드를 호출한 결과, 데이터가 있는 케이스를 반환하는 Stub 클래스

public class StubExistUserRepository implements UserRepository {

    @Override
    public Optional<User> findByEmail(String email) {
        return Optional.of(User.builder()
                .id(1L)
                .email(email)
                .nickname("tester")
                .status(UserStatus.PENDING)
                .verificationCode("1234")
                .build());
    }

    @Override
    public User save(User user) {
        return User.builder()
                .id(1L)
                .email(user.getEmail())
                .nickname(user.getNickname())
                .status(UserStatus.PENDING)
                .verificationCode(user.getVerificationCode())
                .build();
    }
}

 

findByEmail 메서드를 호출한 결과, 빈 Optional 값을 반환하는 Stub 클래스

public class StubEmptyUserRepository implements UserRepository {

    @Override
    public Optional<User> findByEmail(String email) {
        return Optional.empty();
    }

    @Override
    public User save(User user) {
        return User.builder()
                .id(1L)
                .email(user.getEmail())
                .nickname(user.getNickname())
                .status(UserStatus.PENDING)
                .verificationCode(user.getVerificationCode())
                .build();
    }
}

 

Stub를 사용하는 테스트 코드

class StubUserServiceTest {

    @Test
    void 중복된_이메일_회원가입_요청이_오면_예외를_발생한다() {

        // given (준비)
        UserCreateDto userCreateDto = UserCreateDto.builder()
                .email("tester@test.com")
                .nickname("tester")
                .build();

        // when (실행) // then (단언 assert)
        assertThatThrownBy(() -> {
            UserService userService = UserService.builder()
                    .userRepository(new StubExistUserRepository())
                    .verificationEmailSender(new DummyVerificationEmailSender())
                    .build();
            userService.register(userCreateDto);
        }).isInstanceOf(IllegalArgumentException.class)
                .hasMessage("이미 가입된 이메일입니다.");

    }

    @Test
    void 이메일_회원가입을_하면_가입_보류_상태가된다() {

        // given (준비)
        UserCreateDto userCreateDto = UserCreateDto.builder()
                .email("tester@test.com")
                .nickname("tester")
                .build();

        // when (실행)
        UserService userService = UserService.builder()
                .userRepository(new StubEmptyUserRepository())
                .verificationEmailSender(new DummyVerificationEmailSender())
                .build();

        // then (단언 assert)
        User user = userService.register(userCreateDto);

        assertThat(user.getStatus()).isEqualTo(UserStatus.PENDING);
        assertThat(user.getEmail()).isEqualTo(userCreateDto.getEmail());
    }
}

 

테스트 결과 및 실행 시간

 

이처럼 Stub는 실제 구현체의 응답을 흉내 냅니다. 그렇게 해서 테스트 환경을 조작할 수 있습니다. 덕분에 Stub은 외분 연동을 하는 컴포넌트나 클라이언트를 대체하는데 자주 사용됩니다.

 

오늘날 테스트 세계에 널리 퍼져 있는 오해 중 하나는 테스트 대역을 만들기 위해 Mockito 같은 프레임워크를 사용해야만 한다고 생각하는 것입니다. ( Mockito는 테스트 대역을 쉽게 구현할 수 있다 ) 하지만 위에서 보듯이 Dummy, Stub 등 테스트 대역을 구현하는데 이러한 프레임워크가 꼭 필요하지는 않습니다. 오히려 테스트 프레임워크 없이 테스트 대역을 만들어 사용하는 편이 설계상 휠씬 자연스럽기도 합니다. Mockito는 테스트 대역을 쉽게 만들 수 있게 도와주는 도구일 뿐입니다. 그래서 Mockito 없이도 테스트 대역을 만들 수 있어야 하고 Mockito가 없어서 테스트 대역을 못 만든다 같은 말이 나와서는 안됩니다.

 

다만, Mockito를 사용하면 Stub을 조금 더 쉽게 만들 수 있고 세부적인 동작 지정도 할 수 있다는 점은 분명합니다. 아래 Mockito를 이용해서 메서드를 대체할 수 있습니다.

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Autowired
    private MockMvc mockMvc;

    @InjectMocks
    private UserService userService;

    @Mock
    private UserRepository userRepository;

    @Mock
    VerificationEmailSender verificationEmailSender;

    @Test
    void 이메일_회원가입을_하면_가입_보류_상태가된다() {
        // given (준비)
        UserCreateDto userCreateDto = UserCreateDto.builder()
                .email("tester@test.com")
                .nickname("tester")
                .build();

        User user = User.builder()
                .id(1L)
                .email(userCreateDto.getEmail())
                .nickname(userCreateDto.getNickname())
                .status(UserStatus.PENDING)
                .verificationCode(UUID.randomUUID().toString())
                .build();

        when(userRepository.findByEmail(userCreateDto.getEmail())).thenReturn(Optional.empty());
        when(userRepository.save(any(User.class))).thenReturn(user);

        // when (실행)
        User registeredUser = userService.register(userCreateDto);

        // then (단언 assert)
        Assertions.assertThat(registeredUser.getStatus()).isEqualTo(UserStatus.PENDING);
        Assertions.assertThat(registeredUser.getEmail()).isEqualTo(userCreateDto.getEmail());

        // Verify that the email sender was called once
        verify(verificationEmailSender, times(1)).send(any(User.class));
    }

    @Test
    void 중복된_이메일_회원가입_요청이_오면_예외를_발생한다() {

        // given (준비)
        Optional<User> user = Optional.ofNullable(User.builder()
                .email("test@email.com")
                .build());

        // when (실행)
        when(userRepository.findByEmail(anyString())).thenReturn(user);

        // then (단언 assert)
        Assertions.assertThatThrownBy(() -> {
            userService.register(UserCreateDto.builder()
                    .email("test@email.com")
                    .build());
        }).isInstanceOf(IllegalArgumentException.class)
                .hasMessage("이미 가입된 이메일입니다.");
    }
}

 

이처럼 Mockito를 이용하면 테스트 코드를 좀더 단순화할 수 있습니다. 더불어 불필요한 대역 클래스들이 무분별하게 만들어지는 상황도 방지할 수 있습니다.

 

Fake

Dummy가 아무런 동작을 하지 않고, Stub는 미리 준비된 응답을 반환하고 끝이라면 Fake(페이크)는 한단계 더 발전된 유형의 태스트 대역입니다. Fake는 테스트를 위한 자체적인 논리를 갖고 있습니다.

public class FakeUserRepository implements UserRepository {
    
    private long authGenerateId = 0L;
    private final List<User> data = new ArrayList<>();

    @Override
    public Optional<User> findByEmail(String email) {
        return data.stream().filter(user -> user.getEmail().equals(email)).findAny();
    }

    @Override
    public User save(User user) {
        if ( user.getId() == null || user.getId() == 0 ) {
            // 등록 동작
            User createUser = User.builder()
                    .id(++authGenerateId)
                    .email(user.getEmail())
                    .nickname(user.getNickname())
                    .status(UserStatus.PENDING)
                    .verificationCode(user.getVerificationCode())
                    .build();
            data.add(user);
            return createUser;
        }
        else {
            // update 동작
            data.removeIf(u -> u.getId() == user.getId());
            data.add(user);
            return user;
        }
    }
}

 

JPA 동작을 흉내내서 FakeUserRepository라는 클래스를 만들었습니다. List로 선언된 data 변수를 통해 save 메서드가 호출되면 데이터를 저장하고 findByEmail 메서드가 호출되면 조건과 일치하는 데이터를 불러오게 했습니다. 이제 이 코드는 다음과 같은 테스트 코드를 작성할 수 있게 됩니다.

public class FakeUserServiceTest {


    @Test
    void 중복된_이메일_회원가입_요청이_오면_예외를_발생한다() {

        // given (준비)
        UserCreateDto userCreateDto = UserCreateDto.builder()
                .email("tester@test.com")
                .nickname("tester")
                .build();

        User user = userCreateDto.toDomain();

        UserRepository userRepository = new FakeUserRepository();
        User saveUser = userRepository.save(user);

        UserService userService = UserService.builder()
                .userRepository(userRepository)
                .verificationEmailSender(new DummyVerificationEmailSender())
                .build();

        // when (실행)
        assertThatThrownBy(() -> {
            userService.register(userCreateDto);
        }).isInstanceOf(IllegalArgumentException.class).hasMessage("이미 가입된 이메일입니다.");
    }

    @Test
    void 이메일_회원가입을_하면_가입_보류_상태가된다() {

        UserCreateDto userCreateDto = UserCreateDto.builder()
                .email("tester@test.com")
                .nickname("tester")
                .build();

        UserService userService = UserService.builder()
                .verificationEmailSender(new DummyVerificationEmailSender())
                .userRepository(new FakeUserRepository())
                .build();

        User user = userService.register(userCreateDto);

        assertThat(user.getStatus()).isEqualTo(UserStatus.PENDING);
        assertThat(user.getEmail()).isEqualTo(userCreateDto.getEmail());

    }
}

 

잘 만들어진 Fake는 테스트의 가독성을 높여줄뿐더러 여러 테스트에서 재활용할 수 있습니다. 

 

* 이렇게도 할 수 있다는 것이지 이렇게 하라고 권장하는 것은 아닙니다. 개발 환경이 실제 배포 환경과 멀어지는 것은 좋은 방향이 아니기 때문입니다. 따라서 가능하면 테스트 환경이든, 로컬 환경이든 실제 구현 코드를 직접 실행해 보는 것이 가장 좋습니다.

 

이쯤에서 상기했으면 하는 사실이 하나 있습니다. 테스트 대역을 사용했더니 중형 테스트, 대형 테스트처럼 보였던 테스트가 소형 테스트로 바뀌었습니다. 이게 얼마나 대단한 일인지 다시 한번 생각해 보면 좋겠습니다. 위 코드는 애플리케이션 서비스를 테스트하는 코드임에도 소형 테스트입니다. 이 코드들은 단일 서버, 단일 프로세스, 단일 스레드에서 동작하며, 디스크 I/O 같은 블로킹 호출도 없습니다. 게다가 항상 일관된 결과를 반환합니다. 스프링 프레임워크가 구동되길 기다릴 필요도 없고 JPA 로딩을 기다릴 필요도 없습니다. 그러니 테스트 속도도 빠릅니다.

 

그런데 만약 이코드를 테스트하기 위해 실제로 이메일을 보내고, H2를 이용해 저장소에 데이터를 저장했다면 어땠을까요? 테스트를 위해 H2라는 프로세스를 별도로 실행해야 했을 것입니다. 그러면 이 테스트는 멀티 서버와 멀티 프로세스 환경에서 돌아가는 테스트가 됐을 테니 대형 테스트 or 중형 테스트가 되었을 겁니다.

 

Mock

Mock(목) 은 번역하면 "모조품" 이라는 뜻입니다. Dummy, Stub, Fake와 마찬가지로 테스트 대역 중 하나입니다. 주로 메서드 호출이 발생했는지 여부를 검증하는 역할을합니다.

 

테스트에 관심이 있는 개발자라면 한번쯤 Mockito라는 테스트 프레임워크에 관해 들어봤을것입니다. Mockito는 자바로 작성된 오프소스 테스팅 프로임워크로 테스트 대역을 쉽게 만들 수 있게 지원하는 역할을합니다. Mockito를 이용하면 구현이 없는 객체(Dummy)를 만들 수 있으며, 특정 객체의 메서드 호출을 시뮬레이션하는 객체(Stub)를 만들 수 있습니다.

 

Mockito라는 이름의 유래는 이번에 소개할 테스트 대역 중 하나인 Mock의 언어유희로 만들어진 것입니다. 그런데 Mockito라는 이름이 너무 유명해져서 그런지 안타까운 사실이 하나 있습니다. Mock이라는 용어가 최근에는 테스트 대역과 거의 같은 의미로 시용되고 있다는 점입니다. 오늘날 우리는 Dummy, Stub, Fake 모두 Mock라고 부릅니다. 그만큼 Mock이라는 용어가 테스트를 대표하는 단어가 돼어버렸습니다.

 

그런데 엄밀히 말해서 테스트 대역과 Mock는 다릅니다. 왜냐하면 개념적인 차원에서 Mock는 테스트 대역의 부분집합일 뿐입니다.

 

Mock는 관용적인 의미와 개념적인 의미 두가지로 구분하여 이해해야합니다. 관용적인 의미로 Mock는 테스트 대역과 거의 같은 의미입니다. 반면 개념적인 의미로 Mock는 테스트 대역의 부분집합일 뿐입니다. 메서드 호출이 발생했는지를 검증하기 위해 만들어지는 테스트 대역에 해당합니다.

  • Mock는 메서드 호출 및 상호 작용을 기록한다.
  • Mock는 어떤 객체와 상호 작용이 일어났는지 기록한다.
  • Mock는 어떻게 상호 작용이 일어났는지 기록한다.

Mock에 관한 설명을 읽다 보면 상호작용(Interaction)이라는 말이 굉장히 자주 사용되는 것을 알 수 있습니다. 그렇다면 테스트에서 말하는 상호 작용이란 무엇일까요? 그 전에 테스트 코드를 검증하는 데 사용할 수 있는 두 가지 테스트 접근 방식이 있습니다. 바로 상태 기반 검증과 행위 기반 검증이라는 개념입니다.

상태 기반 검증

상태 기반 검증(state-based verfication)은 테스트의 검증 동작에 상태를 사용하는 것을 의미합니다. 즉, 상태 기반 검증으로 동작하는 테스트에서는 테스트를 실행한 후에 테스트 대상의 상태가 어떻게 변화 됐는지를 보고 테스트 실행 결과를 판단합니다.

@Data
@Builder
public class User {

    private Long id;
    private String email;
    private String nickname;
    private UserStatus status;
    private String verificationCode;
    private List<String> bookmark;

    public boolean isPresent() {
        return email != null;
    }

    public void appendBookmark(String bookmark) {
        this.bookmark.add(bookmark);
    }
    public void toggleBookmark(String bookmark) {
        if (this.bookmark.contains(bookmark)) {
            this.bookmark.remove(bookmark);
        } else {
            this.bookmark.add(bookmark);
        }
    }

    public boolean hasBookmark(String bookmark) {
        return this.bookmark.contains(bookmark);
    }
}
public class UserDomainTest {

    @Test
    void 유저는_북마크를_toggle_해서_제거할_수_있다() {

        // given (준비)
        User user = User.builder()
                .bookmark(new ArrayList<>())
                .build();
        user.appendBookmark("foobar");

        // when (실행)
        user.toggleBookmark("foobar");

        // then (단언 assert)
        Assertions.assertThat(user.hasBookmark("foobar")).isFalse();
    }
}

 

위 코드를 보면 user 객체는 최초에 foobar 이라는 문자열을 given 영역에서 추가해서 북마크에 foobar라는 값을 갖고 있게 만듭니다. 이후 toogleBookmark 메서드를 실행하고, 마지막에 user 객체에 북마크가 어떤 식으로 변화됐는지 확인합니다. 상태 기반 검증에서는 이처럼 테스트 이후 객체의 상태 변화를 주목합니다.

 

행위 기반 검증

행위 기반 검증은 테스트의 검증 동작에 메서드 호출 여부를 보게하는 것을 의미합니다. 즉, 행위 기반 검증으로 동작하는 테스트에서는 테스트 대상이나 협력 객체, 협력 시스템의 메서드 호출 여부를 봅니다. 

public class UserDomainTest {

    @Test
    void 유저는_북마크를_toggle_해서_제거_할_수_있다() {

        // given (준비)
        User user = User.builder()
                .bookmark(new ArrayList<>())
                .build();
        user.appendBookmark("foobar");

        // when (실행)
        user.toggleBookmark("foobar");

        // then (단언 assert)
        verify(user).removeBookmark("foobar");
    }

 

위 코드는 마지막 단계인 then 절에서 Mokito 프레임워크의 정적 메서드 중 하나인 verify 메서드를 이용해 user.removeBookmark("foobar") 메서드가 실행되었는지 검증합니다. 메서드가 호출되었다면 이 테스트는 성공할 것이고, 아니라면 테스트는 실패할 것입니다.

 

이처럼 행위 기반 검증에서는 주로 테스트 대상 객체나 협력 객체의 특정 메서드가 호출됐는지 검증합니다. 그래서 이를 협력 객체와 상호 작용했는지 확인한다고 해서 "상호 작용 테스트" 라도고 합니다. 결국 테스트에서 말하는 상호 작용이란 객체간의 협력이며, 이는 곧 객체의 메서드 호출을 뜻합니다.

 

상태 기반 & 행위 기반 ( 상호 작용 )

상태 기반 검증은 시스템의 내부 데이터 상태를 검증하는 테스트라고 볼 수 있습니다. 반면 행위 기반 검증은 주로 시스템 내/외부 동작을 검증하는 테스트라고 볼 수 있습니다. 두 접근 방식은 시스템을 서로 다른 측면에서 시스템 품질을 보장하는데 사용될 수 있기 때문에 적절히 잘 사용해야 합니다.

 

그런데 잘 생각해보면 행위 기반 검증을 사용하는 것은 좋은 전력이 아니라는 것을 금방 눈치챌 수 있습니다. 이유는 "상호 작용 테스트", "행위 기반 검증" 같은 말로 포장했지만 이는 사실상 알고리즘을 테스트 하는 것과 같기 때문입니다. 따라서 행위 기반 검증 테스트가 많아지면 시스템 전체가 경직될 수 있습니다. 왜냐하면 테스트가 논리를 검증하고 있으므로 테스트 대상이 현재 코드 외에 다른 방법으로 개발하는 것이 불가능해지기 때문입니다. 그러므로 가급적이면 상태 기반 테스트를 작성해야 합니다.

 

이쯤에서 다시 Mock로 돌아가 봅시다. Mock는 개념적으로 메서드 호출 및 상호 작용을 기록하고, 실제로 상호 작용이 일어났는지, 어떻게 상호 작용이 일어났는지 확인하는 데 사용되는 객체라고 했습니다. 테스트에서 말하는 상호 작용이 무슨 뜻인지 이해 했다면 이제는 이 말을을 이해할 수 있을것이라 생각합니다.

 

그렇다면 코드 수준에서 Mock를 어떻게 구현하면 좋을지 생각해 봅시다. 그리고 이를 위해 회원 가입할 때 이메일을 발송하는 예시를 확장해 봅시다. 이번에는 실제로 이메일을 발송하는 메서드가 실제로 호출 되었는지가 궁금합니다. 좀더 노골적으로 VerificationEmalSender.send() 메서드가 실제로 호출되었는지 확인하고 싶습니다.

public class MockUserServiceTest {

    @Test
    void 이메일_회원가입을_하면_가입_보류_상태가_된다() {

        // given (준비)
        UserCreateDto userCreateDto = UserCreateDto.builder()
                .email("test@test.com")
                .nickname("tester")
                .build();

        MockVerificationEmailSender mockVerificationEmailSender = new MockVerificationEmailSender();

        // when (실행)
        UserService userService = UserService.builder()
                .verificationEmailSender(mockVerificationEmailSender)
                .userRepository(new FakeUserRepository())
                .build();

        User user = userService.register(userCreateDto);

        // then (단언 assert)
        Assertions.assertThat(user.getStatus()).isEqualTo(UserStatus.PENDING);
        Assertions.assertThat(mockVerificationEmailSender.isSendCalled).isTrue();
    }
}

 

맨 마지막에 mockVerificationEmailSender 객체의 isSendCalled 값이 true로 변경됐는지 확인합니다. 그렇게 해서 send 메서드 호출 여부를 판단합니다. 그래서 이렇게 작성된 테스트 대역인 mockVerificationEmailSender를 가리켜 Mock라고 부릅니다. 이처럼 Mock는 말 그대로 메서드 호출을 기록하고 상호 작용이 일어났는지 판단하는 대역입니다.

 

Spy

Spy는 어휘 그대로 마치 영화 속 Spy와 같습니다. 실제 객체 대신 사용돼서 만약 실제 객체였다면 어떤 메서드가 호출되고 이벤트가 발생했는지 등을 기록하고 감시합니다. 더불어 메서드가 몇번 호출되었는지, 메서드는 어떤 매개변수로 호출됐는지, 메서드 호출 순서는 어떤지 등 모든것을 기록합니다. 따라서 개념적으로 Spy는 상호 작용을 검증하는 데 주로 사용됩니다. 그래서 Mock와 비슷하다고 볼 수 있습니다.

하지만 Mock와 Spy는 결정적인 차이점이 있습니다. 바로 "내부 구현이 진짜인가 가짜인가" 의 차이입니다.

 

Mock으로 만들어진 객체는 기본적으로 모든 메서드 호출이 Dummy 또는 Stub 처럼 동작합니다. 반면 Spy로 만들어진 객체는 기본적인 동작이 실제 구현체와 같습니다. 즉, Spy는 실제 객체와 구분할 수 없습니다. 그러니 Spy는 실제 객체의 메서드 구현에 메서드 호출을 기록하는 부수적인 기능들이 추가되는 것이라 생각하면 좋습니다. 어렵다면 영화 속 스파이를 상상해 보세여. 스파이는 마치 우리의 아군처럼 행동합니다. 하지만 실상은 우리의 일거수일투족을 감시해 정보를 빼갑니다. 마찬가지입니다. 테스트에서 Spy는 기본적으로 실제 객체인 것처럼 행동합니다. 하지만 실상은 테스트를 검증하는 데 필요한 정보를 모읍니다. 그리고 검증이 필요한 단계에서 이를 외부에 알립니다. 더불어 원할한 테스트 환경을 구축하기 위해 특정 코드가 원하는 대로 동작하도록 조작하기도 합니다.

 

그런 의미에서 Spy는 Mock, Fake, Stub, Dummy와 동작도 다른 형태이고 의미론적으로도 다릅니다. Mock, Fake, Stub, Dummy의 의미를 생각해보세요. 각각 어떤가요? Mock는 모조품이고, Fake는 실제 구현을 가진 가짜이며, Stub는 부본이고, Dummy는 아무런 동작을 하지 않는 가짜입니다. 즉 모두가 가짜입니다. 따라서 모두 실제를 대체할 만한 대역이 아닙니다.

 

코드는 나중에 정리...

정리

지금까지 총 5개의 테스트 대역에 대해서 알아봤습니다. 각 테스트 대역은 Mockito 같은 Mock 프레임워크의 도움 없이도 만들 수 있습니다. Mockito는 테스트 대역을 간편하게 만들 수 있게 도와주는 도구일 뿐입니다. 

 

Mockito를 다루는 능력은 기술입니다. 기술을 잘 다룰 줄 아는 것은 분명 엄청난 장점입니다. 하지만 기반 지식 없이 익힌 기술은 활용도가 매우 떨어집니다. 조금만 환경이 변해도 그동한 익힌 기술의 이점이 모두 사라지기 때문입니다.

 

* 테스트 대역을 잘 사용하려면 추상화가 잘 되어 있어야 합니다. 그리고 의존성 역전도 잘 적용돼 있어야 합니다.

'테스트' 카테고리의 다른 글

테스트  (0) 2024.07.16
Mockito 를 활용한 단위 테스트  (0) 2024.01.24
번거로운 동작을 스텁(stub)으로 대체  (0) 2024.01.24
번거로운 동작을 스텁(stub)으로 대체  (0) 2024.01.19
JUnit 기본  (2) 2024.01.03