2차 강의 - TDD로 자동차 경주게임 구현

기능 요구사항

  • 각 자동차에 이름을 부여할 수 있다. 자동차 이름은 5자를 초과할 수 없다.
  • 전진하는 자동차를 출력할 때 자동차 이름을 같이 출력한다.
  • 자동차 이름은 쉼표(,)를 기준으로 구분한다.
  • 자동차 경주 게임을 완료한 후 누가 우승했는지를 알려준다. 우승자는 한명 이상일 수 있다.

 

단위 테스트, TDD를 시작할 때의 감정 상태

TDD → 어디서, 어떻게 시작해야할 지 모르겠고, 막막하고 어렵다.

자동차 경주게임, 앞으로 진행할 로또, 사다리타기 미션들은 실무와 비슷하다. 물론 실무보다는 요구사항이 명확한 상태에서 TDD를 진행할 수 있는데, 실무로 가면 요구사항이 명확하지 않은데, TDD로 개발해야할 상황이 오게 된다. 객체 설계도 해야하고.. 어렵다. 막막하다..

구현후 자동화된 테스트를 만드는건 할만한데.. TDD는 알듯말듯하면서 이게 맞나? 싶고 어렵다.

⇒ 당연한 거다. 연습을 반복 해야한다.

 

도메인 지식, 객체 설계 경험이 있는 경우

  • 요구사항 분석을 통해 대략적인 설계를 한다. - 객체 추출
  • UI, DB 등과 의존관계를 가지지 않는 핵심 도메인 영역을 집중 설계한다.

TDD를 잘 하기 위해서 대략적이더라도 도메인 객체를 설계를 할 수 있어야 한다.

그리고 테스트 하기 어려운 부분에 대해 의존관계를 가지지 않도록 분리할 수 있어야 한다.

이렇게 도메인 객체에 대해서 TDD를 하게되면 좀 쉬워진다. 로직을 구현하는데, 테스트 하기 어려운 코드가 섞여있게 되면 TDD로 개발하기 힘들다. 이를 극복하기위해 설계역량을 길러야하고 많은 연습이 필요하다.

  • 1차적으로는 도메인 로직을 테스트하는 것에 집중하자.

Controller와 View영역에 대한 단위 테스트는 지금 단계에서는 고민하지 마라.

이 부분은 나중에 통합테스트, 인수테스트, end-to-end 테스트와 같은 큰 단위의 테스트를 할 때 고려하면 되고, TDD 할때는 단위테스트에 집중한다.

 

단위 테스트의 관심사는 물론 Controller와 View에도 있지만 핵심 비즈니스로직을 가지는 Domain 영역이다.

즉, Domain 영역의 단위테스트에 집중하자!

Controller와 View 부분은 설계를 대충해도 괜찮다. (중요한 영역이 아니다.)

객체 지향 설계 원칙을 꼭 지키지 않아도 된다. (이 부분은 depth가 2, 3 넘어가도, 메서드 라인 수가 길어져도 괜찮다고 생각한다.)

근데, Domain 영역에 대해서는 꼭 이 원칙을 지키고 연습 하자!

  • 대략적인 도메인 객체 설계

대략적인 도메인 객체를 설계하고, 테스트하기 어려운 Random 값의 경우 경계를 잘 구분하여 분리하고, 테스트하기 쉬운 도메인 객체쪽을 중점적으로 테스트 한다.

but 객체 설계 역량도 부족하고, 단위 테스트가 익숙하지 않고, TDD는 더욱 어렵다면?

 

구현할 기능 목록을 작성하자

  • 구현할 기능 목록을 작성한 후에 TDD로 도전한다
  • 기능 목록을 작성하는 것도 역량이 필요하다.
  • 역량도 중요하지만 연습이 더 중요하다.

그래도 막막하다면 ..

  • 단위테스트도 없고, TDD도 아니고, 객체 설계또 하지 않고, 기능 목록을 분리하지도 않고 지금까지 익숙한 방식으로 일단 구현해라.
  • 구현하려는 프로그래밍의 도메인 지식을 쌓는다.
  • 그리고 구현한 모든 코드를 버린다.
  • 이제 구현할 기능 목록을 간단히 작성해보고 간단한 도메인 설계를 해본다.
  • 기능 목록 중 가장 만만한 녀석부터 TDD로 구현해 본다.
  • 복잡도가 높아져서 리팩토링하기 힘든 상태가 되면 다시 버린다.
  • 무한 반복

코드를 왜 버리는가?

아무것도 없는 상태에서 새롭게 구현하는것보다

레거시 코드가 있는 상태에서 리팩토링 하는것이 몇 배 더 어렵기 때문이다.

기능 목록을 작성하더라도, 빈틈이 존재한다. 개발하면서 게속해서 내가 몰랐던 기능들이 나오게되고 그때마다 업데이트를 해야 한다. 마찬가지로, 도메인 설계도 내가 처음에 했던 것과 달리 개발하고 리팩토링하면서 도메인 객체들이 생겨야하는 상황이 생길 수 있다. 당연한 상황이다.

