052. (Getting Started with Test-Driven Development) 2. TDD 시작

2. TDD 시작

2.1. TDD 이전의개발

TDD를 도입하지않은 기존의 개발하는 방식은 다음과 같다.

  1. 만들 기능에 대한 설계를 고민한다.
  2. 어떤 클래스와 인터페이스를 도출할지 고민하고, 각 타입에 어떤 메서드를 넣을 지 생각한다.
  3. 위의 과정을 수행하면서 구현에 대해서도 고민한다.
  4. 머릿속으로 구현 방안이 그려지면 코드를 작성하기 시작한다.
  5. 기능에 대한 구현이 완료된 것 같으면 해당 기능을 테스트한다.
  6. 테스트 단계에서 의도대로 동작하지않는 경우 코드를 디버깅하면서 원인을 찾는다.

이때 한 번에 작성한 코드가 많은 경우에는 디버깅하느 시간도 길어지게 되며,

원인을 찾기 위해 많은 코드를 탐색해야 한다.

이후 디버깅을 위한 로그 메시지를 추가하고 디버거를 이용해 원인을 찾는다.

코드를 작성하는 시간보다 버그를 찾는 데 더 오래걸리는 경우도 왕왕 발생한다.

테스트를 위한 과정도 쉽지 않다.

특정 기능을 테스트하기위해 서버를 구동해야하는 경우 계속해서 서버를 재시작해야하며,

데이터베이스가 엮여있는 경우 테스트용 데이터를 미리 산입하는 것도 테스트 비용에 포함된다.

이러한 개발 비용을 줄이는 데, TDD는 많은 도움을 주는 방법론이다.

2.2. TDD란?

쉽게말해 TDD는 실제 구현 전에 테스트부터 시작하는 방법이다.

구현한 코드가 없는데 어떻게 테스트할 수 있을까?

여기서 테스트를 먼저 한다는 것은 기능이 올바르게 동작하는 지 검증하는 테스트 코드를 작성함을 의미한다.

즉, 기능을 검증하는 테스트 코드를 먼저 작성하고 이후 테스트를 통과시키기 위한 개발을 진행하는 것이다.

이해를 돕기 위해 간단한 덧셈 기능을 TDD로 구현해보자.

먼저 테스트 코드를 작성한다.

1
2
3
4
5
6
7
8
class CalculatorTest {

@Test
fun plus() {
var result: Int = Calculator.plus(1, 2)
assertEquals(3, result)
}
}

@Test 어노테이션은 plus() 함수가 테스트 메서드임을 JUnit에 인지시킨다.

테스트 메서드는 기능을 검증하는 코드를 담고 있는 메서드를 뜻한다.

테스트 코드를 실행한 결과값이 의도대로인지 검증하기위해 assertEquals() 메서드를 사용하였다.

assertEquals() 메서드는 파라미터로 주어진 두 값이 동일한지 비교하며, 동일하지않으면 AssertionFailedError를 발생시킨다.

지금은 기능 구현 없이 테스트 코드만 작성되었기 때문에, 당연히 Calculator 클래스가 없다는 컴파일 에러가 발생한다.

이제 실제 기능을 구현하기위해 아래와 같은 고민을 할 차례이다.

  1. 메서드의 이름은 plus가 좋을까, sum이 좋을까?

수학에서 더하기의 부호를 의미하는 plus가 더 적합할 것 같다.

  1. 덧셈 기능을 제공하는 메서드는 파라미터가 몇 개여야할까?
  2. 파라미터의 타입은 무엇이어야할까?
  3. 반환 타입을 무엇이어야할까?

두 값을 더하는 최소한의 기능만을 제공하기 위해 파라미터는 2개로 하면 좋을 것 같다.

최소한의 기능으로 일단 정수와 정수를 더하고 정수를 반환하도록 하기위해 파라미터 타입과 반환 타입도 정수로 한다.

  1. 메서드를 정적 메서드로 구현해야할까, 인스턴스 메서드로 구현해야할까?

나중에 어떤 기능이 추가될지는 미지수이므로, 두 정수의 덧셈이라는 책임은 정적 메서드로도 충분할 것 같다.

  1. 메서드를 제공할 클래스의 이름은 무엇이 좋을까?

클래스명은 단순히 계산기를 의미하는 Calculator로 정한다.

이제 클래스와 메서드를 아래와 같이 작성하자.

1
2
3
4
5
class Calculator {
companion object {
fun plus(a1: Int, a2: Int): Int = 0
}
}

이때 다시 테스트 코드를 수행하면 기대값은 3인 반면에, 반환값은 0이므로 테스트에 실패한다.

이 테스트를 통과시키기 위해 3을 반환하도록 수정해보자.

1
2
3
4
5
class Calculator {
companion object {
fun plus(a1: Int, a2: Int): Int = 3
}
}

이제 테스트는 통과한다.

그럼 기능은 다 구현될 것일까?

이번엔 다른 값을 검증대상에 추가해보자.

1
2
3
4
5
6
7
8
9
class CalculatorTest {

@Test
fun plus() {
var result: Int = Calculator.plus(1, 2)
assertEquals(3, result)
assertEquals(5, Calculator.plus(4, 1))
}
}

