[TDD] 비밀번호 검사 테스트 예제를 통한 TDD 이해하기

728x90

안녕하세요🖐 

오늘은 비밀번호 유효성 검사에 대한 예제를 통하여 TDD에 대해서 공부를 해보았습니다.

 

비밀번호 유효성 검사 조건

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

- 모든 규칙을 충족하면 STRONG

- 2개의 규칙을 충족하면 NORMAL

- 1개의 규칙을 충족하면 WEAK

 

위 조건을 만족하는 테스트 코드를 작성해야 합니다.


 

암호 검사를 하는 경우를 생각해봤을 때, 가장 쉽거나, 가장 예외적인 상황은 무엇일까?

  • 모든 규칙을 충족하는 경우
  • 모든 규칙을 충족하지 않는 경우

모든 규칙을 충족하지 않는 경우는 모든 조건을 다 걸어야하기 때문에, 로직이 복잡하게 짜인다.

그러나 모든 규칙을 충족하는 경우는 쉽게 테스트를 통과시킬 수 있습니다.

 

바로 시작해보겠습니다. 

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

@Test
	void 모든암호_만족() {
		PasswordStrengthMeter meter = new PasswordStrengthMeter();
		PasswordStrength result = meter.meter("ab122!@AB");
		assertEquals(PasswordStrength.STRONG, result);
	}
  • assertEquals(”기대값”,넣는값) ⇒ 즉 넣는값이 기대값이랑 같으면 통과한다.

위 코드를 만족시키기 위해서 클래스를 2개더 생성합니다.

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

public enum PasswordStrength {
	STRONG
}

이렇게만 생성해두고 테스트를 실행하면은 실패가 뜹니다.

이유가 무엇일까요?

바로 org.opentest4j.AssertionFailedError: expected: <STRONG> but was: <null>

테스트해서 기대한 값은, STRONG 인데, 실제 값은 null이라서, 테스트가 실패했습니다.

그러면 null값이 뜨지 않기 위해서는

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

return 하는 부분을 코드를 고쳐줘야 합니다.

이렇게 고치고 테스트를 실행하면은 테스트가 통과가 됩니다.

그리고 나서 모든 규칙을 충족하는 예시를 하나 더 추가하여 테스트를 해봅니다.

@Test
	void 모든암호_만족하는지() {
		PasswordStrengthMeter meter = new PasswordStrengthMeter();
		PasswordStrength result = meter.meter("ab122!@AB");
		assertEquals(PasswordStrength.STRONG, result);
		PasswordStrength result2 = meter.meter("abc1!Add");
		assertEquals(PasswordStrength.STRONG, result2);
	}

이것 또한 잘 통과하는 것을 볼 수 있습니다.

2) 3개의 규칙중, 2가지는 만족하고, 길이가 8글자 미만인 것.

→ 패스워드 문자열의 길이가 8글자 미만이고

→ 나머지 2조건은 충족해야 한다.

→ 보통 암호

@Test
	void 두가지조건만족_길이는8개미만() {
		PasswordStrengthMeter meter = new PasswordStrengthMeter();
		PasswordStrength result = meter.meter("ab12!@A");
		assertEquals(PasswordStrength.NORMAL, result);
	}
package password;

public enum PasswordStrength {
	STRONG,
	NORMAL
}
package password;

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

}

이렇게 코드를 변경을 하면은 Normal 테스트는 통과를 하겠지만, 앞에 만든 STRONG 테스트는 통과하지 못한다. 그러므로 meter() 메소드에서 조건을 주어서 통과를 시키게 만들 수 있습니다.

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

이렇게 해서 1가지 조건을 만족하지 않는다면, NORMAL을 리턴시키는 것이다.

위 경우는 테스트가 잘 통과 되는 것을 확인 할 수 있습니다.

3) 3개의 규칙중, 2가지는 만족하고, 숫자를 포함하지 않는 것

위에 서 한 내용이랑 얼핏보면 비슷해 보일 수도 있습니다.

→ 보통 강도의 암호이다.

