본문 바로가기
프로그래밍 놀이터/안드로이드, Java

[Effective Unit Testing] Chap7. 테스트 가능 설계

by 돼지왕 왕돼지 2019. 3. 17.
반응형

[Effective Unit Testing] Chap7. 테스트 가능 설계


contract test, dependency inversion principle, DIP, effective junit, effective unit testing, final 메서드를 피하라, Interface, interface segregation principle, ISP, junit, liskov substitution principle, LSP, new 는 신중하게 사용하라., OCP, open closed principle, single responsibility principle, sold 설계 원칙, SRP, strategy pattern, test interface, testability, [Effective Unit Testing] Chap7. 테스트 가능 설계, 개방 폐쇄 원칙, 결과 확인 불가, 계약 테스트, 단일 책임 원칙, 맥락을 고려한 모듈러 설계, 메서드 오버라이딩 불가, 메서드 호출 불가, 모둘러 설계를 위한 시운전, 모듈러 설계, 복잡한 private 메서드를 피하라, 상속보다는 컴포지션을 사용하라, 생성자에서는 로직 구현을 피하라, 서비스 호출을 피하라, 소문자 s 싱글톤, 싱글톤을 피하라, 외부 라이브러리를 감싸라, 인터페이스, 전략 패턴, 정적 메서드를 피하라, 종속성, 클래스 생성 불가, 테스트 가능 설계, 테스트 가능 설계를 위한 지침, 테스트 불가 원인, 테스트 용이성, 협력 객체 대체 불가


7.1. 테스트 가능 설계란?


-

테스트 가능 설계의 가장 큰 의의는 당연히 코드를 더 잘 테스트할 수 있도록 해준다는 것이다.



-

테스트 용이성(testability)이란 테스트할 수 있는 소프트웨어냐 아니냐를 설명하는 용어가 아니다.

그보다는 소프트웨어를 멀마나 쉽게 테스트할 수 있느냐를 평가하는 용어다.

단위 테스트를 위한 시나리오 준비는 식은 죽 먹기여야 한다.

테스트 용이성이 떨어질수록 테스트를 작성하는 프로그래머의 부담이 커진다.




7.1.1. 모듈러 설계


-

제품은 특정 역할을 담당하는 독립 모듈로 구성된다는 그 본질만 잘 반영하면 자연스럽게 모듈러 설계가 만들어진다.

제품의 전체 기능을 뚜렷한 역할로 나누고 그 역할 각각을 독립된 구성 요소에 맡기면 최종적으로 상당히 유연한 설계를 만들게 된다.



-

자신의 역할 완수에 필요한 모든 것을 갖춘 독립 모듈을 조합하여 전체 설계를 완성하려면 다양한 모듈 간 연결 인터페이스가 필요하다.

그리고 그 인터페이스가 바로 유연성을 높여주는 원천이다.

이와 같은 프로그래밍 방식에서는 모듈 사이의 종속성을 최소한으로 줄이는 것을 목표로 한다.




7.1.2. SOLID 설계 원칙


-

SOLID 와 같은 객체지향 설계 원칙은 테스트 용이성과도 잘 어울린다.

그리고 코딩 시 이런 설계 원칙을 잘 지켜주면 모듈러 설계가 될 가능성이 상당히 높아진다.



-

단일 책임 원칙(SRP, Single Responsibility Principle)은 “클래스를 수정해야 하는 이유는 오직 하나뿐이어야 한다.”는 원칙이다.

다르게 말하면, 클래스는 작고 한 가지 역할에만 충실하고 응집력이 높아야 한다.

메서드를 수정해야 하는 이유 역시도 하나뿐이어야 한다.



-

단일 책임 원칙을 지키며 작성한 코드는 이해하기 쉽고 원하는 부분을 빠르게 찾을 수 있다.

자연스럽게 테스트하기도 쉬워진다.

테스트라는 것의 본질이 기대하는 동작을 설명하고 코드가 풀려는 문제를 이해해서 서술하는 활동이기 때문이다.



-