새로 추가한 검증 대상으로 인해 테스트는 다시 실패한다.

참고 예시를 든 덧셈 기능이 너무 간단하기에 바로 간파할 수 있지만, 이는 구현에 대한 검증을 단계적으로 밟아가는 TDD의 단계를 설명하기 위함이다.

이제 모든 두 정수의 덧셈을 통과할 수 있도록 코드를 수정해보자.

1
2
3
4
5
class Calculator {
companion object {
fun plus(a1: Int, a2: Int): Int = a1 + a2
}
}

이제 모든 테스트를 통과하는 것을 볼 수 있다.

2.3. 예시 : 암호 검사기

위의 간단한 예제로 TDD의 개념적인 부분을 이해했다면 이제 조금 더 현실적인 기능을 TDD로 구현해보자.

이번에 구현할 기능은 암호 검사기이다.

이 암호 검사기는 문자열을 검사해서 권장되는 암호 수준에 적합한지를 검증하고 검증 결과에 따라 ‘약함’, ‘보통’, ‘강함’으로 구분해준다.

좀 더 자세한 검사 규칙에 대한 요구사항은 아래와 같다.

  1. 길이가 8글자 이상이어야 한다.
  2. 0부터 9사이의 숫자를 포함해야한다.
  3. 대문자를 포함해야 한다.
  4. 1부터 3까지의 규칙을 충족하면 이 암호는 ‘강함’으로 간주한다.
  5. 1부터 3까지의 규칙 중 두 가지를 충족하면 이 암호는 ‘보통’으로 간주한다.
  6. 1부터 3까지의 규칙 중 1개 이하만을 충족하면 이 암호는 ‘약함’으로 간주한다.

먼저 테스트할 기능의 이름을 정의해보자.

‘약함’, ‘보통’, ‘강함’은 일종의 등급이므로 PasswordLevel이라는 이름을 부여할 수 있다.

혹은 등급 대신에 강도라고 생각하면 PasswordStrength도 적합하다.

테스트할 기능을 제공하는 클래스의 이름으로는 암호 강도 측정기를 뜻하는 PasswordStrengthMeter를 선택하였다.

이제 무작정 테스트 코드를 하나 작성한다.

1
2
3
4
5
6
7
class PasswordStrengthMeterTest {

@Test
fun name() {
// Do Nothings.
}
}

이 테스트 코드는 아무것도 검증하지 않으므로 당연히 성공으로 처리된다.

참고 아무것도 아닌 행위같지만, 테스트 실행 환경이 준비되었는지는 검증하는 행위이다.

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

첫 번째 테스트를 만들어보자.

첫 번째 테스트를 잘 선택하지못하면 이후 진행 과정이 험난해지므로 잘 선택해야한다.

따라서 첫 번째 테스트를 선택할 때에는 가장 쉽거나, 가장 예외적인 상황을 선택하는 것이 좋다.

암호 강도 측정의 경우 모든 규칙을 충족하거나, 모든 규칙을 충족하지 않는 경우라고 볼 수 있다.

이때 한 가지 고민을 해보는 것이 좋다.

모든 규칙을 충족하는 것과 모든 규칙을 충족하지 않는 경우, 무엇이 테스트 코드 작성이 간편할까?

정답은 모든 규칙을 충족하는 경우이다.

모든 규칙을 충족하지 않는 경우를 구현하려면 결국 충족하지않는 다는 것을 검증할 코드를 다 작성해야한다는 것의 의미하고,

이는 테스트 후 구현이 아닌 구현 후 테스트와 동일하기 때문이다.

반면 모든 규칙을 충족하는 경우는 단순히 ‘강함’을 반환시키면 테스트를 통과시킬 수 있다.

이제 ‘암호가 모든 조건을 충족하면 암호 강도는 강함이어야 한다’를 테스트 코드로 작성해보자.

1
2
3
4
5
6
7
8
9
class PasswordStrengthMeterTest {

@Test
fun meetsAllCriteria_Then_Strong() {
val meter = PasswordStrengthMeter()
var result: PasswordStrength = meter.meter("ab12!@AB")
// assertEquals(expected, result)
}
}

기대값과 결과값을 어떻게 정의해야할까?

위에서 사전에 정의한 PasswordStrength에 각 강도에 대한 열거 타입을 추가하여 ‘강함’의 경우 PasswordStrength.STRONG으로 결정한다.

즉 테스트 코드는 아래와 같이 작성된다.

1
2
3
4
5
6
7
8
9
class PasswordStrengthMeterTest {

@Test
fun meetsAllCriteria_Then_Strong() {
val meter = PasswordStrengthMeter()
var result: PasswordStrength = meter.meter("ab12!@AB")
assertEquals(PasswordStrength.STRONG, result)
}
}

당연히 실제 프로덕션 코드는 존재하지않으므로 컴파일 에러가 발생한다.

컴파일 에러를 해소하기 위해 아래 코드를 추가한다.

1
2
3
enum class PasswordStrength {
STRONG,
}
1
2
3
4
5
6
class PasswordStrengthMeter {

fun meter(password: String): PasswordStrength? {
return null
}
}

