Justin의 개발 로그
article thumbnail
TDD는 테스트부터 시작한다. 먼저 테스트를 하고 그 다음에 구현을 한다.

 

TDD 개발 순서

  • 테스트 코드를 먼저 작성한다. 
    개발하고자 하는 기능이 올바르게 동작 했을 때의 결과를 확인 가능한 테스트 코드를 먼저 작성한다.
  • 테스트 코드에 올바르게 동작하는 기능을 개발한다.

 

비밀번호 복잡도 평가 로직 개발

/**
 * 비밀번호 보안 수준 테스트
 *  [보안 수준 조건]
 *  -길이 8글자 이상
 *  -0~9 사이의 숫자를 포함
 *  -대문자 포함
 *
 *  위의 세 가지 조건을 충족하면 암호는 강함.
 *  2개의 조건을 충족하면 보통.
 *  1개 이하의 조건을 충족하면 약함.
 *  비밀번호가 공백(Empty)이거나, 없으면 인식불가.
 */

 

비밀번호 복잡도의 테스트 로직을 아래와 같이 만든다.

  • 테스트 대상 클래스명
  • 테스트 대상 메소드명, 파라미터 형식
  • 메소드 호출 리턴 타입
  • 메소드 호출 결과

src/test/chap01/PasswordStrengthMeterTest.java

import org.junit.jupiter.api.Test;

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

public class PasswordStrengthMeterTest {

    @Test
    void meetsAllCriteria_Then_String() {
        PasswordStrengthMeter meter = new PasswordStrengthMeter();
        PasswordStrength result = meter.meter("ab12!@AB");

        assertEquals(PasswordStrength.STRONG, result);
    }
}

컴파일 에러를 막기위해 최소한의 클래스를 다음과 같이 추가한다.

  • Target 클래스 추가
  • Target 메소드 선언 (메소드명, 파라미터, 리턴값)
  • 기본 리턴값 하드 코딩

src/main/chap01/PasswordStrength.java

public enum PasswordStrength {
    STRONG
}

src/main/chap01/PasswordStrengthMeter.java

public class PasswordStrengthMeter {

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

 

 

테스트에 통과하도록 로직을 수정 후 테스트

public class PasswordStrengthMeter {

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

 

 

보통 수준의 암호 테스트 케이스 추가

테스트할 로직에 대한 주석 추가 

테스트 메서드명 한글로 

보통 수준의 암호 케이스 추가 

/**
 * 길이 8글자 이상
 * 9~9 사이의 숫자를 포함
 * 대문자 포함
 * 위의 세 가지 조건을 충족하면 암호는 강함.
 * 2개의 조건을 충족하면 보통.
 * 1개 이하의 조건을 충족하면 약함.
 */
public class PasswordStrengthMeterTest {

    @Test
    void 강한암호_테스트() {
        PasswordStrengthMeter meter = new PasswordStrengthMeter();
        PasswordStrength result = meter.meter("ab12!@AB");

        assertEquals(PasswordStrength.STRONG, result);
    }

    @Test
    void 보통수준암호_대문자_숫자_테스트() {
        PasswordStrengthMeter meter = new PasswordStrengthMeter();
        PasswordStrength result = meter.meter("AB12!2");

        assertEquals(PasswordStrength.NORMAL, result);
    }
}

컴파일 에러가 안나도록 enum PasswordStrength에 NORMAL 추가

public enum PasswordStrength {
    STRONG,
    NORMAL
}

테스트 실행

org.opentest4j.AssertionFailedError: 
Expected :NORMAL
Actual   :STRONG

 

두 테스트 모두 통과하도록 로직 추가 

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

 

* TDD 방식의 개발 진행에서는 한방에 완성된 로직을 짜려고 하는 기존 개발 방식의 습관을 지양해야 한다.
   한번에 두 개 이상의 로직을 한꺼번에 추가하는 것을 지양하고, 최소한의 로직만으로 테스트를 통과 시킨 후 
  테스트 케이스를 추가하면서 로직도 점차 완성되어 가도록 해야 한다.

