058. (Getting Started with Test-Driven Development) 8. 테스트 가능한 설계

8. 테스트 가능한 설계

8.1. 테스트가 어려운 코드

아쉽게도 개발자가 작성한 모든 코드를 테스트할 수 있는 것은 아니다.

실제 개발을 진행하다보면 테스트하기 어려운 코드를 마주치게 되는데, 각 케이스별로 사례를 살펴보고 이를 대응하는 방법에 대해서 알아보자.

8.1.1. 하드 코딩된 경로

결제 대행업체가 있고, 결제 내역의 유효성을 검증할 수 있도록 익일 오전에 결제 결과를 파일로 제공한다고 가정하자.

이 파일을 읽어온 뒤 DB에 결제 내역은 아래 코드로 반영한다.

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 PaySync {

private val payInfoDao = PayInfoDao()

@Throws(IOException::class)
fun sync() {
val path = Paths.get("""D:\\data\\pay\\cp0001.csv""")

val payInfos = mutableListOf<PayInfo>()

Files.readAllLines(path).forEach { line ->
val data = line.split(",")
payInfos.add(
PayInfo(
data[0],
data[1],
data[2].toInt()
)
)
}
payInfos.forEach {
payInfoDao.insert(it)
}
}
}

위 코드에서 경로는 D:\\data\\pay\\cp0001.csv로 하드 코딩되어 있는 것을 볼 수 있다.

따라서 이 코드를 직접 테스트하려면 해당 경로에 파일이 반드시 존재해야한다는 전제 조건이 필요해진다.

또한 윈도우 운영체제에 맞는 파일 경로를 사용하고 있기에, 다른 운영체제에서 테스트 코드를 수행할 수 없다.

비단 경로 뿐만 아니라 IP 주소나 포트 번호도 하드 코딩되어있으면 테스트가 어려워진다.

8.1.2. 의존 객체를 직접 생성

하드 코딩된 경로 뿐만 아니라 의존 대상인 PayInfoDao를 직접 생성하고 있는 것도 문제가 된다.

1
2
3
4
5
6
class PaySync {

private val payInfoDao = PayInfoDao()

// ...
}

위 코드를 테스트하려면 PayInfoDao가 올바르게 동작한다는 것을 보장해야하고, 이를 위한 데이터베이스와 테이블도 추가로 생성해야한다.

테스트를 실행한 뒤에는 실제로 데이터가 데이터베이스에 추가되므로 같은 테스트를 실행하려면 추가된 데이터를 소거해야하는 문제도 발생한다.

8.1.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
26
class LoginService(
val customerRepository: CustomerRepository,
) {

private val authKey = "somekey"

fun login(id: String, password: String): LoginResult {
val authorized = AuthUtil.authorize(authKey) // HERE
val response = if (authorized) {
AuthUtil.authenticate(id, password) // HERE
} else {
-1
}

if (response == -1) {
return LoginResult.badAuthKey()
}

return if (response == 1) {
val customer = customerRepository.findOne(id)
LoginResult.authenticaed(customer)
} else {
LoginResult.fail(response)
}
}
}

위의 코드에서는 AuthUtil 클래스의 정적 메서드를 사용하고 있다.

AuthUtil 클래스가 인증 서버와 통신하는 경우, 이 코드를 테스트하기위해 실제 동작중인 인증 서버가 필요해진다.

또한 AuthUtil 클래스가 통신을 인증 서버 정보를 시스템 프로퍼티에서 가져온다면 시스템의 프로퍼티도 테스트 환경에 맞게 설정해야 한다.

게다가 다양한 상황을 테스트하려면 인증 서버에 저장되어있는 유효한 아이디와 암호를 사용해야 한다.

8.1.4. 실행 시점에 따라 달라지는 결과

이번엔 코드를 먼저 살펴보자.

