본문 바로가기
독서/자바,스프링 개발자를 위한 실용주의 프로그래밍

16장 테스트와 설계

by Thinking 2024. 11. 19.

테스트와 소프트웨어 설계는 긴밀한 상관관계를 맺습니다. 상호보완적이죠. 당연한 이유는 좋은 소프트웨어 설계와 테스트가 추구하는 목표가 일정 부분 같기 때문입니다. 즉, 테스트가 추구하는 가치와 좋은 설계가 추구하는 가치에 일정 부분 교집합이 있는 것입니다. 좋은 설계는 시스템이 모듈로 분해되고, 각 모듈이 독립적으로 개발될 수 있게 하는 것을 추구합니다. 유연하게 확장될 수 있는 것을 추구하죠. 확장할 수 있는 시스템이란 다양한 기능을 제공할 수 있다는 의미도 있지만, 다양한 환경에도 이식할 수 있는 시스템이라는 뜻이기도 하죠. 

 

16.1 테스트와 SRP, ISP

UserService 컴포넌트의 login, Register 메서드를 분리하는 것이 맞을까요? SRP 관점에서 컴포넌트를 사용하려는 주체가 시스템 미가입자와 시스템 가입자로 서로 다른 메세지를 보내는 2명의 액터가 있다고 볼 수 있기에, 분리하는 것이 맞습니다.

 

또한 테스트는 인터페이스 분리를 유도합니다. 인터페이스가 통합되어있으니, 테스트할 때 테스트하지 않는 다른 메서드에 대해 고민이 생깁니다. 우리는 테스트를 만들 때 '우리가 테스트하고 싶은 것'에만 관심을 두고 싶습니다. 하나로 통합된 인터페이스를 여러 개로 분리하고, 해당 구현 클래스에서 필요한 인터페이스를 상속받으면 됩니다. 이렇게 전략을 사용하면 인터페이스가 꼭 하나의 컴포넌트로 관리될 필요가 없다는 장점이 생기죠. 즉, ISP을 지키면 우리는 구현체가 실제로 필요로 할 때 구현할 수 있습니다.

 

 

16.2 테스트와  OCP, DIP

코드가 '유연한 설계'를 따르고 있는지 아닌지 어떻게 판단하면 좋을까요? 가장 원식적이며 확실한 방법은 컴포넌트 간 의존 관계를 전부 파악하는 것입니다. 의존 관계를 추적해서 모든 컴포넌트의 대체 가능성과 확장 가능성을 판단해 보는거죠. 이는 하지만 설계가 계속 변하기 때문에, 들인 수고에 비해 조사 결과를 오래 활용할 수 없습니다. 

 

이때 사용할 수 있는 것이 테스트입니다. 코드의 유연성과 확장성이 어떤지 판단할 수 있죠. 왜냐하면 테스트가 실행되는 환경은 배포 환경과 다르기 때문입니다. 테스트 환경은 배포 환경의 요구사항과 다른 새로운 요구사항에 대처하는 곳입니다. 의존성 역전 원칙은 어떤가요? 추상화를 뜻하진 않지만, 추상화를 통해 유연성을 추구할 수 있습니다.

 

구현체를 상속해 테스트 대역을 만들려는 시도는 테스트를 복잡하게 만듭니다. 서비스가 추상에 의존하게 만들면 이런 고민을 할 필요없고, 역할에 의존하는 코드를 만들고 역할에 충실한 컴포넌트를 배포 환경이나 테스트 환경에 만들면 됩니다.

 

 

16.3 테스트와 LSP

이제 어떤 것을 테스트해야할지 알아보죠. 많이 나오는 대답은 Right-BICEP(어떤 것을 테스트해야 하는지 알려줍니다.)과 CORRECT(테스트 환경을 가정할 때 데이터의 경계 조건에는 어떤 것이 있는지를 알려줍니다.) 원칙입니다.

 

Right-BICEP

1) Right : 결과가 올바른지 확인해 봐야 합니다.

2) Boundary : 경계 조건에서 코드가 정상적으로 동작하는지 확인해봐야 합니다.