 TDD가 무엇인지는 알지만, TDD 방식으로 개발을 하지 못하는 가장 큰 원인이 이 개발 방식의 패러다임을 이해하지 못한채 코딩 수준에서 Test 케이스 작성까지만 이해했기 때문임

 Test-Driven-Development는 테스트 조건을 만들고, 이를 하나씩 통과시켜 나가면서 미완성의 로직을 완성해 나가는 여정

크고 복잡한 클래스 구조, 복잡한 로직의 설계를 최소화하고, 한 단계씩 차근차근 진행하는 과정에서 Side Effect이 적은 코드가 만들어져 가고, TDD 과정에서 자연스러운 리팩토링을 통해 코드 퀄리티가 계속 유지된다.

 

보통 수준 암호 테스트 케이스 추가 

    @Test
    void 보통수준암호_대문자포함_8자초과_숫자없음_테스트() {
        PasswordStrengthMeter meter = new PasswordStrengthMeter();
        PasswordStrength result = meter.meter("AB!@abcde");

        assertEquals(PasswordStrength.NORMAL, result);
    }
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;
    }
}

 

 

 

숫자 체크 로직 리팩토링 - 메서드 분리

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;
    }
}
    /**
     * 숫자 포함 여부 - 람다표현식
     * @param s
     * @return
     */
    public static boolean isContainsNumber(String s) {
        return s.chars()
                .filter(Character::isDigit)
                .findAny()
                .isPresent();
    }

 

 

테스트 코드의 중복 소스 리팩토링

  • 모든 테스트 케이스에 반복되는 PasswordStrengthMeter 객체 생성 부분을 클래스 멤버객체로 뽑아 중복을 제거한다.

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

    @Test
    void 강한암호_테스트() {
        PasswordStrength result = meter.meter("ab12!@AB");

        assertEquals(PasswordStrength.STRONG, result);
    }

    @Test
    void 보통수준암호_대문자숫자_테스트() {
        PasswordStrength result = meter.meter("AB12!2");

        assertEquals(PasswordStrength.NORMAL, result);
    }

    @Test
    void 보통수준암호_대문자8자초과_테스트() {
        PasswordStrength result = meter.meter("AB!@abcde");

        assertEquals(PasswordStrength.NORMAL, result);
    }
}

 

  • 각 테스트 케이스에 반복 등장하는 아래와 같은 부분을 리팩토링(메서드 분리) 한다.
PasswordStrength result = meter.meter("암호값");
assertEquals(PasswordStrength.암호강도, result);

  • 메서드 이름을 assertPasswordStrength 로 지정한다.

분리된 메서드는 아래와 같다.

    private void assertPasswordStrength() {
        PasswordStrength result = meter.meter("ab12!@AB");
        assertEquals(PasswordStrength.STRONG, result);
    }
  • 추가로 "ab12!@AB" 부분을 파라미터로 리팩토링한다. 

  • 파라미터 이름 : password
  • PasswordStrength.STRONG 부분도 파라미터로 뽑아낸다. passwordStrength

  • 리팩토링이 끝나면 IntelliJ가 자동으로 방금 전 리팩토링에 의해 중복 제거가 가능한 소스들의 추가 변환 여부를 묻는다.
  • [Replace]를 눌러 자동 리팩토링이 되는 신세계를 경험해 보자.

 

IntelliJ에 의해 중복 소스가 모두 자동으로 분리된 메서드로 리팩토링된다.

메서드 리펙토링 완료 전
메서드 리펙토링 완료 후

 

새롭게 분리된 메소드를 클래스 하단으로 이동

Option + 화살표(위) 로 블럭 지정

Option + Shift + 화살표(위/아래)로 이동

 

