반응형

* 본 아티클은, '좋은 코드 나쁜 코드' 10장을 기반으로 작성된 글입니다.

 

 

 

 


테스트 더블

테스트하는 코드가 다른 것들에 의존하고, 의존성을 실제로 사용하는 것이 불가능하거나 바람직하지 않은 경우가 있다.

이런 경우에, 의존성을 실제로 사용하지 않고 테스트 더블을 사용할 수 있다.

테스트 더블은 테스트에 적합한 형태로 의존성을 시뮬레이션하는 객체이다.

테스트 더블을 사용하는 이유

  • 테스트 단순화 : 의존성 설정, 하위 의존성 설정, 구현 세부 사항과의 결합을 없앤다.
  • 테스트로부터 외부 세계 보호 : 의존하는 것에 대한 부수 효과를 방지한다.
    • 실제 서버 요청, 실제 DB 변경 등을 막는다.
  • 외부로부터 테스트 보호
    • DB의 값을 읽어서 테스트한다 → DB의 값이 바뀔 수 있다!

목(Mock)

목은 클래스나 인터페이스를 시뮬레이션하여, 단순히 멤버 함수에 대한 호출만 기록한다.

특정 함수가 특정 인자를 가지고 호출되었는지만 판단하는 형태로 테스트를 진행할 수 있다.