@Test
	void 두가지조건만족_숫자포함안함() {
		PasswordStrengthMeter meter = new PasswordStrengthMeter();
		PasswordStrength result= meter.meter("abcdESDSD!@");
		assertEquals(PasswordStrength.NORMAL, result);
	}

위처럼 테스트 코드를 작성하면, 될 것같지만, NORMAL을 반환하는 조건이 맞지않아 테스트를 실패하게 됩니다.

위 테스트를 통과하기 위해서는, 암호가 숫자를 포함했는지,안했는지를 판단하게 하면 된다.

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

각 문자를 char로 만든 후, 0~9사이의 값을 갖는 문자가 없으면 NORMAL을 리턴하도록 하는 로직이다.

위 로직을 작성 후 테스트를 돌리면, 테스트는 통과합니다.

🖐 깨알 코드 리팩토링 🖐

위 코드를 리팩토링 해보겠습니다. 메서드화를 시켜서 가독성을 좋게 만들어 보겠습니다.

package password;

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

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

}

세 개의 테스트 메서드를 완성했습니다.

@Test
	void 메서드이름() {
		PasswordStrengthMeter meter = new PasswordStrengthMeter();
		PasswordStrength result= meter.meter(암호);
		assertEquals(PasswordStrength.값, result);
	}

위 코드 처럼 같은 형태를 가지고 있습니다.

테스트 코드 또한 코드이기 때문에 유지보수 대상입니다.

 

그러므로, 테스트 메서드에서 발생하는 중복또한 없어야 한다.

지금은 모든 메서드마다 PasswordStrengthMeter 객체를 생성해서 테스트를 한다.

 

이럴땐 스프링 IOC인 DI를 통하여, 해결할 수도 있지만, 일단 여기서는

전역에, 객체를 생성해서 사용할 수 있게 만들었습니다.

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

import org.junit.jupiter.api.Test;

public class PasswordStrengthMeterTest {
	private PasswordStrengthMeter meter = new PasswordStrengthMeter();
	
	@Test
	void 모든암호_만족하는지() {
		PasswordStrength result = meter.meter("ab122!@AB");
		assertEquals(PasswordStrength.STRONG, result);
		PasswordStrength result2 = meter.meter("abc1!Add");
		assertEquals(PasswordStrength.STRONG, result2);
	}

	@Test
	void 두가지조건만족_길이는8개미만() {
		PasswordStrength result = meter.meter("ab12!@A");
		assertEquals(PasswordStrength.NORMAL, result);
		PasswordStrength result2 = meter.meter("Ab12@A");
		assertEquals(PasswordStrength.NORMAL, result2);
	}

	@Test
	void 두가지조건만족_숫자포함안함() {
		PasswordStrength result= meter.meter("abcdESDSD!@");
		assertEquals(PasswordStrength.NORMAL, result);
	}
}

코드를 수정 후 테스트를 실행하여도, 오류가 생기지 않는다.

그리고 또 하나 더 중복을 없앨 수 있는 부분이 있다. 바로 암호 강도를 측정하고 실행하는 부분이다.

 

위 코드를 리팩토링 해보겠습니다.

간단하게 이것도, 전역에 메서드를 생성하고, 그 메서드를 사용하면 가독성이 좋은 코드가 될 것입니다.

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

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

import org.junit.jupiter.api.Test;

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 이름() {
	}

	//모든 암호를 만족하는 경우 로직
	@Test
	void 모든암호_만족하는지() {
		assertStrength("ab122!@AB",PasswordStrength.STRONG);
		assertStrength("abc1!Add",PasswordStrength.STRONG);
	}

	@Test
	void 두가지조건만족_길이는8개미만() {
		assertStrength("ab12!@A",PasswordStrength.NORMAL);
		assertStrength("Ab12@A",PasswordStrength.NORMAL);
	}

	@Test
	void 두가지조건만족_숫자포함안함() {
		assertStrength("abcdESDSD!@",PasswordStrength.NORMAL);
	}
}

4) 값이 없는 경우

  • 테스트 데이터가 없는 경우를 의미함