아래 코드는 특정 사용자의 포인트를 계산하는 로직을 담고 있다.

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 UserPointCalculator(
val subscriptionDao: SubscriptionDao,
val productDao: ProductDao,
) {

@Throws(NoSubscriptionException::class)
fun calculatePoint(user: User): Int {
val subscription = subscriptionDao.selectByUser(user.id)

if (subscription == null) {
throw NoSubscriptionException()
}
val product = productDao.selectById(subscription.productId)

val now = LocalDate.now() // HERE

var point = 0
if (subscription.isFinished(now)) { // HERE
point += product.getDefaultPoint()
} else {
point += product.getDefaultPoint() + 10
}

if (subscription.grade == GOLD) {
point += 100
}
return point
}
}

calculatePoint() 메서드는 사용자의 구독 상태나 제품에 따라 계산한 결과 값을 반환한다.

여기서 테스트를 어렵게 만드는 코드는 LocalDate.now()isFinished() 메서드이다.

LocalDate.now()는 현재 시간을 반환하므로 언제 호출하느냐에 따라 값이 계속해서 바뀌기때문에 어제 성공한 테스트가 오늘은 실패할 수도 있다.

이 값을 기준으로 isFinished() 메서드가 수행되는데, 구독 종료 여부가 계속해서 바뀔 수 있는 것이다.

참고 똑같은 흐름으로 Random을 이용한 난수를 사용하는 경우도 마찬가지 현상이 발생할 수 있다.

8.1.5. 역할이 섞여 있는 코드

사용자의 포인트를 계산하는 코드를 다시 살펴보자.

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 UserPointCalculator(
val subscriptionDao: SubscriptionDao,
val productDao: ProductDao,
) {

@Throws(NoSubscriptionException::class)
fun calculatePoint(user: User): Int {
val subscription = subscriptionDao.selectByUser(user.id)

if (subscription == null) {
throw NoSubscriptionException()
}
val product = productDao.selectById(subscription.productId)

val now = LocalDate.now()

var point = 0
if (subscription.isFinished(now)) {
point += product.getDefaultPoint()
} else {
point += product.getDefaultPoint() + 10
}

if (subscription.grade == GOLD) {
point += 100
}
return point
}
}

이 코드의 또 다른 문제는 포인트 계산 로직만 보고 테스트하기 어렵다는 것이다.

포인트 계산 결과를 테스트하려면 SubscriptionDaoProductDao의 대역을 구성해야한다.

포인트 계산 자체는 위의 두 dao와 상관이 없지만, 포인트 계산만 테스트할 수가 없는 것이다.

8.1.6. 그 외 테스트가 어려운 코드

예시로 든 것 외에는 아래와 같은 경우들이 테스트하기 어렵다고 볼 수 있다.

  • 메서드 중간에 소켓 통신 코드가 포함되어 있다.
  • 콘솔에서 입력을 받거나 결과를 콘솔에 출력한다.
  • 테스트 대상이 사용하는 의존 대상 클래스나 메서드나 final이다. 이 경우 대역으로 대체하는 것이 어려울 수 있다.
  • 테스트 대상의 소스를 소유하고 있지않아 수정이 어렵다.

참고 소켓 통신이나 HTTP 통신은 호출 대상인 서버를 로컬에 띄워서 처리하기도 한다.

8.2. 테스트 가능한 설계

여태까지 테스트가 어려운 경우에 대해서 살펴보았다.

테스트가 어려운 대부분의 이유는 의존하는 코드를 교체할 수 있는 수단이 없기 때문이다.

이때 상황에 따라 적절한 방법으로 외부 의존성을 교체할 수 있도록 할 수 있다.

8.2.1. 하드 코딩된 상수를 생성자나 메서드 파라미터로 받기

하드 코딩된 경로가 테스트하기 어려운 이유는 상술했듯 테스트 환경에 따라 경로를 다르게 줄 수 있는 수단이 없기 때문이다.

만약 하드 코딩된 상수때문에 테스트가 힘들다면 해당 상수를 교체할 수 있도록 코드를 수정하면 된다.

