ETC

[TDD] 테스트 주도 개발 시작하기 - TDD 시작(+예제)

연듀 2024. 3. 11. 21:45

 

TDD에 대해 공부하고 싶어

최범균 - 테스트 주도 개발 시작하기 책을 빌려서 읽기 시작했다.

차근차근 실습을 시작해보도록 한다.

 

 

TDD(Test-driven Development)

 

테스트를 먼저 하고 그다음에 구현한다.

package chap02;

import org.junit.jupiter.api.Test;

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

public class CalculatorTest {
    @Test // 메서드를 테스트 메서드로 인식한다. 
    void plus(){
        int result = Calculator.plus(1, 2);
        assertEquals(3, result); // 인자로 받은 두 값이 동일한지 비교
    }
}

 

이 코드를 작성하면 Calculator 클래스가 없다는 컴파일 에러가 발생한다.

package chap02;

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

 

Calculatoro 클래스를 만들어 테스트를 성공시킨다. 

package chap02;

import org.junit.jupiter.api.Test;

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

public class CalculatorTest {
    @Test
    void plus(){
        int result = Calculator.plus(1, 2);
        assertEquals(3, result);
        assertEquals(5, Calculator.plus(4,1)); // 추가
    }
}

 

통과하지 못한다. 

package chap02;

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

 

해당 클래스를 src/main/java 로 옮겨 배포 대상에 포함시킨다.

덧셈 기능을 검정하는 테스트 코드를 먼저 작성한다.

이 과정에서 클래스 이름, 메서드 이름 등을 고민했다. → 코드 설계

테스트 코드를 작성한 뒤 컴파일 오류를 없애는데 필요한 클래스와 메서드를 작성했다.

테스트에 실패하면 테스트를 통과시킬만한 코드를 추가하는 과정을 반복하며 기능을 완성해나간다.

 

 


 

TDD 예제 : 암호 검사기

 

검사할 규칙

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

세개 규칙 만족 - 암호는 강함

2개 규칙 만족 - 암호 보통

1개 이하 규칙 만족 - 암호는 약함

 

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

package chap02;

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("ab12!@AB");
        assertEquals(PasswordStrength.STRONG, result);
    }
}

 

컴파일 에러를 없애도록 클래스들을 만든다.

package chap02;

public class PasswordStrengthMeter {
    public PasswordStrength meter(String s){
        return null;
    }
}

package chap02;

public enum PasswordStrength {
    STRONG
}

 

당연히 테스트에는 실패한다.

package chap02;

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

 

테스트를 통과하도록 바꾼다.

package chap02;

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("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에 NORMAL 열거 타입을 추가한다.

테스트는 실패하므로 meter() 가 NORMAL을 리턴하도록 수정한다. 그런데 그러면 앞의 테스트가 통과하지 못하므로 코드를 수정한다.

 

package chap02;

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

 

통과한다.

 

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

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

 

package chap02;

public class PasswordStrengthMeter {
    public PasswordStrength meter(String s){
        if(s.length()<8){
            return PasswordStrength.NORMAL;
        }
        
        boolean containsNum = false;
        for(char ch: s.toCharArray()){
            if(ch>='0' && ch<='9'){
                containsNum=true;
                break;
            }
        }
        if(!containsNum) return PasswordStrength.NORMAL; 
        return PasswordStrength.STRONG;
    }
}

 

테스트에 성공한다. 리팩토링해보자.

package chap02;

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 static boolean meetsContainingNumberCriteria(String s) {
        for(char ch: s.toCharArray()){
            if(ch>='0' && ch<='9'){
                return true;
            }
        }
        return false;
    }
}

 

테스트 코드 정리

private static final PasswordStrengthMeter meter = new PasswordStrengthMeter();

 

각각의 메서드에서 객체를 생성하는 코드의 중복을 없애기 위해 필드에서 객체를 생성한다.

암호 강도 측정 기능을 실행하고 확인하는 코드의 중복도 제거한다.

 

package chap02;

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

public class PasswordStrengthMeterTest {
    private static final PasswordStrengthMeter meter = new PasswordStrengthMeter();
    private static 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);
    }

}

 

4. 값이 없는 경우

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

 

