TDD

[TDD] 시작하기

큐범 2023. 1. 6. 02:39

TDD, 즉 테스트 주도 개발(Test-driven Development)

테스트부터 시작한다. 구현을 먼저하고 나중에 테스트하는 것이 아니라 먼저 테스트를 하고 그 다음에 구현한다.

 

TDD 예: 암호 검사기

검사할 규칙

  1. 길이가 8글자 이상
  2. 0부터 9사이의 숫자를 포함
  3. 대문자 포함

등급

  • 3개의 규칙을 충족하면 암호는 강함
  • 2개의 규칙을 충족하면 암호는 보통
  • 1개의 규칙을 충족하면 암호는 약함

assertEquals(기댓값,결과값);

 

테스트 코드 작성

첫 번째 테스트 - 모든 규칙을 충족하는 경우

public enum PasswordStrength {
	INVALID,NORMAL,STRONG
}
@Test
void meetsAllCriteria_Then_Strong(){
    PasswordStrengthMeter meter = new PasswordStrengthMeter();
    PasswordStrength result = meter.meter("ab12!@AB");
    assertEquals(PasswordStrength.STRONG, result);
    PasswordStrength result2 = meter.meter("abc1!Add");
    assertEquals(PasswordStrength.STRONG, result2);
}

두 번째 테스트 - 길이만 8글자 미만이고 나머지 조건은 충족하는 경우

@Test
void meetsOtherCriteria_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);
}

세번째 테스트 - 숫자를 포함하지 않고 나머지 조건은 충족하는 경우

@Test
void meetsOtherCriteria_except_for_number_The_Normal(){
    PasswordStrengthMeter meter = new PasswordStrengthMeter();
    PasswordStrength result = meter.meter("ab!@ABqwer");
    assertEquals(PasswordStrength.NORMAL,result);
}

코드 정리 - 중복 제거

위의 중복코드를 제거하면 첫 번째 예시 기준으로 다음과 같은 코드가 된다.

public class PasswordStrengthMeterTest {

    private PasswordStrengthMeter meter = new PasswordStrengthMeter();

    @Test
    void meetsAllCriteria_Then_Strong(){
        PasswordStrength result = meter.meter("ab12!@AB");
        assertEquals(PasswordStrength.STRONG, result);
        PasswordStrength result2 = meter.meter("abc1!Add");
        assertEquals(PasswordStrength.STRONG, result2);
    }
}

다음 중복 코드를

PasswordStrength result = meter.meter("ab12!@AB");
assertEquals(PasswordStrength.STRONG, result);

아래와 같이 메서드를 생성해

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

아래와 같이 중복 코드를 제거한다.

@Test
void meetsAllCriteria_Then_Strong(){
    assertStrength("ab12!@AB",PasswordStrength.STRONG);
    assertStrength("abc1!Add",PasswordStrength.STRONG);
}

@Test
void meetsOtherCriteria_except_for_Length_Then_Normal(){
    assertStrength("ab12!@A",PasswordStrength.NORMAL);
    assertStrength("Ab12!c",PasswordStrength.NORMAL);
}

@Test
void meetsOtherCriteria_except_for_number_The_Normal(){
    assertStrength("ab!@ABqwer",PasswordStrength.NORMAL);
}

주의할 점 : 중복을 제거 이후에도 꼭 테스트 코드를 실행해보고 무분별한 제거는 가독성을 떨어뜨릴 수 있다.

네 번째 테스트: 값이 없는 경우

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

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

아래와 같은 검증을 추가해준다.

if (s == null || s.isEmpty()) return PasswordStrength.INVALID;

다섯번째 테스트: 대문자를 포함하지 않고 나머지 조건을 충족하는 경우

@Test
void meetsOtherCriteria_except_for_Uppercase_Then_Normal(){
    assertStrength("ab12!@df",PasswordStrength.NORMAL);
}

아래와 같은 검증을 추가해준다.

private boolean meetsContainingUppercaseCriteria(String s) {
    for (char ch : s.toCharArray()){
        if (Character.isUpperCase(ch)){
            return true;
        }
    }
    return false;
}

여섯 번째 테스트: 길이가 8글자 이상인 조건만 충족하는 경우

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

meter함수의 내용을 다음과 같이 수정한다.

public PasswordStrength meter(String s) {
    if (s == null || s.isEmpty()) return PasswordStrength.INVALID;
    boolean lengthEnough = s.length() >= 8;
    boolean containsNum = meetsContainingNumberCriteria(s);
    boolean containsUpp = meetsContainingUppercaseCriteria(s);
    if(!lengthEnough){
        return PasswordStrength.NORMAL;
    }
    if (!containsNum) return PasswordStrength.NORMAL;
    if (!containsUpp) return PasswordStrength.NORMAL;
    return PasswordStrength.STRONG;
}

위의 코드를 보면 if 절의 위치를 두 로직을 구분하기 위해 이동하였다.

  1. 개별 규칙을 검사하는 로직
  2. 규칙을 검사한 결과에 따라 암호 강도를 계산하는 로직

일곱 번째 테스트: 숫자 포함 조건만 충족하는 경우

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

meter함수의 내용을 다음과 같이 수정한다.

if (!lengthEnough && containsNum && !containsUpp)
    return PasswordStrength.WEAK;

여덟 번째 테스트: 대문자 포함 조건만 충족하는 경우

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

meter함수의 내용을 다음과 같이 수정한다.

if (!lengthEnough && !containsNum && containsUpp)
    return PasswordStrength.WEAK;

코드정리: meter() 메서드 리팩토링

전체 리팩토링 이전