기능 목록을 작성한 후 테스트 가능한 부분을 찾아 TDD로 도전하자

  • 참여자의 이름을 split하고 자동차를 생성한다.
  • 1자 이상, 5자 이하의 정상적인 이름인지 확인한다.
  • 자동차 이동 유뮤를 확인한다.
  • 자동차 이동 거리에 따라 “-”를 생성한다.
  • 경주에 참여한 자동차 중에서 우승자를 찾는다.
  • 우승자의 이름을 출력한다.

→ TDD는 테스트를 최대한 잘게 나눌 수 있는게 좋다. (그래야 테스트하기 쉽고 TDD로 개발하기 더 쉬워진다.)

1단계 - Util 성격의 기능이 TDD로 도전하기 좋다.

TDD 난이도 낮다. 시간 투자 대비 효과도 낮다.

  • 1자 이상, 5자 이하의 정상적인 이름인지 확인한다.
  • 자동차 이동 거리에 따라 “-”를 생성한다.

2단계 - 로직의 복잡도가 낮으면서 단위 테스트 가능한 기능을 TDD로 도전한다.

TDD 난이도 낮다. 시간 투자 대비 효과도 낮다.

  • 참여자의 이름을 split하고 자동차를 생성한다.

3단계 - 로직의 복잡도가 높으면서 단위 테스트 가능한 기능을 TDD로 도전한다.

TDD 난이도 중간. 시간 투자 대비 효과도 높다.
로직의 복잡도가 높은 만큼 요구사항 변경, 리팩토링의 빈도가 상대적으로 높다.

  • 경주에 참여한 자동차 중에서 우승자 찾기
public class Car {
    private static final int FORWARD_NUM = 4;
    private static final int MAX_BOUND = 10;

    private final String name;
    private int poisiton = 0;

    public Car(final String name) {
        if (StringUtils.isBlank(name)) {
            throw new IllegalArgumentException("자동차 이름은 값이 존재해야 합니다.");
        }
        this.name = name.trim();
    }

    public int getPoisiton() {
        return poisiton;
    }

    public String getName() {
        return name;
    }

    public void move() {
        Random random = new Random();
        int randomNo = random.nextInt(MAX_BOUND);
        if (randomNo >= FORWARD_NUM) {
            this.poisiton++;
        }
    }
}

위와 같이 Car 객체를 만들었다고 생각해보자. 우승자를 찾는 로직을 TDD 해보자.

public class WinnersTest {
    @Test
    void findWinners() {
        우승자 목록 = findWinners(// 경주를 진행한 자동차 목록);
    }
}

우승자를 찾기위한 findWinners 메소드가 있어야하고, 그 메소드의 인풋과 아웃풋이 뭔지를 고민해야 한다. (그 객체의 값이 어떤 상태인지 고민하는것보다 객체의 행동을 보는 것이다.)

public class WinnersTest {
    @Test
    void findWinners() {
        List<Car> cars = new ArrayList<>();
        cars.add(new Car("pobi"));
        cars.add(new Car("jason"));
        cars.add(new Car("cu"));

        List<Car> winners = findWinners(cars);
        assertThat(winners).contains(?)
    }
}

이제 위와같이 테스트코드를 작성했다. 근데 cars에서 누가 우승자인지 알 수가 없다. 그래서 테스트를 위해 “pobi”를 우승자로 만들고 싶으면 어떻게 해야할까?

public class Car {
    private static final int FORWARD_NUM = 4;
    private static final int MAX_BOUND = 10;

    private final String name;
    private int poisiton = 0;

    public Car(final String name) {
        if (StringUtils.isBlank(name)) {
            throw new IllegalArgumentException("자동차 이름은 값이 존재해야 합니다.");
        }
        this.name = name.trim();
    }

    public int getPoisiton() {
        return poisiton;
    }

    public String getName() {
        return name;
    }

    public void move() {
        Random random = new Random();
        int randomNo = random.nextInt(MAX_BOUND);
        if (randomNo >= FORWARD_NUM) {
            this.poisiton++;
        }
    }