이제 테스트 코드를 실행하면 null과 PasswordStrength.STRONG은 일치하지않으므로 실패한다.

성공으로 바꾸기 위해 null대신 PasswordStrength.STRONG을 반환하도록 수정하자.

1
2
3
4
class PasswordStrengthMeter {

fun meter(password: String): PasswordStrength = PasswordStrength.STRONG
}

이제 테스트는 통과한다.

아래와 같이 또 다른 검증 조건을 추가하더라도 테스트는 성공한다.

1
2
3
4
5
6
7
8
9
10
11
class PasswordStrengthMeterTest {

@Test
fun meetsAllCriteria_Then_Strong() {
val meter = PasswordStrengthMeter()
var result: PasswordStrength = meter.meter("ab12!@AB")
assertEquals(PasswordStrength.STRONG, result)
var result2: PasswordStrength = meter.meter("abc!Add")
assertEquals(PasswordStrength.STRONG, result2)
}
}

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

두 번째 테스트 메서드를 추가해보자.

이번에 테스트할 대상은 패스워드의 길이가 8글자 미만이고 나머지 조건은 충족하는 암호이다.

이 암호의 강도는 보통으로 구분되어야 하며, PasswordStrength.NORMAL로 표현하도록 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class PasswordStrengthMeterTest {

@Test
fun meetsAllCriteria_Then_Strong() {
// ...
}

@Test
fun meetsOtherCriteria_except_for_Length_Then_Normal() {
val meter = PasswordStrengthMeter()
var result: PasswordStrength = meter.meter("ab12!@A")
assertEquals(PasswordStrength.NORMAL, result)
}
}

컴파일 에러를 해소하기 위해 PasswordStrengthNORMAL을 추가하자.

1
2
3
4
enum class PasswordStrength {
STRONG,
NORMAL,
}

컴파일은 되지만 테스트는 실패하게 된다.

meter() 메서드의 반환값을 변경해보자.

1
2
3
4
5
class PasswordStrengthMeter {

// fun meter(password: String): PasswordStrength = PasswordStrength.STRONG
fun meter(password: String): PasswordStrength = PasswordStrength.NORMAL
}

이렇게 변경하면 meetsOtherCriteria_except_for_Length_Then_Normal() 테스트 메서드는 통과하지만

기존에 성공하던 meetsAllCriteria_Then_Strong() 테스트 메서드는 실패한다.

여기서 두 가지를 알 수 있다.

첫 번째로 두 테스트를 동시에 통과하기 위한 코드를 구현해야함을 테스트의 실패로 바로 인지할 수 있다는 것,

두 번째는 코드의 변경이 기존에 성공하던 테스트를 실패시킴으로서, 변경 사항 중 무언가가 잘못되었음을 인지할 수 있다는 것이다.

간단하게 아래와 같이 코드를 구현해보자.

1
2
3
4
5
6
7
8
9
10
class PasswordStrengthMeter {

fun meter(password: String): PasswordStrength {
return if (password.length < 8) {
PasswordStrength.NORMAL
} else {
PasswordStrength.STRONG
}
}
}

이제 두 개의 테스트 메서드를 모두 통과하게 되었다.

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

세 번째 테스트 메서드를 추가해보자.

테스트 대상은 숫자를 포함하지않고 나머지 조건은 충족하는 암호를 검증하여 ‘보통’ 강도로 판정하는 것이다.

이번에도 테스트 코드를 먼저 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class PasswordStrengthMeterTest {

@Test
fun meetsAllCriteria_Then_Strong() {
// ...
}

@Test
fun meetsOtherCriteria_except_for_Length_Then_Normal() {
// ...
}

@Test
fun meetsOtherCriteria_except_for_Number_Then_Normal() {
val meter = PasswordStrengthMeter()
var result: PasswordStrength = meter.meter("ab!@ABqwer")
assertEquals(PasswordStrength.NORMAL, result)
}
}

PasswordStrengthMeter 클래스의 meter()에 구현된 로직상으로는 파라미터로 주어진 패스워드의 길이가 8글자 이상이기에

Strong으로 판정되어 테스트는 실패하게 된다.

이번엔 아래와 같이 구현을 변경해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class PasswordStrengthMeter {

fun meter(password: String): PasswordStrength {
if (password.length < 8) {
return PasswordStrength.NORMAL
}
var containsNumber = false
password.forEach { c ->
if (c.isDigit()) {
containsNumber = true
}
}
if (containsNumber.not()) {
return PasswordStrength.NORMAL
}

return PasswordStrength.STRONG
}
}

암호로 주어진 문자열 내에 숫자가 없다면 PasswordStrength.NORMAL을 반환하도록 변경하여 모든 테스트를 통과한다.

가독성을 위하여 코드를 아래와 같이 리팩토링 해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class PasswordStrengthMeter {

fun meter(password: String): PasswordStrength {
if (password.length < 8) {
return PasswordStrength.NORMAL
}
var containsNumber = meetsContainingNumberCriteria(password)
if (containsNumber.not()) {
return PasswordStrength.NORMAL
}

return PasswordStrength.STRONG
}

private fun meetsContainingNumberCriteria(password: String): Boolean {
password.forEach { c ->
if (c.isDigit()) {
return true
}
}
return false
}
}

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