NullPointerException 이 발생하며 테스트는 당연히 실패한다.

meter() 메소드에 아래와 같이 추가한다.

 

if(s==null) return PasswordStrength.INVALID; 
  @Test
    void emptyInput_Then_Invalid(){
        assertStrength("", PasswordStrength.INVALID);
    }
if(s==null || s.isEmpty()) return PasswordStrength.INVALID; // 추가 

 

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

    @Test
    void meetsOtherCriteria_except_for_Uppercase_Then_Normal(){
        assertStrength("ab12!@df", PasswordStrength.NORMAL);
    }
    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; 
    }

 

메서드 추출로 코드를 정리했다. 테스트가 성공한다.

 

6. 길이가 8글자 이상인 조건만 충족

    @Test
    void meetsOnlyLengthCriteria_Then_Weak(){ // 길이 조건은 충족하고 나머지 두 조건은 충족하지 않았을 때 WEAK 리턴
        assertStrength("abdefghi", PasswordStrength.WEAK);
    }
    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 절 아래로 이동
        if(!lengthEnough){
            return PasswordStrength.NORMAL;
        }
        if(!containsNum) return PasswordStrength.NORMAL;
        if(!containsUpp) return PasswordStrength.NORMAL;
        return PasswordStrength.STRONG;
    }

 

개별 규칙을 검사하는 로직과

규칙을 검사한 결과에 따라 암호 강도를 계산하는 로직을 분리한다.

 

7. 숫자 포함 조건만 충족

   @Test
    void meetsOnlyNumCriteria_Then_Weak(){
        assertStrength("12345", PasswordStrength.WEAK);
    }
 if(!lengthEnough && containsNum && !containsUpp) return PasswordStrength.WEAK;
// 추가

 

8. 대문자 포함 조건만 충족

    @Test
    void meetsOnlyUpperCriteria_Then_Weak(){
        assertStrength("ABZEF", PasswordStrength.WEAK);
    }
if(!lengthEnough && !containsNum && containsUpp) return PasswordStrength.WEAK; // 추가

 

meter() 리팩토링

 

충족하는 조건 개수에 따라 강도를 리턴하도록 리팩토링한다.

    public PasswordStrength meter(String s){
        if(s==null || s.isEmpty()) return PasswordStrength.INVALID;
        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;
        if(metCounts==2) return PasswordStrength.NORMAL;

        return PasswordStrength.STRONG;
    }
    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;
    }

 

9. 아무 조건도 충족하지 않은 경우

    @Test
    void meetsNoCriteria_Then_Weak(){
        assertStrength("abc", PasswordStrength.WEAK);
    }
if(metCounts<=1) return PasswordStrength.WEAK; // 수정

 

코드 가독성 개선

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

 

가독성이 좋아졌고 전반적인 로직이 한눈에 들어온다.

이 메서드만 보면 암호 규칙을 바로 알 수 있다.

마지막으로

PasswordStrengthMeter, PasswordStrength 파일을 src/test/java 폴더에서 src/main/java로 이동시킨다.

 

TDD 흐름 정리

  1. 기능을 검증하는 테스트를 먼저 작성한다.
  2. 작성한 테스트를 통과하지 못하면 테스트를 통과할 만큼만 코드를 작성한다.
  3. 테스트를 통과한 뒤에는 개선할 코드가 있으면 리팩토링한다.
  4. 리팩토링을 수행한 후에는 다시 테스트를 실행해 기존 기능이 망가지지 않았는지 확인한다.
  5. 위의 과정을 반복하며 기능을 완성해 나간다.

 

테스트 코드를 만들면 다음 개발 범위가 정해지고

코드가 추가되면서 검증하는 범위가 넓어질 수록 구현도 완성되어 나간다.

이렇게 테스트가 개발을 주도해 나간다.

지속적으로 코드를 정리하며 코드 품질이 나빠지지 않게 막고, 유지 보수 비용을 낮추는데 기여한다.

코드 수정에 대해 빠른 피드백이 가능해 잘못된 코드가 배포되는 것을 방지할 수 있다.

 

 

 

 

 

참고: https://m.yes24.com/Goods/Detail/89145195