Search

TDD 톺아보기

Tags
TDD
Date
2024/04/25

1. TDD가 나오게 된 배경

테스트가 중요한 상황을 예시로 가정해보겠습니다.
아날로그팜은 현재 어떻게든 잘 돌아가는 레거시 프로젝트를 운영 중입니다. DTX 규제가 풀림에 따라 서비스는 꾸준하게 성장하고 있습니다. 이에 맞춰 인력을 두 배 투입 했으나 개발 속도두 배가 되지 않았습니다.
실제 있는 사례를 바탕으로 재구성한 내용이고 개발 속도가 늦춰진 원인에는 배포하기가 무섭다 라는 고민이 은연 중에 퍼져있었기 때문입니다.

팀원들은 왜 배포하기 무서워 했을까.

배포를 통해 잘 동작하던 기능이 갑자기 동작하지 않는 회귀 버그 (Regression)가 생길 수 있기 때문입니다.
따라서 회귀버그에 대처할 수 있는 테스트 자동화(TDD) 를 도입하고자 합니다.

무조건적인 TDD 도입. 만능일까.

TDD를 도입해도 여전히 문제가 발생할 수 있습니다.
1.
회사가시적인 성과를 내야 하는 조직입니다. 하지만 테스트는 가시적인 성과를 내기 위한 작업이 아닌 프로젝트의 안정화를 위한 작업입니다. 따라서 회사는 가시적인 성과를 위해 커버리지의 증가를 목표로 합니다. 자연스럽게 커버리지를 높이기 위해 간단한 코드임에도 불구하고 테스트코드를 작성하기 시작하였습니다.
2.
요령없이 커버리지를 위해 작성한 테스트 코드는 비효율적입니다.
예를 들어, 모키토나 각종 의존성 주입을 통해 테스트를 진행한다면 테스트 하나를 위해 해야되는 준비 과정이 너무 많습니다.
데이터 베이스 까지 연동해서 테스트한다면 스프링 부트를 띄우고 의존성을 추적하고 테스트 해야되니 엄청 많은 시간이 소요됩니다.
또한 어제는 성공하고 오늘은 실패하는 비 결정적인 테스트도 존재합니다.
앞선 상황의 문제점은 다음과 같습니다.
무작정 테스트를 작성하는 것이 TDD가 아닙니다. TDD는 리팩토링을 위한 코드 개선 또한 과정에 포함됩니다. 회귀 방지에만 초점을 맞추는 것이 아닌 유연한 설계를 해야됩니다.
유연한 설계란 테스트를 쉽게 만들어주고, 테스트를 비 결정적이게 만들어줍니다.

해결 방안.

본인의 코드에 테스트를 넣는 방법을 알아야 합니다. 여기서 핵심은 자연스럽게 넣는 방법을 알아야 합니다.
하지만 프로젝트가 스프링이나 JPA에 의존되는 상황이라면 자연스럽게 넣기는 어렵습니다.
따라서 커버리지에 집착하면 안됩니다. 커버리지는 테스트가 얼마나 촘촘하게 짜여 있는지 평가합니다. 하지만 테스트를 추가했을 때 얻는 효용성 또한 생각해야 합니다. 또한, Mock, jUnit에 너무 의존하면 안됩니다.

지향해야 하는 개발 방식.

회귀 버그 하나 만을 신경쓰는 것은 자동차 부품들이 잘 작동 하는지 테스트 하는 것과 같습니다. 우리가 지향해야 될 방식은 테스트와 확장성을 동시에 지닌 서비스 성장 방식입니다.
예를 들어, 서비스를 기차라고 가정한다면 사용자가 늘어나면 후미에 후차를 추가하면 됩니다. 확정된 기능이 있다면 추가하고 테스트를 돌려보면 됩니다.

2. TDD란?

TFD (Test First Development) + 리팩토링
TDD는 프로덕션 코드를 구현하기 전에 단위 테스트 코드를 먼저 작성하는 것입니다.
여기에서 알 수 있는 것은 TDD 단위 테스트는 다르다는 것입니다.
하지만 단순히 테스트 코드를 작성하는 것만이 아닌 리팩토링 과정이 필수적으로 포함되어 있습니다.
리팩토링은 기능에 대한 변화는 없으면서 클래스 구조, 메소드 분리 등의 설계 활동입니다.
이렇게 하게 되면 작은 단위로 기능을 추가할 때 마다 끊임없이 설계를 개선해 나갈 수 있습니다.

TDD by Example에서는