     public void move(int randomNo) { // 테스트하기 어려운 값을 외부로 부터 주입받기
        if (randomNo >= FORWARD_NUM) {
            this.poisiton++;
        }
    }
}

이렇게 move를 오버로딩하여 메소드 시그니쳐를 다르게하여 랜덤값에대한 의존을 분리 할 수있다.

public class WinnersTest {
    @Test
    void findWinners() {
        List<Car> cars = new ArrayList<>();
        Car pobi = new Car("pobi");
        pobi.move(4);
        pobi.move(4);
        cars.add(pobi);
        Car jason = new Car("jason");
        jason.move(4);
        cars.add(jason);
        cars.add(new Car("cu"));

        List<Car> winners = findWinners(cars);
        assertThat(winners).contains(pobi)
    }
}

테스트를 위한 데이터를 초기화 하는 것을 test fixture 라고 하는데, 위와 같이하면 pobi가 우승했다는 테스트 결과를 얻을 수는 있다.

하지만 테스트를 정제하는 과정에서 코드가 너무 지저분해진다. (또한 메서드 시그니쳐를 변경하면 컴파일 에러가 발생하는 곳도 많아 지기 때문에 좋은 방법이 아니다. 나중에 뒤에서 다시 설명)

public class Car {
    private static final int FORWARD_NUM = 4;
    private static final int MAX_BOUND = 10;

    private final String name;
    private int poisiton = 0;

    public Car(final String name) {
        if (StringUtils.isBlank(name)) {
            throw new IllegalArgumentException("자동차 이름은 값이 존재해야 합니다.");
        }
        this.name = name.trim();
    }

    // 위치를 받는 생성자 추가
    public Car(String name, int position) {
        this.name = name;
        this.position = position;
    }

    ...
}
public class WinnersTest {
    @Test
    void findWinners() {
        List<Car> cars = new ArrayList<>();
        cars.add(new Car("pobi", 3)); // pobi 우승
        cars.add(new Car("jason", 2));
        cars.add(new Car("cu", 1));

        List<Car> winners = findWinners(cars);
        assertThat(winners).contains(new Car("pobi", 3))
    }
}

이런 경우에는 Car의 상태를 생성자를 통해서 바로 정해주면 훨씬 간단해 진다.

public class Car {
    private static final int FORWARD_NUM = 4;
    private static final int MAX_BOUND = 10;

    private final String name;
    private int poisiton = 0;

    public Car(final String name) {
        this(name, 0);
    }

    // 위치를 받는 생성자 추가
    public Car(String name, int position) {
        if (StringUtils.isBlank(name)) {
            throw new IllegalArgumentException("자동차 이름은 값이 존재해야 합니다.");
        }
        this.name = name.trim();
        this.position = position;
    }

    ...
}

참고 : 생성자가 여러개 생기게 되면 중복이 많아진다. 따라서 인자가 많은 파라미터에서 예외처리와 같은 로직을 수행하고 다른 생성자에서는 해당 생성자를 호출 (this) 방식으로 구현하면 훨씬 간단해 진다.

근데 이렇게 생성자를 이용하여 position을 변경할 수 있게 하는게 결국 테스트 코드만을 위한 것인데 이렇게 사용해도 되는게 맞는것인가? 고민하게 된다.

결론은 생성자를 이용하여 테스트를 하기 쉽든, 해당 객체를 만들기 편하게 하든, 생성자를 추가하는 것은 언제든 허용하고 장려한다.

이렇게 Car의 데이터를 바꿀 수 있는 생성자를 여러개 만드는 것은 얼마든지 해도 좋다! 이런거에 대한 거부감은 없어도 된다!

(생성자를 많이 만들게 되면 Car를 사용하는 개발자는 상당히 편의성이 높아지기 때문이다. 확장성도 좋아진다. ⇒ 한번 생성된 이후부터는 레이싱 게임이 일어나는 와중에 절때로 해당 객체의 값이 바뀔일이 없기 때문!)

가능한 생성자를 사용하도록 하고, 바뀔 일이 없는 필드들은 final 키워드를 꼭 사용하자!

public class Car {
    private static final int FORWARD_NUM = 4;
    private static final int MAX_BOUND = 10;

    private final String name;
    private int poisiton = 0;

    ...

    public int setPoisiton(int position) { // 절대 하지말자
        this.position = position;
    }
    ...
}

하지만 하지말아야 할것은 테스트를 위한 메서드를 추가하는 것은 절대로 해서는 안된다!!

우승자 찾는 테스트 코드를 작성해 보자.

public class WinnersTest {
        @Test
        void findWineers() {
            List<Car> cars = new ArrayList<>();
            cars.add(new Car("pobi", 3));
            cars.add(new Car("pobi", 3));
            cars.add(new Car("pobi", 3));

            List<Car> winners = Winners.findWinners(cars);
            assertThat(winners).contains(new Car("pobi", 3));
        }
}

Winners.findWinners 이 부분에 대한 구현이 없으므로 테스트는 실패한다. → 만든다.

public class Winners {
    public static List<Car> findWinners(List<Car> cars) {
        int maxPosition = 0;
        for (Car car : cars) {
            if (maxPosition < car.getPosition()) {
                    maxPosition = car.getPosition();
            }
        }

        List<Car> winners = new ArrayList<>();
        for (Car car : cars) {
            if (car.getPosition() == maxPosition) {
                    winners.add(car);
            }
        }
        return winners;
    }
}

이렇게 구현하고 테스트 코드를 돌리면 실패한다. 왜냐하면 Car 객체에 대한 equals가 재정의 되지 않았기 때문이다. (테스트 코드에서 서로 다른 인스턴스 끼리비교하기 때문에 실패한다.)

public class Car {
    private static final int FORWARD_NUM = 4;
    private static final int MAX_BOUND = 10;

