본문 바로가기
Test

테스트 코드에 작성 순서가 있다고?

by oncerun 2022. 12. 10.
반응형

 

막상 TDD를 읽고 적용시킨다고 생각하여 회사 코드를 머릿속으로 생각해보았는데, 

 

뭐부터 해야 하지?라는 의문점이 강하게 들었다. 

 

이 의문점을 해소시켜보자. 

 

 

우선 테스트 주도 개발에서 테스트 코드를 작성할 때 따르는 규칙? 권장되는 규칙이 있다고 한다.

 

1. 쉬운 경우에서 어려운 경우로 진행 

 

2. 예외적인 경우에서 정상인 경우로 진행

 

 

초반에 복잡한 테스트부터 추가하게 되면 해당 테스트를 통과시키기 위해 한 번에 구현해야 할 코드가 많아진다. 

 

현재 복잡한 테스트를 먼저 하고 있다고  판단할 수 있는 좋은 상황은 바로 막혔을 때이다. 

 

쉬운 테스트는 아주 빠르게 생각하고 테스트를 통과하기 위한 코드가 빠르게 나올 수밖에 없다.

그런데 복잡한 테스트는 해당 테스트를 통과하기 위해 진행해야 할 절차가 많아질 수밖에 없다.

따라서 고민하는 시간이 길어진다.  혹은 진행하고 있다 해도 코드의 양이 기존에 비해 상당히 많은 경우일 수 있다고 생각한다.

 

보통 수 분에서 십여 분 이내에 구현을 완료해서 테스트를 통과시킬 수 있을 만큼 쉬운 것을 선택한다고 한다. 

 

예외적인 경우를 먼저 진행하는 이유에 대해 궁금하다.

 

다양한 예외 상황은 복잡한 if-else 블록이 등장할 확률이 높다. 

 

예외 상황을 전혀 고려하지 않은 코드에 예외 상황을 반영하려면 코드의 구조를 뒤집거나 코드 중간에 예외 상황을 처리하기 위해 조건문을 중복해서 추가하는 일이 발생한다. 

 

그렇다는 것은 예외 상황을 먼저 고려하면 if -else 블록이 등장하지 않는다는 것은 아니다.

 

즉 예외 상황을 통해 if-else 구조가 미리 만들어지기 때문에 코드 구조가 덜 변경된다는 것이다. 

또한 TDD를 진행하는 동안 예외 상황을 찾고 반영하게 되면 예외 상황을 처리하지 않아 발생하는 버그도 줄여준다.

 

예외적인 상황이나 쉬운 순서를 골라서 점진적으로 테스트를 작성하는 것을 추천한다고 했다.

 

다음 의문점은 한 번에 작성하는 코드량이다. 이 경우 TDD를 처음 접한다면 다음과 같은 단계를 거쳐보라고 추천한다.

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

테스트를 만들고 통과시키는 과정에서 구현이 막힐 때가 있다. 이럴 때 위 단계를 이용해서 TDD를 연습한 개발자는 조금씩 기능을 구현해 나갈 수 있지만 그러한 연습을 하지 않은 경우 진전을 이루지 못하고 막혀 있거나 한 번에 많은 코드를 구현하려고 시도하다 구현에 실패하게 될 수 있다.

 

이 부분에 대해서 예제를 진행해보자.

 

길이가 8글자 미만이지만 나머지 두 규칙을 충족하는 상황은 Normal을 반환한다.

 

@Test
public void meetsOtherCriteria_expect_for_Length_Then_Normal() throws Exception {
    PasswordStrengthMeter meter = new PasswordStrengthMeter();
    PasswordStrength result = meter.meter("1Asc");
    assertEquals(NORMAL, result);
}

 

딱 테스트를 통과할 만큼만 코드를 작성해보자.

 

정해진 값을 리턴한다는 것은 무엇일까?

 

public PasswordStrength meter(String pwd) {

    if (pwd == "1Asc") {
        return NORMAL;
    }
    return WEAK;
}

 

네 이것입니다. 

 

다음으로 동일한 조건을 검증하기 위한 테스트를 추가한다.

PasswordStrength result2 = meter.meter("SDs12");
assertEquals(NORMAL, result2);
public PasswordStrength meter(String pwd) {

    if ("1Asc".equals(pwd) || "SDs12".equals(pwd)) {
        return NORMAL;
    }
    return WEAK;
}

 

또 상수를 이용해서 테스트를 통과했다.  이를 값 비교를 이용해서 정해진 값을 리턴하는 것이라고 한다.

 

이제 상수를 제거하고 일반화하는 과정을 진행하자.

 

public PasswordStrength meter(String pwd) {

    if (pwd.length() < 8) {
        return NORMAL;
    }
    return WEAK;
}

 

