본문 바로가기
Spring/테스트

[테스트 주도 개발 시작하기] Chap03 테스트 코드 작성 순서

by compile-this 2024. 9. 1.

 

 

저번 chpater에서는 TDD의 흐름(Red - Green - Refacotr)에 대해서 알아봤다. 

이번 chpater에서는 TDD의 테스트 코드 작성 순서에 대해서 알아본다.

 

1. 테스트 코드 작성 순서

 

1-1. 초반에 복잡한 테스트부터 시작하면 안 되는 이유

복잡한 테스트부터 시작할 때의 단점

  • 테스트를 통과시키기 위해 한 번에 구현해야 할 코드가 많아진다.
  • 구현하는데에 쉬운 테스트보다 시간이 많이 필요하다.
  • 한번에 많은 구현은 버그를 유발할 가능성이 높다.
  • 다음 테스트가 원활히 진행되지 않을 수 있다.

chapter2의 요구사항을 토대로 다음 예시를 통해 복잡한 테스트부터 시작할 때의 단점을 실제로 확인해보자.

더보기

요구 사항

  • 문자열을 검사해서 아래 조건 모두를 충족하면 '강함', 두 가지만 충족하면 '보통', 한가지만 충족하면 '약함'으로 구분

조건

  • 길이가 8글자 이상
  • 0부터 9 사이의 숫자를 포함
  • 대문자 포함

 

1. 대문자 포함 규칙만 충족하는 경우 - 테스트 코드 & 구현 코드

// 테스트 코드
@Test
void meetsOnlyUpperCriteria_Then_Weak() {
    PasswordStrengthMeter meter = new PasswordStrengthMeter();
    PasswordStrength result = meter.meter("abcDef");
    assertEquals(PasswordStrength.WEAK, result);
}

// 구현 코드
public class PasswordStrengthMeter {
    public PasswordStrength meter(String s) {
    	return PasswordStrength.WEAK;
    }
}

 

2. 모든 규칙을 충족하는 경우 - 테스트 코드 & 구현 코드

// 테스트 코드
@Test
void meetsAllCriteria_Then_Strong() {
    PasswordStrengthMeter meter = new PasswordStrengthMeter();
    PasswordStrength result = meter.meter("abcDef12");
    assertEquals(PasswordStrength.STRONG, result);
}

// 구현 코드
public class PasswordStrengthMeter {
    public PasswordStrength meter(String s) {
    	if("abcDef12".equals(s)) {
            return PasswordStrength.STRONG;
        }
    	return PasswordStrength.WEAK;
    }
}

 

3. 모든 규칙을 충족하는 경우 - 테스트 예시 추가

// 테스트 코드
@Test
void meetsAllCriteria_Then_Strong() {
    PasswordStrengthMeter meter = new PasswordStrengthMeter();
    PasswordStrength result = meter.meter("abcDef12");
    assertEquals(PasswordStrength.STRONG, result);
    
    PasswordStrength result2 = meter.meter("aZcDef123");
    assertEquals(PasswordStrength.STRONG, result2);
}

 

4. 모든 규칙을 충족하는 경우 - 테스트를 통과하기 위한 구현 코드 작성

 

2번째 테스트인데, 벌써 모든 조건을 충족하는 경우를 작성해야 한다.

이렇게 되면 위에서 설명한 복잡한 테스트부터 시작할 때의 단점들이 발생한다.

만약 2번째 테스트를 1번째 테스트로 진행했다면 어땠을까?

아래에서 자세히 살펴보자.

 

 

1-2. 구현하기 쉬운 테스트부터 시작하기

구현하기 쉬운 테스트부터 시작할 때의 장점

  • 빠르게 테스트를 통과시킬 수 있다.
  • 점진적으로 구현을 완성해 나가기 때문에, 버그 발생 가능성을 줄일 수 있다.
  • 디버깅에 유리하다.