패스워드 null 에 대한 테스트 케이스 추가

  • null 테스트 케이스 추가
    @Test
    void 비밀번호_NULL_테스트() {
        assertPasswordStrength(null, PasswordStrength.INVALID);
    }
  • 컴파일 가능하도록 enum 추가
public enum PasswordStrength {
    STRONG,
    NORMAL,
    INVALID
}
  • 테스트

  • null 검출 로직 추가 
public class PasswordStrengthMeter {
    public PasswordStrength meter(String s) {
        if(s == null) return PasswordStrength.INVALID;


... 생략 ...

  • 비밀번호 공백 테스트 케이스 추가
    @Test
    void 비밀번호_공백_테스트() {
        assertPasswordStrength("", PasswordStrength.INVALID);
    }

  • 비밀번호 공백 검출 로직 추가 
public class PasswordStrengthMeter {
    public PasswordStrength meter(String s) {
        if(s == null || s.isEmpty()) return PasswordStrength.INVALID;
        
        ... 생략 ...

    //대문자 포함x
    @Test
    void 보통수준암호_숫자_8자이상_테스트() {
        assertPasswordStrength("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 containsUpperCase = false;
        for(char ch:s.toCharArray()) {
            if(Character.isUpperCase(ch)) {
                containsUpperCase = true;
                break;
            }
        }
        if(containsUpperCase == false) return PasswordStrength.NORMAL;
		//------- 추가한 코드 ----------//
        
        return PasswordStrength.STRONG;
    }

 

 

방금 추가한 코드를 메서드 분리

  • 분리된 메서드에서 불필요한 코드 정리
    private boolean isContainsUpperCase(String s) {
        for(char ch: s.toCharArray()) {
            if(Character.isUpperCase(ch)) { 
                return true;
            }
        }
        return false;
    }
  • 숫자 포함 확인 메서드 이름 리팩토링 -> isContainsNumber

 

    /**
     * 대문자 포함 여부 -> 람다 표현식
     * @param s
     * @return
     */
    public static boolean isContainsUpperCase(String s) {
        return s.chars()
                .filter(Character::isUpperCase)
                .findAny()
                .isPresent();
    }

낮은 수준 암호 테스트 케이스 추가

    @Test
    void 낮은수준암호_8자이내_테스트() {
        assertPasswordStrength("ab12", PasswordStrength.WEAK);
    }

 

 

복잡도 조건을 카운트 하도록 로직 변경

public class PasswordStrengthMeter {
    public PasswordStrength meter(String s) {
        if(s == null || s.isEmpty()) return PasswordStrength.INVALID;

        int meetCounts = 0;

        if(s.length() >= 8) meetCounts++;

        boolean containsNum = isContainsNumber(s);
        if(containsNum) meetCounts++;

        boolean containsUpperCase = isContainsUpperCase(s);
        if(containsUpperCase) meetCounts++;

        if(meetCounts == 3)
            return PasswordStrength.STRONG;
        if(meetCounts == 2)
            return PasswordStrength.NORMAL;

        return PasswordStrength.WEAK;
    }
    
    ... 생략 ...
  • enum에 WEAK 추가
public enum PasswordStrength {
    INVALID,
    WEAK,
    NORMAL,
    STRONG
}

 

 

PasswordStrengthMeter 메서드의 두 가지 역할을 분리

  • 비밀번호 보안 수준에 일치하는 갯수를 세는 역할
  • 갯수에 따라 비밀번호 보안 수준 강도를 결정하는 역할
  • IntelliJ의 메소드 분리 기능으로 간단하게 메소드 분리 후 이름을 getMeetPasswordCriteriaCount로 변경

 

 

하드코딩 제거  - 비밀번호 길이 8 -> MINIMUM_PASSWORD_LENGTH_CRITERIA

    private int getMeetPasswordCriteriaCount(String s) {
        int meetCounts = 0;

        if(s.length() >= MINIMUM_PASSWORD_LENGTH_CRITERIA) meetCounts++;

 

 

하드코딩 또 제거 - 갯수를 비교하는 부분의 하드코딩을 없애고 싶다.

  • 하드 코딩은 "병적이다" 싶을 정도로 제거하는 것이 좋습니다.
  •  
// 변경 전
public class PasswordStrengthMeter {
    public static final int MINIMUM_PASSWORD_LENGTH_CRITERIA = 8;
    
    public PasswordStrength meter(String s) {
        if(s == null || s.isEmpty()) return PasswordStrength.INVALID;

        int meetCounts = getMeetPasswordCriteriaCount(s);

        if(meetCounts == 3)
            return PasswordStrength.STRONG;
        if(meetCounts == 2)
            return PasswordStrength.NORMAL;

        return PasswordStrength.WEAK;
    }
        
        
// 변경 후 
public class PasswordStrengthMeter {
    public static final int MINIMUM_PASSWORD_LENGTH_CRITERIA = 8;

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

        int meetCounts = getMeetPasswordCriteriaCount(s);

        return PasswordStrength.getPasswordStrength(meetCounts);
    }
    ... 생략 ...
    



public enum PasswordStrength {
    INVALID(-1),
    WEAK(0),
    NORMAL(2),
    STRONG(3);

    private int passwordCriteriaCnt;

    PasswordStrength(int passwordCriteriaCnt) {
        this.passwordCriteriaCnt = passwordCriteriaCnt;
    }

    public static PasswordStrength getPasswordStrength(int meetCriteriaCnt) {
        if(meetCriteriaCnt == PasswordStrength.STRONG.passwordCriteriaCnt) return PasswordStrength.STRONG;

        if(meetCriteriaCnt == PasswordStrength.NORMAL.passwordCriteriaCnt) return PasswordStrength.NORMAL;

        if(meetCriteriaCnt >= PasswordStrength.WEAK.passwordCriteriaCnt) return PasswordStrength.WEAK;

        return PasswordStrength.INVALID;
    }
}

 

 

복잡도를 더 줄이고 싶다.

  • 현재 정리된 로직에서는 굳이 PasswordStrength와 getMeetPasswordCriteriaCount를 분리할 필요는 없어 보인다.
  • 오히려 깔끔하게 합치는 것이 이해가 쉬울 것 
// 변경 전
public class PasswordStrengthMeter {

    public static final int MINIMUM_PASSWORD_LENGTH_CRITERIA = 8;

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

        int meetCounts = getMeetPasswordCriteriaCount(s);

        return PasswordStrength.getValue(meetCounts);
    }

    private int getMeetPasswordCriteriaCount(String s) {
        int meetCounts = 0;

        if(s.length() >= MINIMUM_PASSWORD_LENGTH_CRITERIA) meetCounts++;

        boolean containsNum = isContainsNumber(s);
        if(containsNum) meetCounts++;

        boolean containsUpperCase = isContainsUpperCase(s);
        if(containsUpperCase) meetCounts++;
        return meetCounts;
    }
// 변경 후
public class PasswordStrengthMeter {

    public static final int MINIMUM_PASSWORD_LENGTH_CRITERIA = 8;

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

        int meetCounts = 0;

        if(s.length() >= MINIMUM_PASSWORD_LENGTH_CRITERIA) meetCounts++;
        if(isContainsNumber(s)) meetCounts++;
        if(isContainsUpperCase(s)) meetCounts++;

        return PasswordStrength.getPasswordStrength(meetCounts);
    }

 

최종 소스 

public enum PasswordStrength {
    INVALID(-1),
    WEAK(0),
    NORMAL(2),
    STRONG(3);

    private int passwordCriteriaCnt;

    PasswordStrength(int passwordCriteriaCnt) {
        this.passwordCriteriaCnt = passwordCriteriaCnt;
    }

    public static PasswordStrength getPasswordStrength(int meetCriteriaCnt) {
        if(meetCriteriaCnt == PasswordStrength.STRONG.passwordCriteriaCnt) return PasswordStrength.STRONG;

        if(meetCriteriaCnt == PasswordStrength.NORMAL.passwordCriteriaCnt) return PasswordStrength.NORMAL;

        if(meetCriteriaCnt >= PasswordStrength.WEAK.passwordCriteriaCnt) return PasswordStrength.WEAK;

        return PasswordStrength.INVALID;
    }
}
public class PasswordStrengthMeter {
    public static final int MINIMUM_PASSWORD_LENGTH_CRITERIA = 8;

    /**
     * 비밀번호 보안 수준 측정
     */
    public PasswordStrength meter(String s) {
        if(s == null || s.isEmpty()) return PasswordStrength.INVALID;

        int meetCounts = 0;

        if(s.length() >= MINIMUM_PASSWORD_LENGTH_CRITERIA) meetCounts++;
        if(isContainsNumber(s)) meetCounts++;
        if(isContainsUpperCase(s)) meetCounts++;

        return PasswordStrength.getPasswordStrength(meetCounts);
    }

    /**
     * 대문자 포함 여부
     * @param s
     * @return
     */
    private boolean isContainsUpperCase(String s) {
        return s.chars()
                .filter(Character::isUpperCase)
                .findAny()
                .isPresent();
    }

    /**
     * 숫자 포함 여부
     * @param s
     * @return
     */
    private boolean isContainsNumber(String s) {
        return s.chars()
                .filter(Character::isDigit)
                .findAny()
                .isPresent();
    }
}
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

/**
 * 비밀번호 보안 수준 테스트
 *  [보안 수준 조건]
 *  -길이 8글자 이상
 *  -0~9 사이의 숫자를 포함
 *  -대문자 포함
 *
 *  위의 세 가지 조건을 충족하면 암호는 강함.
 *  2개의 조건을 충족하면 보통.
 *  1개 이하의 조건을 충족하면 약함.
 *  비밀번호가 공백이거나, 없으면 인식불가.
 */
public class PasswordStrengthMeterTest {
    PasswordStrengthMeter meter = new PasswordStrengthMeter();


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

    @Test
    void 강한암호_테스트() {
        assertPasswordStrength("ab12!@AB", PasswordStrength.STRONG);
    }

    @Test
    void 보통수준암호_대문자숫자_테스트() {
        assertPasswordStrength("AB12!2", PasswordStrength.NORMAL);
    }

    @Test
    void 보통수준암호_대문자8자초과_테스트() {
        assertPasswordStrength("AB!@abcde", PasswordStrength.NORMAL);
    }

    @Test
    void 비밀번호_NULL_테스트() {
        assertPasswordStrength(null, PasswordStrength.INVALID);
    }

    @Test
    void 비밀번호_공백_테스트() {
        assertPasswordStrength("", PasswordStrength.INVALID);
    }

    @Test
    void 보통수준암호_숫자포함8자이상_테스트() {
        assertPasswordStrength("ab12!@df", PasswordStrength.NORMAL);
    }

    @Test
    void 낮은수준암호_8자이내_테스트() {
        assertPasswordStrength("ab12", PasswordStrength.WEAK);
    }
    
    @Test
    void 대문자포함_테스트() {
        boolean containsUpppercase = PasswordStrengthMeter.isContainsUpperCase("abcde");
        Assertions.assertFalse(containsUpppercase);

        containsUpppercase = PasswordStrengthMeter.isContainsUpperCase("123ABab");
        Assertions.assertTrue(containsUpppercase);
    }

    @Test
    void 숫자포함_테스트() {
        boolean containsUpppercase = PasswordStrengthMeter.isContainsNumber("abcde");
        Assertions.assertFalse(containsUpppercase);

        containsUpppercase = PasswordStrengthMeter.isContainsNumber("123ABab");
        Assertions.assertTrue(containsUpppercase);
    }
}

 

 

profile

Justin의 개발 로그

@라이프노트

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!