몇 차례 상수를 이용해서 테스트를 통과시키고 뒤에 구현을 일반화하는 과정이 처음에는 매우 지루하게 느껴지지만 이러한 연습 과정은 나중에 구현할 코드가 잘 떠오르지 않을 때 점진적으로 구현을 진행할 수 있는 밑거름이 된다.

 

 

그럼 리팩토링을 하는 기준을 잡아보자

 

테스트 대상 코드에서 상수를 변수로 바꾸거나 이름을 변경하는 것과 같은 작은 리팩터링은 발견하면 바로 진행하는 반면

메서드 추출과 같이 메서드의 구조에 영향을 주는 리팩터링은 큰 틀에서 구현 흐름이 눈에 들어오기 시작한 뒤에 진행한다. 

큰 크기의 리팩토링은 코드의 구조가 명확해지거나 코드의 의미가 충분히 드러났다면 그때 시도하는 것이 좋다.

 

다음 예제로 리팩토링을 적용하는 과정을 살펴보자.

@Test
public void 만원_납부하면_한달_뒤가_만료일이_됨() throws Exception {
    assertExpiryDate(LocalDate.of(2022, 12, 10),
            10_000,
            LocalDate.of(2023,1,10));

    assertExpiryDate(LocalDate.of(2022, 7, 23),
            10_000,
            LocalDate.of(2022,8,23));

}
public LocalDate calculateExpiryDate(LocalDate billingDate, int payAmount) {
    return billingDate.plusMonths(1);
}

기존 코드에서 만료일의 예외상황인 납부일 기준으로 연달아 한 달마다 만료일을 구해야 한다. 

 

따라서 파라미터가 증가되는 것이 보이고 이를 파라미터로 던지는 것보단 파라미터를 객체로 추출하는 방법을 사용해 처리하도록 리팩터링 하려고 한다.  분명 이 과정도 tdd가 적용되어야 할 것이다.

 

첫 번째 

ExpiryDateCalculator cal = new ExpiryDateCalculator();
PayData payData = new PayData();
//when
LocalDate expiryDate= cal.calculateExpiryDate(payData);

PayDate를 생성하고 이를 호출하도록 한다. 이후 테스트를 진행하여 통과를 확인한다.

 

두 번째 

리팩터링을 완성하기 위해 payData 클래스를 생성한다. 

@Getter
@Builder
public class PayData {

    private LocalDate billingDate;
    private int payAmount;
}

 

세 번째 

 

기존 메서드를 남겨두고 새롭게 메서드를 정의한다.

public LocalDate calculateExpiryDate(LocalDate billingDate, int payAmount) {
    return billingDate.plusMonths(1);
}

public LocalDate calculateExpiryDate(PayData payData) {
    return payData.getBillingDate().plusMonths(1);
}

 

네 번째 

 

테스트의 메서드도 새로만든 메서드를 사용하도록 변경한다. ( 테스트한다)

 

private void assertExpiryDate(PayData payData, LocalDate expectedExpiryDate) {
    ExpiryDateCalculator cal = new ExpiryDateCalculator();
    LocalDate expiryDate = cal.calculateExpiryDate(payData);
    //then
    assertEquals(expiryDate, expectedExpiryDate);
}

 

이후 실제 테스트코드를 새로 만든 메서드와 PayData를 사용하도록 테스트 코드를 변경한다.

@Test
public void 만원_납부하면_한달_뒤가_만료일이_됨() throws Exception {


    assertExpiryDate(PayData.builder()
                    .billingDate(LocalDate.of(2022, 12, 10))
                    .payAmount(10000)
                    .build(),
            LocalDate.of(2023, 1, 10));


    assertExpiryDate(PayData.builder()
                    .billingDate(LocalDate.of(2022, 7, 23))
                    .payAmount(10000)
                    .build(),
            LocalDate.of(2022, 8, 23));

}

@Test
public void 납부일과_한달_뒤_일자가_같지_않음() throws Exception {

    assertExpiryDate(PayData.builder()
                    .billingDate(LocalDate.of(2022, 1, 31))
                    .payAmount(10000)
                    .build(),
            LocalDate.of(2022, 2, 28));

    assertExpiryDate(PayData.builder()
                    .billingDate(LocalDate.of(2022, 5, 31))
                    .payAmount(10000)
                    .build(),
            LocalDate.of(2022, 6, 30));

    assertExpiryDate(PayData.builder()
                    .billingDate(LocalDate.of(2020, 1, 31))
                    .payAmount(10000)
                    .build(),
            LocalDate.of(2020, 2, 29));

}

 

이후 테스트하여 리팩토링에 대해 검증한다.

 

 

추가적으로 독자에게 연습문제로 주었던 1년 3개월에 대한 코드를 작성하자.