chapter2의 요구사항을 토대로 다음 예시를 통해 구현하기 쉬운 테스트부터 시작할 때의 장점을 실제로 확인해보자.

더보기

요구 사항

  • 문자열을 검사해서 아래 조건 모두를 충족하면 '강함', 두 가지만 충족하면 '보통', 한가지만 충족하면 '약함'으로 구분

조건

  • 길이가 8글자 이상
  • 0부터 9 사이의 숫자를 포함
  • 대문자 포함

 

1. 모든 규칙을 충족하는 경우 - 테스트 코드 & 구현 코드

// 테스트 코드
@Test
void meetsAllCriteria_Then_Strong() {
    PasswordStrengthMeter meter = new PasswordStrengthMeter();
    PasswordStrength result = meter.meter("abcDef123");
    assertEquals(PasswordStrength.STRONG, result);
}

// 구현 코드
public class PasswordStrengthMeter {
    public PasswordStrength meter(String s) {
    	return PasswordStrength.STRONG;
    }
}

 

2. 모든 규칙을 충족하는 경우 - 테스트 예시 추가

// 테스트 코드
@Test
void meetsAllCriteria_Then_Strong() {
    PasswordStrengthMeter meter = new PasswordStrengthMeter();
    PasswordStrength result = meter.meter("abcDef123");
    assertEquals(PasswordStrength.STRONG, result);
    
    PasswordStrength result = meter.meter("aZedF154");
    assertEquals(PasswordStrength.STRONG, result);
}

// 구현 코드 (변경할 게 없다)
public class PasswordStrengthMeter {
    public PasswordStrength meter(String s) {
    	return PasswordStrength.STRONG;
    }
}

 

3. 다음 테스트: 두 규칙만 충족하는 경우 

  • 두 규칙만 충족 하는 경우 = 충족하지 않는 규칙이 하나 존재
    • 한 규칙을 충족하는지 검사하는 구현 코드만 추가하면 된다.

 

[1-1. 초반에 복잡한 테스트부터 시작하면 안 되는 이유]에서 본 테스트 흐름과 다르게,
짧은 시간내에 테스트를 통과시키고, 구현을 점진적으로 완성한다.

 

 

1-3. 예외 상황을 먼저 테스트해야 하는 이유

예외 상황을 먼저 테스트했을 때의 장점

  • 버그 발생 가능성을 줄일 수 있다.
  • 코드의 구조를 최대한 덜 바꿀 수 있고, 복잡성을 증가시키지 않을 수 있다.

 

1-4. 완급 조절 

TDD를 처음 접할 때 테스트를 만들고 통과시키는 과정에서 구현이 막히거나,

얼마만큼의 코드를 작성해야 될지 감이 안잡히면 아래 단계를 이용해서 연습해보자.

  1. 정해진 값을 리턴
  2. 값 비교를 이용해서 정해진 값을 리턴
  3. 다양한 테스트를 추가하면서 구현을 일반화

chapter2의 요구사항을 토대로 [암호 길이가 8글자 미만이지만, 나머지 두 조건은 충족하는 경우]에 대한 테스트를
위 단계대로 연습해보자.

더보기

요구 사항

  • 문자열을 검사해서 아래 조건 모두를 충족하면 '강함', 두 가지만 충족하면 '보통', 한가지만 충족하면 '약함'으로 구분

조건

  • 길이가 8글자 이상
  • 0부터 9 사이의 숫자를 포함
  • 대문자 포함

 

1. 암호 길이가 8글자 미만이지만, 나머지 두 조건은 충족하는 경우 - 테스트 코드 작성

@Test
void meetsOtherCreteria_except_for_Length_Then_Normal() {
    PasswordStrengthMeter meter = new PasswordStrengthMeter();
    PasswordStrength result = meter.meter("ab12!@A");
    assertEquals(PasswordStrength.NORMAL, result);
}

 