여태까지 총 3개의 테스트 코드를 작성하였다.

전체 코드는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class PasswordStrengthMeterTest {

@Test
fun meetsAllCriteria_Then_Strong() {
val meter = PasswordStrengthMeter()
var result: PasswordStrength = meter.meter("ab12!@AB")
assertEquals(PasswordStrength.STRONG, result)
var result2: PasswordStrength = meter.meter("abc!Add")
assertEquals(PasswordStrength.STRONG, result2)
}

@Test
fun meetsOtherCriteria_except_for_Length_Then_Normal() {
val meter = PasswordStrengthMeter()
var result: PasswordStrength = meter.meter("ab12!@A")
assertEquals(PasswordStrength.NORMAL, result)
}

@Test
fun meetsOtherCriteria_except_for_Number_Then_Normal() {
val meter = PasswordStrengthMeter()
var result: PasswordStrength = meter.meter("ab!@ABqwer")
assertEquals(PasswordStrength.NORMAL, result)
}
}

아래와 같이 중복 코드를 분리해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class PasswordStrengthMeterTest {
private val meter = PasswordStrengthMeter()

@Test
fun meetsAllCriteria_Then_Strong() {

var result: PasswordStrength = meter.meter("ab12!@AB")
assertEquals(PasswordStrength.STRONG, result)
var result2: PasswordStrength = meter.meter("abc!Add")
assertEquals(PasswordStrength.STRONG, result2)
}

@Test
fun meetsOtherCriteria_except_for_Length_Then_Normal() {
var result: PasswordStrength = meter.meter("ab12!@A")
assertEquals(PasswordStrength.NORMAL, result)
}

@Test
fun meetsOtherCriteria_except_for_Number_Then_Normal() {
var result: PasswordStrength = meter.meter("ab!@ABqwer")
assertEquals(PasswordStrength.NORMAL, result)
}
}

테스트 코드의 리팩토링 후에 수행한 테스트에서 모두 성공했음을 확인한다면 다음 리팩토링을 수행하자.

또 다른 중복 코드인 암호 강도 측정 기능을 분리해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class PasswordStrengthMeterTest {
private val meter = PasswordStrengthMeter()

private fun assertStrength(password: String, expected: PasswordStrength) {
val result = meter.meter(password)
assertEquals(expected, result)
}

@Test
fun meetsAllCriteria_Then_Strong() {
assertStrength("ab12!@AB", PasswordStrength.STRONG)
assertStrength("abc!Add", PasswordStrength.STRONG)
}

@Test
fun meetsOtherCriteria_except_for_Length_Then_Normal() {
assertStrength("ab12!@A", PasswordStrength.NORMAL)
}

@Test
fun meetsOtherCriteria_except_for_Number_Then_Normal() {
assertStrength("ab!@ABqwer", PasswordStrength.NORMAL)
}
}

다시 수행한 테스트의 성공을 통해 안전하게 리팩토링되었음을 확인할 수 있다.

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

여태까지 작성한 테스트는 모두 유효한 패스워드가 주어지는 경우를 상정하였다.

만약 값이 없는 패스워드가 주어지는 경우는 NullPointerException 등의 예외가 발생하게 될 것이다.

값이 없는 경우는 어떻게 처리하면 좋을까?

  1. IllegalArgumentException을 발생시킨다.
  2. 유효하지않은 비밀번호를 뜻하는 PasswordStrength.INVALID를 반환한다.

이 중 두 번째 방법을 선택하자.

먼저 테스트 코드를 추가한다.

