Study/TDD

TDD - TDD(Test-Driven Development)란?

hou27 2023. 1. 6. 04:30

TDD를 프로젝트에 적용해보고 싶다는 생각만 항상 했었는데,

GDSC에서 스터디를 TDD로 진행하게 되어 이번 기회에 제대로 공부해보고자 한다.

 

참고용 도서는 아래 책으로 결정했다.

http://www.yes24.com/Product/Goods/89145195

 

테스트 주도 개발 시작하기 - YES24

TDD(Test-Driven Development)는 테스트부터 시작한다. 구현을 먼저 하고 나중에 테스트하는 것이 아니라 먼저 테스트를 하고 그다음에 구현한다. 구현 코드가 없는데 어떻게 테스트할 수 있을까? 여기

www.yes24.com

 

실습 환경

- Java 17

- Spring Boot 3.0.1

- wsl 2

 

TDD란?

TDD는 테스트부터 시작한다.

우선 테스트를 작성하고, 그 후에 구현을 진행하게 된다.

 

 

책에 나온 예시로 함께 살펴보도록 하겠다.

CalculatorTest

...

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class CalculatorTest {

  @Test
  void plus() {
    assertEquals(2, Calculator.plus(1, 1)); // 현재 정적 메서드로 설계함.
  }
  
}

TDD로 개발할 때 가장 먼저 해야 할 일은

테스트 코드를 작성하는 일이다.

 

Calculator 클래스와 메서드를 구현하기 전에 기능을 검증할 테스트 코드부터 작성해 주었다.

현재 Calculator 클래스를 작성하지 않은 상태이므로 

Cannot resolve symbol 'Calculator'라는 에러가 발생하는 것을 볼 수 있으며

 

당연하게도 테스트 실행 시 에러가 발생한다.

 

Calculator

package com.hou27.chap02;

public class Calculator {

  public static int plus(int a, int b) {
    return a + b;
  }

}

이제 Calculator 클래스를 작성하고, plus라는 이름으로 정적 메서드를 구현해 주었다.

테스트 통과 ^^ 

 

구현 로직이 plus 메서드와 같이 단순하지 않다면 점진적으로 구현해 나가는데,

지금은 많이 단순한 예시였기 때문에 테스트를 통과하는 로직을 바로 완성해 주었다.

 

 

이번엔 조금 더 현실적인 예시를 통해 TDD를 더 맛보도록 하겠다.

 

TDD 예 : 암호 검사기

검사기의 규칙은 다음과 같다.

  • 길이가 8글자 이상
  • 0부터 9 사이의 숫자 포함
  • 대문자 포함
  • 규칙 3개 모두 충족 시 암호는 강함
  • 규칙 2개 충족 시 암호는 보통
  • 1개 이하일 시 암호는 약함

 

'약함', '보통', '강함' : PasswordStrength (암호 강도)

검사기 클래스명 : PasswordStrengthMeter (암호 강도 측정기)

 

중요한 이름을 정해주고, 이제 테스트를 작성해보도록 하겠다.

 

1. 모든 규칙을 충족하는 경우

첫 번째 테스트를 정하는 건 중요한 부분이라고 한다.

가장 쉽거나 예외적인 상황을 선택해야만 순탄하게 다음 과정도 진행할 수 있다.

 

지금은 2가지를 고려해볼 수 있다.

  1. 모든 규칙을 충족하는 경우
  2. 모든 조건을 충족하지 않는 경우

이 중 각 조건을 충족하지 않는 코드가 모두 필요한

두 번째 경우보단 첫 번째가 시작하기에 좋은 경우이다.

 

PasswordStrengthMeterTest

...

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class PasswordStrengthMeterTest {
  
  @Test
  void meetsAllCriteria_Then_Strong() {
    PasswordStrengthMeter meter = new PasswordStrengthMeter();
    PasswordStrength result = meter.meter("123ABCDE456");
    assertEquals(PasswordStrength.STRONG, result);
  }

}

PasswordStrengthMeterTest

위와 같이 테스트 코드를 작성하였으며,

열거타입을 통해 암호 강도를 PasswordStrength.STRONG과 같이 나타내었다.

 

이제 컴파일 에러를 없애기 위해

열거타입 PasswordStrength와 클래스 PasswordStrengthMeter를

테스트를 통과시킬 수 있을 만큼 작성하도록 하겠다.

 

PasswordStrength

public enum PasswordStrength {
  STRONG
}

STRONG 뿐만 아니라 NORMAL, WEEK를 지금 추가할 수 있지만

TDD는 테스트를 통과시킬 만큼의 코드를 작성하는 것이라고 한다.

 

PasswordStrengthMeter

public class PasswordStrengthMeter {

    public PasswordStrength meter(String s) {
      return PasswordStrength.STRONG;
    }

}

지금 테스트를 통과시키는 것이 목적이므로 STRONG을 반환하도록 구현하였다.

테스트 통과 ^^

2. 길이만 8글자 미만이고 나머지 조건은 충족하는 경우

이번엔 암호의 강도가 보통인 경우이다.

 