2. 암호 길이가 8글자 미만이지만, 나머지 두 조건은 충족하는 경우 - 정해진 값 리턴

// 구현 코드 (변경할 게 없다)
public class PasswordStrengthMeter {
    public PasswordStrength meter(String s) {
    	if("ab12!@A".equals(s)) {
            return PasswordStrength.NORMAL;
        }
        return PasswordStrength.STRONG;
    }
}

 

3. 암호 길이가 8글자 미만이지만, 나머지 두 조건은 충족하는 경우 - 테스트 예시 추가

@Test
void meetsOtherCreteria_except_for_Length_Then_Normal() {
    PasswordStrengthMeter meter = new PasswordStrengthMeter();
    PasswordStrength result = meter.meter("ab12!@A");
    assertEquals(PasswordStrength.NORMAL, result);
    
    PasswordStrength result2 = meter.meter("Ab12!c");
    assertEquals(PasswordStrength.NORMAL, result2);
}

 

4. 암호 길이가 8글자 미만이지만, 나머지 두 조건은 충족하는 경우 - 값 비교 추가

// 구현 코드 (변경할 게 없다)
public class PasswordStrengthMeter {
    public PasswordStrength meter(String s) {
    	if("ab12!@A".equals(s) || "Ab12!c".equals(s)) {
            return PasswordStrength.NORMAL;
        }
        return PasswordStrength.STRONG;
    }
}

 

5. 암호 길이가 8글자 미만이지만, 나머지 두 조건은 충족하는 경우 - 구현 일반화

// 구현 코드 (변경할 게 없다)
public class PasswordStrengthMeter {
    public PasswordStrength meter(String s) {
    	if(s.length() < 8) {
            return PasswordStrength.NORMAL;
        }
        return PasswordStrength.STRONG;
    }
}

 

 

1-5. 지속적인 리팩토링

테스트 대상 코드의 리팩토링 시점

  • 상수를 변수로, 변수 이름을 변경하는 것과 같은 작은 리팩토링은 발견 즉시 실행
  • 이외에 구조를 변경하거나 중복을 제거하는 리팩토링은 매번 테스트 통과한 뒤에 하지 않고, 어느정도 명확해지면 실행

리팩토링의 장점

  • 코드 가독성 향상
  • 코드 유지보수성 향상

 

2. 테스트 작성 순서 예제

다음 요구사항을 위에서 봤던 테스트 작성 순서대로 진행해보자.

 

요구사항

  • 매달 비용을 지불해야 사용할 수 있는 유료 서비스의 서비스 만료일을 계산하는 기능

조건

  • 서비스를 사용하려면 매달 1만원을 선불로 납부한다. 납부일 기준으로 한 달 뒤가 서비스 만료일이 된다.
  • 2개월 이상 요금을 납부할 수 있다.
  • 10만원을 납부하면 서비스를 1년 제공한다.

 

2-1. 쉬운 것부터 테스트

처음 제일 쉽게 할 수 있는 테스트는 요구사항의 첫번째 조건(만원을 납부하면 한달 뒤가 만료일 이다)일 것이다.

 

1. 가장 쉬운 것 부터 테스트

public class ExpiryDateCalculatorTest {
    @Test
    void 만원_납부하면_한달_뒤가_만료일이_됨() {
        LocalDate billingDate = LocalDate.of(2024, 8, 1);
        int payAmount = 10_000;
        
        ExpiryDateCalculator cal = new ExpiryDateCalculator();
        LocalDate expiryDate = cal.calculateExpiryDate(billingDate, payAmount);

        assertEquals(LocalDate.of(2024, 9, 1), expiryDate);
    }
}

 

 

2. 상수를 리턴해서 테스트 통과

public class ExpiryDateCalculator {
    public LocalDate calculateExpiryDate(LocalDate billingDate, int payAmount) {
        return LocalDate.of(2024, 9, 1);
    }
}

 

 

2-2. 예를 추가하면서 구현을 일반화

