Test Driven Develop - 2
화폐 예제
0장. 테스트 주도 개발(TDD)의 리듬
- 재빨리 테스트를 하나 추가한다.
- 모든 테스트를 실행하고 새로 추가한 것이 실패하는지 확인한다.
- 코드를 조금 바꾼다.
- 모든 테스트를 실행하고 전부 성공하는지 확인한다.
- 리팩토링을 통해 중복을 제거한다.
1장. 다중 통화 지원 Money 객체
테스트 항목
- 통화가 다른 두 금액을 더해서 주어진 환율에 맞게 변한 금액을 결과로 얻을 수 있어야 한다.
- 어떤 금액(주가)을 어떤 수(주식의 수)에 곱한 금액을 결과로 얻을 수 있어야 한다.
테스트 코드
- 테스트 작성시 오퍼레이션의 완벽한 인터페이스에 대해 상상해보는 것이 좋다.
- 실패하는 테스트가 주어진 상태이고, 최대한 빨리 초록 막대를 보고 싶을 뿐이다.
- 당장의 목표는 완벽한 해법을 구하는 것이 아니라 테스트를 통과하는 것일 뿐이다.
// 간단 곱셈 테스트 코드 예시
public void testMultiplication() {
Dollar five = new Dollar(5);
five.times(2);
assertEquals(10, five.amount);
}
// 에러1. Dollar 클래스 없음
class Dollar
// 에러2. 생성자 없음
Dollar(int amount){}
// 에러3. times(int) 메서드 없음
void times(int multiplier){}
// 에러4. amount 필드 없음
int amount
의존성과 중복
- (스티븐 프리만) 테스트와 코드 간의 문제는 중복이 아니다. 문제는 테스트와 코드 사이에 존재하는 의존성이다.
- 즉, 코드나 테스트 중 한쪽을 수정하면 반드시 다른 한쪽도 수정해야만 한다는 것이다.
- 우리의 목표는 코드를 바꾸지 않으면서도 뭔가 의미 있는 테스트를 하나 더 작성하는 것인데, 현재의 구현으로는 불가능하다.
- 의존성이 문제 그 자체라면 중복(duplication)은 문제의 징후이다.
- 중복된 로직이란 동일한 문장이 코드의 여러 장소에 나타나는 것을 의미하며, 중복된 로직을 하나로 끄집어내는 일엔 객체를 이용하는 것이 최고다.
- 프로그램에서는 중복만 제거해주면 의존성도 제거된다. 이게 바로 TDD 의 두 번째 규칙이 존재하는 이유이다.
중복 제거
// 5와 2가 두 곳에 존재 (테스트에 있는 데이터와 코드에 있는 데이터 사이 = 테스트와 코드 사이에 존재하는 의존성)
int amount = 5 * 2;
- 5와 2가 두 곳에 존재한다. 이 중복을 제거해야 한다.
- 객체의 초기화 단계에 있는 설정 코드를 times() 메서드 안으로 옮긴다.
- TDD의 핵심은 이런 작은 단계를 밟아 나가며, 이런 작은 단계를 밟을 능력을 갖추는 것이다.
int amount
void times(int multiplier) {
amount = 5 * 2;
}
// 5를 어디서 얻을 수 있을까?? 이건 생성자에서 넘어오는 값이니 amonut 변수에 저장
Dollar(int amount) {
this.amount = amount;
}
// amount 가져다 사용 가능
void times(int multiplier) {
amount = amount * 2;
}
// multiplier 의 값이 2이므로, 상수를 이 인자로 대체
void times(int multiplier) {
amount *= multiplier;
}
2장. 타락한 객체
일반적인 TDD 주기
- 테스트 작성
- 실행 가능하게 만들기 (가장 중요한 것은 빨리 초록 막대를 보는 것)
- 올바르게 만들기 (직전에 저질렀던 죄악을 수습)
작동하는 깔끔한 코드 얻기
public void testMultiplication() { // 곱셈 테스트
Dollar five = new Dollar(5);
// [문제] times() 를 호출한 이후에 five 는 더 이상 5가 아니다. 그러면 새로운 객체를 반환하게 만들면 어떨까?
five.times(2);
assertEquals(10, product.amount);
five.times(3);
assertEquals(15, product.amount);
}
Dollar five = new Dollar(5);
// 메서드에 반환값이 없기 때문에 컴파일조차 되지 않는다.
Dollar product = five.times(2);
assertEquals(10, product.amount);
product = five.times(3);
assertEquals(15, product.amount);
// 아래 같이 수정해야 컴파일이 된다. 하지만 실행되지 않는다.
Dollar times(int multiplier) {
amount *= multiplier;
return null;
}
빠르게 초록색 보는 전략
- 가짜로 구현하기 : 상수를 반환하게 만들고, 진짜 코드를 얻을 때까지 단계적으로 상수를 변수로 변경
- 명백한 구현 사용하기 : 실제 구현을 입력
학습 내용 검토
- 설계상 결함(Dollar 부작용)을 그 결함으로 인해 실패하는 테스트로 변환
- 스텁 구현으로 빠르게 컴파일을 통과하도록 구현
- 올바르다고 생각하는 코드를 입력하여 테스트를 통과
느낌(부작용에 대한 혐오감)을 테스트(하나의 Dollar 객체에 곱하기 두 번 수행)로 변환하는 것은 TDD의 일반적 주제
3장. 모두를 위한 평등
값 객체 패턴 (Value Object Pattern)
- 지금의 Dollar 객체같이 객체를 값처럼 쓸 수 있는 방식
- 객체의 인스턴스 변수가 생성자를 통해서 일단 설정된 후 결코 변하지 않는 것이 제약사항
- 값 객체를 사용하면 별칭 문제에 대해 걱정할 필요가 없다는 것이 아주 큰 장점
- 두 번째 수표의 값 변경시 첫 번째 수표의 값까지 변경되는 것이 별칭 문제
값 객체는 equals() 를 구현
// 빨간 막대
public void testEquality() {
assertTrue(new Dollar(5).equals(new Dollar(5)));
}
// 가짜로 구현
public boolean equals(Object object) {
return true;
}
삼각측량법
- 만약 라디오 신호를 두 수신국이 감지
- 수신국 사이의 거리가 알려져 있고, 각 수신국이 신호의 방향을 알고 있다면,
- 이 정보들만으로 충분히 신호의 거리와 방위를 알 수 있음
public void testEquality() {
assertTrue(new Dollar(5).equals(new Dollar(5)));
assertFalse(new Dollar(5).equals(new Dollar(6)));
}
// 동치성 일반화 => Dollar 와 Dollar 를 직접 비교 가능
public boolean equals(Object object) {
Dollar dollar = (Dollar) object;
return amount == dollar.amount;
}
4장. 프라이버시
amount 를 private 으로
// 개념적으로 Dollar.times() 연산은 호출받은 객체의 값에 인자로 받은 곱수만큼 곱한 값을 갖는 Dollar 를 반환해야 함
// 하지만 테스트가 정확히 그것을 의미하지 않음
public void testMultiplication() {
Dollar five = new DOllar(5);
Dollar product = five.times(2);
assertEquals(10, product.amount); // Dollar 를 반환하지 않는 것을 의미하는 부분
product = five.times(3);
assertEquals(15, product.amount);
}
// 단언(assertion)을 Dollar와 Dollar를 비교하는 것으로 재작성
public void testMultiplication() {
Dollar five = new Dollar(5);
assertEquals(new Dollar(10), five.times(2));
assertEquals(new Dollar(15), five.times(3));
}
// 테스트를 고치고 나니 이제 Dollar의 amount 인스턴스 변수를 사용하는 코드는 Dollar 자신밖에 없어서, private 로 변경
- 만약 동치성 테스트가 동치성에 대한 코드가 정확히 작동한다는 것을 검증하는 데 실패한다면, 곱하기 테스트 역시 실패한다.
- 이것은 TDD 를 하면서 적극적으로 관리해야 할 위험요소다. 우리는 완벽함을 위해 노력하지 않는다.
- 모든 것을 두 번 말함으로써(코드와 테스트로 한 번씩) 자신감을 가지고 전진할 수 있을 만큼만 결함의 정도를 낮추길 희망한다.
- 그럼에도 결함이 생긴다면, 테스트를 어떻게 작성해야 했는지에 대한 교훈을 얻고 다시 앞으로 나아간다.
5장. 솔직히 말하자면
5달러 + 10프랑 = 10달러 (환율 고려)
// Dollar 객체와 비슷하게 작동하는 Franc 라는 객체
public void testFrancMultiplication() {
Franc five = new Franc(5);
assertEquals(new Franc(10), five.times(2));
assertEquals(new Franc(15), five.times(3));
}
// Dollar 코드를 복사해서 Dollar 를 Franc 로 변경
// 1. 테스트 작성
// 2. 컴파일되게 하기
// 3. 실패하는지 확인하기 위해 설명
// 4. 실행하게 만듦
// 5. 중복 제거
- 각 단게에는 서로 다른 목적이 있다.
- 처음 네 단계는 빨리 진행해야 한다. 그러면 새 기능이 포함되더라도 잘 알고 있는 상태에 이를 수 있다.
- 주기의 다섯 번째 단계 없이는 앞의 네 단계도 제대로 되지 않는다.
- 적절한 시기에 적절한 설계를 해라. 돌아가게 만들고, 올바르게 만들어라.
class Franc {
private int amount;
Franc(int amount) {
this.amount = amount;
}
Franc times(int multiplier) {
return new Franc(amount * multiplier);
}
public boolean equals(Object object) {
Franc franc = (Franc) object;
return amount == franc.amount;
}
}
// 중복이 엄청 많다. equals() 를 일반화하는 것부터 시작하자.
6장. 돌아온 ‘모두를 위한 평등’
선 테스트통과 후 청소
- 5장에서 실제로 새로운 테스트 케이스를 하나 작동하게 만듦
- 하지만 테스트를 빨리 통과하기 위해 몇 톤이나 되는 코드를 복사해서 붙이는 엄청난 죄를 저지름
- 이제 청소할 시간
// !! 두 클래스의 공통 상위 클래스 찾기
// Money - Dollar, Franc
// Money 클래스가 공통의 equals 코드 갖게 하면 어떨까?
class Money
// Dollar 가 Money 를 상속
class Dollar extends Money {
private int amount;
}
// amount 인스턴스 변수를 Money 로 옮김
// 하위 클래스에서도 변수를 볼 수 있도록 가시성을 private 에서 protected 로 변경
class Money {
protected int amount;
}
class Dollar extends Money { }
// equals() 코드를 위(class Money)로 올리기
// 케스트(cast) 부분 변경 및 임시 변수 명칭 변경
// 이러면 이제 equals() 메서드를 Dollar 에서 Money 로 옮길 수 있다.
public boolean equals(Object object) {
Money money = (Money) object; // before : Money dollar = (Dollar) object;
return amount = money.amount;
}
Franc.equals() 제거
// 테스트 작성 (Dollar 테스트 복사)
// 또 중복 존재 (두 줄이나 더!)
public void testEquality() {
assertTrue(new Dollar(5).equals(new Dollar(5)));
assertFalse(new Dollar(5).equals(new Dollar(6)));
assertTrue(new Franc(5).equals(new Franc(5)));
assertFalse(new Franc(5).equals(new Franc(6)));
}
// Money 상속 및 amount 필드 제거
class Franc extends Money {
private int amount;
}
class Franc extends Money { }
// Franc.equals() 는 Money.equals() 와 거의 비슷
// Franc 의 불필요한 코드 제거 (Money 로 옮김)
public boolean equals(Object object) {
Money money = (Money) object; // before : Money franc = (Franc) object;
return amount == money.amonut;
}
- 공통된 코드를 첫 번째 클래스(Dollar) 에서 상위 클래스(Money) 로 단계적으로 옮김 (Franc 도 마찬가지)
- 불필요한 구현을 제거하기 전에 두 equals() 구현을 일치시킴
7장. 사과와 오렌지 (서로 다른 걸 비교할 수 없다)
// Franc 과 Dollar 를 비교
public void testEquality() {
assertFalse(new Franc(5).equals(new Dollar(5)));
}
// 동치성 코드에서는 Dollar 가 Franc 과 비교되지 않는지 검사해야 함
// 두 객체의 클래스를 비교해서, 오직 금액과 클래스가 서로 동일할 때만 두 Money 가 서로 같은 것이다.
// 혼합된 통화 간의 연산에 대해 다루어야 함
public boolean equals(Object object) {
Money money = (Money) object;
return amount == money.amount && getClass().equals(money.getClass());
}
8장. 객체 만들기
팩토리 메서드 패턴 (Factory Method Pattern)
- 객체 생성(인스턴스화)을 서브클래스에 위임하여, 클래스 간의 결합도를 낮추는 생성 디자인 패턴
- 즉, 객체를 직접 생성하지 않고, 객체 생성을 위한 인터페이스만 정의하고, 실제 생성은 서브클래스에서 담당
- 객체 생성을 캡슐화하여, 코드의 유연성과 확장성을 높인다.
- 장점
- 확장성 향상 : 새로운 객체 생성 로직이 필요할 때, 기존 코드를 수정하지 않고 새로운 팩토리 클래스만 만들면 됨
- 느슨한 결합 : 클라이언트는 어떤 구체적인 클래스가 생성되는지 몰라도 됨
- 단점
- 클래스 수 증가 : 구체 팩토리 및 제품 클래스가 많아질 수 있음
- 과한 구조 : 단순 객체 생성에는 과한 구조
// 1. Product
public interface Animal {}
// 2. Concrete Product
public class Dog implements Animal {}
public class Cat implements Animal {}
// 3. Creator
public abstract class AnimalFactory {
public abstract Animal createAnimal();
}
// 4. Concrete Creator
public class DogFactory extends AnimalFactory {
public Animal createAnimal() {
return new Dog();
}
}
public class CatFactory extends AnimalFactory {
public Animal createAnimal() {
return new Cat();
}
}
// Client
public class Main {
public static void main(String[] args) {
AnimalFactory factory = new DogFactory(); // 팩토리 변경시 생성 객체 변경
Animal animal = factory.createAnimal();
}
}
두 times() 구현 코드가 거의 동일
Franc times(int multiplier) {
return new Franc(amount * multiplier);
}
Dollar times(int multiplier) {
return new Dollar(amount * multiplier);
}
// 양쪽 모두 Money 반환하게 만들어 더 비슷하게 만들 수 있음
Money times(int multiplier) {
return new Franc(amount * multiplier);
}
Money times(int multiplier) {
return new Dollar(amount * multiplier);
}
- Money 의 두 하위 클래스의 역할이 거의 없어, 제거하면 좋아보임
- 하위 클래스에 대한 직접적인 참조가 적어진다면 하위 클래스를 제거하기 위해 한 발짝 더 다가선 것
- Money 클래스에 Dollar 를 반환하는 팩토리 메서드(Factory Method) 도입 가능
// 테스트 코드
public void testMultiplication() {
Dollar five = Money.dollar(5);
assertEquals(new Dollar(10), five.times(2));
assertEquals(new Dollar(15), five.times(3));
}
// Money.dollar()
static Dollar dollar(int amount) {
return new Dollar(amount);
}
// Dollar 에 대한 참조가 사라지길 바라며 테스트 선언부 아래와 같이 변경
public void testMultiplication() {
Money five = Money.dollar(5);
assertEquals(new Dollar(10), five.times(2));
assertEquals(new Dollar(15), five.times(3));
}
// Money 에는 times() 정의되지 않아서, Money 를 추상 클래스로 변경 후 times() 선언
abstract class Money
abstract Money times(int multiplier);
// 팩토리 메서드 변경
static Money dollar(int amount) {
return new Dollar(amount);
}
// 테스트 코드에 팩토리 메서드 적용
public void testMultiplication() {
Money five = Money.dollar(5);
assertEquals(Money.dollar(10), five.times(2)); // 적용
assertEquals(Money.dollar(15), five.times(3)); // 적용
}
public void testEquality() {
assertTrue(Money.dollar(5).equals(Money.dollar(5))); // 적용
assertFalse(Money.dollar(5).equals(Money.dollar(6))); // 적용
assertTrue(new Franc(5).equals(new Franc(5)));
assertFalse(new Franc(5).equals(new Franc(6)));
assertFalse(new Franc(5).equals(Money.dollar(5))); // 적용
}
- 위처럼 어떤 클라이언트 코드도 Dollar 라는 이름의 하위 클래스가 있다는 사실을 모른다.
- 하위 클래스의 존재를 테스트에서 분리(decoupling)함으로써 어떤 모델 코드에도 영향을 주지 않고 상속 구조를 마음대로 변경할 수 있게 됐다.
// Franc 도 팩토리 메서드 적용
static Money franc(int amount) {
return new Franc(amount);
}
9장. 우리가 사는 시간(time = 곱하기)
통화 개념을 어떻게 테스트할까?
public void testCurrency() {
assertEquals("USD", Money.dollar(1).currency());
assertEquals("CHF", Money.franc(1).currency());
}
// Money 에 currency() 메서드 선언 및 하위 클래스에 구현
// Money
abstract String currency();
// Franc
String currency() {
return "CHF";
}
// Dollar
String currency() {
return "USD";
}
- 두 클래스를 모두 포함할 수 있는 동일한 구현이 되어야 한다.
- 통화를 인스턴스 변수에 저장하고, 메서드에서는 그냥 그걸 반환하게 만들 수 있게 (equals 테스트를 위해)
// Franc
private String currency;
Franc(int amount) {
this.amount = amount;
currency = "CHF";
}
String currency() {
return currency;
}
// Dollar
private String currency;
Dollar(int amount) {
this.amount = amount;
currency = "USD";
}
String currency() {
return currency;
}
- 두 currency() 가 동일하므로, 변수 선언과 currency() 구현을 둘다 위로 올릴 수 있게 됐다.
// Money
protected String currency;
String currency() {
return currency;
}