PasswordStrengthMeterTest

...

public class PasswordStrengthMeterTest {

  ...

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

}

0부터 9 사이의 숫자를 포함하고 대문자도 포함하지만 길이가 8글자 미만인 경우를 테스트하도록 하였다.

 

PasswordStrength

public enum PasswordStrength {
  STRONG, NORMAL
}

NORMAL을 추가하고,

 

PasswordStrengthMeter

public class PasswordStrengthMeter {

    public PasswordStrength meter(String s) {
      return PasswordStrength.NORMAL;
    }

}

NORMAL을 반환하도록 해주었다.

 

그런데 반환값을 NORMAL로 바꿔준 탓에 첫 번째 테스트는 실패하는 것을 확인할 수 있었다.

 

PasswordStrengthMeter

public class PasswordStrengthMeter {

    public PasswordStrength meter(String s) {
        if (s.length() < 8) {
            return PasswordStrength.NORMAL;
        }
        return PasswordStrength.STRONG;
    }

}

그래서 코드를 위와 같이 수정해 주었다.

 

테스트 통과 ^^

 

 

3. 숫자를 포함하지 않고 나머지 조건은 충족하는 경우

이번에도 암호 강도는 보통인 경우이다.

 

PasswordStrengthMeterTest

public class PasswordStrengthMeterTest {

  ...

  @Test
  void meetsOtherCriteria_except_for_Number_Then_Normal() {
    PasswordStrengthMeter meter = new PasswordStrengthMeter();
    PasswordStrength result = meter.meter("ABCDEexceptNumber");
    assertEquals(PasswordStrength.NORMAL, result);
  }

}

길이 조건과 대문자 조건을 만족하지만 숫자를 포함하지 않은 경우를 테스트하도록 하였다.

 

PasswordStrengthMeter

public class PasswordStrengthMeter {

    public PasswordStrength meter(String s) {
        if (s.length() < 8 || !s.matches(".*[0-9].*")) {
            return PasswordStrength.NORMAL;
        }
        return PasswordStrength.STRONG;
    }

}

정규식을 통해 문자열에 숫자가 포함되어있는지도 판단하도록 조건문을 수정해 주었다.

 

테스트 통과 ^^

 

리팩토링

지금까지 작성한 테스트 코드를 정리하고 넘어가도록 하겠다.

 

PasswordStrengthMeterTest

public class PasswordStrengthMeterTest {

  @Test
  void meetsAllCriteria_Then_Strong() {
    PasswordStrengthMeter meter = new PasswordStrengthMeter();
    PasswordStrength result = meter.meter("123ABCDE456");
    assertEquals(PasswordStrength.STRONG, result);
  }

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

  @Test
  void meetsOtherCriteria_except_for_Number_Then_Normal() {
    PasswordStrengthMeter meter = new PasswordStrengthMeter();
    PasswordStrength result = meter.meter("ABCDEexceptNumber");
    assertEquals(PasswordStrength.NORMAL, result);
  }

}

코드를 확인해 보면, 각 메서드 내에 중복된 코드가 상당하다는 것을 알 수 있다.

 

public class PasswordStrengthMeterTest {
  private PasswordStrengthMeter meter = new PasswordStrengthMeter();

  private void assertStrength(String password, PasswordStrength expStr) {
    PasswordStrength result = meter.meter(password);
    assertEquals(expStr, result);
  }

  ...

}

중복되는 객체를 필드로 관리하고,

반복되는 작업은 하나의 메서드로 분리해서 리팩토링 해주었다.

 

public class PasswordStrengthMeterTest {
  private PasswordStrengthMeter meter = new PasswordStrengthMeter();

  private void assertStrength(String password, PasswordStrength expStr) {
    PasswordStrength result = meter.meter(password);
    assertEquals(expStr, result);
  }

  @Test
  void meetsAllCriteria_Then_Strong() {
    assertStrength("123ABCDE456", PasswordStrength.STRONG);
  }

  @Test
  void meetsOtherCriteria_except_for_Length_Then_Normal() {
    assertStrength("12345A", PasswordStrength.NORMAL);
  }

  @Test
  void meetsOtherCriteria_except_for_Number_Then_Normal() {
    assertStrength("ABCDEexceptNumber", PasswordStrength.NORMAL);
  }

}

정리한 후 변경된 코드이다.

 

4. 값이 없는 경우

이번에는 놓칠 수 있었던 null 또는 빈 문자열에 대한 경우이다.

 

PasswordStrengthMeterTest

...

public class PasswordStrengthMeterTest {
  ...

  @Test
  void nullInput_Then_Invalid() {
    assertStrength(null, PasswordStrength.INVALID);
    assertStrength("", PasswordStrength.INVALID);
    assertStrength("  ", PasswordStrength.INVALID);
  }

}

Exception을 발생시킬 수 있지만

INVALID 값을 리턴하는 방식을 선택했다.

 

PasswordStrength

public enum PasswordStrength {
  STRONG, NORMAL, INVALID
}

타입을 추가하고,

 

PasswordStrengthMeter

