040. (Pragmatic Unit Testing in Kotlin with JUnit) 4. 테스트 코드 구조화

4. 테스트 코드 구조화

원제 : Organizing Your Tests

이번 포스팅에서는 단순히 단위 테스트를 작성하고 수행하는 것이 아닌 JUnit을 활용해 테스트 코드를 구조화하는 방법에 대해서 알아보자.

4.1. AAA 패턴을 이용한 테스트 일관성 유지

원제 : Keeping Tests Consistent with AAA

첫 번째 포스팅에서 다루었던 answersArithmeticMeanOfTwoNumbers() 단위 테스트 코드를 다시 가져와보자.

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

@Test
fun answersArithmeticMeanOfTwoNumbers() {
// Arrange
val collection = ScoreCollection()
with(collection) {
add(object : Scoreable {
override fun getScore() = 5
})
add(object : Scoreable {
override fun getScore() = 7
})
}

// Act
val actualResult = collection.arithmeticMean()

// Assert
assertEquals(actualResult, 6)
}
}

위의 코드에서 Arrange, Act, Assert 라는 주석을 확인할 수 있는데, 이를 AAA 패턴 이라고 한다.

각각 테스트 준비 영역, 테스트 실행 영역, 테스트 검증 영역으로 코드를 작성하는 스타일을 의미한다.

이제 AAA 패턴을 인지한 상태이므로 불필요한 주석은 제거해도 된다.

단, 개행은 유지하는 것이 영역을 구분할 때 가시적으로 좋다.

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

@Test
fun answersArithmeticMeanOfTwoNumbers() {
val collection = ScoreCollection()
with(collection) {
add(object : Scoreable {
override fun getScore() = 5
})
add(object : Scoreable {
override fun getScore() = 7
})
}

val actualResult = collection.arithmeticMean()

assertEquals(actualResult, 6)
}
}

이 AAA 패턴은 앞으로 작성하게 될 모든 테스트 코드에 적용할 패턴이다.

각 영역을 좀 더 자세히 알아보도록 하자.

준비(Arrange)
테스트 코드를 실행하기 전에 시스템이 적절한 상태에 있는지 확인한다.

예를 들어 테스트에 필요한 객체를 생성하거나, 생성한 객체와의 상호작용등이 있다.

만약 준비된 시스템이 사전에 존재한다면 생략이 되기도 한다.

실행(Act)
테스트 코드를 실행한다.

통상 단일 메서드를 호출한다.

검증(Assert)
실행한 테스트 코드의 결과가 기대한대로 반환되었는지 검증한다.

주로 실행한 코드의 반환값 혹은 그 외 필요한 객체들의 새로운 상태를 검증한다.

4.2. 동작 테스트 vs 메서드 테스트

원제 : Testing Behavior Versus Testing Methods

테스트를 작성할 때 중요한 것은 개별 메서드를 테스트한다고 생각하지 않는 것이다.

우리가 테스트하는 것은 각각의 메서드가 아닌 테스트 대상인 클래스의 동작이다.

예제를 통해 이해해보도록 하자.

하나의 ATM 기기를 클래스로 디자인해보는 경우를 생각해보자.

이 ATM 클래스는 입금을 위한 함수 deposit(), 출금을 위한 함수 withdraw(), 잔액을 조회하기 위한 함수 balance()가 존재한다.

이때 입금에 대한 테스트는 단 건 혹은 여러 건을 동시에 하는 경우를 생각해볼 수 있다.

단 건인 경우 makeSingleDeposit(), 여러 건인 경우 makeMultipleDeposits()로 표현할 수 있겠다.

출금에 대한 테스트는 단 건, 여러 건, 그리고 잔액 부족인 경우이므로

makeSingleWithdrawal(), makeMultipleWithdrawals(), attemptToWithdrawTooMuch() 로 표현할 수 있다.

이때 입금 혹은 출금결과를 검증하려면 balance() 메서드를 호출해야 한다.

그렇다면 balance() 메서드에 테스트는 의미가 있을까?

