개발/Unit testing

Unit Testing - 5장 목과 테스트 취약성

시나모온 2022. 4. 9. 21:11

도서 정보 : http://www.yes24.com/Product/Goods/104084175

 

단위 테스트 - YES24

소프트웨어 개발에 있어 단위 테스트는 이제 선택이 아니라 필수가 됐다. 단위 테스트에 대한 오해를 바로잡고, 올바른 단위 테스트에 대한 원칙, 테스트를 작성하는 스타일과 효과적인 테스트

www.yes24.com

 

정리

1. 나중에 정리하자

2. 

3. 

4.

 

테스트에서 목을 사용하는 것은 논란의 여지가 있는 주제이다.

어떤 사람들은 목이 훌륭한 도구이며 대부분에 적용해야한다고 주장한다. (런던파)

또 다른 사람들은 목이 테스트 취약성을 초래하며 사용하지 말아야 한다고 주장한다. (고전파)

 

이 책에서는 목으로 인해 리팩터링 내성이 부족해지는 테스트들을 살펴본다.

하지만 목을 적용할 수 있는 경우가도 있고 심지어 바람직한 경우도 있다. 이 경우도 소개한다.

 

목에 대한 논란은 런던파는 불변 의존성을 제외한 모든 의존성을 테스트 대역을 써서 격리하고자 하고

고전파는 테스트 간의 공유 의존성에 대해서만 테스트 대역을 사용하기 때문에 발생한다.

 

하지만 목과 테스트 취약성 사이에는 깊고 불가피한 관련이 있음에느 확실하다. 어떻게 관련있고 리팩터링 내성 저하 없이 어떻게 목을 사용하는지 알아본다.

 


5.1 목과 스텁 구분

5.1.1 테스트 대역 유형

테스트 대역 : 모든 유형의 비운영용 가짜 의존성을 설명하느 포괄적인 용어

더미, 스텁, 스파이, 목, 페이크 라는 다섯 가지 종류로 대역을 구분할 수 있다. 하지만 사실 목과 스텁 2가지 중 하나로 볼 수 있다.

 

테스트 대역 = 목 + 스텁

  • 목 : 외부로 나가는 상호 작용을 모방. 상호 작용하는 SUT가 상태를 변경하기 위한 의존성을 호출하는 것에 해당한다.
  • 스텁 : 내부로 들어오느 상호 작용을 모방. 상호 작용하는 SUT가 입력 데이터를 얻기 위한 의존성이다.

이메일 발송은 SMTP 서버에 외부로 나가는 상호 작용.(목) 데이터 베이스에서 데이터를 검새하는 것은 내부로 들어오는 상호 작용.(스텁)

스파이 : 수동으로 작성

목 : 목 프레임워크의 도움을 받아 생성

더미 : null값이나 가짜 문자열과 같이 단순 하드 코딩된 값

스텁 : 시나리오마다 다른 값을 반환하게 끔 구성할 수 있는 의존성

페이크 : 아직 존재하지 않는 의존성을 대체하고자 할때 사용

 

목과 스텁의 또 다른 차이점 : 목은 SUT와 관련 의존성 간의 상호 작용을 모방하고 검사한다. 하지만 스텁은 모방만 한다.

 

 

5.1.2 도구로서의 목과 테스트 대역으로서의 목

목이라는 용어에 여러 의미가 혼재되어 있다.

테스트 대역을 나타낼 때의 목이라고 부르지만, 목 라이브러리의 클래스도 목이라고 부른다.

Mock(도구) 라이브러리를 써서 mock(목)을 생성

도구로서의 목을 사용해 목과 스텁, 두 가지 유형의 테스트 대역을 생성할 수 있기 때문에 테스트 대역의 목과 혼동하지 않는것이 중요하다.

Mock(도구) 라이브러리를 사용해 스텁 생성

5.1.3 스텁으로 상호 작용을 검증하지 말라

목은 SUT에서 관련 의존성으로 나가는 상호 작용을 모방하고 검사한다.

하지만 스텁은 내부로 들어오는 상호 작용을 모방만 하고 검사하지 않는다.

