[테스트 주도 개발 시작하기] CHAPTER 2 - TDD 시작

myeonji·2023년 1월 5일
0
  • TDD란
  • TDD 예: 암호 검사기

TDD란

  • TDD는 테스트부터 시작한다.
  • 테스트를 먼저 한다는 것은 기능이 올바르게 동작하는지 검증하는 테스트 코드를 작성한다는 것을 의미한다.
  • 기능을 검증하는 테스트 코드를 먼저 작성하고, 테스트를 통과시키기 위해 개발을 진행한다.

간단한 덧셈 기능을 TDD로 구현해보았다.

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

public class CalculatorTest {

    @Test
    void plus() {
        int result = Calculator.plus(1, 2);
        assertEquals(3, result);
        assertEquals(5, Calculator.plus(4, 1));
    }
}

int result = Calculator.plus(1, 2); 코드를 만들기 위해 몇 가지 고민을 해야 한다.

  1. 메서드 이름은 plus가 좋을까? sum이 좋을까?
  2. 덧셈 기능을 제공하는 메서드는 파라미터가 몇 개여야 할까? 타입은? 반환할 값은?
  3. 메서드를 정적 메서드로 구현할까, 인스턴스 메서드로 구현할까?
  4. 메서드를 제공할 클래스 이름은 뭐가 좋을까?

위처럼 덧셈 기능을 제공할 클래스, 메서드, 반환 타입 등에 대해 고민하고 그 결과에 맞게 작성해야 한다.

public class Calculator {
    public static int plus(int a1, int a2) {
        return a1 + a2;
    }
}

Calculator 클래스를 생성한다.
처음에는 return 3; , return 5; 처럼 점진적으로 구현을 완성해 나가야 한다.
최종적으로 return a1 + a2; 라는 코드가 되었고,
src/test/java 소스 폴더에 있던 Calculator 클래스를 src/main/java 소스 폴더로 이동시켜서 배포 대상에 포함시킨다.

src/test/java 소스 폴더는 배포 대상이 아니므로 src/test/java 폴더에 코드를 만들면 완성되지 않은 코드가 배포되는 것을 방지하는 효과가 있다.

✔ 위의 덧셈 예제에서는,

  1. 덧셈 기능을 검증하는 테스트 코드를 먼저 작성했다.
    • 이 과정에서 테스트 대상이 될 클래스 이름, 메서드 이름, 파라미터 개수, 리턴 타입을 고민했다. 또한, 새로운 객체를 생성할지 아니면 정적 메서드로 구현할지 등을 함께 고민했다.
  2. 테스트 코드를 작성한 뒤에는 컴파일 오류를 없애는 데 필요한 클래스와 메서드를 작성했다.
  3. 테스트를 실행했고 테스트에 실패했다.
  4. return 3처럼 테스트를 통과할 만큼만 코드를 구현하고 테스트에 통과했다.
  5. 테스트를 통과시킨 뒤에 새로운 테스트를 추가했고 다시 그 테스트를 통과시키기 위한 코드를 작성했다.
  6. 계속 반복하면서, 최종적으로 return a1+a2;라는 코드로 기능을 완성하였다.

이 과정이 실제 코드를 설계하는 과정과 유사하다.


TDD 예 : 암호 검사기

암호 검사기를 구현해보았다.
문자열을 검사해서 규칙을 준수하는지에 따라 암호를 '약함', '보통', '강함'으로 구분한다.

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

세 규칙을 모두 충복하면 '강함', 2개의 규칙을 충족하면 '보통', 1개 이하의 규칙을 충적하면 '약함' 이라고 한다. (PasswordStrength)

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

'암호가 모든 조건을 충족하면 암호 강도는 강함이어야 함'

ublic class PasswordStrengthMeterTest {

    @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);
    }
}
public enum PasswordStrength {
    STRONG
}
public class PasswordStrengthMeter {
    public PasswordStrength meter(String s) {
        return PasswordStrength.STRONG;
    }
}

PasswordStrengthMeterPasswordStrength.STRONG를 리턴한다. 따라서 위 테스트는 통과하게 된다.

PasswordStrength에서 WEAKNORMAL을 미리 추가할 수 있겠지만, TDD는 테스트를 통과시킬 만큼의 코드를 작성한다.

두 번째 테스트 : 길이만 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);
}
public enum PasswordStrength {
    NORMAL, STRONG
}
public class PasswordStrengthMeter {
    public PasswordStrength meter(String s) {
        if (s.length() < 8) {
            return PasswordStrength.NORMAL;
        }
        return PasswordStrength.STRONG;
    }
}