1
2
3
4
5
6
7
8
class PasswordStrengthMeterTest {
// ...

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

컴파일 에러 해소를 위해 INVALID를 추가한다.

1
2
3
4
5
enum class PasswordStrength {
INVALID,
STRONG,
NORMAL,
}

현재 작성된 코드는 password의 non-null을 보장하는 상황이므로 아직도 컴파일 에러가 발생한다.

테스트를 위해 nullable로 변경하고 변경사항을 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class PasswordStrengthMeter {

fun meter(password: String?): PasswordStrength {
if (password == null) {
return PasswordStrength.INVALID
}
if (password.length < 8) {
return PasswordStrength.NORMAL
}
var containsNumber = meetsContainingNumberCriteria(password)
if (containsNumber.not()) {
return PasswordStrength.NORMAL
}

return PasswordStrength.STRONG
}

private fun meetsContainingNumberCriteria(password: String): Boolean {
password.forEach { c ->
if (c.isDigit()) {
return true
}
}
return false
}
}

사실상 암호 강도 검증이라는 기능에선 null과 빈 문자열을 동치이므로 테스트 코드를 하나 더 추가하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
class PasswordStrengthMeterTest {
// ...

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

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

현재 프로덕션 코드는 빈 문자열인 경우 NORMAL을 반환하여 테스트가 실패한다.

따라서 아래와 같이 변경한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class PasswordStrengthMeter {

fun meter(password: String?): PasswordStrength {
if (password.isNullOrEmpty()) {
return PasswordStrength.INVALID
}
if (password.length < 8) {
return PasswordStrength.NORMAL
}
var containsNumber = meetsContainingNumberCriteria(password)
if (containsNumber.not()) {
return PasswordStrength.NORMAL
}

return PasswordStrength.STRONG
}

private fun meetsContainingNumberCriteria(password: String): Boolean {
password.forEach { c ->
if (c.isDigit()) {
return true
}
}
return false
}
}

이제 모든 테스트가 통과된다.

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

이제 대문자를 포함하지않고 나머지 조건을 충족하는 경우에 대해서 테스트 코들르 추가해보자.

1
2
3
4
5
6
7
8
class PasswordStrengthMeterTest {
// ...

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

기대값이 NORMAL인데, STRONG이 반환되어 테스트는 실패한다.

이제 프로덕션 코드를 수정해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class PasswordStrengthMeter {

fun meter(password: String?): PasswordStrength {
if (password.isNullOrEmpty()) {
return PasswordStrength.INVALID
}
if (password.length < 8) {
return PasswordStrength.NORMAL
}
var containsNumber = meetsContainingNumberCriteria(password)
if (containsNumber.not()) {
return PasswordStrength.NORMAL
}
var containsUppercase = false
password.forEach { c ->
if (c.isUpperCase()) {
containsUppercase = true
}
}
if (containsUppercase.not()) {
return PasswordStrength.NORMAL
}

return PasswordStrength.STRONG
}

private fun meetsContainingNumberCriteria(password: String): Boolean {
// ...
}
}

테스트 코드의 통과를 확인하였다면 이제 별도 메소드로 분리해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class PasswordStrengthMeter {

fun meter(password: String?): PasswordStrength {
if (password.isNullOrEmpty()) {
return PasswordStrength.INVALID
}
if (password.length < 8) {
return PasswordStrength.NORMAL
}
var containsNumber = meetsContainingNumberCriteria(password)
if (containsNumber.not()) {
return PasswordStrength.NORMAL
}
var containsUppercase = meetsContainingUppercaseCriteria(password)
if (containsUppercase.not()) {
return PasswordStrength.NORMAL
}

return PasswordStrength.STRONG
}

private fun meetsContainingNumberCriteria(password: String): Boolean {
// ...
}

private fun meetsContainingUppercaseCriteria(password: String): Boolean {
password.forEach { c ->
if (c.isUpperCase()) {
return true
}
}
return false
}
}

수정 후에도 테스트가 모두 성공함을 확인할 수 있다.

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

이제 남은 것은 한 가지 조건만 충족하거나 모든 조건을 충족하지않는 경우뿐이다.

먼저 8글자인 조건만 충족하는 경우를 테스트해보자.

이 경우 암호 강도는 ‘약함’으로 구분한다.

제일 먼저 테스트 코드를 추가해보자.

1
2
3
4
5
6
7
8
class PasswordStrengthMeterTest {
// ...

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

컴파일 에러 소거를 위해 WEAK을 추가한다.

1
2
3
4
5
6
enum class PasswordStrength {
INVALID,
WEAK,
NORMAL,
STRONG,
}

테스트하면 기대값은 WEAK인데 결과값은 NORMAL이어서 실패한다.

이 테스트를 통과시키려면 길이 조건을 충족하는 상황에서 나머지 두 조건을 충족하지않는 경우를 구별해야 한다.

이를 위해 길이가 8이상인지를 초기화해둔 변수를 추가하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class PasswordStrengthMeter {

fun meter(password: String?): PasswordStrength {
if (password.isNullOrEmpty()) {
return PasswordStrength.INVALID
}
var lengthEnough = password.length >= 8
if (lengthEnough.not()) {
return PasswordStrength.NORMAL
}
// if (password.length < 8) {
// return PasswordStrength.NORMAL
// }
var containsNumber = meetsContainingNumberCriteria(password)
if (containsNumber.not()) {
return PasswordStrength.NORMAL
}
var containsUppercase = meetsContainingUppercaseCriteria(password)
if (containsUppercase.not()) {
return PasswordStrength.NORMAL
}

return PasswordStrength.STRONG
}

private fun meetsContainingNumberCriteria(password: String): Boolean {
// ...
}

private fun meetsContainingUppercaseCriteria(password: String): Boolean {
// ...
}
}

코드를 대치하였음에도 meetsOnlyLengthCriteria_Then_Weak()을 제외한 기존 테스트가 모두 통과했음을 확인한 뒤

아래와 같이 코드를 변경하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class PasswordStrengthMeter {

fun meter(password: String?): PasswordStrength {
if (password.isNullOrEmpty()) {
return PasswordStrength.INVALID
}
var lengthEnough = password.length >= 8
var containsNumber = meetsContainingNumberCriteria(password)
var containsUppercase = meetsContainingUppercaseCriteria(password)
if (lengthEnough.not()) {
return PasswordStrength.NORMAL
}
if (containsNumber.not()) {
return PasswordStrength.NORMAL
}
if (containsUppercase.not()) {
return PasswordStrength.NORMAL
}

return PasswordStrength.STRONG
}

private fun meetsContainingNumberCriteria(password: String): Boolean {
// ...
}

private fun meetsContainingUppercaseCriteria(password: String): Boolean {
// ...
}
}

반환하는 위치를 변경한 이유는 개별 규칙을 검사하는 로직과 규칙을 검사한 결과에 따라 암호 강도를 계산하는 로직을 분리하기 위해서이다.

위치를 변경하여도 meetsOnlyLengthCriteria_Then_Weak()을 제외한 기존 테스트가 모두 통과했음을 확인할 수 있다.

이제 meetsOnlyLengthCriteria_Then_Weak() 테스트 메서드를 통과시킬 차례이다.

아래와 같이 한 가지 조건만 충족하는 경우를 판정하도록 코드를 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class PasswordStrengthMeter {

fun meter(password: String?): PasswordStrength {
if (password.isNullOrEmpty()) {
return PasswordStrength.INVALID
}
var lengthEnough = password.length >= 8
var containsNumber = meetsContainingNumberCriteria(password)
var containsUppercase = meetsContainingUppercaseCriteria(password)

if (lengthEnough && containsNumber.not() && containsUppercase.not()) {
return PasswordStrength.WEAK
}

if (lengthEnough.not()) {
return PasswordStrength.NORMAL
}
if (containsNumber.not()) {
return PasswordStrength.NORMAL
}
if (containsUppercase.not()) {
return PasswordStrength.NORMAL
}

return PasswordStrength.STRONG
}

private fun meetsContainingNumberCriteria(password: String): Boolean {
// ...
}

private fun meetsContainingUppercaseCriteria(password: String): Boolean {
// ...
}
}

이제 모든 테스트가 통과한다.

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

이번엔 숫자 포함 조건만 충족하고 나머지 조건을 충족하지않는 경우를 판정해보자.

테스트 코드는 아래와 같다.

1
2
3
4
5
6
7
8
class PasswordStrengthMeterTest {
// ...

@Test
fun meetsOnlyNumberCriteria_Then_Weak() {
assertStrength("12345", PasswordStrength.WEAK)
}
}

기존에 작업해둔 코드들이 있기에 이 테스트를 통과시키는 방법은 간단하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class PasswordStrengthMeter {

fun meter(password: String?): PasswordStrength {
if (password.isNullOrEmpty()) {
return PasswordStrength.INVALID
}
var lengthEnough = password.length >= 8
var containsNumber = meetsContainingNumberCriteria(password)
var containsUppercase = meetsContainingUppercaseCriteria(password)

if (lengthEnough && containsNumber.not() && containsUppercase.not()) {
return PasswordStrength.WEAK
}
if (lengthEnough.not() && containsNumber && containsUppercase.not()) {
return PasswordStrength.WEAK
}

if (lengthEnough.not()) {
return PasswordStrength.NORMAL
}
if (containsNumber.not()) {
return PasswordStrength.NORMAL
}
if (containsUppercase.not()) {
return PasswordStrength.NORMAL
}

return PasswordStrength.STRONG
}

private fun meetsContainingNumberCriteria(password: String): Boolean {
// ...
}

private fun meetsContainingUppercaseCriteria(password: String): Boolean {
// ...
}
}

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

이번엔 대문자 포함 조건만 충족하고 나머지 조건을 충족하지않는 경우를 판정해보자.

테스트 코드는 아래와 같다.

1
2
3
4
5
6
7
8
class PasswordStrengthMeterTest {
// ...

@Test
fun meetsOnlyUppercaseCriteria_Then_Weak() {
assertStrength("ABZEF", PasswordStrength.WEAK)
}
}

이번에도 테스트 통과시키는 방법은 간단하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class PasswordStrengthMeter {

fun meter(password: String?): PasswordStrength {
if (password.isNullOrEmpty()) {
return PasswordStrength.INVALID
}
var lengthEnough = password.length >= 8
var containsNumber = meetsContainingNumberCriteria(password)
var containsUppercase = meetsContainingUppercaseCriteria(password)

if (lengthEnough && containsNumber.not() && containsUppercase.not()) {
return PasswordStrength.WEAK
}
if (lengthEnough.not() && containsNumber && containsUppercase.not()) {
return PasswordStrength.WEAK
}
if (lengthEnough.not() && containsNumber.not() && containsUppercase) {
return PasswordStrength.WEAK
}

if (lengthEnough.not()) {
return PasswordStrength.NORMAL
}
if (containsNumber.not()) {
return PasswordStrength.NORMAL
}
if (containsUppercase.not()) {
return PasswordStrength.NORMAL
}

return PasswordStrength.STRONG
}

private fun meetsContainingNumberCriteria(password: String): Boolean {
// ...
}

private fun meetsContainingUppercaseCriteria(password: String): Boolean {
// ...
}
}

모든 테스트가 통과됨을 확인할 수 있다.

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

지금까지 작성한 PasswordStrengthMeter 클래스의 코드를 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

class PasswordStrengthMeter {

fun meter(password: String?): PasswordStrength {
if (password.isNullOrEmpty()) {
return PasswordStrength.INVALID
}
var lengthEnough = password.length >= 8
var containsNumber = meetsContainingNumberCriteria(password)
var containsUppercase = meetsContainingUppercaseCriteria(password)

if (lengthEnough && containsNumber.not() && containsUppercase.not()) {
return PasswordStrength.WEAK
}
if (lengthEnough.not() && containsNumber && containsUppercase.not()) {
return PasswordStrength.WEAK
}
if (lengthEnough.not() && containsNumber.not() && containsUppercase) {
return PasswordStrength.WEAK
}

if (lengthEnough.not()) {
return PasswordStrength.NORMAL
}
if (containsNumber.not()) {
return PasswordStrength.NORMAL
}
if (containsUppercase.not()) {
return PasswordStrength.NORMAL
}

return PasswordStrength.STRONG
}

private fun meetsContainingNumberCriteria(password: String): Boolean {
password.forEach { c ->
if (c.isDigit()) {
return true
}
}
return false
}

private fun meetsContainingUppercaseCriteria(password: String): Boolean {
password.forEach { c ->
if (c.isUpperCase()) {
return true
}
}
return false
}
}

모든 테스트를 통과하는 것은 좋은데, 코드의 복잡도 및 분기 메서드가 너무 많아졌다.

하나씩 리팩토링해보자.

먼저 아래 부분이다.

1
2
3
4
5
6
7
8
9
if (lengthEnough && containsNumber.not() && containsUppercase.not()) {
return PasswordStrength.WEAK
}
if (lengthEnough.not() && containsNumber && containsUppercase.not()) {
return PasswordStrength.WEAK
}
if (lengthEnough.not() && containsNumber.not() && containsUppercase) {
return PasswordStrength.WEAK
}

테스트를 통과하기위해 코드를 다 작성하고보니, 비슷하면서도 한 가지 조건만 충족하는 경우 WEAK으로 판단하고 있음을 확인할 수 있다.

이를 개선하기 위해 아래와 같이 조건을 충족하는 개수를 파악해서 1개만 충족하는 경우 WEAK으로 반환하도록 리팩토링해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
class PasswordStrengthMeter {

fun meter(password: String?): PasswordStrength {
if (password.isNullOrEmpty()) {
return PasswordStrength.INVALID
}
var metCounts = 0
var lengthEnough = password.length >= 8
if (lengthEnough) {
metCounts++
}
var containsNumber = meetsContainingNumberCriteria(password)
if (containsNumber) {
metCounts++
}
var containsUppercase = meetsContainingUppercaseCriteria(password)
if (containsUppercase) {
metCounts++
}

if (metCounts == 1) {
return PasswordStrength.WEAK
}

// if (lengthEnough && containsNumber.not() && containsUppercase.not()) {
// return PasswordStrength.WEAK
// }
// if (lengthEnough.not() && containsNumber && containsUppercase.not()) {
// return PasswordStrength.WEAK
// }
// if (lengthEnough.not() && containsNumber.not() && containsUppercase) {
// return PasswordStrength.WEAK
// }

if (lengthEnough.not()) {
return PasswordStrength.NORMAL
}
if (containsNumber.not()) {
return PasswordStrength.NORMAL
}
if (containsUppercase.not()) {
return PasswordStrength.NORMAL
}

return PasswordStrength.STRONG
}

private fun meetsContainingNumberCriteria(password: String): Boolean {
password.forEach { c ->
if (c.isDigit()) {
return true
}
}
return false
}

private fun meetsContainingUppercaseCriteria(password: String): Boolean {
password.forEach { c ->
if (c.isUpperCase()) {
return true
}
}
return false
}
}

다행히 리팩토링 후 모든 테스트는 통과한다.

또한 만족하는 규칙이 1개인 경우 WEAK으로 판단한다는 게 코드에서 잘 묻어나온다.

이번엔 아래 코드를 살펴보자.

1
2
3
4
5
6
7
8
9
if (lengthEnough.not()) {
return PasswordStrength.NORMAL
}
if (containsNumber.not()) {
return PasswordStrength.NORMAL
}
if (containsUppercase.not()) {
return PasswordStrength.NORMAL
}

1개의 규칙만 만족하는 경우는 이미 WEAK으로 판정되므로 사실 위 코드는 2개의 규칙을 만족하면 NORMAL로 판정함을 유추할 수 있다.

따라서 아래와 같이 리팩토링 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class PasswordStrengthMeter {

fun meter(password: String?): PasswordStrength {
if (password.isNullOrEmpty()) {
return PasswordStrength.INVALID
}
var metCounts = 0
var lengthEnough = password.length >= 8
if (lengthEnough) {
metCounts++
}
var containsNumber = meetsContainingNumberCriteria(password)
if (containsNumber) {
metCounts++
}
var containsUppercase = meetsContainingUppercaseCriteria(password)
if (containsUppercase) {
metCounts++
}

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

// if (lengthEnough.not()) {
// return PasswordStrength.NORMAL
// }
// if (containsNumber.not()) {
// return PasswordStrength.NORMAL
// }
// if (containsUppercase.not()) {
// return PasswordStrength.NORMAL
// }

return PasswordStrength.STRONG
}

private fun meetsContainingNumberCriteria(password: String): Boolean {
// ...
}

private fun meetsContainingUppercaseCriteria(password: String): Boolean {
// ...
}
}

이때도 모든 테스트는 통과한다.

이제 lengthEnough, containsNumber, containsUppercase 변수는 단순히 만족하는 규칙의 개수만 카운트하고 있으므로 아래와 같이 리팩토링할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class PasswordStrengthMeter {

fun meter(password: String?): PasswordStrength {
if (password.isNullOrEmpty()) {
return PasswordStrength.INVALID
}
var metCounts = 0
if (password.length >= 8) {
metCounts++
}
if (meetsContainingNumberCriteria(password)) {
metCounts++
}
if (meetsContainingUppercaseCriteria(password)) {
metCounts++
}

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

return PasswordStrength.STRONG
}

private fun meetsContainingNumberCriteria(password: String): Boolean {
// ...
}

private fun meetsContainingUppercaseCriteria(password: String): Boolean {
// ...
}
}

많은 부분을 리팩토링했지만 여전히 모든 테스트는 성공한다.

코드가 변경되었어도 기존 기능의 정상 동작을 보장하며, 동시에 가독성을 확보하게된 것이다.

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

모든 게 끝난 것 같지만 아직 테스트하지 않은 케이스가 있다.

바로 아무 조건도 충족하지 않는 암호이다.

테스트 코드를 아래와 같이 추가한다.

1
2
3
4
5
6
7
8
class PasswordStrengthMeterTest {
// ...

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

문자열 abc는 충족하는 조건의 개수가 0개이므로, 1개인 WEAK, 2개인 NORMAL 판정을 모두 피하고 STRONG을 반환하게 되어

테스트가 실패하게 된다.

이를 통과시키는 방법은 다양한다.