    private final String name;
    private int poisiton = 0;

    ...

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return tfalse;
        Car car = (Car) o;
        return position == car.position && Objects.equals(name, car.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, position);
    }

    ...
}

따라서 equals와 hashCode를 재정의하여 값이 같을때 비교가 가능하도록 한다. 이렇게 구현하면 테스트는 성공하게 된다.

이제 리팩토링의 차례다. findWinners 메서드가 많은 역할을 담당하고 있기 때문에 해당 내용을 최대 위치와, 우승자를 찾는 메서드로 분리한다.

public class Winners {
    public static List<Car> findWinners(List<Car> cars) {
        return findWinners(cars, maxPosition(cars));
    }

    private static List<Car> findWinners(List<Car> cars, int maxPosition) {
        List<Car> winners = new ArrayList<>();
        for (Car car : cars) {
            if (car.getPosition() == maxPosition) {
                    winners.add(car);
            }
        }
        return winners;
    }

    private static int maxPosition(List<Car> cars) {
        int maxPosition = 0;
        for (Car car : cars) {
            if (maxPosition < car.getPosition()) {
                    maxPosition = car.getPosition();
            }
        }
        return maxPosition;
    }
}        

위 코드에서 또다른 리팩토링 포인트를 보면 for 문안에 if문을 줄여야 한다. (indent depth가 2이다.)

(보통 이런 indent 2 이상은 stream을 써서 쉽게 해결할 수 있다.) ⇒ 도저히 depth를 줄일 수 없다면 자바 프로그래밍의 한계이기 때문에 넘어가도 상관 없다. but 1을 만들기 위해 노력은 한다!

 

indent에 따른 리팩토링도 있지만, getter 메서드가 등장하면 이부분을 항상 의심하자!

객체에 상태 데이터를 get으로 꺼내는게아니라, 상태 데이터를 가지는 객체에게 메시지를 보내서 확인할 수 있도록 해야 한다.

따라서 car 객체에서 최대 이동 거리를 판단하도록 만든다.

먼저 테스트 코드를 작성한다.

public class CarTest {
    @Test
    void 최대이동거리_유무() {
        int maxPosition = 3;
        Car car = new Car("pobi", 3);
        assertThat(new Car("pobi", 3).isMaxPosition(maxPosition)).isTrue();
        assertThat(new Car("pobi", 2).isMaxPosition(maxPosition)).isFalse();
    }
}

실패한다.

그리고 isMaxPosition 메서드를 만든다.

public class Car {
    private static final int FORWARD_NUM = 4;
    private static final int MAX_BOUND = 10;

    private final String name;
    private int poisiton = 0;

    ...

    public boolean isMaxPosition(int maxPosition) {
        return this.position == maxPosition;
    }

    ...
}

테스트가 성공한다.

이제 다시 findWinners 메서드로 돌아가서 getter 메서드 대신 isMaxPosition 메서드를 사용할 수 있게 된다.

public class Winners {
    public static List<Car> findWinners(List<Car> cars) {
        return findWinners(cars, maxPosition(cars));
    }

    private static List<Car> findWinners(List<Car> cars, int maxPosition) {
        List<Car> winners = new ArrayList<>();
        for (Car car : cars) {
            if (car.isMaxPosition(maxPosition) { // 변경.
                    winners.add(car);
            }
        }
        return winners;
    }

    private static int maxPosition(List<Car> cars) {
        int maxPosition = 0;
        for (Car car : cars) {
            if (maxPosition < car.getPosition()) {
                    maxPosition = car.getPosition();
            }
        }
        return maxPosition;
    }
}        

이렇게 변경하게 되면 findWinners를 테스트 하고싶지만 private 메서드라 테스트하기 힘든 상황에서, getter를 제거하고 객체에 메시지를 보내게 되면서 테스트가 가능한 형태로 바꿀 수 있게 되었다.

더 중요한 것은 getter를 사용한 비교를 사용하게 되면 다른 비즈니스 로직에도 동일한 상황을 계속해서 중복해서 코드를 작성할 수 밖에 없다. 그런데 객체에 메시지를 던지게 되면 만들어둔 메소드를 가지고 중복을 제거하며 코드를 훨씬 간단하게 짤 수 있게 된다.

근데 이렇게 해도 indent는 depth 2이다.

 

이 상황에서는 stream을 써서 하는게 맞다. 하지만 객체지향을 연습하는 입장에서는 최대한 stream을 배재하고 구현해보는걸 연습하자. (물론 지금 상황에서는 depth 2가 최선이다. 객체에게 메시지를 보내는 연습을 계속 하게된다면, 그때부터는 stream을 마음 껏 쓰자!)

maxPosition을 구하는 메서드도 indent depth가 2이고, getter를 두번이나 쓰고 있다.

이를 객체에게 메시지를 보낼 수 없을까? 또 다시 테스트 코드를 작성해보자.

public class CarTest {
    @Test
    void 최대이동거리_구하기() {
        Car car = new Car("pobi", 3);
        assertTHat(car.maxPosition(2)).isEqualsTo(3);
        assertTHat(car.maxPosition(4)).isEqualsTo(4);
    }
}

car.getMaxPosition보다 내가 구하고 싶은 값 의 이름 그대로 car.maxPosition으로 쓰는 방법도 좋다.

테스트는 실패한다.

public class Car {
    private static final int FORWARD_NUM = 4;
    private static final int MAX_BOUND = 10;

