TDD 를 '어떻게' 할 것인가

2023년 5월 17일 작성

TestTDD by Example

탑다운? 바텀업?

  • 나는 무조건 통합테스트를 우선시 했었는데 다시 생각해보니
    • 통합테스트를 사용하면 여러 계층에 대한 의존을 끊고 TDD할 수가 없다.
    • 통합테스트로 TDD를 하려면 in-out 방식으로 구현해야한다. (의존 방향의 반대 순서로 개발.)
    • 동시 개발이 어렵다.
    • 계층별로 구현할 때 단위 테스트를 사용하면
    • TDD를 하면서도 out-in 방식으로 구현 가능.

나는 out-in 으로 개발하는 것이 훨씬 쉬웠던 것 같다. 일단 내가 최종적으로 제공해야하는 인터페이스에 맞게 가짜 결과를 가공하고, 이후에는 도메인 로직에 집중하는 것이 더 재미있고 효율적이었던 것 같다.

수정합니다. 나는 Fake + Stub 정도는 구현을 하고 만드는게 좋았던 것 같다. <내가 명세해주지 않아도 스스로 행동할 수 있는 정도>

예를 들어, 지하철 노선 관리 애플리케이션을 만든다고 치자.

Station, 역이라는 객체의 저장소를 다음과 같이 인터페이스로 정의했다면

public interface StationRepository {
 
    Optional<Station> findById(final Long id);
 
    Optional<Station> findByName(final String name);
 
    Long create(final Station station);
 
    List<Station> findById(List<Long> ids);
 
    List<Station> findAll();
}

이렇게 Fake를 만들어 구현하는 것이다. (실제 동작하는 객체를 만들었기 때문에, Fake도 TDD로 진행할 수 있게된다. 그러면 나중에 실제 구현체를 만들 때는 이미 만들어진 테스트를 돌려가며 만들 수 있다.)

public class SimpleStationRepository implements StationRepository {
 
    private Long idIndex;
 
    final List<Station> stations;
 
    public SimpleStationRepository() {
        this.stations = new ArrayList<>();
        idIndex = 1L;
    }
 
    @Override
    public Optional<Station> findById(Long id) {
        return stations.stream()
                .filter(station -> station.getId().equals(id))
                .findFirst();
    }
 
    @Override
    public Optional<Station> findByName(String name) {
        return stations.stream()
                .filter(station -> station.getName().equals(name))
                .findFirst();
    }
 
    @Override
    public Long create(Station station) {
        stations.add(new Station(idIndex, station.getName()));
        return idIndex++;
    }
 
    @Override
    public List<Station> findById(List<Long> ids) {
        return ids.stream()
                .map(id -> stations.stream()
                        .filter(station -> station.getId().equals(id)).findFirst().orElseThrow())
                .collect(Collectors.toList());
    }
 
    @Override
    public List<Station> findAll() {
        return stations;
    }
}
 

근데 이 방법의 단점은 fake 만드는데도 시간이 걸린다는 것이다. 내가 아직 TDD가 익숙하지 않아서 그럴 수 있지만 결과적으로 tdd로 시간 쏟아 만든게 fake 객체라는 건 기분이 썩 좋지 않았다. 뭐 이거는 내 개인적인 문제라 치더라도, 이렇게 해보니 살짝 아쉬운 점 중 하나가, 나중에 DAO를 구현해서 데이터베이스를 사용하는 Repository를 만들었을 때, DAO 테스트를 해야하는가..? 라는 생각이 좀 들었었다. 이거에 대한 보완은 이런식으로 할 수 있을 것 같다.

  • 기존 Repository 테스트는 유지한다. 객체 저장소에게 기대할 역할은 여전히 잘 검증하고 있다.
  • DAO 테스트를 새로 만들지만, 여기에서는 Connection과, SQL 문법 등을 검증한다. DAO의 책임을 데이터베이스와의 통신.. 쯤으로 두는 것이다.

내 생각을 정리해도, 상황마다 그리고 풀어야할 문제마다 정해가 다르다는 느낌을 받았다. 머릿속이 복잡할 땐! 책을 가져와서 읽어야지.

TDD by Example 에서는 다음과 같이 이야기한다.

전체 계산 중 간단한 하나의 사례를 나타내는 테스트에서 시작했다면, 이 테스트를 통해 자라는 프로그램은 하향식으로 작성된 것으로 보일 수 있다. 반면 전체의 작은 한 조각을 나타내는 테스트에서 시작하여 조금씩 붙여나가는 식이었다면, 이 프로그램은 상향식으로 작성된 것으로 보일 수도 있다.

사실은 상향식, 하향식 둘 다 TDD의 프로세스를 효과적으로 설명해 줄 수 없다.

첫째로, 이와 같은 수직적 메타포는 프로그램이 시간에 따라 어떻게 변해가는지에 대한 단순화된 시각일 뿐이다. 이보다는 ‘성장’이란 단어를 보자. 성장은 일종의 자기유사성을 가진 피드백 고리를 암시하는데, 이 피드백 고리에서는 환경이 프로그램에 영향을 주고 프로그램이 다시 환경에 영향을 준다.