  • 충족하는 조건의 개수가 0개이면 WEAK을 반환한다.
  • 충족하는 조건의 개수가 1개이하이면 WEAK을 반환한다.
  • 충족하는 조건의 개수가 3개이면 STRONG을 반환하고, 나머지 경우에 WEAK을 추가한다.

첫 번째 방법을 적용하는 것이 제일 간단해보이므로 채택해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class PasswordStrengthMeter {

fun meter(password: String?): PasswordStrength {
if (password.isNullOrEmpty()) {
return PasswordStrength.INVALID
}
var metCounts = 0
if (password.length >= 8) {
metCounts++
}
if (meetsContainingNumberCriteria(password)) {
metCounts++
}
if (meetsContainingUppercaseCriteria(password)) {
metCounts++
}

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

return PasswordStrength.STRONG
}

private fun meetsContainingNumberCriteria(password: String): Boolean {
// ...
}

private fun meetsContainingUppercaseCriteria(password: String): Boolean {
// ...
}
}

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

가독성을 더 높이기 위해 코드를 더 정리해보자.

먼저 충족하는 규칙을 계산하는 메서드를 외부로 분리한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class PasswordStrengthMeter {

fun meter(password: String?): PasswordStrength {
if (password.isNullOrEmpty()) {
return PasswordStrength.INVALID
}
var metCounts = getMetCriteriaCounts(password)

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

return PasswordStrength.STRONG
}

private fun getMetCriteriaCounts(password: String): Int {
var metCounts = 0
if (password.length >= 8) {
metCounts++
}
if (meetsContainingNumberCriteria(password)) {
metCounts++
}
if (meetsContainingUppercaseCriteria(password)) {
metCounts++
}
return metCounts
}


private fun meetsContainingNumberCriteria(password: String): Boolean {
// ...
}

private fun meetsContainingUppercaseCriteria(password: String): Boolean {
// ...
}
}

그 다음으로 개수에 따라 강도를 반환는 로직을 리팩토링한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class PasswordStrengthMeter {

fun meter(password: String?): PasswordStrength {
if (password.isNullOrEmpty()) {
return PasswordStrength.INVALID
}
val metCounts = getMetCriteriaCounts(password)

return when {
metCounts <= 1 -> PasswordStrength.WEAK
metCounts == 2 -> PasswordStrength.NORMAL
metCounts == 3 -> PasswordStrength.STRONG
else -> PasswordStrength.WEAK
}
}

private fun getMetCriteriaCounts(password: String): Int {
// ...
}


private fun meetsContainingNumberCriteria(password: String): Boolean {
// ...
}

private fun meetsContainingUppercaseCriteria(password: String): Boolean {
// ...
}
}

이로써, 코드의 가독성 및 안정성까지 모두 확보하면서 모든 테스트의 성공이 보장되는 코드가 되었다.

2.4. TDD 흐름

지금까지 암호 강도 검사기를 TDD를 통해 구현해보았다.

결국 TDD는 테스트 > 코딩 > 리팩토링의 무한 반복이다.

간략하게 TDD 흐름을 정리해보자.

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

프로덕션 코드보다 테스트 코드가 먼저 작성되면, 테스트가 개발을 주도하게 된다.

테스트 코드를 먼저 작성하면 그 테스트를 통과할 만큼만의 개발 범위가 정해지고,

이후 테스트 코드가 추가되면서 점진적으로 개발 범위가 확장되게 된다.

또한 구현을 완료한 뒤에는 리팩토링을 진행하는 것이 좋다.

당장 리팩토링을 하지 않더라도, 테스트 코드의 존재 자체가 리팩토링을 과감하게 진행할 수 있게 해주는 원천이 되어준다.