따라서 스텁의 상호 작용은 검증하지 말아야 한다.

참고| 스텁과의 상호 작용을 검증하는 것은 취약한 테스트를 야기하는 일반적인 안티 패턴이다.

stub의 상호 작용을 정의하고, 상호 작용을 검증하고 있다.

테스트에서 거짓 양성을 피하고 리팩터링 내성을 향상시키는 방법은 구현 세부 사항이 아니라 최종 결과를 검증하는 것 뿐이다.

예제 5.1이 이런 의미를 잘 구현했고 예제 5.2는 아니다. 예제 5.2에서 GetNumberOfUser()를 호출하는 것은 내부 구현 세부 사항이다. 최종 결과가 아닌 사항을 검증하는 이러한 관행을 과잉 명세라고 부른다. 그리고 과잉 명세는 상호 작용을 검사할 때 흔하게 발생한다.

 

 

5.1.4 목과 스텁 함께 쓰기

떄로는 목과 스텁 특성으 모두 나타내는 테스트 대역이 필요하기도 하다.

스텁으로서 HasEnoughInventory에 대한 응답이 설정되어 있고, 목으로서 SUT에서 수행한 호출을 검사하고 있다.

보통 이럴 땐 스텁이라고 부르지 않고 목이라고 부른다.

 

5.1.5 목과 스텁은 명령과 조회에 어떻게 관련돼 있는가?

목과 스텁의 개념은 명령 조회 분리(CQS, Command Query Separation) 원칙과 관련있다.

CQS의 원칙은 모든 메서드는 명령이거나 조회여야 하고, 이 둘을 혼용해서는 안 된다는 원칙이다.

명령 : 부작용 O, 반환 값 X

조회 : 부작용 X, 반환 값 O

명령은 목과 조회는 스텁과 일치한다.

항상 CQS 원칙을 따를 수 있는 것은 아니다. 부작용을 초래하고 값을 반환하는 것이 옳바른 경우도 있다.

예를 들어, stack.Pop()이 그러한 경우이다. 그래도 가능하면 CQS 원칙을 따르는 것이 좋다.

 

CQRS(Command Query Responsibility Separation)
CQRS는 CQS에서 확장된 개념이다. CQS는 메서드 단위에서의 분리이지만, CQRS는 객체나 시스템 단위에서 분리한다.

 

5.2 식별할 수 있는 동작과 구현 세부 사항

모든 제품 코드는 2차원으로 분류할 수 있다.

  • 공개 API 또는 비공개 API
  • 식별할 수 있는 동작 또는 구현 세부 사항

대부분의 프로그래밍 언어는 코드베이스의 공개 API와 비공개 API를 구별할 수 있는 간단한 메커니즘을 제공한다.

예를 들어, private, public 키워드들이 그 예이다.

 

하지만 식별할 수 있는 동작과 내부 구현 세부 상항에는 미묘한 차이가 있다. 코드가 시스템의 식별할 수 있는 동작이려면 다음 중 하나를 해야한다.

  • 클라이언트가 목표로 달성하는데 도움이 되는 연산(Operation)을 노출하라. 연산은 계산을 수행하거나 부작용을 초래하거나 둘 다 하는 메서드다.
  • 클라이언트가 목표를 달성하는 데 도움이 되는 상태(State)를 노출하라. 상태는 시스템의 현재 상태다.

 

구현 세부 사항은 이 두가지 중 아무것도 하지 않는다.

코드가 식별할 수 있는 동작인지 여부는 해당 클라이언트가 누구인지, 그리고 해당 클라이언트의 목표가 무엇인지에 달려있다.

이상적으로 시세틈의 공개 API는 식별할 수 있는 동작과 일치해야 하며, 모든 구현 세부 사항은 클라이언트 눈에 보이지 않아야 한다.

공개 API = 식별할 수 있는 동작, 비공개 API = 구현 세부 사항. 아주 이상적인 모습이다.

하지만 종종 공개 API가 식별할 수 있는 동작의 범위를 넘어 구현 세부 사항을 노출하기도 한다.