public LocalDate calculateExpiryDate(PayData payData) {
    int monthsToAdd = payData.getPayAmount() >= 100_000 ? 12 + (payData.getPayAmount() - 100_000) / 10_000 : payData.getPayAmount() / 10_000;
    if (payData.getFirstBillingDate() != null) {
        return expiryDateUsingFirstBillingDate(payData, monthsToAdd);
    } else {
        return payData.getBillingDate().plusMonths(monthsToAdd);
    }

}
@Test
public void 십만원이상을_납부하면_1년_추가월수_제공() throws Exception {

    assertExpiryDate(
            PayData.builder()
                    .billingDate(LocalDate.of(2019, 1, 28))
                    .payAmount(130_000)
                    .build(),
            LocalDate.of(2020,4,28)
    );

    assertExpiryDate(
            PayData.builder()
                    .billingDate(LocalDate.of(2020, 2, 29))
                    .payAmount(180_000)
                    .build(),
            LocalDate.of(2021,10,29)
    );
}

 

 

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

 

이 과정은 도메인을 이해하는 부분에 있어서도 상당히 도움이 될 것 같다. 

 

또한 테스트할 목록을 정리할 때 쉽거나, 예외인 상황을 앞으로 배치하고 점진적으로 복잡한 테스트를 뒤에 작성하게 되면

순차적으로 테스트를 구현해도 될 듯하다.

 

추가적으로 테스트 과정에서 새로운 테스트 사례를 발견했다면 그 사례를 목록에 추가하여 놓치지 않도록 해야 한다. 

처음부터 모든 사례를 정리할 수 없다. 그럴 경우 시간이 매우 부족해진다.

 

지라나 트렐로와 같은 시스템을 사용하면 해당 테스트 사례를 하위 작업으로 등록해서 테스트 통과 여부를 추적할 수 있다는데?  자동으로 테스트 성공 시 연동된다는 건가?

 

난 노션을 사용하니까 노션을 사용해봐야겠다.

 

그리고 중요한 것은 모든 테스트 목록을 다 적었다고 해서 테스트를 한 번에 다 작성하면 안 된다.

 

한 번에 작성한 테스트 코드가 많으면 구현 초기에도 리팩터링을 마음껏 못 하게 된다.!!

또한 개발 리듬감을 찾는 것도 중요하다. 하나의 테스트 코드를 만들고 이를 통과시키고 리팩터링 하고 다시 다음 테스트 코드를 만들고 통과시키고 리팩터링 하는 과정은 매우 짧은 리듬을 반복한다.

이는 다루는 범위가 작고 개발 주기도 짧으므로 개발 집중력도 높아질 수 있다.

 

만약 크기가 큰 리팩터링을 진행하기 전에는 코드를 커밋하자. 또는 별도 브랜치에서 진행하자. 

그래야 큰 범위에 리팩터링이 실패했을 때 다시 동작하는 마지막 상태로 쉽게 돌아올 수 있다.

 

 

만약 시작이 안된다면

 

바로 검증하는 코드부터 작성해보자.

 

assertEquals(기대하는 만료일, 실제 만료일);

 

이 경우 만료일을 타입을 정하는 것부터 시작할 수 있다.  이후 변수를 추출하고 변수에 값을 할당하는 메서드를 생각해내고 이렇게 순차적인 생각 흐름을 갖게 될 수 있다고 조언한다.

 

만약 나도 막히는 부분이 있다면 검증 코드부터 올라가는 코드 작성을 시도해보겠다.

 

또한 구현이 막히는 경우 과감하게 코드를 지우고 미련 없이 다시 시작해라. 어떤 순서로 테스트 코드를 작성했는지 돌이켜보고 순서를 바꿔서 다시 진행한다. 

 

다시 진행할 때는 꼭 기억하자

1. 쉬운 테스트, 예외적인 테스트 ( 이를 테스트 목록으로 산출해 놓고 난이도 별 순서를 지정해놓고 지정해보자)

 

2. 완급 조절 ( 개발 리듬을 유지하는 연습을 해보자)

 

 

ps 

 

추가적으로 TDD를 공부한다면 리팩토링하는 기본기가 있어야 한다. 

 

현재 클린코드, 리팩터링 2, 디자인 패턴을 동시에 공부하고 적용하는 과정에 있는데 이는 상당히 도움이 많이 된다. 

 

TDD를 접하여 바로 시작하는 것도 좋지만 리팩터링 과정에서 더 열린 시야로 볼 수 있다는 것은 매우 강점이 될 것이다.

반응형

'Test' 카테고리의 다른 글

Spy  (0) 2022.12.14
Junit 5  (0) 2022.12.12
TDD init  (0) 2022.12.10
TDD 시작  (0) 2022.12.10
JUnit  (0) 2022.11.08

댓글