void testSettleInvoice_accountDebited() {
	BankAccount mockAccount = createMock(BankAccount); // BankAccount의 목 객체 생성
	...
	paymentManager.settleInvoice(mockAccount, invoice);
	
	verifyThat(mockAccount.debit)
			.wasCalledOnce()
			.withArguments(invoiceBalance)

테스트로부터 외부 세계를 보호했다.

하지만, 테스트가 비현실적이고, 중요한 버그를 놓칠 수 있다.

또한, 구현 세부 사항에 의존한다.

리팩터링을 통해 settleInvoice() 함수가 BankAccount의 debit() 함수를 호출하지 않게 될 수도 있다.

스텁을 알아본 후, 그 문제점을 아래에서 더 살펴볼 것이다.

스텁(Stub)

스텁은 함수가 호출되면 미리 정해 높은 값을 반환하는 방식이다.

목과 스텁은 차이가 있지만, 개발자들이 일상적으로 목이라고 말할 때는 둘 다 지칭한다.

그리고 많은 테스트 도구에서 목을 만들어야 스텁할 수 있다.

PaymentManager.settleInvoice() 함수에, 잔액이 부족한지 확인하는 로직이 추가되었다고 가정하자.

그리고 이를 스텁으로 테스트해보자.

void testSettleInvoice_insufficientFundsCorrectResultReturned() {
	MonetaryAmount invoiceBalance = new MonetaryAmount(10.0, Currency.USD);
	Invoice invoice = new Invoice(invoiceBalance, "test-id");
	BankAccount mockAccount = createMock(BankAccount); // BankAccount의 목 객체 생성
	when(mockAccount.getBalance())
			.thenReturn(new MonetaryAmount(9.99, Currency.USD)); // 스텁을 설정하여, mockAccount.getBalance()가 항상 9.99달러를 반환하도록 한다.
	PaymentManager paymentManager = new PaymentManager();
	PaymentResult paymentResult =	paymentManager.settleInvoice(mockAccount, invoice);
	
	assertThat(result.getStatus()).isEqualTo(INSUFFICIENT_FUNDS);
}

스텁을 통해 외부로부터 테스트를 보호했다.

하지만 이 또한 단점이 있다.

아래에서 목과 스텁의 단점을 알아보자.

목과 스텁의 문제

  • 목이나 스텁이 실제 의존성과 다른 방식으로 동작하도록 설정되면 → 잘못된 테스트를 할 수 있다.
  • 구현 세부 사항과 테스트가 밀접하게 결합한다.
  • 잘못된 테스트
    • 목은 단순히 호출 여부만 테스트하여, 잘못되는 경우를 테스트하기 어렵다.
      • 음수 값의 Invoice가 입력되는 경우, BankAccount가 예외를 발생시킬 수 있지만, mockAccount에서는 문제가 되지 않는다.
    • 스텁을 통해 반환 받는 값 또한, 실제 의존성 코드가 반환하는 값인지 검증하지 않는다.
  • 구현 세부 정보와 결합한다.
    • 내부에서 호출되는 함수가 바뀔 수 있다. → 리팩터링이 잘 되었더라도, 테스트가 실패할 수 있다.

따라서,

실제 의존성을 사용하거나 → 페이크를 사용하거나(아래에서 알아보자) → 가능한 대안이 없다면 목이나 스텁을 사용

위와 같은 순서로 생각하는 것이 좋다.

페이크

페이크는 실제 의존성의 공개 API를 테스트 용으로 단순하게 구현한 객체이다.

외부 시스템과 통신하는 대신, 페이크 내의 멤버 변수에 상태를 저장한다.

실제 의존성에 대한 코드를 유지보수하는 팀이, 페이크 코드도 유지보수해야 한다.

예시 코드를 아래에서 알아보자.

class FakeBankAccount implements BankAccount {
	private MonetaryAmount balance;

	FakeBankAccount(MonetaryAmount startingBalance) {
		this.balance = startingBalance;
	}
	
	override void debit(MonetaryAmount amount) {
		if (amount.isNegative()) {
			throw new ArgumentException("액수는 0보다 적을 수 없다.");
		}
		balance = balance.subtract(amount);
	}
	
	...
	
}

위처럼 페이크 내부에 실제 의존성의 논리를 담을 수 있다.

또한, 구현 세부 정보로부터 테스트를 분리할 수 있다.

물론 이 또한, 아래와 같은 문제가 있을 수 있다 (개인 의견)

  • 페이크 객체를 유지보수해야 한다.
  • 페이크 객체 내부의 논리가, 테스트 하는 코드가 의존하는 코드의 논리와 일치해야 한다.

목과 스텁에 대한 추가 의견

  • 목 찬성론자 : ‘런던 학파’라고도 일컬어지며, 단위 테스트에서 의존성 실제 사용을 피해야 한다고 주장한다.
  • 고전주의자 : ‘디트로이트 학파’라고도 일컬어지며, 개발자는 테스트에서 의존성을 실제로 사용하는 것을 최우선으로 해야 한다고 주장한다. 그렇지 못할 때 페이크를 사용하고, 목과 스텁은 최후의 수단으로 사용되어야 한다고 주장한다.

목은 상호작용을 테스트한다. (How)

고전주의 방법은 결과를 테스트한다. (What)

목 사용을 지지하는 다음의 주장도 있다.

  • 단위 테스트가 더욱 격리된다.
    • 문제가 생긴 코드를 의존하는 코드는 실패하지 않는다.
  • 테스트 코드 작성이 쉬워진다.

고전주의적 접근법에 찬성하고, 목에 반대하는 다음의 주장도 있다.

  • 목은 호출을 확인할 뿐, 호출이 유효한지 검증하지 않는다. 코드에 문제가 있어도 테스트는 통과할 수 있다.
  • 고전적인 접근 방법은 구현 세부 사항에 독립적이다.

필자는, 목을 사용하는 주된 이유가 테스트 코드 작성이 쉬워지는 것 뿐이라고 말한다.

테스트 철학

  • 테스트 주도 개발(Test-Driven Development) : TDD는 실제 코드 작성 전 테스트 케이스를 먼저 작성하는 것을 지지한다. 실제 코드는 테스트만 통과하도록 최소한으로 작성하고, 이후 구조 개선 및 중복 제거 등의 리팩터링을 한다.
    • 테스트 케이스를 격리하고, 하나의 동작만 테스트하도록 집중하며, 구현 세부 사항을 테스트하지 않는 등 다른 원칙들을 지지한다.
  • 행동 주도 개발(Behavior-Driven Development) : BDD는 사용자, 고객, 비즈니스 관점에서 소프트웨어의 행동을 식별하는 데 집중한다.
  • 수용 테스트 주도 개발(Acceptance Test-Driven Development) : ATDD는 BDD와 비슷한데, 고객 관점에서 소프트웨어가 보여줘야 하는 동작을 식별하고, 소프트웨어가 전체적으로 필요에 따라 작동하는지 검증하기 위해 자동화된 수락 테스트를 만든다.
    • TDD와 마찬가지로, 실제 코드 구현 전 테스트를 생성해야 한다.
      • 이론적으로 Acceptance test가 모두 통과하면 소프트웨어는 고객이 수락하기에 완전한 것이다.

우리는 하나의 철학만 고수하고, 나머지를 배척할 것이 아니라,

많은 철학을 통해 옳다고 생각하는 바를 신중하게 선택하는 것이 좋다.

우리가 궁극적으로 달성하고자 하는 목표는 고품질의 테스트 코드를 철저하게 작성하고, 고품질의 소프트웨어를 생산하는 것이다.

반응형

+ Recent posts