fragile and resilient

정리

[Test] Test Double

Green Lawn 2022. 5. 26. 10:35

테스트가 외부 요인에 의존하는 경우가 있는데요.

예시로 살펴보면,

  • 테스트에서 DB를 연동하는 경우
  • 테스트에서 HTTP 서버와 통신하는 경우

테스트가 이런 외부 요인에 의존하면 테스트하기가 어려워지게 됩니다.

또한, 테스트에서 사용하는 외부 API 서버가 일시적으로 장애가 나면 테스트를 수행할 수 없게 됩니다.

이렇게 테스트가 의존하는 외부 요인 때문에 테스트가 어려운 경우에는 대역을 써서 테스트를 진행할 수 있는데요.

대역의 종류에 대해 알아봅시다.

대역의 종류

  • Fake
    • 프로덕션에는 적합하지 않지만, 실제 동작하는 구현을 제공한다.
  • Stub
    • 테스트에 맞게 단순히 원하는 동작을 수행한다.
  • Spy
    • 호출된 내역을 기록한다. 기록한 내용은 테스트 결과를 검증할 때 사용한다.
  • Mock
    • 기대한 대로 상호작용하는 행위를 검증한다. 기대한 대로 동작하지 않으면 익셉션을 발생할 수 있다.
    • Mock은 Stub이자 Spy도 된다.

지금까지 미션을 진행하면서 Fake와 Mock 대역을 써보았는데요.

루나와 Fake, Mock, 프로덕션을 사용하여 테스트한 경험에 대한 이야기를 나누고, 이건 정리해 봐야지!라고 다짐하여 적어보았다.

Fake를 사용한 테스트

DB 데이터에 접근하는 DAO 객체를 Fake로 만든 경우를 살펴보겠습니다.

public interface LineDao {

    Line save(Line line);

    Line findById(Long id);

    int update(Long id, Line line);

    int delete(Long id);
}

프로덕션과 테스트에 각각 다른 DAO를 두기 위해, 인터페이스를 선언합니다.

@Repository
public class LineJdbcDao implements LineDao {

    private final JdbcTemplate jdbcTemplate;

    public LineJdbcDao(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public Line save(Line line) {
        final String sql = "insert into Line (name, color) values (?, ?)";
        KeyHolder keyHolder = new GeneratedKeyHolder();
        jdbcTemplate.update(connection -> {
            PreparedStatement ps = connection.prepareStatement(sql, new String[]{"id"});
            ps.setString(1, line.getName());
            ps.setString(2, line.getColor());
            return ps;
        }, keyHolder);
        return new Line(keyHolder.getKey().longValue(), line.getName(),
                line.getColor());
    }

    @Override
    public Line findById(Long id) {
        final String sql = "select * from line where id = (?)";
        return jdbcTemplate.queryForObject(sql, lineRowMapper(), id);
    }

		@Override
    public int update(Long id, Line line) {
        final String sql = "update line set (name, color) = (?, ?) where id = ?";
        return jdbcTemplate.update(sql, line.getName(), line.getColor(), id);
    }Line save(Line line);

    @Override
    public int delete(Long id) {
        final String sql = "delete from line where id = ?";
        return jdbcTemplate.update(sql, id);
    }
}

프로덕션 DAO에는 실제 DB에 접속하여 데이터 CRUD하는 코드가 작성되어 있습니다.

해당 객체는 프로덕션 service에서 사용이 되고 있습니다.

public class FakeLineDao implements LineDao {

    private static final Map<String, Line> LINES = new HashMap<>();

    private static Long seq = 0L;

    @Override
    public Line save(Line line) {
        if (LINES.containsKey(line.getName())) {
            throw new ClientException("이미 등록된 지하철노선입니다.");
        }
        Line persistLine = new Line(++seq, line.getName(), line.getColor());
        LINES.put(line.getName(), persistLine);
        return persistLine;
    }

    @Override
    public Line find(Long id) {
        return LINES.keySet()
                .stream()
                .filter(key -> LINES.get(key).getId() == id)
                .map(LINES::get)
                .findAny()
                .orElseThrow(() -> new ClientException("존재하지 않는 노선입니다."));
    }

    @Override
    public int update(Long id, Line line) {
        if (LINES.containsKey(line.getName())) {
            throw new ClientException("등록된 지하철노선으로 변경할 수 없습니다.");
        }
        Line existingLine = find(id);
        LINES.remove(existingLine.getName());
        LINES.put(line.getName(), new Line(id, line.getName(), line.getColor()));
        if (LINES.containsKey(line.getName())) {
            return 1;
        }
        return 0;
    }

    @Override
    public int delete(Long id) {
        String lineName = LINES.keySet()
                .stream()
                .filter(key -> LINES.get(key).getId() == id)
                .findAny()
                .orElseThrow(() -> new ClientException("존재하지 않는 노선입니다."));
        LINES.remove(lineName);
        if (LINES.containsKey(lineName)) {
            return 0;
        }
        return 1;
    }
}

반면 Fake DAO에는 DB에 연동되지 않아 외부 요인에 영향을 받지 않고, 자바 코드 자체로 동작할 수 있도록 구현할 수 있습니다.

class LineServiceTest {

    private LineService lineService;

    @BeforeEach
    void setUp() {
        lineService = new LineService(new FakeLineDao());
    }