개방 폐쇄 원칙(OCP, Open-Closed Principle)이란 “클래스는 확장을 위해서는 개방적이되 수정에 대해서는 폐쇄적이어야 한다.”는 원칙이다.

쉽게 말하면, 코드 수정 없이도 클래스의 기능을 변경할 수 있도록 하자는 얘기다.

이는 전략 패턴(Strategy pattern)과 이어진다.



-

리스코프 치환 원칙(LSP, Liskov Substitution Principle)이란 “상위 클래스는 하위 클래스로 대체될 수 있어야 한다.”는 원칙이다.

한마디로 클래스 A의 인스턴스를 사용하는 코드에 A의 하위 클래스인 B의 인스턴스를 넣어도 문제없이 동작해야 한다는 뜻이다.



-

리스코프 치환 원칙을 지키면 정당한 근거로 만들어진 클래스 계층 구조만 남게 된다.

즉, 코드 재사용을 편하게 할 요량으로 만들어진 계층 구조는 사라진다.



-

리스코프 치환 원칙이 잘 지켜진 클래스 계층 구조라면 계약 테스트(Contract Test)가 가능하여 테스트 용이성이 높아진다.

계약 테스트란 인터페이스에 정의된 기능을 제공하겠다는 계약을 그 구현체가 제대로 지키는지 검증하는 것을 말한다.

즉, 인터페이스 동작을 확인하는 테스트 스위트 중 하나로 그 인터페이스를 구현한 클래스 모두를 검증하는 걸 말한다.



-

인터페이스 분리 법칙(ISP, Interface Segregation Principle)이란 “하나의 범용 인터페이스보다 쓰임새별로 최적화한 인터페이스 여러 개가 낫다”는 원칙이다.

한마디로 인터페이스는 작고 한 가지 목적에 충실하도록 만들어야 한다는 뜻이다.



-

인터페이스가 작으면 테스트 더블도 쉽게 작성할 수 있어 테스트 용이성도 같이 좋아진다.



-

의존 관계 역전 원칙(DIP, Dependency Inversion Principle)이란 “코드는 구현체가 아닌 추상 개념에 종속되어야 한다.”는 원칙이다.

극단적으로 보면, 의존 관계 역전 원칙에 따르면 클래스는 협력 객체를 직접 생성하지 말고 인터페이스로 건네받아야 한다.



-

협력 객체를 외부에서 넘겨줄 수 있다는 말은 테스트 대상 코드를 오버라이딩하지 않고도 기능을 변경할 수 있다는 말과 같다.

종속 객체 주입 방식을 적용하면 협력 객체를 마음대로 교체할 수 있음은 물론이고, 제품 코드가 사용하는 방식 그대로 테스트할 수 있기 때문에 테스트 용이성이 크게 좋아진다.




7.1.3. 맥락을 고려한 모듈러 설계


-

맥락을 고려한 모듈러 설계는 그리 간단하지 않다.

주어진 문제에 대한 해법을 찾는 능력은 뛰어날지 몰라도, 그 해법이 더 거대한 전체 시스템과도 잘 어울리는지까지 함께 고려하는 건 훨씬 어렵기 때문이다.



-

모듈을 조합하여 시스템을 구성할 수 있게 하는 것은 물론 중요하다.

하지만 당장은 그 시스템이 아무리 크고 멋있어 보이더라도 언젠가 더 큰 시스템의 일부로 될 수 있도록 설계해야 한다.




7.1.4. 모듈러 설계를 위한 시운전


-

제품 코드보다 테스트를 먼저 작성하게 되면 확실히 API 의 사용자인 고객의 관점에서 바라보게 된다.

이는 목적에 잘 들어맞게끔 설계한 가능성이 높아진다는 뜻이기도 하다.

더불어 ‘테스트는 쉽게 할 수 있을까’ 라는 질문 자체가 필요 없게 된다.



-

TDD 는 여러 가지 면에서 모듈러 설계에 도움이 된다.

테스트를 먼저 작성할 때의 이점 뿐만 아니라, TDD 실천자는 수시로 리펙토링하기 때문에 작게 나눠야 할 큰 메서드나 더 적절한 추상화 수준, 제거할 중복 등을 끊임없이 찾아낸다.