구현 세부 사항까지 침범해버렸다!

5.2.2 구현 세부 사항 유출: 연산의 예

NomalizeName은 50자 넘으면 너무 긴 이름을 자르는 역할을 한다.

UserController는 클라이언트 코드이다. RenameUser 메소드에서 User를 사용하는데, 이 메서드의 목표는 사용자의 이름을 변경하는 것이다.

User 클래스의 API는 잘 못 설계되었다. 왜냐하면 NomalizeName가 클래스의 공개 API로 유출되는 구현 세부 사항이기 때문이다.

User 클래스에 대한 세부 사항

 

API가 잘 설계된 User 클래스

API가 잘 설계된 User 클래스
API가 잘 설계된 User 클래스

5.2.3 잘 설계된 API와 캡슐화

잘 설계된 API를 유지 보수 하는 것은 캡슐화 개념과 관련이 있다. 캡슐화 위반은 불변성 위반이라고도 하는 모순을 방지하는 조치다. 불변성은 항상 참이어야 한다.

이전 예제에서 User 클래스에는 사용자 이름이 50자를 초과하면 안 된다는 불변성이 있었다. 하지만 클라이언트는 불변성을 우회해서 이름을 먼저 정규화하지 않고 새로운 이름을 할당할 수 있었다.

 

장기적으로 코드베이스 유지 보수에는 캡슐화가 중요하다. 복잡도 때문이다. 코드베이스가 복잡해질수록 작업하기가 더 어려워지고, 개발 속도가 느려지고, 버그 수가 증가한다.

 

가장 좋은 방법은 캡슐화를 올바르게 유지해 코드베이스에서 잘못할 수 있는 옵션조차 제공하지 않도록 하는 것이다.

마틴 파울러의 '묻지 말고 말하라'라는 유사한 원칙이 있다. 데이터를 연산 기능과 결합하는 것을 의미한다. 이 원칙은 캡슐화 실천의 귀결로 볼 수 있다.

이런 설계를 하면 자연스럽게 캡슐화된 것이라고 볼 수 있고 이는 곳 객체지향을 구현한 것과 같은 것 같다. (내 생각..)

 

5.2.4 구현 세부 사항 유출: 상태의 예

하위 렌더링 클래스 콜렉션(SubRenderers)가 public 상태이다. 즉, SubRenderers는 구현 세부 사항 유출이다.

이 예제를 다시 꺼낸 이유가 있다. 알다시피 깨지기 쉬운 테스트를 설명할 때 이 예제를 사용했었다.

테스트 코드는 구현 세부 사항에 결합되어 있어 깨지기 쉽고 테스트 대상을 Render 메서드로 바꿔서 불안정성을 해소했다.

이는 클라이언트 코드가 유일하게 관심을 갖는 출력으로 식별할 수 있는 동작에 해당한다.

 

모든 구현 세부 사항을 비공개로 하면 테스트가 식별할 수 있는 동작을 검증하는 것 외에는 다른 선택지가 없으며, 이로 인해 리팩터링 내성도 자동으로 좋아진다.

 

코드의 공개 여부와 목적의 관계. 구현 세부 사항을 공개하지 말라.

 

5.3 목과 테스트 취약성 간의 관계

5.3.1 육각형 아키텍처 정의

도메인 계층 : 애플리케이션의 필수 기능으로 비즈니스 로직이 포함돼 있다.

애플리케이션 서비스 계층 : 외부 환경과의 통신을 조정한다.

  • 데이터베이스를 조회하고 해당 데이터로 도메인 클래스 인스턴스 구체화
  • 해당 인스턴스에 연산 호출
  • 결과를 데이터베이스에 다시 저장

다른 어플리케이션도 육각형으로 나타낸다.

메시지 버스 서비스, SMTP 서비스, 서드 파티 서비스와 소통한다.