public class PasswordStrengthMeter {
    public PasswordStrength meter(String s) {
        if (s == null || s.isEmpty()) return PasswordStrength.INVALID;
        boolean lengthEnough = s.length() >= 8;
        boolean containsNum = meetsContainingNumberCriteria(s);
        boolean containsUpp = meetsContainingUppercaseCriteria(s);

        if (lengthEnough && !containsNum && !containsUpp)
            return PasswordStrength.WEAK;
        if (!lengthEnough && containsNum && !containsUpp)
            return PasswordStrength.WEAK;
        if (!lengthEnough && !containsNum && containsUpp)
            return PasswordStrength.WEAK;

        if(!lengthEnough){
            return PasswordStrength.NORMAL;
        }
        if (!containsNum) return PasswordStrength.NORMAL;
        if (!containsUpp) return PasswordStrength.NORMAL;
        return PasswordStrength.STRONG;
    }

    private boolean meetsContainingUppercaseCriteria(String s) {
        for (char ch : s.toCharArray()){
            if (Character.isUpperCase(ch)){
                return true;
            }
        }
        return false;
    }

    private boolean meetsContainingNumberCriteria(String s) {
        for (char ch : s.toCharArray()){
            if(ch >= '0' && ch <= '9'){
                return true;
            }
        }
        return false;
    }
}

WEAK 리팩토링

리팩토링 이전

if (lengthEnough && !containsNum && !containsUpp)
    return PasswordStrength.WEAK;
if (!lengthEnough && containsNum && !containsUpp)
    return PasswordStrength.WEAK;
if (!lengthEnough && !containsNum && containsUpp)
    return PasswordStrength.WEAK;

리팩토링 이후

int metCounts = 0;
boolean lengthEnough = s.length() >= 8;
if (lengthEnough) metCounts++;
boolean containsNum = meetsContainingNumberCriteria(s);
if (containsNum) metCounts++;
boolean containsUpp = meetsContainingUppercaseCriteria(s);
if (containsUpp) metCounts++;

if (metCounts == 1) return PasswordStrength.WEAK;

NORMAL 리팩토링

리팩토링 이전

if(!lengthEnough){
    return PasswordStrength.NORMAL;
}
if (!containsNum) return PasswordStrength.NORMAL;
if (!containsUpp) return PasswordStrength.NORMAL;

리팩토링 이후

if (metCounts == 2) return PasswordStrength.NORMAL;

전체 리팩토링

    public PasswordStrength meter(String s) {
        if (s == null || s.isEmpty()) return PasswordStrength.INVALID;
        int metCounts = 0;
        if (s.length() >= 8) metCounts++;
        if (meetsContainingNumberCriteria(s)) metCounts++;
        if (meetsContainingUppercaseCriteria(s)) metCounts++;

        if (metCounts == 1) return PasswordStrength.WEAK;
        if (metCounts == 2) return PasswordStrength.NORMAL;

        return PasswordStrength.STRONG;
    }

아홉 번째 테스트: 아무 조건도 충족하지 않는 경우

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

meter함수의 내용을 다음과 같이 수정한다.

if (metCounts <= 1) return PasswordStrength.WEAK;

코드정리: 코드 가독성 개선

int metCounts = 0;
if (s.length() >= 8) metCounts++;
if (meetsContainingNumberCriteria(s)) metCounts++;
if (meetsContainingUppercaseCriteria(s)) metCounts++;
public PasswordStrength meter(String s) {
        if (s == null || s.isEmpty()) return PasswordStrength.INVALID;
        int metCounts = getMetCriteriaCounts(s);

        if (metCounts <= 1) return PasswordStrength.WEAK;
        if (metCounts == 2) return PasswordStrength.NORMAL;

        return PasswordStrength.STRONG;
    }

    private int getMetCriteriaCounts(String s) {
        int metCounts = 0;
        if (s.length() >= 8) metCounts++;
        if (meetsContainingNumberCriteria(s)) metCounts++;
        if (meetsContainingUppercaseCriteria(s)) metCounts++;
        return metCounts;
    }

이제 다른 개발자가 보더라도 다음과 같은 코드를 읽을 수 있다.

  1. 암호가 Null이거나 빈 문자열이면 암호 강도는 INVALID이다.
  2. 충족하는 규칙 개수를 구한다.
  3. 충족하는 규칙 개수가 1개 이하면 암호 강도는 WEAK이다.
  4. 충족하는 규칙 개수가 2개면 암호 강도는 NORMAL이다.
  5. 이 외 경우(즉 충족하는 규칙 개수가 2개보다 크면) 암호 강도는 STRONG이다.

TDD 흐름

테스트 - 코딩 - 리팩토링 - 테스트를 반복한다.

TDD는 테스트를 먼저 작성하고 테스트를 통과시킬 만큼 코드를 작성하고 리팩토링으로 마무리 하는 과정을 반복한다.

아래의 1번부터 4번을 반복한다.

  1. 기능을 검증하는 테스트를 작성
  2. 작성한 테스트를 통과하지 못하면 통과하는 코드를 작성
  3. 작성한 코드를 리팩토링
  4. 리팩토링 후 다시 테스트 실행

레드-그린-리팩터

TDD 사이클을 레드(Red)-그린(Green)-리팩터(refactor)로 부르기도 한다.

레드는 실패하는 테스트를 의미한다. 레드는 테스트 코드가 실패하면 빨간색을 이용해서 실패한 테스트를 보여주는데서 비롯했다.

그린은 성공한 테스트를 의미한다.

리팩터는 리팩토링 과정을 의미한다.

 

Reference.

 

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

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

www.yes24.com