7.2. 테스트 불가 원인


-

프로그래머가 테스트 작성에 애를 먹는 원인은 크게 두 가지이다.

하나는 원하는 것에 접근하지 못하기 때문이고, 다른 하나는 대상 코드의 특정 부분을 마음대로 교체할 수 없어서다.




7.2.1. 클래스 생성 불가


-

테스트 작성자가 제일 처음 해야 할 일 중 하나가 바로 객체 생성이다.

이 때 테스트 대상 자체를 생성할 수 없는 경우도 물론 있지만, 그보다는 그 대상에 넘겨줘야 할 협력 객체를 만들 수 없는 경우가 훨씬 많다.

테스트 용이성을 고려하지 않고 설계된 서드파티 라이브러리가 흔히 일으키는 문제다.

보통은 멀리 내다보지 못하고 접근제한자(visibility modifier)를 너무 보수적으로 설정해서인 경우가 많다.

또한, 정적 초기화 블록을 잘못 사용하면 생성자만으로 클래스 생성을 온전히 제어할 수 없게 되어 테스트를 실행하면 전혀 예상하지 못했던 예외가 발생하곤 한다.




7.2.2. 메서드 호출 불가


-

private 메서드를 호출하고 싶을 때가 있는데, 보수적으로 설정한 접근제한자가 문제가 될 수 있다.

리펙토링하여 설계를 바꾸지 않고서는 석연치 않은 방법 중 하나를 선택해야 한다.

테스트를 포기하거나 리플렉션 API 를 써서 접근제한자를 우회하는 방법이다.




7.2.3. 결과 확인 불가


-

원하는 메서드를 호출할 수는 있지만, 결과가 올바른지는 확인할 수 없는 경우도 있다.

메서드를 하나 호출해서 반환값을 확인하는 게 단위 테스트의 기본 동작이다.

따라서 아무것도 반환하지 않는 void 메서드거나 다른 협력 객체와 상호작용하는 메서드라면 문제가 복잡해진다.



-

확인해야 할 상호작용을 가로챌 방법이 없을 때가 있다.

협력 객체가 테스트할 메서드 안에 꽁공 묶여 있어 테스트 더블로 대체하지 못할 때가 그렇다.

대상 메서드 안에서 또 다른 작업 스레드가 만들어지지만, 테스트 코드에서 그 스레드에 접근할 수단이 없는 경우도 있다.




7.2.4. 협력 객체 대체 불가


-

상호 작용이 잘 이루어졌는지 확인해야 하는 협력 객체가 있는데, 그 객체를 생성하는 로직이 제품 코드에 하드코딩되어 있는 경우 협력 객체로 대체하기 어렵다.

이를 달리 표현하면 관찰 대상을 가로챌 수 있는 이음매가 없어 협력 객체를 대체하는 게 기술적으로 불가능한 상황이다.




7.2.5. 메서드 오버라이딩 불가


-

테스트 더블로 협력 객체를 대체하기보다는 대상 객체의 일부 코드만 변경하고 싶을 때도 있다.

그러나 private static final 등의 modifier 가 이를 불가능하게 만드는 경우가 많다.





7.3. 테스트 가능 설계를 위한 지침


7.3.1. 복잡한 private 메서드를 피하라


-

private 메서드를 쉽게 테스트하는 방법이란 없다.

이를 꼭 명심하고 애초에 private 메서드는 직접 테스트할 필요가 없도록 만들어야 한다

private 메서드를 테스트하지 말라는 얘기는 아니다.

다만, 직접 테스트하지는 않아야 한다.

private 메서드의 용도를 public 메서드의 가독성을 높이기 위한 간단한 유틸리티로 제한한다면 public 메서드만 테스트해도 private 메서드까지 확실하게 검증된다.



-

private 메서드 사용법이 명확하지 않고 전용 테스트까지 만들고 싶은 마음이 생긴다면 오히려 코드를 리펙토링하라는 신호로 생각하자.