    private final String name;
    private int poisiton = 0;

    ...

    public int maxPosition(int maxPosition) {
        if (maxPosition < this.position) {
                return this.position;
        }
        return maxPosition;
    }

    ...
}

maxPosition을 구현하면 테스트에 성공한다.

public class Winners {
    public static List<Car> findWinners(List<Car> cars) {
        return findWinners(cars, maxPosition(cars));
    }

    private static List<Car> findWinners(List<Car> cars, int maxPosition) {
        List<Car> winners = new ArrayList<>();
        for (Car car : cars) {
            if (car.isMaxPosition(maxPosition) { // 변경.
                    winners.add(car);
            }
        }
        return winners;
    }

    private static int maxPosition(List<Car> cars) {
        int maxPosition = 0;
        for (Car car : cars) {
                maxPosition = car.maxPosition(maxPosition);
        }
        return maxPosition;
    }
}        

이렇게 getter의 사용을 없애고, depth까지 줄일 수 있게 됐다.

객체지향 설계원칙에 따르면 getter와 setter의 사용을 지양하라고 나와있다.

특히 setter는 절때 사용하면 안된다. 유일하게 dto를 통한 데이터 변환이 일어날때만 허용한다.

하지만 아무리 getter / setter를 사용하지 않으려고해도 안되는 부분이 있다. dto에 데이터를 담거나 view 단을 통한 객체의 데이터를 출력할때가 있다. 그때 진짜 필요한 getter를 오픈해야 한다!

또한, 객체에 만든 메서드 이름을 너무 프로그래밍적으로 만들지 말고 도메인에 맞는 이름을 정하는게 좋다.

public class Car {
    private static final int FORWARD_NUM = 4;
    private static final int MAX_BOUND = 10;

    private final String name;
    private int poisiton = 0;

    ...

    public boolean isWinner(int maxPosition) { // isWinner로 이름 변경
        return this.position == maxPosition;
    }

    ...
}

이렇게 되면 car가 winner인가? 로 더 명확하다. 또한 isMaxposition 메서드이름은 position의 이름이 변경되면 메서드이름도 바뀔 가능성이 높지만, isWinner는 그렇지 않다.

도메인 객체와 대화한다고 생각하라!

4단계 - 단위 테스트하기 어려운 부분을 TDD로 도전한다.

TDD 난이도 높다. 시간 투자 대비 효과는 기능에 따라 다르다.

  • 자동차 이동 유무
  • 우승자 이름 출력하기
  • 데이터베이스 CRUD

참고 : 데이터베이스 CRUD는 데이터베이스 테이블, 테이블에 밀어넣을 데이터 준비, dao 로직 등 준비해야할 상황이 너무 많기 때문에 TDD로 개발하지 않는다. (통합 테스트로 처리한다.)

참고 : UI에 대한 테스트는 TDD로 개발하지 않는다.

참고 : Controller에 대한 테스트는 상황에 따라 만들 수도 있고 안만들 수도 있다.

참고 : Service에 대한 테스트는 만들지 않는다. Service에다가 로직을 구현하지 말고 도메인 객체에다가 로직을 구현하면 Service에 대한 테스트는 필요하지 않다. ⇒ 볼링 1단계에서 해당 내용을 뼈저리게..? 느끼게 될 것이다.

참고 : 우리는 unit test(단위 테스트)와 end-to-end test(인수 테스트 : controller → service → repository → db → repository → service → controller)만 진행한다.

단위 테스트하기 어려운 코드를 TDD로 도전하는 두 가지 방법

  • TDD로 구현할 대상에서 제외한다.
    • 우승자 이름 출력하기
    • 데이터베이스 CRUD
  • 테스트하기 어려운 부분을 분리해 테스트 가능한 부분에 대해 TDD로 도전한다.
    • 자동차 이동 유무
    • 이걸 잘해야 service 계층에 있는 비즈니스 로직을 도메인 객체로 넘길 수 있다.

테스트 하기 어려운 코드를 보자.

public class Car {
    private static final int FORWARD_NUM = 4;
    private static final int MAX_BOUND = 10;