첫 번째 테스트와 이번 두 번째 테스트를 모두 통과하기 위해서는 PasswordStrengthMeter에서 if문을 통해 NORMALSTRONG으로 구분해주었다.

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

@Test
void meetsOtherCriteria_except_for_number_Then_Normal() {
    PasswordStrengthMeter meter = new PasswordStrengthMeter();
    PasswordStrength result = meter.meter("ab!@ABqwer");
    assertEquals(PasswordStrength.NORMAL, result);
}
public class PasswordStrengthMeter {
    public PasswordStrength meter(String s) {
        if (s.length() < 8) {
            return PasswordStrength.NORMAL;
        }
        boolean containsNum = meetsContainingNumberCriteria(s);

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

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

코드 가독성을 위해 숫자 포함 여부를 확인하는 코드를 따로 메서드로 추출하였다.
세 번째 코드도 마찬가지로 NORMAL이다.

코드 정리 : 테스트 코드 정리

기존 PasswordStrengthMeterTest의 테스트 메서드를 보면 PasswordStrengthMeter를 생성하는 코드가 중복된다.

public class PasswordStrengthMeterTest {

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

    @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_Then_Normal() {
        PasswordStrengthMeter meter = new PasswordStrengthMeter();
        PasswordStrength result = meter.meter("ab!@ABqwer");
        assertEquals(PasswordStrength.NORMAL, result);
    }
}

각 메서드에서 생성하고 있던 코드를 필드에서 생성하도록 수정할 수 있다.
또한 암호 강도 측정 기능을 실행하고 이를 확인하는 코드도 assertStrength 메서드를 추가하여 중복을 제거할 수 있다.

public class PasswordStrengthMeterTest {
    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("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_Then_Normal() {
        assertStrength("ab!@ABqwer", PasswordStrength.NORMAL);;
    }
}

중복 제거 후 코드는 위와 같다.

테스트 코드의 중복을 무턱대고 제거하면 안 된다. 중복을 제거한 뒤에도 테스트 코드의 가독성이 떨어지지 않고 수정이 용이한 경우에만 중복을 제거해야 한다.

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

null을 입력할 경우 암호 강도 측정기는 어떻게 반응할지?

  • illegalArgumentException을 발생한다.
  • 유효하지 않은 암호를 의미하는 PasswordStrength.INVALID를 리턴한다.

두 번째 방법을 선택하여 구현해보았다.

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

@Test
void emptyInput_Then_Invalid() {
    assertStrength("", PasswordStrength.INVALID);;
}
public class PasswordStrengthMeter {
    public PasswordStrength meter(String s) {
        if (s == null || s.isEmpty()) return PasswordStrength.INVALID;

        if (s.length() < 8) {
            return PasswordStrength.NORMAL;
        }
        boolean containsNum = meetsContainingNumberCriteria(s);

        if (!containsNum) return PasswordStrength.NORMAL;
        return PasswordStrength.STRONG;
    }
 ...생략

null인 경우와 빈 문자열인 경우도 고려하여 테스트를 추가한다.

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

@Test
 void meetsOtherCriteria_except_for_Uppercase_Then_Normal() {
     assertStrength("ab12!@df", PasswordStrength.NORMAL);;
 }
public class PasswordStrengthMeter {
    public PasswordStrength meter(String s) {
        if (s == null || s.isEmpty()) return PasswordStrength.INVALID;

        if (s.length() < 8) {
            return PasswordStrength.NORMAL;
        }
        boolean containsNum = meetsContainingNumberCriteria(s);
        if (!containsNum) return PasswordStrength.NORMAL;

        boolean containsUpp = meetsContainingUppercaseCriteria(s);
        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;
    }
}

세 번째 테스트와 마찬가지로, 메서드 추출을 이용해서 대문자 포함 여부를 확인하는 메서드를 따로 작성하였다.

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

이제 한 가지 조건만 충족하거나 모든 조건을 충족하지 않는 경우를 테스트 해본다.
이 경우에는 암호 강도가 WEAK 이다.

@Test
void meetsOnlyLengthCriteria_Then_Weak() {
    assertStrength("abdefghi", PasswordStrength.WEAK);;
}
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) {
            return PasswordStrength.NORMAL;
        }
        if (!containsNum) return PasswordStrength.NORMAL;
        if (!containsUpp) return PasswordStrength.NORMAL;

        return PasswordStrength.STRONG;
    }
    
... 생략

if (lengthEnough && !containsNum && !containsUpp) 코드를 통해 길이만 충족할 경우를 만족시켜주었다.

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

여섯 번째 테스트와 같이, if (!lengthEnough && containsNum && !containsUpp) 코드를 통해 숫자 포함 조건만 충족하는 경우를 만들어주면 된다.

// PasswordStrengthMeterTest 클래스

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

...생략

// PasswordStrengthMeter 클래스

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

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

여섯 번째, 일곱 번째 테스트 경우와 같다.

// PasswordStrengthMeterTest 클래스

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

...생략

// PasswordStrengthMeter 클래스

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

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

PasswordStrengthMeter 클래스를 확인해보았다.
코드가 꽤 복잡해보여 리팩토링을 진행하였다.

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) { // 길이 8글자 이상 조건만 만족
            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 meetsContainingNumberCriteria(String s) {
        for (char ch : s.toCharArray()) {
            if (ch>='0' && ch<='9') {
                return true;
            }
        }
        return false;
    }

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

먼저 WEAK를 반환하는 if 절이 연달아 3개 존재한다.
더불어 해당 if 절은 세 조건 중에서 한 조건만 충족하고 있다.
따라서 충족하는 조건 개수를 사용하도록 바꿔보았다.

암호 강도는 이제 metCounts 값을 이용해서 계산한다.
이제 lengthEnough, containsNum, containsUpp 변수는 metCounts 값을 증가시킬 때만 사용된다.

public class PasswordStrengthMeter {
    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;
    }

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

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

따라서 위의 코드처럼 lengthEnough, containsNum, containsUpp 변수를 제거하고 if 절의 조건문에 직접 넣어 코드를 리팩토링 하였다.

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

이 경우에는 충족 개수 metCounts가 0이므로 이에 해당하는 코드를 추가해야 한다.
위 코드 정리의 PasswordStrengthMeter에서 if (metCounts <= 1) return PasswordStrength.WEAK; 부분만 바꿔주면 된다.
== 에서 <= 로 변경하였다.

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

metCounts 변수를 계산하는 부분을 메서드로 빼서 meter() 메서드의 가독성을 높일 수 있다.

// PasswordStrengthMeter 클래스 내 getMetCriteriaCounts 메서드 생성

private int getMetCriteriaCounts(String s) {
    int metCounts = 0;

    if (s.length() >= 8) metCounts++;
    if (meetsContainingNumberCriteria(s)) metCounts++;
    if (meetsContainingUppercaseCriteria(s)) metCounts++;
        
    return metCounts;
}

...생략

정리

레드-그린-리팩터

TDD 사이클을 레드(Red)-그린(Green)-리팩터(Refactor)로 부른다.

🔴Red : 실패하는 테스트 코드를 먼저 작성한다.
🟢Green : 테스트 코드를 성공시키기 위한 실제 코드를 작성한다.
🟡Yellow : 중복 코드 제거, 일반화 등의 리팩토링을 수행한다.

🔹 일반 개발 방식

요구사항 분석 -> 설계 -> 개발 -> 테스트 -> 배포

소프트웨어 개발을 느리게 하는 잠재적 위험이 존재한다.

🔹 TDD 개발 방식

테스트 코드를 작성한 뒤에 실제 코드 작성

설계 단계에서 프로그래밍 목적을 미리 정의하고, 무엇을 테스트해야 할지 미리 정의해야 한다.
테스트 코드를 작성하는 도중에 발생하는 예외 상황(버그, 수정사항)들은 테스트 케이스에 추가하고 개선한다.

✅ TDD 흐름

테스트 -> 코딩 -> 리팩토링 의 반복

✅ 테스트가 개발을 주도

테스트를 작성하는 과정에서 구현을 생각하지 않았다.
단지 해당 기능이 올바르게 동작하는지 검증할 수 있는 테스트 코드를 만들었을 뿐이다.
지금까지 작성한 테스트를 통과할 만큼만 구현을 진행하면서, 테스트 코드를 추가하고 범위를 점차 넓혀간다.

✅ 지속적인 코드 정리

구현을 완료한 뒤에는 리팩토링을 진행한다.
당장 리팩토링할 대상이나 어떻게 리팩토링해야 할지 생각나지 않으면 다음 테스트를 진행하고, 대상이 눈에 들어오면 리팩토링을 진행해서 코드를 정리한다.
테스트 코드 자체도 리팩토링 대상에 넣는다.

0개의 댓글