테스트 더블 (Test Double)

초코칩·2024년 4월 13일
0

Test

목록 보기
2/4
post-thumbnail

테스트 더블

단위 테스트는 "비교적 격리된 방식"으로 코드 단위를 테스트하는 것을 목표로한다. 하지만 종종 다른 컴포넌트와 상호작용하고 의존하는 경향이 있고, 코드의 모든 동작을 완벽하게 테스트하기 위해 입력을 설정하고 부수 효과를 검증해야 한다. 이럴 때 테스트에서 의존성을 실제로 사용하는 것이 항상 가능하거나 바람직한 것만은 아니다.

의존성을 실제로 사용하는 것에 대한 대안으로 테스트 더블(Test Double)이 있다. 테스트 더블은 의존성을 시뮬레이션하는 객체지만 테스트에 더 적합하게 사용할 수 있도록 만들어진다.

테스트 더블을 사용하는 이유

테스트 단순화

일부 의존성은 테스트에 사용하기 까다롭고 힘들다. 의존성은 많은 설정이 필요하거나 하위 의존성을 설정해야 할 수도 있다. 이러면 테스트는 복잡하고 구현 세부 사항과 밀접하게 결합될 수 있다. 의존성을 실제로 사용하는 대신 테스트 더블을 사용하면 작업이 단순해진다.

테스트로부터 외부 세계 보호

일부 의존성을 실제로 부수 효과를 발생한다. 코드의 종속성 중 하나가 실제 서버에 요청을 전송하거나 실제 데이터베이스에 값을 쓰게 되면, 사용자나 비즈니스에 중요한 프로세스에 나쁜 결과를 초래할 수 있다. 이러힌 상황에서 테스트 더블을 사용하면 외부 세계에 있는 시스템을 테스트 동작으로부터 보호할 수 있다.

외부로부터 테스트 보호

다른 시스템이 데이터베이스에 쓴 값을 의존성 코드가 읽는다면 이 값은 시간이 지남에 따라 변경될 수 있다. 이 경우 테스트 결과를 신뢰하기 어려울 수 있고, 시간이 지난 후에 테스트가 실패할 수 있다. 테스트 더블로 항상 동일하게 결정적 방식으로 작동하도록 설정할 수 있다.

테스트 더블의 종류

Dummy

Dummy는 실제로 아무런 동작도 하지 않는 객체다. 주로 메서드의 매개변수로 객체를 전달해야 하지만 해당 객체의 동작이 필요하지 않을 때 사용된다. 더미는 그저 자리를 차지하고 있는 역할을 하며, 실제 동작을 제공하지 않는다.

먼저 Dummy 객체를 사용하는 예시를 살펴보자. 가령, 사용자 목록을 관리하는 UserManager 클래스가 있다고 가정해보자. 이 UserManager 클래스는 사용자를 추가하는 기능을 제공한다. 이때, 테스트 중에는 실제 데이터베이스에 사용자를 추가하는 것이 아니라 단순히 메서드 호출만을 확인하고자 할 수 있다. 이때 Dummy 객체를 사용할 수 있다.

public class UserManager {
    private Database database;

    public UserManager(Database database) {
        this.database = database;
    }

    public void addUser(String username) {
        // 사용자를 데이터베이스에 추가하는 동작
        database.addUser(username);
    }
}

public interface Database {
    void addUser(String username);
}

이제 Dummy 객체를 사용하여 테스트할 수 있다.

public class UserManagerTest {
    // Dummy 객체 정의
    private static class DummyDatabase implements Database {
        @Override
        public void addUser(String username) {
            // Dummy 객체이므로 아무 동작도 하지 않음
        }
    }

    @Test
    public void testAddUser() {
        // Dummy 객체 생성
        Database dummyDatabase = new DummyDatabase();

        // 테스트 대상 객체 생성 및 Dummy 객체 주입
        UserManager userManager = new UserManager(dummyDatabase);

        // 사용자 추가 메서드 호출
        userManager.addUser("testUser");

        // Dummy 객체는 실제로 동작하지 않으므로 특별한 검증은 필요하지 않음
        // 대신, 사용자 추가 메서드가 호출되었는지만 확인
    }
}

이와 같이 Dummy 객체를 사용하면 실제 동작을 시뮬레이션하면서도 테스트에서는 특정 동작을 확인할 수 있다.

Stub

Stub은 실제 객체의 대체품으로, 특정 메서드 호출에 대해 미리 정의된 답변을 반환한다. 스텁은 실제 동작을 가지지 않고, 단순히 미리 정의된 결과를 반환하여 다른 객체들이 특정 상황에서 어떻게 동작해야 하는지를 시뮬레이션한다.

아래 네트워크를 통한 데이터 요청에서 예시를 보자.

public class NetworkRequestHandler {
    private NetworkClient networkClient;

    public NetworkRequestHandler(NetworkClient networkClient) {
        this.networkClient = networkClient;
    }