    private int position = 0;

    public void move() {
        Random random = new Random();
        int randomNo = random.nextInt(MAX_BOUND);
        if (randomNo >= FORWARD_NUM)
                this.position++;
        }
}

randomNo가 move 메서드 안에 있기 때문에 테스트하기 힘들다.

테스트 하기 어려운 부분을 찾아 테스트 가능한 구조로 개선한다.

  • Object Graph에서 다른 Object와 의존관계를 가지지 않는 마지막 노드(Node)를 먼저 찾는다.
  • 예를들어 RacingMain → RacingGame → Car와 같이 의존관계를 가진다면 Car가 테스트 가능한지 확인한다.

Random객체의 nextInt()는 어떤 값이 나올지 모르므로 테스트하기 힘들다.

그러므로 Car의 move()도 테스트하기 힘들어진다. 그렇게 되면 Car에 의존관계를 갖는 RacingGame의 race()도 테스트하기 힘들어지고, RacingMain의 main()도 테스트하기 힘들어진다. (object graph의 연쇄작용..)

해결책

  • 테스트하기 어려운 코드의 의존관계를 Object Graph의 상위로 이동시킨다.

이렇게 RacingMain이 Random에 의존관계를 가지게 한다면 RacingGame의 race()와 Car의 move()는 테스트가 가능하게 된다.

하지만 결국에 RacingMain은 테스트 하기 힘들어지지만, 얘는 테스트를 하지 않아도 괜찮다.

Car의 getRandomNo()를 RacingGame 으로 옮긴다면

public class RacingGame {
    private static final int MAX_BOUND = 10;
    private final List<Car> cars;
    private int tryNo;

    public RacingGame(String carNames, int tryNo) {
        this.cars = initCars(carNames);
        this.tryNo = tryNo;
    }

    private static List<Car> initCars(String carNames) {
        if (StringUtils.isBlank(carNames)) {
                throw new IllegalArgumentException("자동차 이름은 값이 존재해야 합니다.");
        }
        String[] names = carNames.split(',');
        List<Car> cars = new ArrayList<>();
        for (String name : names) {
                cars.add(new Car(name));
        }
        return cars;
    }

    public void race() {
        this.tryNo--;
        moveCars();
    }

    private void moveCars() {
        for (Car car : cars) {
                car.move();
        }
    }

    // car에 있던 메소드를 가져옴.
    private int getRandomNo() {
        Random random = new Random();
        return random.nextInt(MAX_BOUND);
    }

    public boolean racing() {
            return this.tryNo > 0;
    }

    public List<Car> getCars() {
        Collections.unmodifiableList(cars);
    }

    public List<Car> getWinners() {
        return null;
    }
}

여기서 getRandomNo를 car.move의 파라미터로 넘기게 바꾼다면

public class RacingGame {

    ...

    private void moveCars() {
        for (Car car : cars) {
                car.move(getRandomNo);
        }
    }

    // car에 있던 메소드를 가져옴.
    private int getRandomNo() {
        Random random = new Random();
        return random.nextInt(MAX_BOUND);
    }

    ...
}
public class Car {
    private static final int FORWARD_NUM = 4;

    private int position = 0;
    ...
    public void move(int randomNo) {
        if (randomNo >= FORWARD_NUM)
                this.position++;
        }
    }
    ...
}

Car의 기존의 move 시그니쳐가 변경되면서, 기존에 만들어둔 Car의 움직임에 대한 테스트가 컴파일에러가 발생하게 된다.

우리가 실제로 레거시 코드 복잡도가 있는 곳에서 메서드의 시그니쳐를 바꾸면 그 메서드들에 대한 컴파일에러가 연쇄적으로 발생하게 된다..

그래서 리팩토링할때 천천히 하나씩 바꿔나가야한다. (한번에 메서드 시그니쳐를 바꾸게되면 컴파일 에러나는 부분을 계속 고쳐야하므로 불안하고 장애를 일으킬 확률이 높아진다.)

그래서 이런 경우에는

public class Car {
    private static final int FORWARD_NUM = 4;

    private int position = 0;
    ...
    public void move() {
        int randomNo = getRandomNo();
        if (randomNo >= FORWARD_NUM)
                this.position++;
        }
    }

    private int getRandomNo() {
        Random random = new Random();
        return random.nextInt(MAX_BOUND);
    }
    ...
}

우선 기존 메서드들은 그대로 두고

public class Car {
    private static final int FORWARD_NUM = 4;

    private int position = 0;
    ...
    public void move() {
        int randomNo = getRandomNo();
        if (randomNo >= FORWARD_NUM)
                this.position++;
        }
    }

    // 복사
    public void move(int randomNo) {
        if (randomNo >= FORWARD_NUM)
                this.position++;
        }
    }

    private int getRandomNo() {
        Random random = new Random();
        return random.nextInt(MAX_BOUND);
    }
    ...
}