TDD란 프로그래밍 의사 결정과 피드백 사이의 간극을 의식하고 제어하는 기술이다.
TDD는 아이러니 중 하나는 테스트 기술이 아니라는 점이다. TDD는 분석 기술이며, 설계 기술이기도 하다
TDD를 잘 작성하려면 TO DO LIST를 잘 작성해야 합니다. 참고로 좋은 TO DO LIST는 요구 사항 분석이 잘 된 결과 중 하나 입니다.
TDD를 하는 이유는 다음과 같습니다.
디버깅 시간을 줄여줍니다.
동작하는 문서 역할을 합니다.
변화에 대한 두려움을 줄여준다.

TDD의 사이클

1.
실패하는 테스트를 구현합니다.
2.
테스트가 성공하도록 프로덕션 코드를 구현합니다.
3.
프로덕션 코드와 테스트 코드를 리팩토링 합니다.
리팩토링 시 프로덕션 코드만 리팩토링 하는 것이 아닌 테스트 코드도 함께 리팩토링 합니다. 그렇지 않는다면 어느 시점에 테스트 코드에 엄청난 중복이 생길 수 있습니다.

TDD 원칙

1.
실패하는 단위 테스트를 작성할 때 까지 프로덕션 코드를 작성하지 않습니다.
2.
컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성합니다.
3.
현재 실패하는 테스트를 통과할 정도만 실제 코드를 작성합니다.
즉, 너무 많은 부분을 예측해서 개발하는 것이 아닌 현재 테스트 코드를 만족할 수준의 프로덕션 코드를 작성하는데 집중하는 것입니다.
(DON’T OVERENGINEERING)

프로덕션 코드가 없는 상태에서 단위 테스트.

“어디서, 어떻게 시작해야될지 모르겠다.”, “막막하다.”
위와 같은 감정은 당연한 감정입니다.
도메인 지식, 객체 설계 경험이 있는 경우
요구 사항 분석을 통해 대략적인 객체 설계
UI, DB 등과 의존 관계를 가지지 않는 핵심 도메인 영역을 집중 설계
주의할 점은 TDD 라고 해서 설계를 안하는 것이 아닙니다.
View, Controller는 테스트 하기 어렵기 때문에 핵심 도메인 로직이 포함되어 있는 도메인 영역에 대한 단위 테스트를 진행합니다.
그래서 대략적인 도메인 객체 설계가 필요합니다.
참고로 테스트 하기 어려운 Random 값 생성 같은 경우는 격리 시키는 과정이 필요합니다.

3. TDD로 숫자 야구 게임 구현