둘째로, 만약 메타포가 어떤 방향성을 가질 필요가 있다면 ‘known-to-unknown’ (아는 것에서 모르는 것으로) 라는 방향이 유용할 것이다. ‘아는 것에서 모르는 것으로’ 는 우리가 어느 정도의 지식과 경험을 가지고 개발을 시작한다는 점, 개발하는 중에 새로운 것을 배우게 될 것임을 예상한다는 점 등을 암시한다. 이 두 가지를 합쳐보자. 우리는 아는 것에서 모르는 것으로 성장하는 프로그램을 갖게 된다.

이걸 읽고 보니, 내가 out-in으로 더 좋은 경험을 했던 이유는 TDD로 진행했던 모든 경우에, 항상 깡통같은 빈 프로그램에 주어진 요구사항을 적용해야했기 때문인 것 같다.

중심로직 외에 데이터를 최종적으로 가공하거나, 요청을 읽어오는 기능들은 미션/사람마다 다 비슷해서 눈감고도 코딩할 수 있었기 때문에 ‘가장 잘’ 아는 것이었고, 매 미션마다 고민해야하는 다른 도메인은 미지의 영역이었다.

바텀업으로 도메인 부터 차곡차곡 의존 방향의 반대로 구현을 하다보면 결국 최종적인 요구사항을 테스트 하는 것은 마지막 일이 되었다. 이건 은근한 스트레스가 되었던 것이, 내가 당장 하고 있는 이 구현이 내가 알고 있는 최종 요구사항을 진짜로 잘 실행할 수 있을지 확신할 수 없었기 때문이다. (자연스레, 테스트의 주기도 길어지게 되었던 것도 한 몫.)

앞으로 내가 참고할만한 것들?

  • 테스트 피라미드

https://martinfowler.com/bliki/TestPyramid.html

근데 이에 관해서는 법칙처럼 외우기보다는 마틴 파울러가 하고싶었던 말이 무엇인지를 알아 두어야한다.

그래도 내가 고수하고 싶은 것: Sociable test

나는 협력객체와 함께 검증하는 sociable test를 더 선호하게 되었다. 테스트에 자세한 구현이나 협력객체의 명세가 없기 때문에, 리팩토링이 수월해진다는 큰 메리트가 있기 때문이다. 사실 mockito도 필요한 상황이 있지만 되도록 안쓰려하는 것이, mockito가 진짜 나빠서가 아니라.. 테스트 짜면서 열심히 구현을 모킹해놨기 때문에 리팩토링에 대한 심리적인 부담감이 커지는 것이 진짜 이유이다. 소프트웨어는 변화하는 것이 아이덴티티인데, 이전의 코드를 너무 고수하게 되는 것같아서, 모킹은 나 스스로 억제하는 것이 맞다고 생각했다.

모킹을 잘하면서 리팩토링도 주저없이 하는 사람이라면 난 그의 선택을 응원한다...

다만 이런 식으로 TDD를 진행하면서 느낀 점은, 통합테스트를 작성하면서 개발하게 되면 가장 아래 계층(도메인)을 제외하고는 협력객체가 필요해지므로, in-out 방식으로 개발할 수 밖에 없다는 것이다. 따라서 이번 미션에서 나는 페어와 함께 인수테스트 작성 후, 계층별 단위테스트를 하지 않았더니 in-out 방식으로 도메인부터 개발하게 되었던 것 같다.

그냥 앞으로는 인수테스트로 기능 요구사항을 코드로 표현만 해놓고 in out으로 하는게 나쁘지 않을 것 같다. 다만 설계를 꼼꼼히 하고 아는 것이 많은 상태로 할 수 있게 해야하지 않을까..?


내가 이상적으로 생각하는 것과 내 실제 개발 습관의 간극

나는 어떻게 해야할까?? 법칙을 정하기 보다는 켄트 백을 따라해보려고 한다. 내가 도메인을 잘 알아서 그부분을 명확히 만들 수만 있다면 바텀업으로 개발하고, 다른 부분은 하나도 구현되어있지 않아서 헷갈린다면 요구사항만으로 탑다운 방식을 가지고 시작할 수 있을 것이다.

말은 참 쉽다는 생각이 들면서, 나는 원래 아는 것부터 해결하는 사람이었는지 돌아 보았다.

그런적도 있었고, 아닌 적도 있었다. 그렇담 이상적인 목표와 실제 내 습관의 간극을 어떻게 줄일까?

사실 답은 알고 있다. ‘의식적인 연습’ 그리고 깨진창문을 그대로 두지 않기...!

그리고 이번에 책을 잠깐 읽으면서, 테스트도 다른 장인들이 만들어놓은 방식(또는 행동 프레임워크라 해야하나.)들이 많아서 그런 것들을 계속해서 참고해보고 고민을 많이 해서 나만의 방법을 좀 만들어봐야겠다는 생각이 들었다.

참고 TDD By Example, 켄트 백 정말로 단위 테스트를 통합 테스트보다 많이 작성해야 할까?