8 ~ 11장의 궁극적인 목표: Dollar, Franc 중복 제거
저번 시간에 이어 times() 의 중복을 없애면 Dollar, Franc 클래스는 아무 일도 하지 않게 된다.
다만, 이 두 하위 클래스를 없애는 큰 단계를 한 번에 밟기보다는 우선 작은 단계부터 시작한다.
우선 하위 클래스(Dollar, Franc)에 대한 직접적인 참조를 줄이는 작은 작업을 먼저 한다.
좀 더 나아가서 Money.dollar 및 Money.franc 의 리턴 타입을 Money 로 올려 구체 타입에 대한 참조를 좀 더 없앤다.
이제 하위 클래스의 존재를 테스트 코드로부터 분리하여 어떤 모델 코드에도 영향을 주지 않고 상속 구조를 맘대로 변경할 수 있게 됐다.
다만, Money 에는 아직 times() 가 없으므로 10장에서는 이 메서드를 구현한다.
10장에서 각 자식 클래스에 있는 times() 를 상위 클래스인 Money 로 끌어올리기 전,
어떻게 하면 불필요한 하위 클래스(Dollar, Franc)를 제거하여 times() 를 상위 클래스로 끌어올릴 수 있을까 생각할 필요가 있다.
-> 클래스 대신 통화 개념(USD, CHF)을 이용하여 구분
이렇게 한 단계씩 과정을 밟아 나가다 보면 조금 답답할 수 있다.
그럴 때는 보폭을 넓혀 조금 큰 단계를 한 번에 진행하는 것도 좋은 방법이다.
반대로 너무 넓은 것 같으면 조금씩 줄여가며 진행할 수 있다.
마찬가지로 크게 중요하지 않다고 생각되는 부분은 건너 뛰면서 진행하는 중입니다.
통화 개념(USD, CHF)를 통해 구분하기 때문에 더 이상 하위 클래스(Dollar, Franc)가 필요 없다
이 장에서는 times, ==(equals) 메서드 2개를 리팩토링합니다.
이 상태에서 테스트를 돌려보면 잘 통과되던 테스트의 맨 마지막 줄에서 실패가 발생한다.
이는 타입으로 비교했던 이전 방식 때문이다.
currency (통화 개념)을 추가했으므로 이를 이용하는 방식으로 변경한다.
Money 에서 times 를 nil 을 반환하는 스텁으로 구현했으므로 테스트가 실패한다.
times 를 정상적으로 구현해준다.
하위 클래스 2개에 대해 각각 존재했던 테스트 코드인데,
현재는 하위 클래스가 모두 사라졌으므로 중복되는 테스트 코드로 판단하여 제거한다.
할일 목록
$5 + 10 CHF = $10 (환율이 2:1인 경우)
$5 + $5 = $10
이전과 같았으면 우선 테스트 통과를 위한 가짜 구현, 즉 plus 스텁 메서드를 만들었겠지만
앞으로는 이렇게 구현이 복잡하지 않고 명확한 경우에는 바로 구현한다.
이를 통해 TDD를 하면서 어떻게 단계의 크기를 조절할 수 있는지 배울 수 있다.
$5 + 5CHF 와 같은 다른 통화를 더하는 연산을 구현하는게 이 1부의 최종 목표다.
해법은 두 Money의 합(Sum)을 나타내는 객체를 만드는 것이다.
($2 + 3CHF) * 5 와 같은 수식이 있다면 2, 3을 나타내는 건 Money 가 된다.
그리고 연산의 결과로 Expression(인터페이스)들이 생기는데, 그 중 하나는 Sum(구현 클래스)이 될 것이다.
Sum과 같은 연산이 완료되면, 환율을 이용하여 결과를 단일 통화로 축약(reduced)할 수 있다.
이 개념을 테스트로 작성해본다. 마지막 줄부터 작성한다.
이 장에서 할 일은 $5 + $5 였으므로 여기에 맞춰 작성해준다.
func testSimpleAddition() {
...
XCTAssertEqual(Money.dollar(10), reduced)
}
여기서 reduced는 Expression에 환율을 적용함으로써 얻어진다.
여기서 Expression 은 '수식' 정도로 보시면 될 것 같습니다.
그러면 실세계에서 환율을 적용하는 곳은? -> 은행이다.
func testSimpleAddition() {
...
let bank = Bank()
let reduced = bank.reduce(sum, "USD")
XCTAssertEqual(Money.dollar(10), reduced)
}
두 Money 의 합(Sum)은 Expression 이어야 한다.
아래와 같은 테스트코드가 완성된다.
func testSimpleAddition() {
let five = Money.dollar(5)
let sum: Expression = five.plus(five)
let bank = Bank()
let reduced = bank.reduce(sum, "USD")
XCTAssertEqual(Money.dollar(10), reduced)
}
컴파일 및 테스트를 통과시킬 차례다.
이때 Bank 의 reduce 는 테스트만 통과하도록 가짜 구현
할일 목록
$5 + 10 CHF = $10 (환율이 2:1인 경우)
$5 + $5 = $10
$5 + $5에서 Money 반환하기
앞장에서 말했던 대로 Money 의 합을 객체로 즉, Sum으로 표현한다.
Money 의 plus() 에서 Money 대신 Sum 을 리턴하도록 수정
환율을 담당하는 Bank 와 마찬가지로 Money 를 더하는 작업을 담당하는 책임을 분리하기 위해 Sum 을 추가한게 아닐까 싶네요.
아직까지는 과한게 아닌가? 라는 생각도 들지만 일단 끝까지 읽어봐야 할 것 같습니다.
테스트가 정상적으로 통과된다.
이제 Bank.reduce() 는 Sum 을 전달 받으므로 이를 테스트할 필요가 있다.
Bank 가 Sum 의 상세 구현에 대해 너무 많이 알고 있음
위와 같이 Sum 의 상세 구현, 변수를 직접 참조하지 않고 reduce 를 통해 값을 받아올 수 있음
또한 Bank 의 reduce 메서드에 Money 를 인자로 넘겼을 때도 테스트할 필요가 있음
그리고 bank 의 reduce 도 수정
타입을 검사하는 코드 때문에 다소 지저분해진다.
이럴 때는 다형성을 사용하도록 바꾸는게 좋다.
지저분한 캐스팅, 클래스 검사 코드 등을 제거할 수 있다.
class testTests: XCTestCase {
override func setUpWithError() throws {
}
override func tearDownWithError() throws {
}
func testMultiplication() {
let five = Money.dollar(5)
XCTAssertEqual(Money.dollar(10), five.times(2))
XCTAssertEqual(Money.dollar(15), five.times(3))
}
func testEquality() {
XCTAssertTrue(Money.dollar(5) == Money.dollar(5))
XCTAssertFalse(Money.dollar(5) == Money.dollar(6))
XCTAssertFalse(Money.dollar(5) == Money.franc(5))
}
func testCurrency() {
XCTAssertEqual("USD", Money.dollar(1).currency)
XCTAssertEqual("CHF", Money.franc(1).currency)
}
func testSimpleAddition() {
let five = Money.dollar(5)
let sum: Expression = five.plus(five)
let bank = Bank()
let reduced = bank.reduce(sum, "USD")
XCTAssertEqual(Money.dollar(10), reduced)
}
func testPlusReturnsSum() {
let five = Money.dollar(5)
let result: Expression = five.plus(five)
let sum = result as! Sum
XCTAssertEqual(five, sum.augend)
XCTAssertEqual(five, sum.addend)
}
func testReduceSum() {
let sum: Expression = Sum(augend: Money.dollar(3), addend: Money.dollar(4))
let bank = Bank()
let result = bank.reduce(sum, "USD")
XCTAssertEqual(Money.dollar(7), result)
}
func testReduceMoney() {
let bank = Bank()
let result = bank.reduce(Money.dollar(1), "USD")
XCTAssertEqual(Money.dollar(1), result)
}
}
class Bank {
func reduce(_ source: Expression, _ to: String) -> Money {
return source.reduce(to)
}
}
protocol Expression {
func reduce(_ to: String) -> Money
}
struct Sum: Expression {
let augend: Money
let addend: Money
func reduce(_ to: String) -> Money {
let amount = augend.amount + addend.amount
return Money(amount, to)
}
}
class Money: Equatable, Expression {
fileprivate let amount: Int
let currency: String
init(_ amount: Int, _ currency: String) {
self.amount = amount
self.currency = currency
}
static func dollar(_ amount: Int) -> Money {
return Money(amount, "USD")
}
static func franc(_ amount: Int) -> Money {
return Money(amount, "CHF")
}
func times(_ multiplier: Int) -> Money {
return Money(amount * multiplier, currency)
}
func plus(_ addend: Money) -> Expression {
return Sum(augend: self, addend: addend)
}
func reduce(_ to: String) -> Money {
return self
}
static func ==(lhs: Money, rhs: Money) -> Bool {
return lhs.amount == rhs.amount && lhs.currency == rhs.currency
}
}