내가 리팩토링하려는 메서드를 그대로 복사하여 오버로딩한다. 이러면 컴파일 에러가 발생하지 않는다. (점진적인 리팩토링을 위해 복사, 오버로딩을 많이 사용한다!)

public class CarTest {
    @Test
    public void 이동() {
        Car car = new Car("pobi");
        car.move(4);
        assertThat(car.getPosition()).isEqualTo(1);
    }

    @Test
    public void 정지() {
        Car car = new Car("pobi");
        car.move(3);
        assertThat(car.getPosition()).isEqualTo(3);
    }

이렇게 테스트에서 car.move에다가 랜덤 값을 집어넣어줄 수 있으므로 테스트를 쉽게 할 수 있다.

이렇게 3, 4 처럼 경계값을 테스트 하는게 굉장히 중요하다. 부등호에서 =가 들어가냐 안들어가냐에 따라 버그가 많이 발생하기 때문!

이렇게 한 뒤에

public class Car {
    private static final int FORWARD_NUM = 4;

    private int position = 0;
    ...
// 더이상 사용하지 않으므로 삭제한다.
//    public void move() {
//        int randomNo = getRandomNo();
//        if (randomNo >= FORWARD_NUM)
//                this.position++;
//        }
//    }

    // 복사
    public void move(int randomNo) {
        if (randomNo >= FORWARD_NUM)
                this.position++;
        }
    }

// 더이상 사용하지 않으므로 삭제한다.
//    private int getRandomNo() {
//        Random random = new Random();
//        return random.nextInt(MAX_BOUND);
//    }
    ...
}

더이상 move()가 어디에서도 사용되지 않으면 삭제한다. 그렇게 되면 getRandomNo()도 삭제할 수 있게되고 리팩토링이 완료된다.

레거시 리팩토링을 하게 되면 기존 메소드와 리팩토링된 메소드가 공존하게 된다. 혹시나 문제가 생길 경우를 대비해 이전 코드와 리팩토링된 코드가 공존한 상태로 배포를 할 수도 있다. 이렇게 점진적으로 리팩토링을하면 안전하게 코드를 관리할 수 있게 된다. 이러한 연습을 많이 해야한다.

원시값도 객체로 포장해보자. TDD로 만든다.

public class PositionTest {
    @Test
    void create() {
        Position position = new Position(1);
        assertThat(position.getPosition).isEqualTo(1);
    }
}

테스트 실패

public class Position {
    private final int position;

    public Position(int position) {
        this.position = position;
    }

    public int getPosition() {
        return this.position;
    }
}

테스트 성공.

이제 리팩토링을 한다.

테스트 코드에서 getter를 사용했다..

public class PositionTest {
    @Test
    void create() {
        Position position = new Position(1);
        assertThat(position).isEqualTo(new Position(1));
    }
}

객체에서 꺼내서 값을 비교하려고 하지말고 객체 단위로 비교하자! 그렇게 하기 위해서 equals를 오버라이딩 해야한다.

public class Position {
    private final int position;

    public Position(int position) {
        this.position = position;
    }

    public int getPosition() {
        return this.position;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Position position1 = (Position) o;
        return position == position1.position;
    }

    @Override
    public int hashCode() {
        return Objects.hash(position);
    }
}

equals와 hashCode를 재정의하고나면 테스트는 성공한다.

(Position에다가 isSamePosition 메서드를 만들고 객체에 메시지를 보내게 만드는 생각도 좋지만, 객체단위 비교를 위해 equals를 재정의 하는게 더 중요하다!)

만약 Position의 생성자 파라미터로 int가 아닌 String으로 받을 수 있는 요구사항이 추가된다면?

public class Position {
    private final int position;

    public Position(String position) {
        this(Integer.parseInt(position); 
    }

    public Position(int position) {
        this.position = position; // 생성자 파라미터 갯수가 동일한 경우, 마지막에 초기화 해주는 쪽이 마지막에 온다.
    }

    public int getPosition() {
        return this.position;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Position position1 = (Position) o;
        return position == position1.position;
    }

    @Override
    public int hashCode() {
        return Objects.hash(position);
    }
}

이렇게 생성자를 통해서 다양한 케이스에 대한 처리를 허용하게 되면 객체를 쓰는 입장에서 편의성이 굉장히 높아지고 중복 코드도 줄어들게 된다!

public class PositionTest {
    @Test
    void create() {
        Position position = new Position(1);
        assertThat(position).isEqualTo(new Position("1"));
    }
}

테스트도 성공하게 된다.

현재 레이싱 게임에서 Position은 항상 0이상이다.

객체는 자신이 가지고 있는 데이터의 유효성에 대한 책임을 온전히 가져야 한다.

즉, Position이 생성되었다면, 그 Position이 가지고 있는 값은 0 이상임을 Position 객체가 보장해야한다!

TDD로 해보자.

public class PositionTest {
    @Test
    void invalid() {
        assertThatThrownBy(() -> {
            new Position(-1);
        }).isInstanceOf(IllegalArgumentException.class);
    }
}

테스트에 실패한다.

public class Position {
    private final int position;