TDD를 쉽게 시작하는 방법은 기능 구현 목록을 작성한 후 테스트가 가능한 부분을 도전하는 것입니다.
-[ ] 1~9의 숫자 중 랜덤으로 3개의 숫자를 구한다 -[ ] 사용자로부터 입력 받는 3개 숫자를 예외 처리 한다 -[ ] 1~9 사이의 숫자인가? -[ ] 중복 되는 숫자가 존재하는가? -[ ] 3자리 숫자인가? -[ ] 위치와 숫자 값이 같은 경우는 스트라이크로 판별한다 -[ ] 위치는 다른데 숫자 값이 같다면 볼로 판별한다 -[ ] 숫자 값이 다른 경우는 낫싱으로 판별한다 -[ ] 사용자가 입력한 값에 대한 실행 결과를 구한다
Java
복사
1단계 - 가장 테스트 코드를 짜기 쉬운 부분은 유효성 체크 (Util 성) 기능입니다.
ex) 사용자로부터 입력 받는 3개 숫자를 예외 처리 한다
테스트 코드를 처음 짤 때 생각할것은 인풋과 아웃풋을 정하는 것입니다.
예를 들어, 유효성 검증 코드의 인풋과 아웃풋은 다음과 같이 정의할 수 있습니다.
숫자를 넘겨준다 (input)
불리안 값을 받는다 (output)
2단계 - 테스트가 가능한 부분에 대해서 TDD를 작성합니다.
랜덤, 시간 값처럼 무작위 값이 아닌 테스트가 가능한 부분들을 의미합니다.
ex) - 위치와 숫자 값이 같은 경우는 스트라이크로 판별한다 - 위치는 다른데 숫자 값이 같다면 볼로 판별한다 - 숫자 값이 다른 경우는 낫싱으로 판별한다
단, 전체를 한 번에 구현하려고 하는 것은 쉽지 않습니다.
PlayResult result = play(Arrays.asList(1, 2, 3), Arrays.asList(4, 5, 6));
Java
복사
우리는 프로그램을 잘개 쪼개서 구현할 필요가 있습니다.
예를 들어, 123과 456을 비교하는 것은 여러 기능이 필요할 수 있지만 하나의 숫자만 비교하는 것은 비교적 단순합니다.
@Test void nothing() { Ball computer = new Ball(1, 4); BallStatus status = computer.play(new Ball(2, 5)); assertThat(status).isEqualTo(BallStatus.NOTHING); }
Java
복사
TDD는 RED 상태. 즉, 안돌아가는 코드를 만든 다음에 하나씩 만들어가는 과정이 필요합니다.
Ball.java
public class Ball { public Ball(int i, int i1){ } public BallStatus play(Ball ball) { return BallStatus.NOTHING; } }
Java
복사
BallStatus.java
public Enum BallStatus { NOTHING; }
Java
복사
아직 내부적인 기능은 작성하지 않았습니다. 하지만 위 테스트 코드는 잘 돌아갈 것입니다. 테스트가 성공하도록 코드를 짜는 단계, 그린입니다.
그 다음에 실질적인 코드를 채우며 리팩토링을 진행합니다.
BallTest.java
public class BallTest { private Ball computer; @BeforeEach void setup(){ computer = new Ball(1, 4); } @Test void 숫자와_위치가_같다면_스트라이크를_반환한다(){ BallStatus status = computer.play(new Ball(1, 4)); assertThat(status).isEqualTo(BallStatus.STRIKE); } @Test void 숫자는_같은데_위치가_다르다면_볼을_반환한다(){ BallStatus status = computer.play(new Ball(2, 4)); assertThat(status).isEqualTo(BallStatus.BALL); } @Test void 숫자가_다르다면_낫싱을_반환한다(){ BallStatus status = computer.play(new Ball(2, 5)); assertThat(status).isEqualTo(BallStatus.NOTHING); } }
Java
복사
Ball.java
public class Ball { private final int position; private final int number; public Ball(int position, int number) { this.position = position; this.number = number; } public BallStatus play(Ball ball) { if(this.equals(ball)){ return BallStatus.STRIKE; } // 객체의 필드에 직접 접근하는 것 보다는 객체에 메세지를 보내라 if(ball.matchNumber(this.number){ return BallStatus.BALL; } return BallStatus.NOTHING; } public boolean matchNumber(int number){ return this.number == number; } }
Java
복사
3단계 - 조금씩 복잡한 테스트를 간소화 시키자
위 단계에서는 숫자 하나와 하나를 비교 했다면 이제는 숫자 세 개와 하나를 비교할 수 있지 않을까요?
가령 123 과 1을 비교해서 스트라이크를 판단하는 것입니다.
Balls.java
public class Balls { private final List<Ball> balls; private static final BALLS_SIZE = 3; public Balls(List<Integer> input){ this.balls = mapToBalls(input); } public List<Ball> mapToBalls(List<Integer> input) { List<Ball> balls = new ArrayList<>(); for(int i = 0; i<BALLS_SIZE; i++){ balls.add(new Ball(i+1, input.get(i))); } return balls; } public BallStatus play(Ball ball) { return balls.stream() .map(answer -> answer.play(ball)) .filter(BallStatus::isNotNothing) .findFirst() .orElse(BallStatus.NOTHING); } }
Java
복사
BallsTest.java
public class BallsTest { private Balls balls; @BeforeEach void setup(){ balls = new Balls(Arrays.asList(1,2,3)); } @Test void 숫자와_위치가_일차한다면_스트라이크를_반환한다(){ BallStatus status = balls.play(new Ball(1,1)); assertThat(status).isEqualTo(BallStatus.STRIKE); } }
Java
복사
마지막으로 숫자 세 개와 숫자 세 개를 비교하는 로직을 진행할 수 있습니다. 그 전에 천천히 단계를 밟아왔기 때문에 그렇게 어려운 작업이 아닙니다.
BallsTest.java
@Test void play() { PlayResult result = balls.play(Arrays.asList(4,5,6)); assertThat(result.getStrike()).isEqualTo(0); }
Java
복사
아직 코드를 짜지 않은 상태에서 다음과 같은 구성을 생각해볼 수 있습니다.
세 개의 숫자에 대해서 스트라이크, 볼, 낫싱을 판단해야 하니 해당 값을 카운트 해 줄 PlayResult VO 객체를 만들자
play()로 Ball 객체를 넘기는게 아니라 List 형태의 값을 넘기자
Balls.play()
public PlayResult play(List<Integer> input) { Balls inputBalls = new Balls(input); PlayResult result = new PlayResult(); for(Ball ball : balls) { BallStatus status = inputBalls.play(ball); result.report(status); } return result; }
Java
복사
PlayResult.java
@Getter public class PlayResult { private int strike; private int ball; private int nothing; private final static int GAME_END_CRITERION; public void report(BallStatus status) { if(status.isStrike()){ strike++; } else if(strike.isBall()) { ball++; } else if(strike.isNothing()) { nothing++; } } public boolean isGameEnd(){ return this.strike == GAME_END_CRITERION; } }
Java
복사
다시 한번 강조하지만 객체의 값을 가져오는 것이 아닌 객체한테 메세지를 넘기는 행위가 중요합니다.

4. 효과적인 테스트 코드 작성을 위한 junit5

@DisplayName("0을 입력할 수 없습니다.") @Test void constructor() { assertThrows(IllegalArgumentException.class, () -> new Number(0)); } @DisplayName("홀수를 판별합니다.") @ParameterizedTest @ValueSource(ints = {1, 3, 5, 7, 9, Integer.MAX_VALUE}) void isOdd(int number) { assertTrue(new Number(number).isOdd()); } @DisplayName("짝수를 판별합니다.") @ParameterizedTest @ValueSource(ints = {2, 4, 6, 8, 10, Integer.MIN_VALUE}) void isEven(int number) { assertTrue(new Number(number).isEven()); } @DisplayName("비어 있는 문자열을 입력할 경우 0을 반환한다.") @ParameterizedTest @NullAndEmptySource void calculate_NotNullAndNotEmpty(String text) { assertThat(StringCalculator.calculate(text)).isZero(); } @Test @DisplayName("숫자 하나를 문자열로 입력할 경우 해당 숫자를 반환한다.") void calculate_OneNumber() { assertThat(StringCalculator.calculate("5")).isEqualTo(5); } @DisplayName("숫자 이외의 값 또는 음수를 전달할 경우 런타임 예외가 발생한다") @ValueSource(strings = { "-1", "A", "1,A", "1,-2", }) @ParameterizedTest void calculate_NaNWithNegativeNumber(String text) { assertThatIllegalArgumentException() .isThrownBy(() -> StringCalculator.calculate(text)); } @DisplayName("컴마(,) 및 세미콜론(:)으로 구분된 두 숫자의 합을 반환한다") @CsvSource({ "'1,2,3', 6", "'1:3:5', 9", "'4,5:6', 15", }) @ParameterizedTest void calculate_DefaultDelimiter(String text, int sum) { assertThat(StringCalculator.calculate(text)).isEqualTo(sum); } @DisplayName("커스텀 구분자로 구분된 두 숫자의 합을 반환한다") @CsvSource({ "'//;\n1;2;3', 6", "'//@\n4@5@6@7', 22", "'//!\n10!40', 50", }) @ParameterizedTest void calculate_CustomDelimiter(String text, int sum) { assertThat(StringCalculator.calculate(text)).isEqualTo(sum); } @DisplayName("월은 1부터 12까지만 존재한다.") @ParameterizedTest @EnumSource(Month.class) void month(Month month) { assertTrue(month.getValue() >= 1 && month.getValue() <= 12); } @DisplayName("월은 숫자와 매핑된다.") @EnumSource(value = Month.class, names = {"JANUARY"}) void matchWithNumber(Month month) { assertEquals(1, month.getValue()); } @ParameterizedTest @CsvSource(value={"null,true", ",true", " ,true", "Volkswagen,false"}) void constructor_with_invalid_name_using_csv_source(String name, boolean expected) { assertEquals(expected, Strings.isBlank(name)); } @ParameterizedTest @MethodSource("provideInvalidName") // 두 개 이상의 복잡한 인수를 전달하고 싶을 때는 메소드로 전달 가능 합니다. // 외부 메소들를 참조하고 싶다면 모든 경로를 다 작성해주어야 합니다. (예: com.example.junit.car.CarTest.provideInvalidName) void constructor_with_invalid_name_using_method_source(String name, boolean expected) { assertEquals(expected, Strings.isBlank(name)); } private static Stream<Arguments> provideInvalidName() { return Stream.of( Arguments.of(null, true), Arguments.of("", true), Arguments.of(" ", true), Arguments.of("Volkswagen", false) ); } // "자동차 이름은 null이 될 수 없다." 라고 해도 되지만 비전공자도 이해할 수 있는 요구 사항 명세를 작성합니다. @DisplayName("자동차 이름은 비어 있을 수 없다.") @ParameterizedTest // ParameterizedTest를 사용하면 여러 가지 경우에 대해 테스트를 실행할 수 있습니다. @NullAndEmptySource // Null 값과 Empty Value 모두 전달하기 위해 해당 annotation을 사용할 수 있습니다. void constructor_with_invalid_name(String name) { // 하나의 인수 값에 값을 전달할 수 있습니다. assertThatIllegalArgumentException().isThrownBy(() -> new Car(name, 0)); }
Java
복사