package com.hou27.chap02;

public class PasswordStrengthMeter {

    public PasswordStrength meter(String s) {
        if(s == null || s.isBlank()) {
            return PasswordStrength.INVALID;
        }
        ...
    }

}

빈 문자열뿐만 아니라 공백으로만 이루어진 문자열도 잡아내기 위해 isBlank 메서드를 사용하여

새로운 조건을 작성해 주었다.

 

테스트 통과 ^^

 

5. 대문자를 포함하지 않고 나머지 조건을 충족하는 경우

6. 8글자 이상 조건만 충족하는 경우

7. 숫자 포함 조건만 충족하는 경우

8. 대문자 포함 조건만 충족하는 경우

지금까지 진행한 과정과 유사한 위 4가지 경우는 자세한 설명을 생략하도록 하겠다.

 

아래는 8번째 경우까지 진행한 후의 코드이다.

 

PasswordStrengthMeterTest

...

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class PasswordStrengthMeterTest {
  private PasswordStrengthMeter meter = new PasswordStrengthMeter();

  private void assertStrength(String password, PasswordStrength expStr) {
    PasswordStrength result = meter.meter(password);
    assertEquals(expStr, result);
  }

  @Test
  void meetsAllCriteria_Then_Strong() {
    assertStrength("123ABCDE456", PasswordStrength.STRONG);
  }

  @Test
  void meetsOtherCriteria_except_for_Length_Then_Normal() {
    assertStrength("12345A", PasswordStrength.NORMAL);
  }

  @Test
  void meetsOtherCriteria_except_for_Number_Then_Normal() {
    assertStrength("ABCDEexceptNumber", PasswordStrength.NORMAL);
  }

  @Test
  void nullInput_Then_Invalid() {
    assertStrength(null, PasswordStrength.INVALID);
    assertStrength("", PasswordStrength.INVALID);
    assertStrength("  ", PasswordStrength.INVALID);
  }

  @Test
  void meetsOtherCriteria_except_for_Uppercase_Then_Normal() {
    assertStrength("abcde123", PasswordStrength.NORMAL);
  }

  @Test
  void meetsOnlyLengthCriteria_Then_Weak() {
    assertStrength("abcdefghi", PasswordStrength.WEAK);
  }

  @Test
  void meetsOnlyNumCriteria_Then_Weak() {
    assertStrength("12345", PasswordStrength.WEAK);
  }

  @Test
  void meetsOnlyUpperCriteria_Then_Weak() {
    assertStrength("ABCDE", PasswordStrength.WEAK);
  }

  @Test
  void meetsNoCriteria_Then_Weak() {
    assertStrength("abc", PasswordStrength.WEAK);
  }

}

 

PasswordStrength

public enum PasswordStrength {
  STRONG, NORMAL, INVALID, WEAK
}

 

PasswordStrengthMeter

public class PasswordStrengthMeter {

  public PasswordStrength meter(String s) {
    if (s == null || s.isBlank()) {
      return PasswordStrength.INVALID;
    }

    int criteriaCnt = getCriteriaCnt(s);

    if (criteriaCnt == 2) {
      return PasswordStrength.NORMAL;
    } else if (criteriaCnt == 3) {
      return PasswordStrength.STRONG;
    } else {
      return PasswordStrength.WEAK;
    }
  }

  private int getCriteriaCnt(String s) {
    int criteriaCnt = 0;
    if (s.length() >= 8) {
      criteriaCnt++;
    }
    if (s.matches(".*[0-9].*")) {
      criteriaCnt++;
    }
    if (s.matches(".*[A-Z].*")) {
      criteriaCnt++;
    }
    return criteriaCnt;
  }

}

 

모든 테스트 통과 ^^

 

 

TDD 흐름

지금까지 암호 검사기까지 구현해 보면서

 

테스트 -> 구현 -> 리팩토링의 단계를 반복적으로 거치는

전형적인 TDD의 흐름을 이해할 수 있었다.

 

이 TDD 사이클을

Red - Green - Refactor라고 부른다고 한다.

 

Red는 실패하는 테스트를,

Green은 성공한 테스트를,

Refactor는 말 그대로 코드를 리팩토링 하는 과정을 의미한다.

 

테스트를 먼저 작성하고 해당 테스트를 통과시킬 만큼 기능을 구현했으며

이런 흐름 덕분에 테스트 코드에 따른 다음 개발 범위도 정해지게 된다.

 

즉 테스트가 개발을 주도하게 되는 것이다.

 

코드 수정에 대한 피드백이 빠르며 코드를 수정하거나 추가했을 때

즉각적으로 테스트 코드를 통해 피드백받을 수 있다는 점도 또 다른 장점이다.

'Study > TDD' 카테고리의 다른 글

TDD - 대역  (0) 2023.02.08
TDD - 테스트 코드의 구성  (0) 2023.02.03
TDD - JUnit 5 기초  (0) 2023.01.25
TDD - TDD ∙ 기능 명세 ∙ 설계  (2) 2023.01.16
TDD - 테스트 코드 작성 순서  (0) 2023.01.12