1. 동일 상황에 해당하는 테스트 예를 추가

@Test
void 만원_납부하면_한달_뒤가_만료일이_됨() {
    LocalDate billingDate = LocalDate.of(2024, 8, 1);
    int payAmount = 10_000;

    ExpiryDateCalculator cal = new ExpiryDateCalculator();
    LocalDate expiryDate = cal.calculateExpiryDate(billingDate, payAmount);

    assertEquals(LocalDate.of(2024, 9, 1), expiryDate);

    LocalDate billingDate2 = LocalDate.of(2024, 5, 5);
    int payAmount2 = 10_000;

    ExpiryDateCalculator cal2 = new ExpiryDateCalculator();
    LocalDate expiryDate2 = cal2.calculateExpiryDate(billingDate2, payAmount2);

    assertEquals(LocalDate.of(2024, 6, 6), expiryDate2);
}

 

 

2. 상수 대신에 구현을 일반화해서 테스트 통과

public class ExpiryDateCalculator {
    public LocalDate calculateExpiryDate(LocalDate billingDate, int payAmount) {
        return billingDate.plusMonths(1);
    }
}
  • 통과시키기 위해 상수를 사용해도 되지만, 위와 같이 단순한 구현으로 처리할 수 있으면 바로 구현을 일반화해도 된다.

 

2-3. 코드 정리: 중복 제거

지금까지 진행한 테스트 코드에 다음 형태의 중복이 존재한다.

LocalDate billingDate = 납부일;
int payAmount = 납부액;

ExpiryDateCalculator cal = new ExpiryDateCalculator();
LocalDate expiryDate = cal.calculateExpiryDate(billingDate, payAmount);

assertEquals(기댓값, expirtyDate);

 

테스트 코드의 중복을 제거할 때는 주의할 점

  • 각 테스트 메서드는 무엇을 테스트하는지 명확하게 설명할 수 있어야 한다.
  • 무분별하게 중복을 제거했을 때 테스트 메서드가 검증하려는 내용을 알아보기 힘들 수 있다.

 

 

1. 테스트 코드의 중복을 제거

public class ExpiryDateCalculatorTest {
    @Test
    void 만원_납부하면_한달_뒤가_만료일이_됨() {
        int payAmount = 10_000;
        LocalDate billingDate1 = LocalDate.of(2024, 8, 1);
        LocalDate expectedExpiryDate1 = LocalDate.of(2024, 9, 1);

        LocalDate billingDate2 = LocalDate.of(2024, 5, 5);
        LocalDate expectedExpiryDate2 = LocalDate.of(2024, 6, 5);

        this.assertExpiryDate(billingDate1, payAmount, expectedExpiryDate1);
        this.assertExpiryDate(billingDate2, payAmount, expectedExpiryDate2);
    }

    private void assertExpiryDate(LocalDate billingDate, int payAmount, LocalDate expectedExpiryDate) {
        ExpiryDateCalculator cal = new ExpiryDateCalculator();
        LocalDate expiryDate = cal.calculateExpiryDate(billingDate, payAmount);
        assertEquals(expectedExpiryDate, expiryDate);
    }
}

 

 

2-4. 예외 상황 처리

위에서 테스트한 기능에 대해 예외의 경우를 찾아보면 다음과 같은 경우가 있다.

  • 납부일 기준으로 다음 달의 같은 날이 만료일이 아닐 수 있다.
    • 납부일: 2019-01-31, 납부액: 1만원, 만료일: 2019-02-28
    • 납부일: 2019-05-31, 납부액: 1만원, 만료일: 2019-06-30
    • 납부일: 2020-01-31, 납부액: 1만원, 만료일: 2020-02-29

 

1. 테스트 코드 추가

