[자바, Java] TDD - Out-In 보단 In-Out 방식
TDD 가 어려운 이유
사실 nextstep 미션들을 진행하면서 느꼈던 부분은,
그냥 단위 테스트를 추가해 주는것과 TDD 는 난이도가 완전 다르다는 것이다.
맨 처음 미션의 요구사항을 분석하고 테스트 코드를 짜려고 해도,
TDD 를 어디서 어떻게 시작해야 할 지 막막해 지는 경우가 굉장히 많다.
이러한 부분들을 공통 피드백을 통해 학습하고 해결해 나간 과정을 간단히 정리해보려 한다.
1. TDD 의 사이클
TDD 가 무엇인지에 대한 내용까지 정리하려면 너무 많은 시간과 글이 들어가기 때문에 간단히 사이클에 대해서만 정리해보려 한다.
- 구현하려는 메서드에 대해 실패하는 테스트 코드를 먼저 작성한다.
- 실패한 테스트 코드가 성공하기 위해 프로덕션 코드를 구현한다.
- 오로지 테스트 성공만을 위해 막 구현한 프로덕션 코드를 리팩토링 한다.
2. 내가 TDD 를 학습하면서 느꼈던 장점
- 버그 발생의 횟수가 현저히 줄거나 혹은 아예 없는 경우도 있다.
- 미션 규모가 큰게 아니여서 아예 없는 경우도 나오긴 하더라.
- 메서드 하나하나 테스트를 통과시키면서 진행을 하니, 버그가 줄어드는 것은 당연하다.
- 발생한 버그를 잡기가 매우 수월해진다.
- 사실 테스트 코드만 꼼꼼히 잘 작성해 놓으면,
- 프로그램을 직접 실행하면서가 아니라 전체의 테스트 코드를 실행하면서 버그가 발생한 곳을 바로 잡아낼 수 있다.
- 실제로 프로그램 구현 완료 될 때까지 테스트 코드만 한 번씩 돌려주면서 버그 발생 체크만 해줘도, 구현 완료된 후엔 버그가 없는 경우가 많다.
- 리팩토링이 굉장히 수월해진다.
- 하드코딩이 점철된 코드는 코드를 조금만 건드려도 여기 저기서 버그가 빵빵 터져서 리팩토링이 두려워진다.
- TDD 방식이 리팩토링하기 수월한 이유
- TDD 방식으로 개발을 하면, 애초부터 메서드의 기능분리가 잘 되어진다.
- 메서드 단위로 하나씩 구현을 해나가기 때문에, 역할 분담이 잘 되어진다.
- 테스트 코드가 뒷받침 되기 때문에 리팩토링이 전혀 두렵지 않다.
- 테스트 코드 통과를 유지하면서 리팩토링을 해나가면 안전한 리팩토링이 이루어진다.
- 리팩토링을 하면서 버그가 발생하는지 안하는지 바로바로 체크하면서 진행이 가능하다.
- TDD 방식으로 개발을 하면, 애초부터 메서드의 기능분리가 잘 되어진다.
- TDD 는 테스트 코드라는 나의 동반자를 내 옆에 두고 의지할 수 있게 한다.
3. TDD 가 어디서 어떻게 시작할 지 어렵거나 막막한 이유
처음 TDD 란 개념을 nextstep 에서 배웠을 때, 굉장히 막막했다. 지금까지 TDD 가 어렵게 느껴진 이유를 정리해보자면,
- 어떤 메서드부터 TDD 를 시작하느냐에 따라 난이도가 천차만별이다.
- TDD 사이클 대로 테스트 코드부터 작성 했는데, 테스트 하려는 해당 메서드의 기능이 너무 거대한 기능이어서,
- 해당 테스트 하나만 통과시키는 데에 굉장히 많은 프로덕션 코드를 구현해야하는 상황이 오는 경우가 있다.
- 랜덤 요소와 같은 테스트 하기 힘든 코드의 테스트가 힘들다.
- 1 ~ 9 까지의 숫자 중 랜덤으로 반환하는 메서드는 대체 어떻게 테스트 해야 할까??
4. 해결책
- 설계할 때부터 기능을 최대한 작은 단위로 쪼갠다.
- 보통 프로그램을 구현할 때 In-Out 이 아닌 Out-In 방식으로 구현한다.
- 왜냐하면 그 방식이 더 쉽기 때문이다.
- 그러나 TDD 의 관점에선 굉장히 어려워지는 방식이다.
- TDD 는 In-Out 방식처럼 아주아주 작은 기능부터 구현을 해나가야 수월해 진다.
- 그래서 구현 이전에 설계부터 잘 해서 아주 작은 기능 단위부터 추출해서 TDD 를 시작해야 한다.
- 테스트 하기 힘든 요소와 가능한 요소를 분리해서 테스트할 줄 알아야 한다.
- 나 같은 경우는 보통 전략 패턴(인터페이스)를 사용해서 테스트 하기 힘든 코드 안에서 테스트 가능한 코드를 추출한다.
5. 내가 해결책들을 실제로 이용한 방식
지금부터 이 글을 작성하면서 즉석으로 프로그램을 구현한 코드를 예제로 삼아보겠다.
설계할 때부터 기능을 최대한 작은 단위로 쪼갠 뒤, In-Out 방식으로 해결한다.
일단 예제용으로 구현할 아주 간단한 프로그램은 ‘두 숫자의 사칙연산을 수행하는 프로그램’이다.
여기서 TDD 로 구현할 첫번째 메서드로 만약 ‘두 숫자의 사칙연산을 전부 수행하는 기능’을 선택한다면 굉장히 골치 아파진다.
public class CalculatorTest { @ParameterizedTest @DisplayName("두 숫자의 사칙연산 기능") @CsvSource(value = {"3 + 2, 5", "3 - 2, 1", "3 * 2, 6", "3 / 2, 1"}) void calculate(String formula, int result) { Calculator calculator = new Calculator(); assertThat(calculator.calculate(formula)).isEqualTo(result); } }
첫번째로 구현할 메서드부터 모든 사칙연산이 가능한 기능을 구현해야한다.
- 이런식으로 TDD 를 하게되면 막막하고 어려워지기 때문에 TDD를 포기해버리게 된다.
이제 TDD를 하기에 앞서 기능을 최대한 작은 단위로 쪼개본다.
두 숫자의 모든 사칙연산을 구현해야 한다.
그럼 각 사칙연산 기능부터 하나씩 구현하는 것이 좋겠네.
더하기 기능부터 만들어보자.
public class AddTest { @Test @DisplayName("더하기") void add() { Add add = new Add(); assertThat(add.add(Arrays.asList("4", "2"))).isEqualTo(6); } } public class Add { public int add(final List<String> numbers) { return Integer.parseInt(numbers.get(0)) + Integer.parseInt(numbers.get(1)); } }
빼기, 곱하기, 나누기도 차례대로 TDD 방식으로 구현해보자.
public class SubtractTest { @Test @DisplayName("빼기") void subtract() { Subtract subtract = new Subtract(); assertThat(subtract.subtract(Arrays.asList("4", "2"))).isEqualTo(2); } } public class Subtract { public int subtract(final List<String> numbers) { return Integer.parseInt(numbers.get(0)) - Integer.parseInt(numbers.get(1)); } }
public class MultiplyTest { @Test @DisplayName("곱하기") void multiply() { Multiply multiply = new Multiply(); assertThat(multiply.multiply(Arrays.asList("4", "2"))).isEqualTo(8); } } public class Multiply { public int multiply(final List<String> numbers) { return Integer.parseInt(numbers.get(0)) * Integer.parseInt(numbers.get(1)); } }
public class DivideTest { @Test @DisplayName("나누기") void divide() { Divide divide = new Divide(); assertThat(divide.divide(Arrays.asList("4", "2"))).isEqualTo(2); } } public class Divide { public int divide(final List<String> numbers) { return Integer.parseInt(numbers.get(0)) / Integer.parseInt(numbers.get(1)); } }
기능을 최대한 작은 단위로 쪼개서 TDD 로 구현해 나가기 시작하니 굉장히 수월하다.
이제 지금까지 구현한 기능들을 이용해서 사칙연산을 모두 수행 가능한 기능을 TDD 로 구현해보겠다.
먼저 사칙연산 기능들을 통합 관리하기 위해 인터페이스를 이용한다.
@FunctionalInterface public interface CalculateStrategy { int calculate(final List<String> numbers); } public class Add implements CalculateStrategy { @Override public int calculate(final List<String> numbers) { return Integer.parseInt(numbers.get(0)) + Integer.parseInt(numbers.get(1)); } } public class Subtract implements CalculateStrategy { @Override public int calculate(final List<String> numbers) { return Integer.parseInt(numbers.get(0)) - Integer.parseInt(numbers.get(1)); } } public class Multiply implements CalculateStrategy { @Override public int calculate(final List<String> numbers) { return Integer.parseInt(numbers.get(0)) * Integer.parseInt(numbers.get(1)); } } public class Divide implements CalculateStrategy { @Override public int calculate(final List<String> numbers) { return Integer.parseInt(numbers.get(0)) / Integer.parseInt(numbers.get(1)); } }
당연히 테스트 코드의 메서드들도 전부 calculate() 로 변경해준다.
그 후에 아까 TDD 를 실패했던 사칙연산 계산 기능 메서드를 구현한다.
public class CalculatorTest { @ParameterizedTest @DisplayName("두 숫자의 사칙연산 기능") @CsvSource(value = {"3 + 2, 5", "3 - 2, 1", "3 * 2, 6", "3 / 2, 1"}) void calculate(String formula, int result) { Calculator calculator = new Calculator(); assertThat(calculator.calculate(formula)).isEqualTo(result); } } public class Calculator { public static final Map<String, CalculateStrategy> CALCULATOR = new HashMap<>(); static { CALCULATOR.put("+", new Add()); CALCULATOR.put("-", new Subtract()); CALCULATOR.put("*", new Multiply()); CALCULATOR.put("/", new Divide()); } public int calculate(final String formula) { final String[] split = formula.split(" "); return CALCULATOR.get(split[1]).calculate(Arrays.asList(split[0], split[2])); } }
기능을 최대한 작은 단위로 쪼갠 뒤, In-Out 방식으로 TDD 로 구현하니 굉장히 수월하게 구현이 된 모습을 볼 수 있다.
테스트하기 힘든 코드와 가능한 코드로 분리 설계 후 TDD 진행
단위 테스트가 힘든 요구사항
0 ~ 9 까지의 숫자 중 무작위로 뽑아서 4를 초과하면 자동차는 1칸 이동한다.
랜덤의 요소라서 테스트를 어떻게 진행해야 할 지 감이 안잡힌다.
- 참고로 테스트는 어떨 땐 실패, 어떨 땐 성공이라면 그 테스트는 실패한 테스트이다.
- 항상 성공을 유지해야 한다.
public class CarTest { @Test @DisplayName("무브") void move() { Car car = new Car(1); assertThat(car.move()).isEqualTo(2); } } public class Car { private final int position; public Car(final int position) { this.position = position; } public int move() { if (getRandomNum() > 4) { return position + 1; } return position; } private int getRandomNum() { return new Random().nextInt(10); } }
리팩토링 할 부분이 많지만, 예제이기 때문에 이 상태로 진행하겠다.
여기서 TDD 가 더이상 진행이 안될 것이다.
car 가 무브를 할 지, 안할 지 보장이 안되기 때문이다.
전략 패턴(인터페이스) 를 이용해서 테스트 가능한 부분을 추출해보자.
if 문의 조건문에 있는 랜덤 특성을 인터페이스로 추출할 것이다.
@FunctionalInterface public interface MoveStrategy { boolean move(); } public class RandomMoveStrategy implements MoveStrategy { @Override public boolean move() { return getRandomNum() > 4; } private int getRandomNum() { return new Random().nextInt(10); } } public class Car { private final int position; public Car(final int position) { this.position = position; } public int move(MoveStrategy moveStrategy) { if (moveStrategy.move()) { return position + 1; } return position; } } public class CarTest { @Test @DisplayName("무브") void move() { Car car = new Car(1); assertThat(car.move(() -> true)).isEqualTo(2); } } // 테스트 성공
실제로 구현 될 프로그램 모습은, Car 의 상위 객체에서 RandomMoveStrategy 클래스를 매개변수로 전달하는 모습일 것이다.
- 테스트 할 때는 람다식을 이용해서 이동 혹은 정지를 강제할 수 있어서, 완벽한 테스트가 가능해진다.