육각형 아키텍처의 목적

  • 도메인 계층과 애플리케이션 서비스 계층 간의 관심사 분리
    비즈니스 로직은 애플리케이션에서 가장 중요한 부분이다. 따라서 도메인 계층은 해당 비즈니스 로직에 대해서만 책임을 져야 한다. 다른 모든 책임에서는 제외돼야 한다.
    반면에 애플리케이션 서비스에는 어떤 비즈니스 로직도 있으면 안된다.
    도메인 계층을 애플리케이션의 도메인 지식(사용 방법) 모음으로, 애플리케이션 서비스 계층을 일련의 비즈니스 유스케이스(사용 대상)로 볼 수 있다.

  • 애플리케이션 내부 통신
    애플리케이션 서비스 계층에서 도메인 계층으로 흐르는 단방향 의존성 흐름을 규정한다.

  • 애플리케이션 간의 통신
    외부 애플리케이션은 애플리케이션 서비스 계층에 있는 공통 인터페이스를 통해 해당 애플리케이션에 연결된다. 아무도 도메인 계층에 직접 접근할 수 없다.

잘 설계된 API의 원칙에는 프랙탈 특성이 있는데, 이는 전체 계층만큼 크게도, 단일 클래스만큼 작게도 똑같이 적용되는 것이다.

각 계층의 API를 잘 설계하면 테스트도 프랙탈 구조를 갖기 시작한다. 즉, 달성하는 목표는 같지만 서로 다른 수준에서 동작을 검증한다.

애플리케이션 서비스를 다루는 테스트는 해당 서비스가 외부 클라이언트에게 매우 중요하고 큰 목표를 어떻게 이루는지 확인한다.

도메인 클래스 테스트는 그 큰 목표의 하위 목표를 검증한다.

서로 다른 계층의 테스트는 동일한 동작을 서로 다른 수준으로 검증하는 프랙탈 특성이 있다.

이전 장에서 어떤 테스트든 비즈니스 요구 사항으로 거슬러 올라갈 수 있어야 한다고 했다. 각 테스트는 도메인 전문가에게 의미 있는 이야기를 전달해야 한며, 그렇지 않으면 테스트가 구현 세부 사항과 결합돼 있으므로 불안정하다는 강한 암시이다.

 

식별할 수 있는 동작은 바깥 계층에서 안쪽으로 흐른다. 외부 클라이언트에게 중요한 목표는 개별 도메인 클래에서 달성한 하위 목표로 변환된다. 반대로 도메인에서 외부 클라이언트 까지 재귀적으로 추적할 수 있다. 이런 추적성은 식별할 수 있는 동작의 정의에 따른다.

코드 조각이 식별할 수 있는 동작이 되려면 클라이언트가 목표를 달성하도록 도울 필요가 있다.

 

잘 설계된 API로 코드베이스를 검증하는 테스트는 식별할 수 있는 동작만 결합돼 있기 때문에 비즈니스 요구 사항과 관계가 있다.

 

(정리 : API 설계 잘함 -> 클라이언트의 목표를 잘 도와주고 있음 -> 비즈니스 요구 사항과 관계가 깊다고 볼 수 있음)

 

공개 API를 항상 비즈니스 요구 사항에 따라 추적하라는 이 지침은 대부분의 도메인 클래스와 애플리케이션 서비스에 적용되지만, 유티릴리티나 인프라 코드에는 적용되지 않는다. 해당 코드로 해결하는 문제는 종종 너무 낮은 수준이고 세밀해서 구체적인 비즈니스 유스케이스로 추적할 수 없다.

 

 

5.3.2 시스템 내부 통신과 시스템 간 통신

시스템 내부 통신(Inter-system) : 애플리케이션 내 클래스 간의 통신하는 것

시스템 간 통신(Intra-system) : 애플리케이션이 다른 애플리케이션과 통신하는 것

시스템 내부 통신(Intra)과 시스템 간 통신(Inter)

참고| 시스템 내부 통신은 구현 세부 사항이고, 시스템 간 통신은 그렇지 않다.

시스템 내부 통신은 식별할 수 있는 동작이 아니다. 반면에 시스템 간 통신은 식별할 수 있는 동작이다.

 