@Test
void 납부일과_한달_뒤_일자가_같지_않음() {
    int payAmount = 10_000;
    LocalDate billingDate1 = LocalDate.of(2019, 1, 31);
    LocalDate expectedExpiryDate1 = LocalDate.of(2019, 2, 28);
    
    this.assertExpiryDate(billingDate1, payAmount, expectedExpiryDate1);
}
  • 테스트가 한번에 통과된다. 구현 코드의 `LocalDate.plusMoths()`로 위의 경우도 해결할 수 있다.

 

2-5. 다음 테스트 선택

다음 테스트 선택은 길이 상 생략하도록 하겠다.
위 예시대로 스스로 생각해보고 다음으로 쉽게 작성할 수 있는 테스트 코드를 통해 위 요구사항을 구현해보자.

(책의 전체 구현 코드는 여기서 확인 가능하다.)

 

3. 테스트할 목록 정리하기

TDD를 시작할 때 테스트 할 목록을 미리 정리하면 좋다.

이번 chpater의 요구사항을 바탕으로 테스트 할 목록을 정리하면 아래와 같다.

  • 1만원 납부하면 한 달 뒤가 만료일
  • 달의 마지막 날에 납부하면 다음달 마지막 날이 만료일
  • 2만원 납부하면 2개월 뒤가 만료일
  • 3만원 납부하면 3개월 뒤가 만료일
  • 10만원을 납부하면 1년 뒤가 만료일 

이랗게 테스트 할 목록을 정리하면 가장 쉬운 테스트, 그 다음 쉬운 테스트를 선택하는 데에 도움이 된다.

 

4. 시작이 안 될 때는 단언부터 고민

테스트 하기 쉬운 케이스를 골라도, 테스트를 작성하는데에 시작이 안될 때가 있을 수 있다.

이럴 때는 검증 코드부터 작성하기 시작하면 도움이 된다.

이번 chapter의 요구사항을 바탕으로 만료일을 검증하는 경우에 검증하는 코드부터 작성해보면 다음 순서로 진행된다.

 

1. 검증 코드 작성

@Test
void 만원_납부하면_한달_뒤가_만료일이_됨() {
    assertEquals(기대하는 만료일, 실제 만료일);
}

 

2. 만료일 변수 설정

LocalDate expiryDate = 계산하기
assertEquals(LocalDate.of(2019, 8, 9), expiryDate)

 

3. 만료일을 구하는 코드 작성

LocalDate expiryDate = cal.calculateExpirtyDate(파라미터);
assertEquals(LocalDate.of(2019, 8, 9), expiryDate)

 

4. 만료일을 구하는 메서드의 파라미터 설정

LocalDate expiryDate = cal.calculateExpirtyDate(LocalDate.of(2019, 7, 9), 10_000);
assertEquals(LocalDate.of(2019, 8, 9), expiryDate)

 

5. 만료일을 구하는 메서드의 객체 타입 설정

ExpiryDateCalculator cal = new ExpiryDateCalculator();
LocalDate expiryDate = cal.calculateExpirtyDate(LocalDate.of(2019, 7, 9), 10_000);
assertEquals(LocalDate.of(2019, 8, 9), expiryDate)

 

5.  요약

 

이번 챕터에서는 테스트 작성의 순서에 대해 알아보았다.

다시 복기해보면 순서는 다음과 같다.

  1. 쉬운 테스트부터 진행
  2. 예외 상황이 발생하면 예외 상황 테스트
  3. 리팩토링을 해야한다면 테스트의 의미가 불명확해지지 않도록 리팩토링 진행
  4. 그 다음으로 쉬운 테스트 진행
    (* 2번과 3번의 순서는 바뀌거나 한 사이클에 존재하지 않을 수도 있다.)

구현이 막히거나 테스트 시작이 안될 때에는 다음과 같은 방법을 사용해본다.

  • 구현이 막힐 때
    • 완급 조절 (정해진 값 리턴 -> 예시 값 비교 후 정해진 값 리턴 -> 구현 일반화)
  • 테스트 시작이 안될 때
    • 단언(assertion)부터 고민