3) Inverse : 역함수가 있다면 이를 실행해 입력과 일치하는지 확인해봐야 합니다.

4) Cross-Check : 검증에 사용할 다른 수단이 있다면 이를 비교해 봐야 합니다.

5) Error Conditions : 오류 상황에서도 프로그램이 의도한 동작을 하는지 확인해봐야 합니다.

6) Performance : 프로그램이 예상한 성능 수준을 유지하는지 확인해봐야 합니다.

 

CORRECT

1) Conformance(적합성) : 데이터 포맷이 제대로 처리되는지 확인해봐야 합니다.

2) Ordering(정렬) : 출력에 순서가 보장돼야 한다면 이를 확인해봐야 합니다.

3) Range(범위) : 입력에 양 끝점이 있다면 양 끝점이 들어갈 때 정상 동작하는지 확인해봐야 합니다.

4) Reference(참조) : 협력 객체의 상태에 따라 어떻게 동작하는지 확인해봐야 합니다.

5) Existence(존재) : null, blank 같은 값이 입력될 때 어떻게 반응하는지 확인해봐야 합니다.

6) Cardinality(원소 개수) : 입력의 개수가 0,1,2,...,n 일 때 어떻게 동작하는지 확인해봐야 합니다.

7) Time(시간) : 병렬 처리를 한다면 순서가 보장되는지 확인해봐야 합니다.

 

이러한 원칙을 설명하는 것도 좋겠지만, '어떤 것을 테스트해야 하느냐'라는 질문에 어울리는 답변이 있습니다. 유지하고 싶은 상태가 있다면 테스트로 작성하세요. 이는 어떤 시스템에 응당 있어야 할 테스트 케이스가 없다면, 해당 케이스는 시스템에서 유지하지 않아도 된다고 판단하는 것일 수 있다. 테스트는 시스템의 상태를 검증하는 수단입니다. 강조하고 싶은 내용은 유지하고 싶은 상태가 있다면 테스트로 작성하세요, 코드 작성자의 모든 의도를 테스트로 드러내세요.

 

그런데 LSP 역시 시스템이 유지하고 싶은 상태 중 하나입니다. LSP을 검증하는 테스트를 작성하며, LSP을 상시로 유지하고 있는지 검사할 수 있습니다. 일반적으로 LSP가 깨지는 경우는 언제일까요? LSP 원칙을 어긴다는 말은 파생 클래스가 기본 클래스가 정해놓은 계약을 깨트린다는 말과 같죠. 크게 2가지입니다.

1) 원칙을 지키고 있는 파생 클래스를 수정했더니 기본 클래스를 대체하지 못하는 경우 -> 기존 테스트로 감시 가능

2) 새로운 파생 클래스가 처음부터 기본 클래스를 대체하지 못하는 경우 -> 기본 클래스에 대한 테스트 코드 작성하기

 

2)의 경우 주목할 점은 검증할 테스트 클래스임에도 추상 클래스이며 추상 메서드가 정의되어 있다는 것이죠. 이는 추상 테스트 클래스를 상속하는 테스트 코드에 적용돼 활용할 수 있습니다. 파생 클래스의 테스트나 기본 클래스의 테스트 코드가 기본 테스트 추상 클래스를 상속하도록 만드는 것입니다. 따라서 LSP을 통해 검증할 수 있게 되죠.

 

디자인 패턴으로 치면 이는 테스트 코드에 템플릿 메서드 패턴을 사용한 사례이죠. 하지만 이는 파생 테스트 클래스들의 세부 구현이 강제됩니다. 그래서 인터페이스를 만들어서 사용하는 것도 한 가지 방법입니다. 인터페이스를 미리 만들고 파생 클래스를 테스트할 때는 테스트도 상위 인터페이스를 구현하도록 의무화하는 것입니다. 상속을 사용하는 것은 신중해야 하고, 상속보다는 조합을 사용해야 합니다. 클래스를 상속하는 것보다는 인터페이스를 구현하는 것이 좋습니다. 인터페이스는 역할을 기반으로 만들어지는 것이 좋고요.