가장 쉬운 방법은 생성자나 setter를 이용해서 경로를 전달받는 것이다.

아래의 예시를 보자.

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

private var filePath = """D:\\data\\pay\\cp0001.csv""" // HERE

fun setFilePath(filePath: String) { // HERE
this.filePath = filePath
}

private val payInfoDao = PayInfoDao()

@Throws(IOException::class)
fun sync() {
val path = Paths.get(filePath) // HERE

// ...
}
}

위와 같이 filePath를 변경할 수 있게 해주면 아래와 같이 테스트 코드를 작성할 수 있다.

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

@Test
@Throws(IOException::class)
fun someTest() {
val paySync = PaySync()
paySync.setFilePath("src/test/resources/c0111.csv")

paySync.sync()

// 결과 검증
}
}

또 다른 방법은 메서드의 파라미터로 경로를 넘기는 것이다.

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 PaySync {

private val payInfoDao = PayInfoDao()

@Throws(IOException::class)
fun sync(filePath: String) { // HERE
val path = Paths.get(filePath)

val payInfos = mutableListOf<PayInfo>()

Files.readAllLines(path).forEach { line ->
val data = line.split(",")
payInfos.add(
PayInfo(
data[0],
data[1],
data[2].toInt()
)
)
}
payInfos.forEach {
payInfoDao.insert(it)
}
}
}

위의 경우 테스트 코드는 아래와 같이 작성될 수 있다.

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

@Test
@Throws(IOException::class)
fun someTest(filePath: String) {
val paySync = PaySync()
paySync.sync(filePath)

// 결과 검증
}
}

8.2.2. 의존 대상을 주입 받기

의존 대상을 주입받을 수 있는 수단을 제공해서 교체할 수 있도록 하는 방법도 사용할 수 있다.

위와 마찬가지로 생성자나 setter를 사용하면 된다.

의존 대상을 교체할 수 있게 되면 실제 구현 대신에 대역을 사용할 수 있어 테스트를 보다 원활하게 작성할 수 있따.

아래는 대역을 주입받을 수 있도록 수정한 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class PaySync(
val payInfoDao: PayInfoDao, // HERE
) {

private var filePath = """D:\\data\\pay\\cp0001.csv"""

fun setFilePath(filePath: String) {
this.filePath = filePath
}

@Throws(IOException::class)
fun sync() {
// ...
}
}

생성자를 변경하는 경우 많은 레거시의 수정이 필요해져서 부담되는 경우 setter를 사용하면 된다.

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

private var filePath = """D:\\data\\pay\\cp0001.csv"""

fun setFilePath(filePath: String) {
this.filePath = filePath
}

private lateinit var payInfoDao: PayInfoDao
fun setPayInfoDao(payInfoDao: PayInfoDao) { // HERE
this.payInfoDao = payInfoDao
}

@Throws(IOException::class)
fun sync() {
// ...
}
}

의존 대상을 교체할 수 있도록 코드를 수정했다면 이제 대역을 적용해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class PaySyncTest {
// 대역 생성
private val memoryDao = MemoryPayInfoDao()

@Test
@Throws(IOException::class)
fun allDataSaved() {
val paySync = PaySync()
paySync.setPayInfoDao(memoryDao) // 대역으로 교체
paySync.setFilePath("src/test/resources/c0111.csv")

paySync.sync()

// 대역을 이용한 결과 검증
val savedInfos = memoryDao.getAll()
assertEquals(2, savedInfos.size)
}

}

8.2.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
26
27
28
29
class UserPointCalculator(
val subscriptionDao: SubscriptionDao,
val productDao: ProductDao,
) {

@Throws(NoSubscriptionException::class)
fun calculatePoint(user: User): Int {
val subscription = subscriptionDao.selectByUser(user.id)

if (subscription == null) {
throw NoSubscriptionException()
}
val product = productDao.selectById(subscription.productId)

val now = LocalDate.now()

var point = 0
if (subscription.isFinished(now)) {
point += product.getDefaultPoint()
} else {
point += product.getDefaultPoint() + 10
}

if (subscription.grade == GOLD) {
point += 100
}
return point
}
}