Null 값이 암호에 들어 갔을 때를 가정하였을 때 테스트 코드를 짜보겠습니다.

일단 null값이 들어오면, NPE가 발생하기 때문에, 위를 방지하기 위해 예외처리를 해야 합니다.

  • IllegalArgumentException 발생시키기
  • 유효하지 않은 암호를 의미하는 INVALID 리턴 시키기

🖐 테스트 코드를 작성할때, 값이 null일 경우와 빈 값일 경우를 둘다 처리를 해줘야 합니다.

@Test
	void 테스트데이터가_null값() {
		assertStrength(null,PasswordStrength.INVALID);
	}
	@Test
	void 테스트데이터_빈값() {
		assertStrength("",PasswordStrength.INVALID);
	}
if(s==null || s.isEmpty())
			return PasswordStrength.INVALID;

위 로직을 PasswordStrengthMeter에 추가를 해줘야 합니다.

위 과정을 진행하게 되면 테스트를 통과하는 것을 확인할 수 있습니다.

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

테스트 코드

@Test
	void 대문자포함안함_나머지는만족() {
		assertStrength("abcd123@3",PasswordStrength.NORMAL);
	}

PasswordStrengthMeter 클래스에서 메소드와 로직을 하나 추가해줍니다.

boolean containsUpp = meetsContainingUppercase(s);
		if(!containsUpp) {
			return PasswordStrength.NORMAL;
		}

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

위 로직을 추가한 후에, 테스트 데이터를 돌리면, 성공 할 것 입니다.

6) 길이가 8글자 이상인 조건만 충족하는 경우

지금부터는 3가지 조건중, 한가지 조건만 만족하는 경우 테스트 코드를 작성해볼 것입니다.

그러기 위해서는 Enum 클래스에 데이터를 하나 추가해줘야 합니다

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

테스트 코드

@Test
	void 길이8글자이상_나머지불충족() {
		assertStrength("12312312",PasswordStrength.WEAK);
	}

테스트 통과시키기 위한 로직

boolean lengthEnough = s.length() >=8;

if(lengthEnough && !containsUpp && !containsUpp) {
			return 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 = meetsContainingNum(s);
		boolean containsUpp = meetsContainingUppercase(s);

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

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

		return PasswordStrength.STRONG;
	}

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

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

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

테스트 로직

@Test
	void 숫자포함조건만_만족() {
		assertStrength("1212", PasswordStrength.WEAK);
	}

 

해결 로직

if(!lengthEnough && containsNum && !containsUpp) {
			return PasswordStrength.WEAK;
		}
  • 길이 불만족, 대문자 불만족, 숫자 포함 조건만 만족하는 경우

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

@Test
	void 대문자포함조건만_만족() {
		assertStrength("ABC", PasswordStrength.WEAK);
	}
if(!lengthEnough && !containsUpp && containsUpp) {
			return PasswordStrength.WEAK;
		}

 

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

@Test
	void 아무조건도만족안함() {
		assertStrength("abc",PasswordStrength.WEAK);
	}
if(!lengthEnough && !containsUpp && !containsUpp) {
			return PasswordStrength.WEAK;
		}

 

여기까지하면 전체 테스트가 끝이 납니다.

코드 리팩토링

위 조건들을 만족하기 위해서는 PasswordStrengthMeter클래스에 모든 테스트통과 로직을 넣어야합니다.

그 결과 엄청 난잡하고 보기 어지러운 if문 과 코드들이 뭉쳐있습니다.

package password;

public class PasswordStrengthMeter {
	public PasswordStrength meter(String s) {

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

		boolean lengthEnough = s.length() >=8;
		boolean containsNum = meetsContainingNum(s);
		boolean containsUpp = meetsContainingUppercase(s);

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

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

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

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

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

		return PasswordStrength.STRONG;
	}

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

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

위 코드를 리팩토링 해보겠습니다.

 

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

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

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

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

 

위 코드들은 WEAK상황일 때 코드들 입니다.

이 코드를 리팩토링 하기 위해서 생각해낸 방법이, 세 조건 중에서 한 조건만 충족하는 것을 확인하는 방법이다.

변수를 하나 줘서 조건을 만족하는 경우 1씩 증가시키는 것이다.

 

int metCounts = 0;
		
