✌️ Chapter 9: 비용-효율적인 테스트 디자인하기
- 수정하기 쉬운 코드를 작성하는 일은?
- 객체지향 디자인을 이해하고 있어야 한다.
- 코드를 리팩터링하는 법을 익혀야 한다. (코드의 외적인 작동방식을 변경하지 않으면서)
- 수정 가능한 코드를 작성하려면 높은 수준의 테스트를 짤 수 있어야 한다.
📚 의도를 가지고 테스트하기
- 테스트는 버그를 줄여주고 문서의 역할을 하며 테스트를 먼저 작성하면 애플리케이션의 디자인이 향상된다.
- 진정한 테스트의 목표는 디자 인의 진정한 목표와 똑같이, 코드 작성 비용을 줄이는 것이다.
- 테스트 코드를 디자인하는 데 드는 시간보다 오래 걸린다면 테스트를 작성하는 의미가 없지만, 테스트 작성 비용이 너무 높은 문제를 해결하기 위한 방법은 테스트를 그만두는 것이 아니라 테스트를 더 잘 짤 수 있도록 수련하는 것이다.
🎈 테스트 의도를 알기
🐤 버그 찾아내기
- 초기 단계에서 버그를 발견하기도 쉽고 수정하기도 쉬울 뿐 아니라 코드를 일찍 올바르게 만들어 놓는 것이 코드 디자인에 긍정적인 영향을 미친다.
🐤 문서를 제공하기
- 테스트만이 디자인에 대한 믿을 수 있는 문서를 제공한다.
🐤 디자인 결정을 미루기
- 테스트는 디자인 결정을 안전하게 미룰 수 있도록 해준다.
- 어떤 것이 필요한데 아직 그 무엇을 알아내기에는 충분한 정보가 없는 경우가 있다.
- 테스트가 인터페이스에 기대고 있다면 인터페이스 밑에 숨겨진 구체적인 코드는 나중에 리팩터링할 수 있다.
- 테스트는 인터페이스가 계속해서 올바르게 작동하고 있다는 점을 확인해 줄 수 있고 리팩터링 과정에서 테스트를 다시 작성할 필요도 없다.
- 의도적으로 인터페이스에 의존하는 테스트를 작성하면 아무런 대가를 치르지 않고도 안전하게 디자인 결정을 미룰 수 있다.
🐤 추상화를 돕기
- 좋은 디자인은 추상화된 코드에 기대고 있는 작고 독립적인 객체들을 자연스럽게 만들어낸다. 이런 추상화들 사이의 상호작용으로 조금씩 변해간다.
- 추상화된 디자인이 어느 순간에 도달하면, 테스트 없이는 더 이상 안전하게 코드를 수정할 수 없는 수준에 도달한다.
- 테스트는 모든 추상화된 인터페이스의 기록이기 때문에 우리의 작업을 지지해주는 기반이 된다.
🐤 디자인의 결점 드러내기
- 테스트를 작성하기 위한 준비 작업이 너무 힘겹다면 코드에 너무 많은 맥락(context)이 있다는 뜻이다.
- 객체 하나를 테스트하기 위해 다른 객체를 많이 끌어와야 한다면 이 코드는 의존성이 높다는 뜻이다. 디자인이 나쁠 때 테스트는 힘들어진다.
- 하지만, 테스트가 힘들다고 해서 애플리케이션의 디자인에 문제가 있다는 뜻은 아니다.
- 최소한의 비용으로 테스트가 제공하는 이점을 최대한으로 누리는 것이고 이걸 이루기 위해서는 느슨하게 결합된 테스트를 작성하는 것이다.
🎈 무엇을 테스트할지 알기
- 테스트에서 더 나은 가치를 얻기 위한 방법 중 하나는 테스트를 덜 짜는 것이다. 이를 위해서는 모든 것을 단 한번만 테스트하고 제대로 된 곳에서 테스트해야 한다.
- 테스트에서 중복을 제거하면 애플리케이션이 수정에 맞춰 테스트를 수정해야 하는 비용을 줄일 수 있다.
- 테스트의 핵심내용만 남겨 놓기 위해서는 테스트의 의도를 매우 뚜렷하게 가지고 있어야 하고 이 의도는 우리가 이미 알고 있는 디자인 원칙으로부터 끄집어 낼 수 있다.
- 디자인의 핵심은 다른 객체의 내부에 대한 의도적인 무지에 있고 객체를 그저 메시지에 반응하는 존재처럼 취급할 때 애플리케이션의 수정이 쉬워진다. 그래야 최소한의 비용으로 최대한의 이득을 제공하는 테스트를 작성할 수 있다.
- 모든 객체에서 가장 안정적인 것은 퍼블릭 인터페이스로 우리가 테스트하는 것은 퍼블릭 인터페이스에서 정의된 메시지이다.
- 가장 비효율적인 것은 불필요한 테스트는 객체를 감싸는 방어막을 뚫고 들어가서 객체 내부의 불안정한 세부사항을 테스트하는 것이다. 리팩토링시 유지보수 비용만 높이게 된다.
- 그렇기 때문에 테스트는 객체의 경계를 넘나드는 메시지에 집중해야 한다.
- 객체는 오직 자신의 퍼블릭 인터페이스에 속하는 메시지의 상태만 검증해야 한다.
- 들어오는 메시지에 대해서는 메시지가 반환하는 상태를 테스트한다. 밖으로 나가는 커맨드 메시지(다른 객체에 영향을 미치는 메시지)에 대해서는 이 메시지가 제대로 전송되었는지 테스트해야 한다. 밖으로 나가는 쿼리 메시지는 테스트할 필요가 없다.
- 밖으로 커멘드 메시지가 제대로 전송되었는지만 테스트한다면 실제 코드와 느슨하게 결합되어 있기 때문에 실제 코드가 변경되어도 테스트를 수정할 필요가 없다.
🎈 언제 테스트할지 알기
- 테스트를 먼저 작성하는 것이 좋다. 테스트를 먼저 작성하면 객체를 처음 만드는 순간부터 객체 속에 약간의 재사용 가능성을 각인시켜 놓게 된다.
- 제대로 된 시점에 적당한 양의 테스트를 작성한다면, 그리고 테스트를 먼저 작성한다면 전체적인 개발비용을 줄일 수 있다. 그렇기 때문에 객체지향 디자인의 원칙을 테스트에서도 적용해야 한다.
🎈 어떻게 테스트할지 알기
- 가장 대중적인 테스트 프레임워크 사용하기. 사용자층이 두텁기 때문에 하위호환성을 포기할 수 없고, 덕분에 기존 테스트를 모두 재작성해야하는 변경사항이 적용될 여지가 적다.
- 어떤 프레임워크를 선택할지 뿐만 아니라, 서로 다른 두 가지 스타일인, 테스트 주도 개발(TDD)와 행동 주도 개발(BDD) 사이에서도 고민하고 결정해야 한다. 각자의 경험과 각자의 보다 중요하게 생각하는 가치에 준해 결정한다.
- 두 스타일 모두 테스트를 먼저 작성하지만 BDD는 밖에서 안으로, TDD는 안에서 밖으로 나가는 접근을 취한다. 보통 도메인 객체에 대한 테스트로 시작해서, 이렇게 만든 도메인 객체를 재사용하면서 바로 바깥 층위인 테스트 코드를 작성한다.
- 테스트를 할 땐 테스트 중인 객체를 알아야 하지만 그 이외의 객체에 대해서는 최대한 무지해야 한다.
- 테스트 중인 객체에만 초점을 맞추고 테스트를 진행하려면, 테스트하는 관점을 선택해야 한다. 테스트는 테스트 중인 객체의 가장자리를 따라 시선을 고정하고 그 경계를 넘나드는 메시지만 알고 있는 편이 낫다.
📚 들어오는 메시지 테스트하기
- 들어오는 메시지들은 객체의 퍼블릭 인터페이스, 다시 말해 객체가 외부 세계에 보여지는 모습을 형성한다.
- 애플리케이션의 다른 객체들은 이 메시지의 시그니처와 그 반환 결과에 의존하고 있기 때 문에 이 메시지들은 테스트해야 한다.
- 다음은 3장의 코드이다.
class Gear
attr_reader :chainring :cog, :rim, :tire
def initialize(args)
@chainring = args[:chainring]
@cog = args[:cog]
@rim = args[:rim]
@tire = args[:tire]
end
def ratio
chainring / cog.to_f
end
def gear_inches
ratio * Wheel.new(rim, tire).diameter
end
# ...
end
class Wheel
attr_reader :rim, :trie
def initialize(rim, tire)
@rim = rim
@tire = tire
end
def diameter
rim + (tire * 2)
end
# ...
end
Wheel
은 들어오는 메시지diameter
에 반응한다. (이 메시지는Gear
가 전송한 메시지 또는Gear
에서 밖으로 나온 메시지이다.) 그리고Gear
는 두 개의 들어오는 메시지,gear_inches
와ratio
에 반응한다.
🎈 사용하지 않는 인터페이스 제거하기
- 들어오는 메시지가 딸린 객체를 가지고 있지 않다면 이 메시지는 테스트할 필요가 없다.
객체 | 들어오는 메시지들 | 밖으로 나가는 메시지들 | 메시지에 의존하는 객체가 있는가? |
---|---|---|---|
Wheel | diameter | Yes | |
Gear | diameter | No | |
gear_inches | |||
ratio |
🎈 퍼블릭 인터페이스 검증하기
- 들어오는 메시지들은 메시지가 반환하는 값이나 상태를 검증하는 방식으로 테스트한다.
- 들어오는 메시지를 테스트하는 첫 번째 단계는 여러 상황에서 언제나 올바른 값을 반환하는지 확인하는 것이다.
class WheelTest < MiniTest::Unit::TestCase
def test_calculates_diameter
wheel = Wheel.new(26, 1.5)
assert_in_delta(29, wheel.diameter, 0.01) # 29가 맞는지 검증하는 테스트
end
end
- 위 테스트를 위해 애플리케이션의 다른 객체를 생성해야 하는 번거로움도 없고, 독립적으로 테스트할 수 있다.
- 다음 코드는
Gear
를 테스트하는 것이다.
class GearTest < MiniTest::Unit::TestCase
def test_calculates_gear_inches
gear = Gear.new(
chainring: 52,
cog: 11,
rim: 26,
tire: 1.5
)
assert_in_delta(137.1, gear.gear_inches, 0.01)
end
end
Gear
의gear_inches
메서드는 무조건 새로운 객체Wheel
을 만들고 사용한다.Gear
와Wheel
의 코드에서 그리고 테스트에서도 서로 결합되어 있다. 때문에 수정이 발생했을 때 이 테스트가 고장 날 가능성이 얼마나 높을지를 결정한다. 하지만 이런 문제를 야기하는 결합은Gear
안쪽 깊은 곳에 숨어 있기 때문에 테스트를 통해서 그 내용을 파악할 수 없다.- 테스트가 최소한의 코드만을 실행할 때 그리고 테스트가 호출하는 외부 코드가 디자인과 직접적으로 연관되어 있을 때 테스트는 빠르게 실행된다.
🎈 테스트 중인 객체 고립시키기
gear_inches
가Gear
이외의 객체에 기대고 있다는 사실이다. 이 사실은Gear
를 톡립적으로 테스트할 수 없다는 사실이다.Gear
가 특정한 맥락이 묶여 있고,Gear
를 재사용하기 어렵다는 것을 말해준다.- 아래는
Gear
에서Wheel
을 만드는 부분을 제거해서 이런 결합을 깨뜨렸다.
class Gear
attr_reader :chainring :cog, :wheel
def initialize(args)
@chainring = args[:chainring]
@cog = args[:cog]
@wheel = args[:wheel]
end
def ratio
chainring / cog.to_f
end
def gear_inches
ratio * wheel.diameter
end
# ...
end
- 이러한 결과에서
diameter
메서드는 특정 역할의 퍼블릭 인터페이스를 이루고 있다. - 입력받는 객체의 클래스에 얽매이지 않고 보다 자유롭게 사고할 수 있을 때 더욱 다양한 디자인과 테스트를 시도해 볼 수 있다.
- 테스트 단계에서도
Wheel
인스턴스를 주입하는 것을 통해 실제 코드의 변경사항을 테스트에 반영하고 있다.
class GearTest < MiniTest::Unit::TestCase
def test_calculates_gear_inches
gear = Gear.new(
chainring: 52,
cog: 11,
wheel: Wheel.new(26, 1.5)
)
assert_in_delta(137.1, gear.gear_inches, 0.01)
end
end
- 테스트 코드에서도
Gear
가Wheel
을 사용한다는 점이 명확해졌다. 공개적으로 드러나게 되었다. - 또한 여기서
Wheel
이diameter
의 역할을 수행하고 있다는 것 역시 명확히 드러나지 않았다. 역할이 눈에 보이지도 않고Gear
는Wheel
과 결합되어 있지만 테스트를 이런 방식으로 작성하는 것은 명백한 이점을 하나 가지고 있다.