    public String fetchDataFromServer(String url) {
        // 네트워크 클라이언트를 통해 실제로 데이터를 요청한다.
        // 이 부분을 스텁으로 대체하여 네트워크 응답을 시뮬레이션한다.
        return networkClient.request(url);
    }
}

public interface NetworkClient {
    String request(String url);
}

이제 네트워크 클라이언트를 대체할 Stub을 만들어보자.


public class NetworkClientStub implements NetworkClient {
    @Override
    public String request(String url) {
        // 네트워크 요청 대신에 스텁에서 직접 응답을 생성하여 반환한다.
        // 이 예시에서는 간단하게 "Dummy Data"라는 문자열을 반환하도록 하겠습니다.
        return "Dummy Data";
    }
}

이제 다음과 같이 테스트를 진행할 수 있다. 미리 정의한 NetworkClient의 스텁인 NetworkClientStub 덕분에 예측 가능한 테스트를 진행할 수 있다.

@Test
void stubTest(){
        // NetworkClientStub을 사용하여 NetworkRequestHandler를 생성한다.
        NetworkClient stubClient = new NetworkClientStub();
        NetworkRequestHandler requestHandler = new NetworkRequestHandler(stubClient);

        // fetchDataFromServer 메서드를 호출하여 네트워크 요청을 시뮬레이션하고 응답을 받는다.
        String responseData = requestHandler.fetchDataFromServer("https://example.com/api/data");

        // 스텁이 제공한 응답을 출력한다.
        System.out.println("Received Data: " + responseData);
    }
}

위의 코드를 실행하면, 네트워크 요청 대신에 스텁이 제공한 "Dummy Data"가 출력된다. 이렇게 함으로써 네트워크를 통한 데이터 요청을 시뮬레이션할 수 있다.

Fake

Fake는 실제 객체와 유사한 동작을 가지지만, 더 간단하게 구현된 객체다. 실제 시스템에서 사용되는 것처럼 보이지만, 보다 간소화된 형태로 구현되어 있다. 예를 들어, 메모리에 데이터를 저장하는 간단한 데이터베이스를 페이크로 사용할 수 있다.

인 메모리 데이터베이스를 활용하여 데이터베이스 시스템을 시뮬레이션할 수 있다. 아래 코드와 같이 데이터베이스를 실제로 사용하는 코드가 있다.

public class BoardDao implements BoardRepository {
    private final JdbcConnectionPool connectionPool;

    public BoardDao(final JdbcConnectionPool connectionPool) {
        this.connectionPool = connectionPool;
    }

    @Override
    public void save(final long roomId, ...) {
        final String query = "INSERT INTO ..";
        // 커넥션 획득
        final Connection connection = connectionPool.getConnection();
        try (final PreparedStatement preparedStatement = connection.prepareStatement(query,
                Statement.RETURN_GENERATED_KEYS)) {
            preparedStatement.setLong(1, roomId);
			//...
            preparedStatement.executeUpdate();
        } catch (SQLException exception) {
            throw new RuntimeException(exception);
        } finally {
            connectionPool.releaseConnection(connection);
        }
    }
}

GameService에서 BoardRepository를 사용하고 있다. GameService를 테스트하고 싶은데, BoardDao에서 실제 커넥션 때문에 테스트하기 까다롭다.

public class GameService {

    private final BoardRepository boardRepository;

    public GameService(final BoardRepository boardRepository) {
        this.boardRepository = boardRepository;
    }
}

이때 간소화 버전인 FakeBoardDao를 통해 인메모리에서 처리하게 할 수 있다.

public class FakeBoardDao implements BoardRepository {

    List<FakeBoard> boards = new ArrayList<>();

    @Override
    public void save(final long roomId, ...) {
        boards.add(new FakeBoard(roomId, ...));
    }
}

class GameServiceTest {
    BoardRepository boardRepository;
    GameService gameService;

    long roomId;

    @BeforeEach
    void setUp() {
        boardRepository = new FakeBoardDao();

        roomId = 1L;
        boardRepository.save(roomId, ...);

        gameService = new GameService(boardRepository);
    }
}

FakeBoardDao는 List 형태로 데이터를 저장하는 것처럼 보이게 하여, 데이터베이스에 저장하는 것처럼 작동한다.

Mock

Mock 객체는 테스트 중에 실제 객체의 동작을 대신하여 테스트 동작을 제어하고 관찰할 수 있다. 특정 메서드 호출이 발생했는지, 어떻게 호출되었는지를 확인하거나, 정해진 동작에 따라 특정 값을 반환하도록 설정할 수 있다.

특히 객체의 행위를 검증할 때 유용하다.

Mock 객체를 사용하여 객체의 행위를 검증하는 예시를 보자. 가령, 사용자 인증을 처리하는 클래스를 가정해보자. 이 클래스는 사용자가 올바른 자격 증명을 제공했을 때에만 특정 동작을 수행한. 이때 Mock 객체를 사용하여 해당 동작이 발생하는지 검증할 수 있다.

public class AuthenticationManager {
    private AuthService authService;

    public AuthenticationManager(AuthService authService) {
        this.authService = authService;
    }