새로운 객체를 만들어서 그 private 메서드 안에 숨어 있던 확인하고 싶은 로직을 당당히 public 메서드로 제공하자.




7.3.2. final 메서드를 피하라.


-

final 메서드가 필요한 프로그램은 많지 않다.

메서드를 final 로 만드는 가장 큰 목적은 하위 클래스에서 오버라이딩하지 못하게 막는 것이다.



-

실질적으로 final 로 선언해야 할 합리적인 사유는 실행 도중에 외부 클래스를 로딩하거나 여러분이 옆의 동료를 믿지 못할 때 뿐이다.

( 리플렉션을 사용하면 final 키워드를 제거할 수 있다. )



-

중요한 질문은 이거다. final 이 테스트에 방해되는가?

만약 그렇다면 이 때문에 낮아진 테스트 용이성이 final 로 선언해서 얻는 이득보다 큰 것인가?



-

final 메서드 지지자의 근거 중 하나는 성능이다.

final 메서드는 오버라이딩 불가능하므로 컴파일러가 메서드 인라인해서 최적화할 수 있다는 것이다.

다만, 실행 중 final 키워드를 제거할 수도 있어 컴파일 시간에 최적화하는 것은 안전하지 않다.

그래도 JIT 컴파일러라면 실행 중에라도 인라인 할 수 있고, 성능 향상을 가져올 수 있다.

그러나 진짜 확실한 성능 저하 문제를 발견한 후가 아니라면 final 을 쓰지 않을 것을 권장한다.




7.3.3. 정적 메서드를 피하라.


-

정적 메서드 대부분은 사실 정적 메서드가 아니었어야 한다.

흔히 클래스 인스턴스와 관련이 없거나 소속을 결정하기 어려울 경우 고민하기 귀찮으니 그냥 정적 메서드로 만들어서 유틸 클래스에 몰아넣곤 한다.



-

저자는 단위 테스트에서 언젠가 스텁으로 바꿔야 할듯한 메서드는 정적 메서드로 만들지 않는다.

경험상 순수 계산 작업을 스텁으로 만들 일은 거의 없었던 반면 서비스나 협력 객체를 얻고자 만들었던 정적 메서드는 스텁으로 교체하고 싶을 경우가 많았다고 한다.




7.3.4. new 는 신중하게 사용하라.


-

하드코딩의 가장 흔한 형태가 바로 new 키워드다.

객체를 new 하는 것은 정확한 구현이 그것이라고 못 박는 행위다.

따라서 테스트 더블로 대체할 가능성이 없는 객체만 직접 생성해야 한다.




7.3.5. 생성자에서는 로직 구현을 피하라


-

생성자에서는 단위 테스트에서 교체해야 할만한 코드는 절대 넣어서는 안 된다.

만약 이런 코드를 발견하면 일반 메서드로 추출하거나 외부에서 객체 형태로 입력받게끔 수정하여 테스트에서 원하는 대로 바꿔칠 수 있도록 해야 한다.






7.3.6. 싱글톤을 피하라


-

소프트웨어 업계는 싱글톤 패턴 때문에 천문학적인 비용을 낭비했다.

싱글톤은 클래스의 인스턴스가 단 하나만 만들어진다는 것을 보장하고, 어디에서나 접근할 수 있도록 한 패턴이다.

클래스의 인스턴스가 하나만 만들어지길 바라는 상황은 물로 있을 수 있다.

하지만 싱글톤 페턴은 테스트가 자신에게 필요한 대용품을 만들 수 없게 가로막기도 한다.



-

만약 꼭 정적 싱글톤 메서드를 사용해야겠다면 getInstance() 메서드가 클래스가 아닌 인터페이스를 반환하도록 할 것을 추천한다.

그 싱글톤 객체가 특정 클래스를 상속해야 할 상황만 아니라면 인터페이스로 하는 것이 테스트에서 원하는 대로 조작하기에 훨씬 수월하다.



-

싱글톤보다 훨씬 낫고 테스트하기도 쉬운 구조는 소문자 s 로 시작하는 싱글톤이다.