정답은 “의미가 없다” 이다.

단순히 잔액이라는 값만 반환할 것이기 때문이다.

여기서 우리는 각각의 메서드가 아닌 클래스의 동작을 테스트해야하는 것이 어떤 것인지 추측해볼 수 있다.

결국 테스트 대상이 되는 동작은 입금 혹은 출금과 같은 동작이 먼저 수행 되어야하는 것이다.

결론적으로 단위 테스트 작성을 위해서는 먼저 전체적인 시각으로 바라보고, 클래스의 종합적인 동작을 테스트해야한다.

4.3. 테스트 코드와 제품 코드와의 관계

원제 : Relationship Between Test and Production Code

JUnit 테스트는 제품 코드와 같은 프로젝트에 위치한다.

다만 제품 코드는 배포의 대상이고, 테스트 코드는 배포 대상이 아니므로 같은 프로젝트 내 다른 path에 위치하게 된다.

좀 더 상세하게 말하자면 단위 테스트는 개발자의 관심사항이지, 사용자의 관심사항이 아니기때문에 제품 코드에 포함될 필요가 없다.

따라서 테스트 코드와 제품 코드의 관계는 단방향을 그리게 된다.

4.3.1. 테스트 코드와 제품 코드의 분리

원제 : Separating Tests and Production Code

같은 프로젝트 내 테스트 코드와 제품 코드의 위치를 선택하는 세 가지 방법이 있다.

1. 테스트 코드를 제품 코드와 동일 디렉토리 및 패키지에 위치시키기

동일한 디렉토리 내에 존재하므로 구현”은” 쉬울 것이다.

오히려 배포시 테스트 코드를 걷어내는 스크립트를 작성해야하는 불편함이 생기고,

이를 식별하기 위한 규칙(Suffix가 Test라거나)을 지켜야한다거나 테스트 코드임을 증명할 무언가를 작성해야 한다.

한정된 개발 도구 안에서 불필요한 파일들을 계속 봐야하는 불편함ㄷ 생길 것이다.

2. 테스트 코드를 별도 디렉토리로 분리하고, 동일 패키지에 위치시키기

대부분 회사의 선택지이다.

디렉토리 구조는 아래와 같다.

1
2
3
4
5
6
7
8
9
├── src
│ └── kim
│ └── namhoon
│ ├── ScoreCollection.java
│ └── Scoreable.java
└── test
└── kim
└── namhoon
└── ScoreCollectionTest.java

위와 같은 구조로 작성하면 제품 코드를 패키지 수준으로 접근할 수 있기에 내부 행위의 노출을 통해 테스트를 작성하기 편하게 만들 수도 있다.

이는 설계의 품질을 떨어뜨리는 계기가 될 수도 있기에 지양해야 한다.

참고 당연히 리플렉션으로도 우회할 수 있지만 이 또한 지양해야 하는 것은 자명하다.

3. 테스트 코드를 별도 디렉토리로 분리하고, 유사한 패키지에 유지하기

1
2
3
4
5
6
7
8
9
10
├── src
│ └── kim
│ └── namhoon
│ ├── ScoreCollection.java
│ └── Scoreable.java
└── test
└── something
└── kim
└── namhoon
└── ScoreCollectionTest.java

위와 같은 구조로 작성하면 공개된 인터페이스만을 활용하여 테스트를 작성할 수 있게 된다.

공개되지않은 메서드를 테스트 코드에서 호출하는 것을 정보 은닉 원칙을 위배한다고 보며,

비공개 코드를 호출하는 행위 자체가 구현 세부 사항과 결합되어 테스트가 깨질 가능성이 존재하기 때문이다.

참고
테스트와 구현 세부 사항과의 강결합은 거짓 양성 을 야기한다.
030. (Unit Test Principles) 4. 좋은 단위 테스트의 4대 요소

4.4. 집중적인 단일 목적 테스트

원제 : The Value of Focused, Single-Purpose Tests

이번엔 두 번째 포스팅에서 예제를 다시 가져와보자.

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