포인트 계산 기능만 테스트 하려면 SubscriptionDaoProductDao의 대역 혹은 실제 구현체가 필요하고, LocalDate에 대한 값이 필요하다.

포인트 계산 기능의 테스를 위해 나머지 코드의 동작을 보장해야하는 것이다.

이처럼 기능의 일부만 테스트하고 싶다면 해당 코드를 별도 기능으로 분리하여 테스트를 진행하는 방법을 사용할 수 있다.

아래처럼 포인트를 계산하는 코드만 별도의 클래스로 분리하는 것이다.

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

fun calculate(
subscription: Subscription,
product: Product,
now: LocalDate,
) {
var point = 0
if (subscription.isFinished(now)) {
point += product.getDefaultPoint()
} else {
point += product.getDefaultPoint() + 10
}

if (subscription.grade == GOLD) {
point += 100
}
return point
}
}

기능을 분리했으면 이에 대한 테스트를 수행한다.

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

@Test
fun `만료전_GOLD등급은_130포인트`() {
val rule = PointRule()
val subscription = Subscription(
LocalDate.of(2019, 5, 5),
Grade.GOLD
)
val product = Product()
product.setDefaultPotin(20)

val point = rule.calculate(
subscription = subscription,
product = product,
now = LocalDate.of(2019, 5, 1),
)

assertEquals(130, point)
}
}

기존 코드에 분리된 기능을 적용하면 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class UserPointCalculator(
val subscriptionDao: SubscriptionDao,
val productDao: ProductDao,
) {

@Throws(NoSubscriptionException::class)
fun calculatePoint(user: User): Int {
val subscription = subscriptionDao.selectByUser(user.id)

if (subscription == null) {
throw NoSubscriptionException()
}
val product = productDao.selectById(subscription.productId)

val now = LocalDate.now()

return PointRule().calculate(
subscription = subscription,
product = product,
now = now,
)
}
}

만약 포인트 계산 기능 자체를 대역으로 변경하고 싶다면 setter를 적용할 수 도 있다.

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
class UserPointCalculator(
val subscriptionDao: SubscriptionDao,
val productDao: ProductDao,
) {

private var pointRule: PointRule = PointRule() // 기본 구현 사용
fun setPointRule(pointRule: PointRule) { // 별도로 분리한 계산 기능 혹은 대역을 주입할 수 있는 setter
this.pointRule = pointRule
}

@Throws(NoSubscriptionException::class)
fun calculatePoint(user: User): Int {
val subscription = subscriptionDao.selectByUser(user.id)

if (subscription == null) {
throw NoSubscriptionException()
}
val product = productDao.selectById(subscription.productId)

val now = LocalDate.now()

return pointRule.calculate( // HERE
subscription = subscription,
product = product,
now = now,
)
}
}

8.2.4. 시간이나 임의 값 생성 기능 분리하기

테스트 대상이 시간이나 임의의 값을 사용하면 테스트 시점에 따라 테스트 결과가 달라진다.

이 경우 테스트 대상이 사용하는 시간이나 임의의 값을 제공하는 기능을 별도로 분리해서 테스트 가능성을 높일 수 있다.

아래 코드는 LocalDate.now() 코드를 사용하기때문에 테스트를 실행하는 일자에 따라 값이 달라져 테스트 결과도 달라지는 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
class DailyBatchLoader {
private var basePath = "."

fun load(): Int {
val date = LocalDate.now()
val formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd")
val batchPath = Paths.get(basePath, date.format(formatter), "batch.txt")

// batchPath에서 데이터를 읽어와 저장하는 코드

return result
}
}

현재 일자를 구하는 기능을 분리하고 분리한 대상을 주입할 수 있게 변경하면 테스트를 원하는 상황으로 제어할 수 잇게 된다.

