-
도메인 특화 언어와 단위 테스트 - 2부[옛날 글들] 설계 이야기 2024. 5. 31. 09:56728x90
이전 글 : 도메인 특화 언어와 단위 테스트 - 1부
검증을 위한 SPECIFICATION 패턴 적용
객체지향 프로그래밍의 기본 원칙은 데이터와 데이터를 이용하는 행위를 함께 두는 것이다. 그러나 때로는 데이터와 행위를 뭉쳐 놓는 것이 최선의 선택이 아닐 수도 있다. 예를 들어 대부분의 개발자들은 도메인 개념을 표현하는 엔티티 내에 데이터베이스 저장과 같은 영속성 로직을 위치시키는 ACTIVE RECORD 패턴보다는 별도의 독립적인 클래스로 영속성 로직을 분리하는 DATA MAPPER 패턴을 선호한다.
기준은 응집도와 결합도다. 영속성 로직과 도메인 로직을 엔티티에 함께 섞는 것은 전체적으로 객체들의 응집도를 낮추고 결합도를 높이는 부정적인 결과를 낳는다. 따라서 영속성 로직이 엔티티의 데이터를 사용하더라도 시스템 전체적인 관점에서 응집도를 높이고 결합도를 낮추기 위해서는 영속성 로직을 별도의 클래스로 분리하는 것이 더 좋은 선택이다.
이와 유사하게 엔티티의 정합성을 검증하는 조건 체크 로직이 엔티티의 응집도를 낮추고 결합도를 높인다면 독립적인 클래스로 정합성 검증로직을 분리하는 것이 더 좋다. 이처럼 특정 엔티티에 대한 정합성을 체크하는 로직을 엔티티 자체가 아니라 독립적인 객체에 두는 것을 SPECIFICATION 패턴이라고 한다. SPECIFICATION 패턴은 검증을 위한 술어(predicate)를 명세(specification) 형태의 객체로 표현한다.
먼저 행성의 정합성을 체크하기 위해 사용될 공통 인터페이스인 Specification을 선언하자. Specification 인터페이스는 Planet 객체의 정합성을 체크하는 isSatisfied() 오퍼레이션과 두 개의 Specification을 조합하는 and() 오퍼레이션으로 구성된다. isSatisfied() 오퍼레이션은 검증 조건이 맞을 경우 true를, 틀릴 경우 false를 반환한다.
public interface Specification { boolean isSatisfied(Planet planet); Specification and(Specification other); }
추상 클래스인 AbstractSpecification 은 두 개의 Specification 인스턴스를 조합하는 and() 오퍼레이션의 기본 구현을 제공한다. 모든 Specification 구현 클래스들은 and() 오퍼레이션의 구현 코드를 공유해야 하므로 AbstractSpecification 클래스의 서브 클래스로 선언되어야 한다.public abstract class AbstractSpecification implements Specification { @Override public Specification and(Specification other) { return new AndSpecification(this, other); } }
AndSpecification 클래스는 두 개의 Specification을 공통의 인터페이스로 캡슐화하는 간단한 COMPOSITE이다.public class AndSpecification extends AbstractSpecification { private Specification one; private Specification other; public AndSpecification(Specification one, Specification other) { this.one = one; this.other = other; } @Override public boolean isSatisfied(Planet planet) { return one.isSatisfied(planet) && other.isSatisfied(planet); } }
행성에 포함된 대륙의 개수를 체크하는 ContinentSpecification은 Planet 클래스의 getContinent()로 반환된 리스트의 개수와 자신의 continentSize 속성 값을 비교한다.public class ContinentSpecification extends AbstractSpecification { private int continentSize; public ContinentSpecification(int continentSize) { this.continentSize = continentSize; } @Override public boolean isSatisfied(Planet planet) { return planet.getContinents().size() == continentSize; } }
행성의 제조 가격이 생쥐들이 원하는 가격보다 저렴하거나 적어도 동일한 지 여부를 체크하는 PriceSpecification은 검증을 위해 Planet의 getPrice() 메서드의 반환 값을 이용한다.public class PriceSpecification extends AbstractSpecification { private Money price; public PriceSpecification(Money price) { this.price = price; } @Override public boolean isSatisfied(Planet planet) { return price.isGreaterThanOrEqual(planet.getPrice()); } }
행성이 가지는 대륙의 개수가 2이고 제조 가격이 20,000원 이하인 지를 체크하는 경우 다음과 같이 두 개의 Specification을 and() 메서드로 조합한 후 Planet 인스턴스를 체크하면 된다.Specification specification = new ContinentSpecification(2) .and(new PriceSpecification(Money.wons(9000))); specification.isSatisfied(planet);
<그림 3>은 Planet의 정합성을 검증하는 Specification 계층의 전체적인 구조를 클래스 다이어그램으로 표현한 것이다.<그림 3> Specification 계층 구조단위 테스트의 늪
도메인 모델의 개발이 완료되었으므로 이제 Specification이 정상적으로 동작하는지 확인하는 단위 테스트를 살펴보도록 하자.
일반적으로 테스트 케이스는 ‘픽스처 설치’, ‘SUT 실행’, ‘결과 검증’, ‘픽스처 해체’의 4단계로 구성된다. 이처럼 테스트 케이스를 4단계로 구성하는 것을 4단계 테스트(FOUR-PHASE TEST) 패턴이라고 한다.- 픽스처 설치(setup) - 테스트 대상 코드가 실행될 환경인 컨텍스트를 준비한다. 이 단계에서는 테스트에서 사용할 실제 테스트 픽스처를 생성하거나 Mock 객체 등의 TEST DOUBLE을 준비한다.
- SUT 실행(execute) - SUT(System Under Test의 약자로 현재 테스트 중인 시스템을 의미)를 호출하여 테스트 대상 행위를 실행한다.
- 결과 검증(verify) - 실행 후 SUT가 기대한 대로 동작했는지 여부를 확인한다.
- 픽스처 해체(teardown) - 테스트 픽스처를 제거해 테스트 실행 전 상태로 컨텍스트를 복구한다. 이를 통해 테스트 간의 부수효과를 방지할 수 있다.
“행성(Planet)에 포함된 대륙(Continent)의 개수가 정해진 명세에 일치하는 지 여부”를 검증하는 ContinentSpecification의 테스트 케이스부터 살펴 보자. 테스트 케이스의 목적은 “아시아”와 “유럽”이라는 두 개의 Continent 인스턴스를 속성으로 포함하는 Planet 인스턴스가 존재할 때 ContinentSpecification이 2개의 대륙 정보를 포함하고 있다는 것을 정상적으로 검증하는지를 테스트하는 것이다.
ContinentSpecification에 대한 테스트 케이스 역시 4단계 테스트(FOUR-PHASE TEST) 패턴에 따라 픽스처를 생성하는 것으로 시작한다. 먼저 두 개의 대륙을 가진 Planet 인스턴스를 생성한다. 그 후 실제 테스트 대상인 ContinentSpecification을 생성하고 생성된 Planet을 isSatisfied() 메서드에 전달해서 반환 값이 true인지 확인한다.public class ContinentSpecificationTest { @Test public void continentSize() { Planet planet = new Planet( new Atmosphere(Money.wons(5000), element("N", Ratio.of(0.8)), element("O", Ratio.of(0.2))), Arrays.asList( new Continent("아시아"), new Continent("유럽")), Arrays.asList( new Ocean("태평양", Money.wons(1000)), new Ocean("대서양", Money.wons(1000)))); ContinentSpecification specification = new ContinentSpecification(2); assertTrue(specification.isSatisfied(planet)); } }
이 테스트 케이스를 자세히 살펴보면 픽스처 생성과 관련해서 다음과 같은 몇 가지 문제점이 존재한다는 사실을 알 수 있다. 아래의 문제점 분류는Gerard Meszaros의 저서인 “xUnit Test Patterns”(번역서 : xUnit 테스트 패턴)에서 사용한 용어를 기반으로 한 것이다(Meszaros의 책에서는 리팩토링 전통에 따라 테스트 '냄새’라는 용어를 사용하고 있다).- 애매한 테스트(OBSCURE TEST) - 테스트에서 픽스처와 관련 없는 정보(IRRELEVANT INFORMATION)를 너무 상세하게 보여주기 때문에 테스트 중인 시스템의 동작에 실제로 영향을 미치는 것이 어떤 것인지를 파악하기 어렵다. 위 테스트 케이스에서 Planet의 인스턴스를 생성하기 위해 생성자에 전달된 세 가지 인자 중 대륙의 수를 검증하는 테스트에 직접적으로 관련이 있는 것은 Continent뿐이다. Atmosphere와 Ocean은 테스트 결과와 아무런 상관이 없다. 그럼에도 불구하고 Atmosphere와 Ocean은 Planet을 생성하기 위해 컴파일러가 요구하는 필수 파라미터이기 때문에 테스트와의 관련성과 무관하게 생성자에 전달해야 한다. 이처럼 테스트하려는 행위와 관련이 없는 불필요한 정보는 테스트의 목적을 흐리고 테스트 케이스를 이해하는데 불필요한 정신적 과부하를 초래한다.
- 깨지기 쉬운 테스트(FRAGILE TEST) – 위 테스트 케이스는 Planet, Atmosphere, Continent, Ocean의 생성자를 직접 호출하기 때문에 생성자의 시그니처가 변경될 경우 객체들을 사용하는 모든 테스트 케이스를 수정해야 한다. 이처럼 테스트 케이스에서 참조하는 객체의 API가 변경될 경우 테스트 케이스에서 컴파일 에러(동적 타입 언어일 경우에는 런타임 에러)가 발생하는 것을 인터페이스에 민감함(INTERFACE SENSITIVITY) 문제라고 한다. 도메인 객체들을 사용하는 테스트 케이스가 늘어날수록 테스트 케이스를 수정하고 관리하는 것이 점점 더 어려워진다.
Planet의 생성자를 하나의 테스트 케이스 클래스에서만 호출하고 있다면 큰 문제가 되지는 않는다. 그러나 객체들의 풍부한 협력 관계를 기반으로 하는 대부분의 객체 지향 시스템은 동일한 클래스의 인스턴스를 여러 테스트 클래스의 픽스처로 사용하는 것이 일반적이다.
아래 코드는 “행성의 실제 제조 가격이 생쥐들이 제시한 계약 금액보다 낮거나 동일한 지 여부”를 판단하는PriceSpecification에 대한 테스트 케이스 클래스를 나타낸 것이다. 앞에서 살펴 본 ContinentSpecification의 테스트 케이스에서 사용한 픽스처와 동일한 타입인 Planet을 픽스처로 사용한다는 것을 알 수 있다.public class PriceSpecificationTest { @Test public void price() { Planet planet = new Planet( new Atmosphere(Money.wons(5000), element("N", Ratio.of(0.8)), element("O", Ratio.of(0.2))), Arrays.asList( new Continent("아시아", new Nation("대한민국", Money.wons(1000))), new Continent("유럽", new Nation("영국", Money.wons(1000)))), Arrays.asList( new Ocean("태평양", Money.wons(1000)), new Ocean("대서양", Money.wons(1000)))); Specification specification = new PriceSpecification(Money.wons(9000)); assertTrue(specification.isSatisfied(planet)); } }
두 테스트 케이스 간에 발생하는 문제점은 명확하다. 픽스처를 생성하는 부분에 중복 코드가 존재하는 것이다.- 테스트 코드 중복(TEST CODE DUPLICATION) – 유사한 주제에 대해서 약간씩 다른 시나리오로 테스트를 해야 하는 경우 유사한 픽스처 로직이 여러 테스트 케이스에 중복되어 나타난다. 이를 테스트 코드 중복이라고 한다. 테스트 케이스 간에 유사한 코드가 나타나야 하는 경우 이를 해결하는 가장 간단한 방법은 코드를 잘라 붙여넣기(CUT-AND-PASTE) 한 후 필요한 부분을 수정하는 것이다. 픽스처 생성과 관련된 테스트 코드 중복 문제는 특히 깨지기 쉬운 테스트(FRAGILE TEST)의 원인이 되곤 한다. 여러 테스트 케이스에 생성자를 호출하는 코드가 중복되어 나타나기 때문에 생성자의 시그니처가 변경되면 중복 코드가 나타나는 모든 테스트 케이스를 수정해야 한다.
애매한 테스트(OBSCURE TEST), 깨지기 쉬운 테스트(FRAGILE TEST), 테스트 코드 중복(TEST CODE DUPLICATION) 문제로 속을 썩이는 테스트 케이스는 다음과 같은 유지 보수 문제를 일으키는 골치 덩어리로 전락하고 만다.- 높은 테스트 유지 비용(HIGH TEST MAINTENANCE COST) – 애매하고, 깨지기 쉬우며, 중복 코드에 시달리는 테스트 케이스는 유지보수하기 어렵다. 프로덕션 코드에 이런 문제가 발생한다면 최소한 리팩토링을 해보려는 시도라도 하겠지만 테스트 코드에 이런 문제가 발생하는 경우 시간에 쫓기는 대부분의 사람들은 @Ignore를 붙이거나 테스트 케이스를 삭제해버리고 만다.
- 테스트를 작성하지 않는 개발자(DEVELOPERS NOT WRITING TEST) – 높은 테스트 유지 비용으로 인해 테스트가 개발 과정의 병목이라는 인식이 퍼질수록 개발자들은 테스트를 작성하지 않으려고 한다.
‘깨진 창문 이론’을 기억하라. 테스트가 방치되기 시작하면 빠른 속도로 복잡하고 이해하기 어려운 테스트들이 단순하고 깔끔한 테스트의 비율을 잠식해 나갈 것이다. 더 많은 창문이 깨지기 전에 테스트 케이스를 보수해야 한다.
다음 글 : 도메인 특화 언어와 단위 테스트 - 3부
728x90'[옛날 글들] 설계 이야기' 카테고리의 다른 글
도메인 특화 언어와 단위 테스트 - 4부 (0) 2024.05.31 도메인 특화 언어와 단위 테스트 - 3부 (0) 2024.05.31 도메인 특화 언어와 단위 테스트 - 1부 (0) 2024.05.31 Information Hiding (0) 2024.05.31 테스트 커버리지에 현혹되지 말자 (2) 2024.05.31