|
| 1 | +--- |
| 2 | +title: '테스트 더블' |
| 3 | +author: 'hyunwok' |
| 4 | +--- |
| 5 | + |
| 6 | +- 코드의 모든 동작을 완벽하게 테스트하기 위해 종종 입력을 설정하고 Side Effect를 검증해야 하지만, 테스트에서 의존성을 실제로 사용하는 것이 항상 가능하거나 바람직한 것만은 아니다. |
| 7 | +- 의존성을 실제로 사용하는 것에 대안으로 테스트 더블을 사용하여 테스트에 더 적합하게 사용할 수 있도록 만들어진다. |
| 8 | +- 세 가지 유형의 테스트 더블, 즉 목, 스텁, 페이크에 대해 살펴본다. |
| 9 | + |
| 10 | +### 10.4.1 테스트 더블을 사용하는 이유 |
| 11 | + |
| 12 | +#### 테스트 단순화 |
| 13 | +- 의존성 자체에서 많은 매개변수와 하위 의존성을 설정해야 할 수도 있다. |
| 14 | +- 설정 외에도 하위 의존성에서 원하는 Side Effect가 발생했는지 검증해야 할 수도 있다. |
| 15 | + - 설정을 위한 코드가 많아지고, 수많은 구현 세부 정보와 밀접하게 연결될 수도 있다. |
| 16 | +```txt incorrect |
| 17 | +┌───────────────┐ ┌─────────────────┐ |
| 18 | +│ 테스트 코드 │ ───▶ │ 테스트 대상 코드 │ |
| 19 | +└───────────────┘ └─────────────────┘ |
| 20 | + │ │ |
| 21 | + │ ▼ |
| 22 | + │ ┌────────────────┐ |
| 23 | + ├─────────────────▶ │ 의존성 │ |
| 24 | + │ └────────────────┘ |
| 25 | + │ │ |
| 26 | + │ ┌────────────────────────────┐ |
| 27 | + │ ▼ ▼ |
| 28 | + │ ┌────────────────┐ ┌────────────────┐ |
| 29 | + ├─────────────────▶ │ 하위 의존성 1 │ │ 하위 의존성 2 │ |
| 30 | + │ └────────┬───────┘ └────────┬───────┘ |
| 31 | + │ │ │ |
| 32 | + │ ┌─────────────┴────────────┐ ┌─────────┴─────────┐ |
| 33 | + │ ▼ ▼ ▼ ▼ |
| 34 | + │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ |
| 35 | + └───▶ │ 더 하위 1-1 │ │ 더 하위 1-2 │ │ 더 하위 2-1 │ |
| 36 | + └────────────────┘ └────────────────┘ └────────────────┘ |
| 37 | +``` |
| 38 | + |
| 39 | +- 테스트 더블을 사용하면 테스트를 더 빠르게 실행할 수 있고, 더 간단해진다 |
| 40 | + |
| 41 | +```txt correct |
| 42 | +┌───────────────┐ ┌─────────────────┐ |
| 43 | +│ 테스트 코드 │ ───▶ │ 테스트 대상 코드 │ |
| 44 | +└───────────────┘ └─────────────────┘ |
| 45 | + │ │ |
| 46 | + │ ▼ |
| 47 | + │ ┌────────────────┐ |
| 48 | + └─────────────────▶ │ Dependency의 │ |
| 49 | + │ 테스트 더블 │ |
| 50 | + └────────────────┘ |
| 51 | +``` |
| 52 | + |
| 53 | +#### 테스트로부터 외부 세계 보호 |
| 54 | +- 결제를 처리하는 시스템을 사용하고 고객의 은행게좌에서 돈을 인출하는 코드를 테스트한다고 가정해 보자. |
| 55 | +- 코드가 실제로 실행되면 실제 계좌에서 돈이 실제로 인출될 것이고, 이것은 큰 문제를 일으킬 수 있기에 절대 이렇게 수행되면 안 된다. |
| 56 | + |
| 57 | +```txt incorrect |
| 58 | +┌───────────────┐ |
| 59 | +│ 테스트 코드 │ ───────────────┐ |
| 60 | +└───────────────┘ │ |
| 61 | + │ │ |
| 62 | + ▼ ▼ |
| 63 | + ┌────────────────┐ ┌────────────────┐ ┌─────────────────────┐ |
| 64 | + │ 테스트 대상 코드 │ ────▶ │ BankAccount │ ────▶ │ 실제 은행의 백엔드 시스템 │ |
| 65 | + └────────────────┘ └────────────────┘ └─────────────────────┘ |
| 66 | +``` |
| 67 | + |
| 68 | +- 테스트 더블을 사용하면 테스트로부터 외부 세계를 보호할 수 있다. |
| 69 | + |
| 70 | +```txt correct |
| 71 | +┌───────────────┐ │ |
| 72 | +│ 테스트 코드 │ ───────────────┐ │ |
| 73 | +└───────────────┘ │ │ |
| 74 | + │ │ │ |
| 75 | + ▼ ▼ │ |
| 76 | +┌────────────────┐ ┌────────────────┐ │ ┌─────────────────────┐ |
| 77 | +│ 테스트 대상 코드 │ ────▶ │ BankAccount의 │ │ │ 실제 은행의 백엔드 시스템 │ |
| 78 | +│ │ │ 테스트 더블 │ │ │ │ |
| 79 | +└────────────────┘ └────────────────┘ │ └─────────────────────┘ |
| 80 | +``` |
| 81 | + |
| 82 | +- 테스트 값이 실제 서버에 표시된다면 발생할 수 있는 문제들 |
| 83 | + - 사용자는 이상하고 혼란스러운 값을 볼 수 있다. |
| 84 | + - 모니터링 및 로깅에 영향을 미칠 수 있다. |
| 85 | + |
| 86 | +#### 외부로부터 테스트 보호 |
| 87 | +- 위의 동일한 상태에서 잔액을 가져오는 테스트를 진행해 본다. |
| 88 | +- 실제 은행에서 돈을 입금하거나 인출하게 된다면 값이 변경이 되고, 이것은 테스트의 실패로 이어진다. |
| 89 | + |
| 90 | +### 10.4.2 목 |
| 91 | +- 목은 멤버 함수가 호출될 때 인수에 제공되는 값을 기록하는 것 외에는 어떠한 일도 수행하지 않는다. |
| 92 | +- 테스트 대상 코드에서 Side Effect를 일으키는 의존성을 시뮬레이션하는데 가장 유용하다. |
| 93 | +```java |
| 94 | +class PaymentManager { |
| 95 | + ... |
| 96 | + |
| 97 | + PaymentResult settleInvoice( |
| 98 | + BankAccount customerBankAccount, |
| 99 | + Invoice invoice) { |
| 100 | + customerBankAccount.debit(invoice.getBalance()); // 계좌로부터 청구서의 잔액만큼 인출, 테스트해야 하는 동작 |
| 101 | + return PaymentResult.paid(invoice.getId()); |
| 102 | + } |
| 103 | +} |
| 104 | +``` |
| 105 | + |
| 106 | +- BankAccount 인터페이스 구현 |
| 107 | + |
| 108 | +```java incorrect |
| 109 | +interface BankAccount { |
| 110 | + void debit(MonetaryAmount amount); |
| 111 | + void credit(MonetaryAmount amount); |
| 112 | + MonetaryAmount getBalance(); |
| 113 | +} |
| 114 | + |
| 115 | +class BankAccountImpl implements BankAccount { |
| 116 | + private final BankingBackend backend; // 실제 계좌와 연결 |
| 117 | + ... |
| 118 | + override void debit(MonetaryAmount amount) { ... } |
| 119 | + override void credit(MonetaryAmount amount) { ... } |
| 120 | + override MonetaryAmount getBalance() { ... } |
| 121 | +} |
| 122 | +``` |
| 123 | + |
| 124 | +- 목을 사용하는 테스트 케이스 |
| 125 | + |
| 126 | +```java correct |
| 127 | +void testSettleInvoice_accountDebited() { |
| 128 | + BankAccount mockAccount = createMock(BankAccount); // 목 객체 생성 |
| 129 | + MonetaryAmount invoiceBalance = new MonetaryAmount(5.0, Currency.USD); |
| 130 | + Invoice invoice = new Invoice(invoiceBalance, "test-id"); |
| 131 | + PaymentManager paymentManager = new PaymentManager(); |
| 132 | + |
| 133 | + paymentManager.settleInvoice(mockAccount, invoice); |
| 134 | + |
| 135 | + verifyThat(mockAccount.debit) |
| 136 | + .wasCalledOnce() |
| 137 | + .withArguments(invoiceBalance); |
| 138 | +} |
| 139 | +``` |
| 140 | + |
| 141 | +- 테스트로부터 외부 세계를 보호하는 데에는 성공했지만 테스트가 비현실적이고 중요한 버그를 잡지 못할 위험이 있다. |
| 142 | + |
| 143 | +### 10.4.3 스텁 |
| 144 | +- 스텁은 함수가 호출되면 미리 정해 놓은 값을 반환함으로써 함수를 시뮬레이션하여 의존성을 시뮬레이션할 수 있다. |
| 145 | +- 목과 스텁은 분명한 차이가 있지만, 보통 목을 말할 때는 둘다를 지칭하고, 특정 멤버 함수를 스텁하는 데만 사용하고자 할 때조차 목을 만들어야 한다. |
| 146 | + |
| 147 | +```java |
| 148 | +class PaymentManager { |
| 149 | + ... |
| 150 | + |
| 151 | + PaymentResult settleInvoice( |
| 152 | + BankAccount customerBankAccount, |
| 153 | + Invoice invoice) { |
| 154 | + if (customerBankAccount.getBalance().isLessThan(invoice.getBalance())) { |
| 155 | + return PaymentResult.insufficientFunds(invoice.getId()); |
| 156 | + } |
| 157 | + customerBankAccount.debit(invoice.getBalance()); |
| 158 | + return PaymentResult.paid(invoice.getId()); |
| 159 | + } |
| 160 | +} |
| 161 | +``` |
| 162 | + |
| 163 | +- 계좌 잔액이 없을 때 추가된 분기 처리에 대한 테스트 케이스 |
| 164 | + |
| 165 | +```java correct |
| 166 | +void testSettleInvoice_insufficientFundsCorrectResultReturned() { |
| 167 | + MonetaryAmount invoiceBalance = new MonetaryAmount(10.0, Currency.USD); |
| 168 | + Invoice invoice = new Invoice(invoiceBalance, "test-id"); |
| 169 | + BankAccount mockAccount = createMock(BankAccount.class); // 스텁만 필요하지만 BankAccount 목 객체 생성 |
| 170 | + when(mockAccount.getBalance()).thenReturn(new MonetaryAmount(9.99, Currency.USD)); |
| 171 | + PaymentManager paymentManager = new PaymentManager(); |
| 172 | + |
| 173 | + PaymentResult result = paymentManager.settleInvoice(mockAccount, invoice); |
| 174 | + |
| 175 | + assertThat(result.getStatus()).isEqualTo(INSUFFCIENT_FUNDS); |
| 176 | +} |
| 177 | +``` |
| 178 | + |
| 179 | +- 이것 또한 문제가 될 수 있다. |
| 180 | + |
| 181 | +### 10.4.4 목과 스텁은 문제가 될 수 있다. |
| 182 | + |
| 183 | +#### 목과 스텁은 실제적이지 않은 테스트를 만들 수 있다 |
| 184 | +- 예외를 던질 수 있는 실제와는 다르게 함수의 호출만을 기록하기 때문에, 코드의 버그가 발생하지 않을 수 있다. |
| 185 | +- 개발자가 실제 의존성이 어떻게 동작하는지 이해하지 못하면 목을 설정할 때 실수를 할 가능성이 크다. |
| 186 | + |
| 187 | +```java incorrect |
| 188 | +void testSettleInvoice_negativeInvoiceBalance() { |
| 189 | + BankAccount mockAccount = createMock(BankAccount.class); |
| 190 | + MonetaryAmount invoiceBalance = new MonetaryAmount(-5.0, Currency.USD); |
| 191 | + Invoice invoice = new Invoice(invoiceBalance, "test-id"); |
| 192 | + PaymentManager paymentManager = new PaymentManager(); |
| 193 | + |
| 194 | + paymentManager.settleInvoice(mockAccount, invoice); |
| 195 | + // 동작이 잘 작동한다. |
| 196 | + |
| 197 | + verifyThat(mockAccount.debit) |
| 198 | + .wasCalledOnce() |
| 199 | + .withArguments(invoiceBalance); |
| 200 | +} |
| 201 | +``` |
| 202 | + |
| 203 | +- 실제 코드의 조건 |
| 204 | + |
| 205 | +```java |
| 206 | +interface BankAccount { |
| 207 | + /** |
| 208 | + * @throws ArgumentException 0보다 적은 금액으로 호출되는 경우 |
| 209 | + */ |
| 210 | + void debit(MonetaryAmount amount); |
| 211 | + |
| 212 | + /** |
| 213 | + * @throws ArgumentException 0보다 적은 금액으로 호출되는 경우 |
| 214 | + */ |
| 215 | + void credit(MonetaryAmount amount); |
| 216 | + |
| 217 | + ... |
| 218 | +} |
| 219 | + |
| 220 | +interface BankAccount { |
| 221 | + ... |
| 222 | + |
| 223 | + /** |
| 224 | + * @return 가장 가까운 10의 배수로 반내림한 계좌의 잔액 |
| 225 | + * 예를 들어 실제 잔액이 19달러라면 이 함수는 10달러를 반환한다. |
| 226 | + * 이것은 보안을 위한 것인데 정확한 잔액은 보안 확인을 위한 질문으로 |
| 227 | + * 은행이 사용하기 때문이다. |
| 228 | + */ |
| 229 | + MonetaryAmount getBalance(); |
| 230 | + // 위의 스텁 예제에서 스텁을 구성할 때 주석문에 있는 내용을 간과했다. |
| 231 | +} |
| 232 | +``` |
| 233 | + |
| 234 | +#### 목과 스텁을 사용하면 테스트가 구현 세부 정보에 유착될 수 있다 |
| 235 | +- 음수의 값이 들어온다면 입금이라는 가정을 추가하여 코드를 작성 |
| 236 | +```java |
| 237 | +PaymentResult settleInvoice(...) { |
| 238 | + ... |
| 239 | + MonetaryAmount balance = invoice.getBalance(); |
| 240 | + if (balance.isPositive()) { |
| 241 | + customerBankAccount.debit(balance); |
| 242 | + } else { |
| 243 | + customerBankAccount.credit(balance.absoluteAmount()); |
| 244 | + } |
| 245 | + ... |
| 246 | +} |
| 247 | + |
| 248 | +// 기존 테스트 코드 |
| 249 | +void testSettleInvoice_positiveInvoiceBalance() { |
| 250 | + ... |
| 251 | + verifyThat(mockAccount.debit) |
| 252 | + .wasCalledOnce() |
| 253 | + .withArguments(invoiceBalance); |
| 254 | +} |
| 255 | + |
| 256 | +... |
| 257 | + |
| 258 | +void testSettleInvoice_negativeInvoiceBalance() { |
| 259 | + ... |
| 260 | + verifyThat(mockAccount.credit) |
| 261 | + .wasCalledOnce() |
| 262 | + .withArguments(invoiceBalance.absoluteAmount()); |
| 263 | +} |
| 264 | +``` |
| 265 | + |
| 266 | +- 음수 여부를 판단하기 위한 if-else문의 반복으로 인해 transfer라는 함수로 리팩토링하고 인터페이스에 추가 |
| 267 | + |
| 268 | +```java incorrect |
| 269 | +interface BankAccount { |
| 270 | + ... |
| 271 | + /** |
| 272 | + * 지정된 금액을 계좌로 송금한다. 금액이 0보다 적으면 |
| 273 | + * 계좌로부터 인출하는 효과를 갖는다. |
| 274 | + */ |
| 275 | + void transfer(MonetaryAmount amount); |
| 276 | +} |
| 277 | + |
| 278 | +PaymentResult settleInvoice(...) { |
| 279 | + ... |
| 280 | + MonetaryAmount balance = invoice.getBalance(); |
| 281 | + customerBankAccount.transfer(balance.negate()); |
| 282 | + ... |
| 283 | +} |
| 284 | +``` |
| 285 | + |
| 286 | +- 기존에 테스트는 debit이나 credit 함수가 호출되는지 확인하는 목을 사용하고 있었고, 이 상황에서는 불리지 않기 때문에 테스트는 실패한다. |
| 287 | + |
| 288 | +### 10.4.5 페이크 |
| 289 | +- 페이크는 클래스의 대체 구현체로 테스트에서 안전하게 사용할 수 있다. |
| 290 | +- 외부 시스템과 통신하는 대신 페이크 내의 멤버 변수에 상태를 저장한다. |
| 291 | +- 페이크의 요점은 코드 계약이 실제 의존성과 동일하기 때문에 실제 클래스가 특정 입력을 받아들이지 않는다면 페이크도 마찬가지라는 것이다. |
| 292 | +- 멤버 변수를 통해 상태를 추적한다. |
| 293 | +- 페이크 코드 예시 |
| 294 | +```java correct |
| 295 | +class FakeBankAccount implements BankAccount { |
| 296 | + private MonetaryAmount balance; |
| 297 | + |
| 298 | + FakeBankAccount(MonetaryAmount startingBalance) { |
| 299 | + this.balance = startingBalance; |
| 300 | + } |
| 301 | + |
| 302 | + override void debit(MonetaryAmount amount) { |
| 303 | + if (amount.isNegative()) { |
| 304 | + throw new ArgumentException("액수는 0보다 적을 수 없음"); |
| 305 | + } |
| 306 | + balance = balance.subtract(amount); |
| 307 | + } |
| 308 | + |
| 309 | + override void credit(MonetaryAmount amount) { |
| 310 | + if (amount.isNegative()) { |
| 311 | + throw new ArgumentException("액수는 0보다 적을 수 없음"); |
| 312 | + } |
| 313 | + balance = balance.add(amount); |
| 314 | + } |
| 315 | + |
| 316 | + override void transfer(MonetaryAmount amount) { |
| 317 | + balance.add(amount); |
| 318 | + } |
| 319 | + |
| 320 | + override MonetaryAmount getBalance() { |
| 321 | + return roundDownToNearest10(balance); |
| 322 | + } |
| 323 | + |
| 324 | + MonetaryAmount getActualBalance() { |
| 325 | + return balance; |
| 326 | + } |
| 327 | +} |
| 328 | +``` |
| 329 | + |
| 330 | +#### 페이크로 인해 보다 실질적인 테스트가 이루어질 수 있다 |
| 331 | +- 위에서 보았던 목을 사용하여 -5의 값을 더하는 코드는 예외를 발생시키지 않지만, 페이크를 사용한다면 예외를 발생시켜 테스트가 실패한다. |
| 332 | + |
| 333 | +#### 페이크를 사용하면 구현 세부 정보로부터 테스트를 분리할 수 있다 |
| 334 | +- 다른 테스트들과는 또 다른 이점은 테스트가 구현 세부 사항에 밀접하게 결합하는 정도가 덜 하다는 것이다. |
| 335 | +- 구현 세부 사항 대신 최종 계정 잔액이 정확한지 확인한다. |
| 336 | + - 결과가 같다면 테스트가 통과되기에, 구현 세부 사항과 관련해서 훨씬 더 독립적이다. |
| 337 | + - 동작을 변경하지 않는 한 테스트는 실패하지 않는다. |
| 338 | +```java correct |
| 339 | +assertThat(fakeAccount.getActualBalance()) |
| 340 | + .isEqualTo(new MonetaryAmount(105.0, Currency.USD)); |
| 341 | +``` |
| 342 | + |
| 343 | +### 10.4.6 목에 대한 의견 |
| 344 | +- **목 찬성론자**(런던 학파) : 목과 스텁을 사용해서 의존성을 실제로 사용하는 것을 피해야 한다. |
| 345 | +- **고전주의자**(디트로이트 학파) : 목과 스텁은 최소한으로 사용되어야 하고 개발자는 테스트에서 의존성을 실제로 사용하는 것을 최우선으로 해야 한다. |
| 346 | + |
| 347 | +- 목 접근법은 시험 대상 코드가 **어떻게** 하는가를 확인하는 반면, 고전주의 접근법은 코드를 실행하는 최종 결과가 **무엇인지** 확인하는 경향이 있다. |
0 commit comments