    @DisplayName("노선 저장")
    @Test
    void save() {
        LineRequest line = new LineRequest("4호선", "green");
        LineResponse newLine = lineService.createLine(line);

        assertThat(line.getName()).isEqualTo(newLine.getName());
    }
}

Service 테스트에서 해당 가짜 객체를 사용하여 테스트를 진행하고 있습니다.

장점

  • DB와 연결하지 않기 때문에, 테스트가 외부 요인에 영향을 받지 않습니다.

단점

  • 코드가 변경될 경우 많은 수정 사항이 발생한다.
    • 기존 프로덕션 DAO에 새로운 메서드가 추가되거나, 변경될 경우 Fake도 매번 함께 바꾸어 줘야 하기 때문에 수정하는 것이 번거롭습니다.
  • 실제 프로덕션 코드와 DB를 사용하는 것이 아니기 때문에, 테스트에서는 잘 작동하지만 프로덕션에서는 잘 동작하지 않을 가능성이 있습니다.

Mock을 사용한 테스트

@ExtendWith(MockitoExtension.class)
class LineServiceTest {

    @InjectMocks
    private LineService lineService;

    @Mock
    private StationDao stationDao;

    @Mock
    private LineDao lineDao;

    @Mock
    private SectionDao sectionDao;

    @Test
    @DisplayName("지하철 노선 생성")
    void createLine() {
        given(stationDao.findById(1L)).willReturn(new Station(1L, "강남역"));
        given(stationDao.findById(2L)).willReturn(new Station(2L, "역삼역"));

        given(lineDao.save(new Line("2호선", "green", 50))).willReturn(new Line(1L, "2호선", "green", 50));

        given(sectionDao.save(1L, new Section(1L, 1L, 2L, 10)))
                .willReturn(new Section(1L, 1L, 1L, 2L, 10));

        LineResponse lineResponse = lineService.save((new LineRequest("2호선", "green", 1L, 2L, 10, 50)));
        assertThat(lineResponse.getName()).isEqualTo("2호선");
        assertThat(lineResponse.getId()).isEqualTo(1L);
    }
}

Mock을 사용하면 동작에 대해 기대하는 값을 지정해줄 수 있습니다.

장점

  • DB와 연결하지 않기 때문에 테스트가 외부 요인에 영향을 받지 않습니다.

단점

  • 각각의 동작마다 기대하는 값을 지정해주어야 하기 때문에 번거롭습니다.

위 코드로 예를 들면, 위에서 검증하고 싶은 것은

lineService.save((new LineRequest("2호선", "green", 1L, 2L, 10, 50)));
        assertThat(lineResponse.getName()).isEqualTo("2호선");

노선을 저장하는 부분인데, 그와 연관된 행위(지하철 역, 지하철 구간 저장)에 기대하는 값들을 모두 설정해 주어야 합니다.

  • 프로덕션 코드와 DB를 사용하는 것이 아니기 때문에, 테스트에서는 잘 작동하지만 실제 서비스에서는 잘 동작하지 않을 가능성이 있습니다.

프로덕션 객체를 사용한 테스트

@SpringBootTest
@Sql(scripts = {"classpath:schema.sql"}, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
class LineServiceTest {

    @Autowired
    private LineService lineService;

    @Autowired
    private StationJdbcDao stationJdbcDao;

    @DisplayName("노선 저장")
    @Test
    void save() {
        Station firstStation = stationJdbcDao.save(new Station("역삼역"));
        Station secondStation = stationJdbcDao.save(new Station("삼성역"));

        LineRequest line = new LineRequest("4호선", "green", firstStation.getId(), secondStation.getId(), 10);
        LineResponse newLine = lineService.save(line);

        assertThat(line.getName()).isEqualTo(newLine.getName());
    }
}

프로덕션 DAO를 사용하여 service를 테스트하는 코드입니다.

장점

  • 프로덕션 객체로 테스트하기 때문에, 실제 서비스 상황과 매우 비슷하게 테스트할 수 있습니다.

단점

  • 외부 요인에 의존하고 있기 때문에, 외부 요인에 문제가 생기면 테스트 또한 함께 영향을 받게 됩니다.

정리

미션을 진행하면서 느꼈던 부분들에 대해 정리해 보았는데요.

실제 프로덕션 객체로으로 테스트하는 것은 아니지만, Fake와 Mock를 사용하면 DB와 분리되어 테스트를 진행할 수 있습니다.

반면 프로덕션 도메인 객체를 사용하면 외부 요인에 의존적이긴 하지만, 실제 서비스와 매우 비슷한 상태에서 테스트를 진행하기 때문에 더 확실하게 테스트를 진행할 수 있습니다.

테스트 더블에 대해 학습을 진행하면서 각각의 상황에 따라 어떤 것을 사용하면 좋을지에 대해 고민하면서 선택하면 좋을 것이라고 생각합니다.


References

최범균, 테스트 주도 개발 시작하기, 2020

https://tecoble.techcourse.co.kr/post/2020-09-19-what-is-test-double/

'정리' 카테고리의 다른 글

[Logging] Logback이란?  (0) 2022.08.26
OAuth 2.0 인증 과정  (0) 2022.07.10
[Git] Branch 관리 (Merge, Rebase)  (0) 2022.02.14
[Git] git 영역 및 상태  (0) 2022.02.14