    public boolean authenticate(String username, String password) {
        // AuthService를 통해 사용자 인증을 수행한다.
        // 인증이 성공하면 true를 반환한다.
        return authService.authenticateUser(username, password);
    }
}

public interface AuthService {
    boolean authenticateUser(String username, String password);
}

이제 AuthService를 Mock 객체로 대체하여 사용자 인증이 올바르게 수행되는지를 검증할 수 있다.

import static org.mockito.Mockito.*;

public class AuthenticationManagerTest {
    @Test
    public void testAuthenticationSuccess() {
        // Mock 객체 생성
        AuthService mockAuthService = mock(AuthService.class);
        // 사용자 인증이 성공하도록 설정
        when(mockAuthService.authenticateUser("testUser", "testPassword")).thenReturn(true);

        // 테스트 대상 객체 생성 및 Mock 객체 주입
        AuthenticationManager authenticationManager = new AuthenticationManager(mockAuthService);

        // 사용자 인증 메서드 호출
        boolean isAuthenticated = authenticationManager.authenticate("testUser", "testPassword");

        // Mock 객체의 메서드가 호출되었는지 검증
        verify(mockAuthService).authenticateUser("testUser", "testPassword");

        // 예상 결과와 실제 결과 비교
        assertTrue(isAuthenticated); // 사용자 인증이 성공해야 함
    }
}

위의 테스트 코드에서는 AuthServiceauthenticateUser() 메서드가 특정 매개변수와 함께 호출되는지를 검증하고 있다. 이를 통해 AuthenticationManager가 올바르게 동작하는지 검증할 수 있다.

Spy

스파이는 실제 객체를 감시하고 관찰하는 역할을 한다. 특정 메서드 호출이 발생했는지, 몇 번 호출되었는지, 어떤 매개변수가 전달되었는지 등을 기록하고 확인할 수 있다. 스파이는 테스트 중에 객체의 상태와 동작을 확인하는 데 유용하다.

이제 Spy 객체를 사용하는 예시를 살펴보자. 예를 들어, 파일 로그를 기록하는 Logger 클래스가 있다고 가정해보자. 이 Logger 클래스는 특정 메서드가 호출될 때 로그를 기록한다. 이때 Spy 객체를 사용하여 특정 메서드 호출 여부를 확인할 수 있다.

public class Logger {
    public void log(String message) {
        // 메시지를 로그로 기록하는 동작
        System.out.println("Logging message: " + message);
    }
}

이제 Spy 객체를 사용하여 테스트할 수 있다.

public class LoggerTest {
    @Test
    public void testLogMethod() {
        // Spy 객체 생성
        Logger spyLogger = spy(new Logger());

        // 테스트 대상 객체 주입
        // Spy 객체가 실제로 호출되는지를 검증하기 위해 사용됨
        SomeService service = new SomeService(spyLogger);

        // 특정 동작 수행
        service.doSomething();

        // Spy 객체의 메서드가 호출되었는지 확인
        verify(spyLogger).log("Some message");
    }

    // 테스트용 클래스 정의
    private static class SomeService {
        private Logger logger;

        public SomeService(Logger logger) {
            this.logger = logger;
        }

        public void doSomething() {
            // 특정 동작 수행
            // Logger 객체의 log 메서드를 호출하여 로그를 기록한다.
            logger.log("Some message");
        }
    }
}

위의 예시에서는 Logger 클래스의 log 메서드 호출 여부를 Spy 객체를 사용하여 검증하고 있다. Spy 객체를 사용하여 메서드 호출을 감시하고 그것에 대한 검증을 수행할 수 있다.

Stub, Mock 보다는 Fake

Mock과 Stub은 실제적이지 않은 테스트를 만들 수 있다

클래스나 함수에 대해 Mock 객체를 만들거나 Stub할 때 테스트 코드를 작성하는 개발자는 Mock이나 Stub이 어떻게 동작할지 결정해야 한다.

테스트 코드를 작성하는 개발자는 Mock이나 Stub이 어떻게 동작할지 결정해야 하는데, 클래스나 함수가 실제와 다르게 동작하도록 하는 것은 위험하다. 이렇게 하면 테스트는 통과하고 모든 것이 잘 동작한다고 착각하지만 코드가 실제로 실행되면 부정확하게 동작하거나 버그가 발생할 수 있다.

테스트 유지보수

Stub보다는 Fake를 선호하는 이유는 테스트의 환경이 바뀌면 Stub은 고정된 데이터이기 때문에 이를 변경해줘야 하기 때문이다. 하지만 Fake는 간단히 구현된 버전이기에 변경에 더욱 탈력적이다.

정리

  • 의존성이 강한 컴포넌트 대신 테스트 더블을 통해 검증할 수 있다.
  • 용도에 맞는 테스트 더블을 선택하자. 중요한 것은 테스트의 목적인 테스트를 통해 버그를 잡을 수 있는냐이다.
profile
초코칩처럼 달콤한 코드를 짜자

0개의 댓글