    public Position(String position) {
        this(Integer.parseInt(position); 
    }

    public Position(int position) {
        if (position < 0) { // 생성자에서 유효성 처리!
            throw new IllegalArgumentException("이동거리는 음수가 될 수 없습니다.");
        }
        this.position = position; 
    }

    public int getPosition() {
        return this.position;
    }

    ...
}

생성자로 유효성 처리를 하면 테스트에 성공하게 된다.

이런식으로 우리가 프로그래밍 할 때, 들고다니는 값이 primitive라면 이 값이 0 이상이라는 보장이 없고, 이 값들이 메서드들을 넘나들 때마다 유효성 체크를 해야하는게 아닌가 계속 고민하게 된다..

근데 Position으로 wrapping하는 순간 이미 0이상인 것이 보장되기 때문에 유효한 값이 되고 더 안전한 코드를 작성할 수 있게 된다!

public class Car {

    ...

    public void move(int randomNo) {
        if(randomNo >= FORWARD_NUM) 
                this.position++; <-- 컴파일 에러
        }
    }

    public boolean isWinner(int maxPosition) {
        return this.position == maxPosition; <-- 컴파일 에러
    }

    public int maxPosition(int maxPosition) {
        if (maxPosition < this.posititon) { <-- 컴파일 에러
                return this.position; <-- 컴파일 에러
        } 
        return maxPosition;
    }

원시값으로 Wrapping하게 되면 기존의 메서드들이 다 컴파일 에러가 발생한다.

이것이 의미하는 바는, 이러한 비즈니스 로직이 Position 객체로 이동이 가능하다는 뜻이다.

public class Position {
    private final int position;

    public Position(String position) {
        this(Integer.parseInt(position); 
    }

    public Position(int position) {
        if (position < 0) { // 생성자에서 유효성 처리!
            throw new IllegalArgumentException("이동거리는 음수가 될 수 없습니다.");
        }
        this.position = position; 
    }

    public int getPosition() {
        return this.position;
    }

    public void move(int randomNo) {
        if(randomNo >= FORWARD_NUM) 
                this.position++;
        }
    }

    public boolean isMaxPosition(int maxPosition) {
        return this.position == maxPosition; 
    }

    public int maxPosition(int maxPosition) {
        if (maxPosition < this.posititon) { 
                return this.position;
        } 
        return maxPosition;
    }

    ...
}
public class Car {

    ...

    public void move(int randomNo) {
        if(randomNo >= FORWARD_NUM) 
                this.position++; 
        }
    }

    public boolean isWinner(int maxPosition) {
        return this.position.isMaxPosition(maxPosition); // 변경
    }

    public int maxPosition(int maxPosition) {
        return this.position.maxPosition(maxPosition); // 변경
    }

이렇게 되면 CarTest에서 수행했던 isWinner, maxPosition 테스트를 PostionTest에서 수행하고, CarTest에서는 더이상 해당 테스트를 수행하지 않아도 된다.

이렇게 역할을 분리하면서 단일 책임의 원칙을 지킬 수도있게 된다!

 

 

느낀점

오늘도 정말 많은걸 배웠다. getter를 무작정 쓰지말자, 테스트 코드도 비용이고 모든걸 다 테스트 하지 않는다. 

단위테스트는 도메인 모델에 집중하여 테스트하고, 서비스 계층에 대한 테스트는 하지않고, 인수테스트로 테스트한다.

 

여기서 단위테스트에 집중하여 테스트하고 있는 것들은 미션을 하면서 굉장히 많이 느끼고 있는 중이다. 하지만 서비스 계층을 테스트 하지않고 인수테스트만으로 테스트가 유효하게 할 수 있는지는 아직 정확히 잘 모르겠다. 하지만 예전부터 TDD가 뭔지 정말 두루뭉술했지만, 미션을 수행하고 강의를 들으면서 조금은? 알거같다는 생각이 들었고 규칙을 확립해서 연습을 꾸준히 한다면 더 나은 코드를 작성할 수 있지 않을 까? 생각한다.

 

오늘 강의 한줄 요약 : 도메인 객체와 대화해라!

 

자동차 경주 - 미션 피드백 링크 :

step 3 : https://github.com/next-step/java-racingcar/pull/3263

step 4 : https://github.com/next-step/java-racingcar/pull/3290

step 5 : https://github.com/next-step/java-racingcar/pull/3330

'Next-Step > TDD, Clean Code with Java 14기' 카테고리의 다른 글

3차 강의 - 클래스 분리 & Immutable  (0) 2022.05.08
1차 강의 - TDD  (0) 2022.04.13

댓글

Designed by JB FACTORY