소문자 싱글톤은 객체가 하나만 만들어진다는 장치적 보장은 두지 않고, 단지 “운영 시스템에서는 인스턴스를 단 하나만 만든다” 라고 팀원 간에 합의하는 것이다.




7.3.7. 상속보다는 컴포지션을 사용하라.


-

재사용 목적으로 상속을 이용하는 건 언 발에 오줌 누는 격이다.

상속의 용도는 다형성이지 코드 재사용이 아니다.

기능을 재활용하기 위한 목적이라면 컴포지션 방식이 낫다.




7.3.8. 외부 라이브러리를 감싸라.


-

서드파티 라이브러리의 클래스를 상속할 때에는 촉각을 곤두세우고, 코드베이스 이곳저곳에서 외부 라이브러리를 직접 호출하고 있다면 다시 한 번 잘 생각해보자.



-

상속이 테스트 용이성을 떨어뜨릴 수 있는데, 상속하려는 클래스가  외부 라이브러리의 클래스라면 문제가 훨씬 심각해진다.

상속하려는 코드에 대한 제어권이 우리에게 거의 없기 때문이다.



-

상속이건 직접 호출이건 간에 우리 코드가 외부 라이브러리에 얼기설기 얽힐수록 이들 외부 클래스가 그만큼 더 테스트하기 쉬워야 한다.

외부 라이브러리를 사용할 때는 항시 그 라이브러리의 테스트 용이성에 신경 쓰자.

그리고 문제가 될 것 같다면 직접 다른 구현으로 교체하기 쉽고 테스트하기도 편한 인터페이스를 하나 만들어서 그 라이브러리를 감싸버리자.




7.3.9. 서비스 호출을 피하라.


-

싱글톤 객체를 얻기 위해 정적 메서드를 호출하는 등의 서비스 호출 코드의 대부분은 깔끔한 인터페이스와 테스트 용이성을 잘못 거래한 결과다.





7.4. 요약


-

테스트 가능 설계는 필연적으로 모듈러 설계로 귀결되고, 이어서 단일 책임 원칙, 개방 폐쇄 원칙, 리스코프 치환 원칙, 인터페이스 분리 원칙, 의존 관계 역전 원칙으로 이루어진 SOLD 설계 원칙과 함께한다.

이 원칙을 지켜준다면 더욱 잘 모듈화된 설계, 즉 더욱 쉽게 테스트할 수 있는 설계를 이끌어 낼 수 있다.



-

테스트 용이성을 떨어뜨리는 여러 가지 문제는 테스트 작성을 아예 불가능하게 만들거나 가능은 할지라도 훨씬 많은 노력이 들도록 한다.

예를 들어 다음과 같은 문제가 있을 수 있다.

    객체 생성 불가

    메서드 호출 불가

    메서드 결과 혹은 부수 효과 확인 불가

    테스트 더블로 교체 불가

    메서드 오버라이딩 불가



-

final 이나 static 키워드 그리고 복잡한 private 메서드는 피해야 한다.

new 키워드 또한 정확한 구현 클래스름 명시하는 일종의 하드코딩이라서 테스트가 원하는 테스트 더블로 교체할 수 없게 방해한다.



-

생성자에 로직을 너무 많이 넣으면 오버라이딩하기가 어려우니 역시 피해야 한다.

싱글톤 패턴도 전통적인 구현 방식을 따라서는 안 된다.

그 대신 하나만 만들자고 약속하는 정도면 적당하다.



-

상속보다는 컴포지션을 활용하는 게 좋다.

상속이 만들어내는 클래스 계층 구조는 컴포지션처럼 유연하지 못하기 때문이다.



-

외부 라이브러리에서 정의한 클래스를 상속하거나 그 API 들을 분별없이 직접 호출하는 것도 위험하다.

외부 라이브러리는 우리가 제어할 수 없고 직접 만든 코드보다 테스트 용이성이 떨어질 가능성이 높기 때문이다.



-

마지막으로 서비스 호출보다는 생성자에 종속 객체를 직접 전달하는 것이 좋다.




반응형

댓글