		boolean lengthEnough = s.length() >=8;
		if(lengthEnough) {
			metCounts++;
		}
		
		boolean containsNum = meetsContainingNum(s);
		if(containsNum) {
			metCounts++;
		}
		
		boolean containsUpp = meetsContainingUppercase(s);
		if(containsUpp) {
			metCounts++;
		}
		
		if(metCounts==1 || metCounts==0) {
			return PasswordStrength.WEAK;
		}

이렇게 코드를 바꾸면 3가지 조건중 1가지만 만족할 때 WEAK를 표현하여 테스트를 통과한다.

다음은 3가지 조건중 2가지만 만족할 때를 리팩토링 해보겠습니다.

 

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

이 과정으로 간단하게 리팩토링을 할 수 있습니다.

다음으로는 boolean 변수 또한if문에 합쳐보겠습니다.

 

if(s.length()>=8) {
			metCounts++;
		}
		if(meetsContainingNum(s)) {
			metCounts++;
		}
		if(meetsContainingUppercase(s)) {
			metCounts++;
		}

이렇게 깔끔해졌습니다.

마지막으로 metCounts 또한 메서드로 작성한다면 가독성이 더 좋아질 것 입니다.

 

int metCounts = getMetCounts(s);

private int getMetCounts(String s) {
		int metCounts = 0;
		if(s.length()>=8) {
			metCounts++;
		}
		if(meetsContainingNum(s)) {
			metCounts++;
		}
		if(meetsContainingUppercase(s)) {
			metCounts++;
		}
		return metCounts;
	}

 

이렇게 하며 코드 리팩토링 또한 끝났습니다.

여기서 총 정리를 해보자면은

  • 암호가 null 이거나 빈 문자열이면 암호 강도는 INVALID이다
  • 충족하는 규칙 개수를 구한다
  • 충족하는 규칙 개수가 1개 이하면 암호강도는 WEAK 이다
  • 충족하는 규칙 개수가 2개면 암호 강도는 NORMAL이다
  • 이 외 경우 암호 강도는 STRONG 이다.

 

마지막으로 작성한 코드를 테스트 폴더가 아닌, 실제 프로젝트 src/main/java 에 넣어야지 끝이납니다.

 

 

TDD 흐름

  • 기능을 검증하는 테스트 먼저 작성
    • 작성한 테스트를 통과하지 못하면 테스트를 통과할 만큼 코드 작성
  • 테스트 통과 후 개선할 코드 리팩토링

→ 레드 - 그린 - 리팩터 라고 부르기도 합니다.

 

테스트 코드를 먼저 만들고 진행하면, 개발 범위가 정해진다.

테스트 코드가 추가 되면서, 검증하는 범위가 넓어질수록 구현도 점점 완성되어 갑니다.

 

이렇게 테스트가 개발을 주도해 나가는 것 입니다.

 

구현을 완료한 뒤에 리팩토링을 진행하는데 리팩토링할 대상이 눈에 들어오지 않거나, 어떻게 할지 생각이 나지 않으면, 일단 다음 테스트를 진행하는것도 괜찮은 방법 입니다.

 

당장 리팩토링을 안해도, 테스트 코드만 잘 있으면 리팩토링시 과감하게 진행할 수 있습니다.

잘 동작하는 코드를 수정하는 것은 심리적으로 불안하기 때문에 코드 수정을 꺼리게 됩니다…

 

TDD는 개발 과정에서 지속적으로 코드 정리를 하므로, 코드 유지보수에 확실히 좋다.

그리고 코드 수정에 대한 피드백이 빠릅니다. 즉 잘못된 코드가 배포되는 것을 방지합니다.

 

이상입니다.

 

 

 

 

ref : 테스트 주도 개발하기_최범균 저

728x90