시스템 간 통신의 특성은 별로 애플리케이션이 함께 성장하는 방식에서 비롯됐다. 성장의 주요 원칙 중 하나로 하위 호환성을 지키는 것이다. 시스템 내부에서 하는 리팩터링과 다르게, 외부 애프리케이션과 통신할 때 사용하는 통신 패턴은 항상 외부 애플리케이션이 이해할 수 있도록 유지해야한다.

목을 사용하면 시스템과 외부 애플리케이션 간의 통신 패턴을 확인할 때 좋다.

반대로 시스템 내 클래스 간의 통신을 검증하는 데 사용하면 테스트가 구현 세부 사항과 결합되며, 그에 따라 리팩터링 내성 지표가 미흡해진다.

 

5.3.3 시스템 내부 통신과 시스템 간 통신의 예

다음 비즈니스 유스케이스를 따르는 예제

  • 고객이 상점에서 제품을 구매하려고 한다.
  • 매장 내 제품 수량이 충분하면
    - 재고가 상점에서 줄어든다.
    - 고객에게 이메일로 영수증을 발송한다.
    - 확인 내영을 반환한다.

외부 애플리케이션과 도메인 모델 연결하기

시스템 간 통신 : 서브파티 시스템(외부 클라이언트)의 호출, SMTP 서비스

시스템 내부 통신 : Customer와 Store 도메인 클래스 간의 통신

 

SMTP 서비스에 대한 호출을 목으로 하는 이유는 타당하다. 리팩터링 후에도 이러한 통신 유형이 그대로 유지되도록 하기 때문에 취약성을 야기하지 않는다.

취약한 테스트로 이어지지 않는 목 사용 (좋은 예)
취약한 상태로 이어지는 목 사용 (나쁜 예)

 

5.4 단위 테스트의 고전파와 런던파 재고

런던파는 불변 의존성을 제외한 모든 의존성에 목 사용을 권장하며 시스템 내 통신과 시스템 간 통신을 구분하지 않는다. 그 결과, 테스트는 애플리케이션과 외부 시스템 간의 통신을 확인하는 것처럼 클래스 간 통신도 확인한다.

-> 리팩터링 내성 없음

고전파는 테스트 간 공유하는 의존성(SMTP 서비스나, 메시지 버스 등 프로세스 외부 의존성에 해당)만 교체하자고 하므로 훨씬 유리하다.

저자 왈) 런던파만큼은 아니지만, 고전파도 목 사용을 지나치게 장려한다. ㄷㄷ..

 

5.4.1 모든 프로세스 외부 의존성을 목으로 해야 하는 것은 아니다.

  • 공유 의존성 : 테스트 간에 공유하는 의존성 (제품 코드가 아님)
  • 프로세스 외부 의존성 : 프로그램의 실행 프로세스 외에 다른 프로세스를 점유하는 의존성(DB, 메시지 버스, SMTP 서비스)
  • 비공개 의존성 : 공유하지 않는 모든 의존성

고전파 : 공유 의존성을 피할 것을 권고. (테스트 실행 컨텍스트로 서로 방해, 테스트 병렬 처리 불가, 임의의 순처 처리 불가. 즉, 테스트  격리 안됨)

공유 의존성이 프로세스 외부에 있지 않다면, 해당 의존성을 새 인스턴스로 써서 재사용을 피하기 쉽다.

하지만 공유 의존성이 프로세스 외부에 있으면 테스트가 복잡해진다.

이럴 땐 테스트 대역(목, 스텁)으로 교체하자.

 

이렇게 교체 가능한 이유는 애플리케이션 간 통신에서 하위 호환성을 지켜야 한다는 점에서 비롯된다.

예외 케이스) 프로세스 외부 의존성과의 통신은 외부에서 관찰할 수 없다면 구현 세부 사항이다. 리팩터링 후에 그대로 유지할 필요가 없으므로 목으로 검증해서는 안 된다.

하지만 애플리케이션이 외부 시스템에 대한 프록시 같은 역할을 하고 클라이언트가 직접 접근 할 수 없다면, 하위 호환성 요구 사항이 사라진다. 즉 이러한 시스템의 통신 패턴은 구현 세부 사항이 된다.

 

5.4.2 목을 사용한 동작 검증

 

...