먼저 현재 일자를 구하는 기능을 아래와 같이 분리한다.

1
2
3
4
class Times {

fun today(): LocalDate = LocalDate.now()
}

이후 DailyBatchLoader 클래스가 Times 클래스를 이용해서 오늘 일자를 구하도록 수정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class DailyBatchLoader {
private var basePath = "."
private val times = Times() // HERE

fun load(): Int {
val date = times.today() // HERE
val formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd")
val batchPath = Paths.get(basePath, date.format(formatter), "batch.txt")

// batchPath에서 데이터를 읽어와 저장하는 코드

return result
}
}

이제 테스트 코드를 작성할 때 Times의 대역을 이용해서 원하는 상황을 쉽게 구성할 수 있게 되었다.

아래는 모의 객체를 이용해 제어하는 예시이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class DailyBatchLoaderTest {
private val mockTimes = mock(Times::class.java)
private val loader = DailyBatchLoader()

@BeforeEach
fun setUp() {
loader.setBasePath("src/test/resources")
loader.setTimes(mockTimes)
}

@Test
fun loadCount() {
given(mockTimes.today())
.willReturn(LocalDate.of(2019, 1, 1))

val ret = loader.load()
assert(3, ret)
}
}

8.2.5. 외부 라이브러리는 직접 사용하지 말고 감싸서 사용하기

테스트 대상이 사용하는 외부 라이브러리를 쉽게 대체할 수 없는 경우도 있다.

그 중 한 가지가 외부 라이브러리가 특정 정적 메서드를 제공하는 경우이다.

LoginService 코드를 다시 살펴보자.

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 LoginService(
val customerRepository: CustomerRepository,
) {

private val authKey = "somekey"

fun login(id: String, password: String): LoginResult {
val authorized = AuthUtil.authorize(authKey)
val response = if (authorized) {
AuthUtil.authenticate(id, password)
} else {
-1
}

if (response == -1) {
return LoginResult.badAuthKey()
}

return if (response == 1) {
val customer = customerRepository.findOne(id)
LoginResult.authenticaed(customer)
} else {
LoginResult.fail(response)
}
}
}

상술했듯 AuthUtil 클래스에서 제공하는 정적 메서드 authorize()authenticate()가 대역으로 처리하기 어려운 부분이다.

이러한 경우네는 외부 라이브러리를 직접 사용하지 말고, 외부 라이브러리와 연동하기 위한 타입을 따로 생성한다.

그리고 테스트 대상은 분리한 타입을 사용하도록 교체하면, 테스트 대상 코드는 새로 분리한 타입을 사용함으로써 외부 의존성을 끊어낼 수 있다.

예제에서 문제가 되는 AuthUtil 클래스를 아래와 같이 분리해보자.

1
2
3
4
5
6
7
8
9
10
11
12
class AuthService {
private val authKey = "somekey"

fun authenticate(id: String, password: String): Int {
val authorized = AuthUtil.authorize(authKey)
return if (authorized) {
AuthUtil.authenticate(id, password)
} else {
-1
}
}
}

이제 분리한 AuthService 클래스를 LoginService 클래스에 적용한다.

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 LoginService(
val customerRepository: CustomerRepository,
) {

private var authService = AuthService() // HERE
fun setAuthService(authService: AuthService) { // HERE
this.authService = authService
}

fun login(id: String, password: String): LoginResult {
val response = authService.authenticate(id, password) // HERE

if (response == -1) {
return LoginResult.badAuthKey()
}

return if (response == 1) {
val customer = customerRepository.findOne(id)
LoginResult.authenticaed(customer)
} else {
LoginResult.fail(response)
}
}
}

이제 AuthService를 대역으로 대체할 수 있게 되었으므로 인증 성공 상황과 실패 상황에 대해서 LoginService가 올바르게 동작하는 지 테스트 코드로 검증할 수 있게 되었다.