@Test
fun matches() {
val profile = Profile("Bull Hockey, Inc.")
val question: Question = BooleanQuestion(1, "Got milk?")

// answers false when must-match criteria not met
profile.add(Answer(question, false))
val criteria = Criteria()
criteria.add(Criterion(Answer(question, true), Weight.MustMatch))

assertFalse(profile.matches(criteria))

// answers true for any don't care criteria
profile.add(Answer(question, false))
val criteria = Criteria()
criteria.add(Criterion(Answer(question, true), Weight.DontCare))

assertTrue(profile.matches(criteria))
}
}

구조를 보면 눈치챘겠지만 두 테스트 케이스를 하나로 합쳐서 작성하였다.

이렇게 테스트를 병합하면 각 테스트 코드 수행시 필요한 초기화 비용을 절약할 수 있게 된다.

하지만 JUnit이 제공하는 테스트 격리의 장점을 잃어버리게 된다.

따라서 동작에 따라 테스트를 분리하면 아래와 같은 장점을 유지할 수 있다.

  • 검증이 실패했을 경우 실패한 테스트 메서드명이 표시되기때문에 어떤 동작이 문제인지 바로 파악이 가능하다.
  • 실패한 테스트를 파악하는 비용을 줄일 수 있다.
  • 검증이 실패하면 이후 테스트 케이스는 수행되지 않으므로, 테스트 성공시 모든 테스트 케이스가 실행되었음을 확신할 수 있다.

4.5. 문서 역할로서의 테스트

원제 : Tests as Documentation

단위 테스트는 우리가 만드는 클래스에 대한 지속적이고 신뢰할 수 있는 문서의 역할을 해야 한다.

테스트는 동작을 기준으로하기에, 코드 자체로 설명할 수 없는 것도 표현할 수 있기 때문이다.

테스트 코드를 문서처럼 쓰기 위해선 아래와 같은 규칙들을 지키는 것이 좋다.

4.5.1. 일관적인 테스트 메서드명

원제 : Documenting Our Tests with Consistent Names

테스트가 작아질수록 각 테스트는 하나의 행위 혹은 동작에 집중하게 된다.

메서드명을 통해 테스트하려는 맥락을 표현하기 보다, 어떤 맥락에서 일련의 행동을 호출했을 때 어떤 결과가 나오는 지 명시하는 것이 좋다.

나쁜 예시 좋은 예시
makeSingleWithdrawal withdrawalReducesBalanceByWithdrawnAmount
attemptToWithdrawTooMuch withdrawalOfMoreThanAvailableFundsGeneratesError
multipleDeposits multipleDepositsIncreaseBalanceBySumOfDeposits

합리적인 테스트 이름을 구성하기 위해선 아래의 문법을 추천한다.

문법(국문) : 어떤 동작을 하면, 어떤 결과가 나온다
문법(영문) : doing some operation, generate some result
메서드명 : doingSomeOperationGenerateSomeResult()

혹은 아래와 같은 방법도 추천된다.

문법(국문) : 어떤 결과는, 어떤 조건에서 발생한다.
문법(영문) : some result occurs, under some condition
메서드명 : someResultOccursUnderSomeCondition()

행위 주도 개발(BDD : Behavior-Driven Development) 에서 권장하는 given-when-then 양식을 쓰면 아래와 같은 문법을 사용할 수 있다.

문법(국문) : 주어진 조건에서, 어떤 일을 하면, 어떤 결과가 나온다.
문법(영문) : given some context, when doging some behavior, then some result occurs
메서드명 : givenSomeContextWhenDoingSomeBehaviorThenSomeResultOccurs()

위 구조가 너무 길어서 보통 given은 생략하는 경우가 많다.

문법(국문) : 어떤 일을 하면, 어떤 결과가 나온다.
문법(영문) : when doging some behavior, then some result occurs
메서드명 : whenDoingSomeBehaviorThenSomeResultOccurs()

어떤 형식을 택하든 테스트 코드의 일관성을 유지하는 것이 중요하다.

4.6. @BeforeEach와 @AfterEach 좀 더 깊게 알기

원제 : More on @Before and @After (Common Initialization and Cleanup)

이전 포스팅에서 @BeforeEach을 통해 중복되는 초기화 코드를 일원화했었다.

참고 038. (Pragmatic Unit Testing in Kotlin with JUnit) 2. 진짜 JUnit 테스트 코드 작성하기

JUnit이 @BeforeEach@Test를 어떤 순서로 실행하는 지 알아보자.

아래 예제를 보자.

1
2
3
4
@BeforeEach
fun createAccount() {
account = Account("an account name")
}

테스트 클래스에 위의 createAccount()메서드와 hasPositiveBalance(), depositIncreasesBalance() 메서드가 포함되어있다고 가정하자.

예상되는 흐름은 아래와 같을 것이다.

  1. @BeforeEach createAccount()
  2. @Test depositIncreasesBalance()
  3. @BeforeEach createAccount()
  4. @Test hasPositiveBalance()

@BeforeEach가 적용된 메서드는 모든 테스트 메서드를 실행하기 전에 호출된다.

문제는 초기화하기 위한 요구사항이 늘어나거나 추가 동작이 필요해지는 경우이다.

이때는 @BeforeEach를 적용한 메서드를 추가로 생성하는 것이 좋다.

예를 들어 테스트 전에 어떤 파일을 삭제해야하는 경우는 아래와 같이 처리할 수 있을 것이다.

  1. @BeforeEach createAccount()
  2. @BeforeEach resetAccountLogs()
  3. @Test depositIncreasesBalance()
  4. @BeforeEach createAccount()
  5. @BeforeEach resetAccountLogs()
  6. @Test hasPositiveBalance()

다만 주의해야할 점은 @BeforeEach를 적용한 메서드들간의 호출 순서가 보장이 되어있지 않다는 점이다.

일정 순서가 필요하다면 메서드를 결합하여 초기화 동작의 순서를 보장해야한다.

매우 드물게 @AfterEach를 적용한 메서드가 필요한 경우도 있다.

@AfterEach@BeforeEach와 반대로 테스트 메서드를 수행하고 난 뒤에 호출된다.

데이터베이스의 커넥션을 종료하거나, 파일 입출력의 스트림을 종료하는 경우 사용할 수 있다.

예제에 적용해보면 아래와 같은 순서로 진행된다.

  1. @BeforeEach createAccount()
  2. @Test depositIncreasesBalance()
  3. @AfterEach closeConnections()
  4. @BeforeEach createAccount()
  5. @Test hasPositiveBalance()
  6. @AfterEach closeConnections()

4.6.1. @BeforeAll과 @AfterAll

원제 : BeforeClass and AfterClass

만약 모든 테스트 케이스를 수행하기 전에 단 한번만 초기화해야하는 경우가 있다.

이때는 @BeforeAll을 사용하여 처리할 수 있다.

반대로 모든 테스트 케이스를 수행 후 호출해야한다면 @AfterAll을 사용한다.

아래 예제를 보자.

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
internal class AssertMoreTest {

@BeforeAll
fun initializeSomethingReallyExpensive() {
// Do Something.
}

@BeforeEach
fun createAccount() {
// Do Something.
}

@Test
fun depositIncreasesBalance() {
// Do Something.
}

@Test
fun hasPositiveBalance() {
// Do Something.
}

@AfterEach
fun closeConnections() {
// Do Something.
}

@AfterAll
fun cleanUpSomethingReallyExpensive() {
// Do Something.
}
}

위와 같은 테스트 클래스의 호출 순서는 아래와 같다.

  1. @BeforeAll initializeSomethingReallyExpensive()
  2. @BeforeEach createAccount()
  3. @Test depositIncreasesBalance()
  4. @AfterEach closeConnections()
  5. @BeforeEach createAccount()
  6. @Test hasPositiveBalance()
  7. @AfterEach closeConnections()
  8. @AfterAll cleanUpSomethingReallyExpensive()