해당 내용을 회사 스터디때 공유했었는데 정작 내 블로그에는 올리지 않아서 그대로 올린다. 컨플루언스에 올렸던 거라 조금 깨지는 게 있는 것 같다.

테스트를 왜 할까?

  • 테스트가 없는 프로젝트의 경우, 시작은 유리하지만 이내 진척이 없을 정도로 느려진다.

  • 테스트는 초반에 노력(어쩌면 상당한 노력)이 필요하다는 것이다. 그러나 한 번 노력해두면 프로젝트 후반에도 잘 진행될 것이며 장기적으로 보면 그 비용을 메울 수 있다.

  • 보통 사람들은 테스트가 많으면 많을수록 좋다고 생각한다. 하지만 그렇지 않다. 코드는 자산이 아니라 책임이다. 코드가 더 많아질수록, 소프트웨어 내의 잠재적인 버그에 노출되는 표면적이 더 넓어지고 프로젝트 유지비가 증가한다. 따라서 가능한 한 적은 코드로 문제를 해결하는 것이 좋다.

코드 커버리지 VS 브랜치 커버리지

  • 테스트 커버리지 = 코드 커버리지 = 라인 커버리지 = 실행 코드 라인 수 / 전체 라인 수

  • 그러나 100% 커버리지라고 해서 반드시 양질의 테스트 스위트라고 보장하지 않는다.

  • 분기 커버리지 = 브랜치 커버리지

브랜치 커버리지 설정

라인 커버리지보단 브랜치 커버리지를 사용하자!

  • 그러나 이것들도 테스트 대상 시스템의 모든 가능한 결과를 검증한다고 보장할 순 없다.

  • 외부 라이브러리 코드 경로를 고려할 수 있는 커버리지 지표는 없다.

커버리지 지표를 보는 가장 좋은 방법은 지표 그 자체로 보는 것이며 목표로 여겨서는 안 된다.

병원에 있는 환자를 생각해보자. 체온이 높으면 열이 난다는 것을 의미할 수 있으며, 이는 유용한 관찰이다. 그러나 병원은 환자의 적절한 체온을 목표로 해서는 안 된다. 단순히 목표가 되면, 환자 옆에 에어컨을 설치해서 ‘효율적으로’ 빨리 끝낼 수도 있다. 물론 이런 접근은 의미가 없다.

성공적인 테스트가 갖는 특성

  • 개발 주기에 통합되어 있다

    • 이상적으로는 코드가 변경될 때마다 아무리 작은 것이라도 실행해야 한다.

    • 본인이 개발한 것에 대해서 테스트코드를 바로 추가하자.

  • 코드베이스에서 가장 중요한 부분만을 대상으로 한다.

    • 대부분의 애플리케이션에서 가장 중요한 부분인 비즈니스 로직 부분
  • 최소한의 유지비로 최대의 가치를 끌어낸다.

    • 가치있는 테스트를 식별하고 가치 있는 테스트를 작성하자.

단위 테스트가 정확히 뭘 의미하는 걸까?

  • 단위 테스트가 가지는 속성

    • 작은 코드 조각을 검증하고

    • 빠르게 수행하고

    • 격리된 방식으로 처리하는 자동화된 테스트

  • 보통 고전파와 런던파로 나뉜다.

    • 런던파 : 테스트 대상 시스템 이외의 의존성들을 모두 테스트 대역으로 대체하는 것

    • 고전파 : 실제 인스턴스를 가지고 행위를 테스트하는 것

  • 각자 장단점이 있다.

런던파의 장단점

  • 장점

    • 테스트가 실패하면 코드베이스에서 테스트 대상 시스템이 고장났다는 걸 바로 알 수 있음

    • 객체 그래프를 분할할 수 있음. 그래프가 커져도 테스트하기 쉬움

      • 그래서 레거시 코드를 처음 접했을 때 쉽게 테스트할 수 있음
    • 한 번에 한 클래스만 테스트함

  • 단점

    • 버그가 있으면 그 부분만 터짐

    • 고전파에 비해 정확도가 떨어짐

    • 프로덕션 코드가 바뀌어도 Mocking한 코드는 내가 정의하므로 테스트는 통과할 수 있음

고전파의 장단점

  • 장점

    • 실제 객체를 사용하는 만큼 정확도가 높음

    • 버그가 있으면 의존성이 엮여있는 부분들 모두 다 터질 수 있음(오히려 좋아)

  • 단점

    • 테스트 대상 시스템을 설정하려면 전체 객체 그래프를 다시 생성해야하기 때문에 작업량이 많음

런던파와 고전파의 격리주체, 테스트 대역 사용(Mocking) 대상 차이

  • 런던파는 격리 주체가 단위(단일 클래스)이며 고전파는 격리 주체가 단위 테스트이다.

    • 격리 주체가 단위 테스트라는 건 각 단위 테스트들이 병렬, 순차, 여러 클래스를 한 번에 실행해도 상관없다는 것
  • 런던파는 테스트 대역 사용 대상이 불변 의존성 외 모든 의존성인데 반해 고전파는 공유 의존성만 테스트 대역 사용 대상으로 본다.

    • 불변 객체는 교체하지 않아도 된다. 5라는 숫자를 테스트 대역으로 사용하진 않으니..

DB나 외부 API는 어떻게 해야하나?

  • 공유 의존성 : 테스트간에 공유되고 서로의 결과에 영향을 미칠 수 있는 수단

    • static mutable 필드, DB 등…

    • DB나 파일시스템 등의 공유 의존성에 대한 호출은 오래 걸리므로 통합 테스트 영역으로 넘어간다.

  • 외부 API의 경우, 테스트 속도를 높이고 안정성 있게 테스트 대역으로 사용한다.

통합 테스트란?

  • 런던파에서는 실제 객체를 사용하면 통합 테스트로 간주한다.

  • 고전파는 둘 이상의 동작 단위를 테스트하거나 공유 의존성과 함께 작동하거나 둘 이상의 모듈을 함께 테스트하거나

엔드투엔드 테스트란?

  • 엔드투엔드 테스트는 통합 테스트의 일부

  • 엔드투엔드 테스트가 의존성을 더 많이 포함함. 최종 사용자관점에서 검증하는 것을 의미한다.

통합 테스트 VS 엔드투엔드 테스트

  • 통합 테스트는 DB와 파일 시스템만 포함하고 결제 게이트웨이는 테스트 대역으로 대체

  • 엔드투엔드는 모두 다 테스트

    • 유지보수 측면에서 가장 비용이 많이 들기때문에 모든 단위 테스트와 통합테스트가 통과한 후, 마지막에 넣어주는 게 좋다.

테스트 스타일

  • given, when, then으로 함

    • 보통 given 쪽이 할 게 많은데 너무 크면 private 팩토리 메서드나 별도의 팩토리 클래스로 도출해도 좋다
  • 테스트 대상 시스템을 sut으로 표현해서 작성하면 쉽게 알 수 있다

  • 단위 테스트를 명명할 때 본질에 집중하자. 읽기 쉬운 게 짱이다. should be 같은 거 쓰지말자!

테스트는 코드의 단위를 검증해서는 안된다. 동작의 단위, 즉 문제 영역에 의미가 있는 것, 이상적으로는 비즈니스 담당자가 유용하다고 인식할 수 있는 것을 검증해야 한다.

  • 위 코드의 문제점을 찾아보자.

    • 답은 구매 프로세스라는 단일 작업을 수행하는데 2개의 메서드를 호출한다는 것이다. 테스트는 구매 프로세스라는 동일한 동작 단위를 검증해야한다. 비즈니스 관점에서 구매가 정상적으로 이뤄지면 고객의 제품 획득과 매장 재고 감소는 동일하게 이뤄져야 한다.

    • 만약 소비자가 구매만 하고 재고는 줄어들지 않는다면? 모순이 생겨버린다.

    • 실제로 구현을 하게 된다면 구매 프로세스를 실행하는 서비스가 있을 것이고 Store와 Customer를 의존하고 있을 것이며 고객의 제품 획득과 매장 재고 감소 로직이 함께 들어가야한다.

테스트는 제품 코드의 기능을 무조건 나열하는 것이 아니라 동작에 대해 고수준의 명세가 있어야 한다.

몇 가지 꿀팁

  • @Tag(“쏼라쏼라”) 활용

이후는 발표 내용이 아니고 개인적으로 작성해놓은 것 추가

회귀를 방지해야 한다.

  • 코드를 수정했을 때 기존의 코드 기능이 제대로 동작하지 않는 경우를 회귀라고 한다.

  • 이는 코드베이스가 커지고 복잡도가 클수록 회귀 가능성도 커진다.

  • 회귀를 방지하기 위해서는 복잡한 비즈니스 로직을 위주로 테스트해야한다.

  • 물론 회귀를 방지하기 위한 최고의 방법은 비즈니스 로직, 해당 라이브러리, 외부시스템 등을 모두 테스트하는 것이나 시간이 오래 걸린다.

복잡한 비즈니스 로직을 위주로 테스트하자, 소수의 가치있는 테스트는 평범한 테스트보다 훨씬 효과적이다

결합도를 최대한 낮추고 클래스의 중요한 역할을 테스트해야 한다.

  • 테스트는 실패하나 기능은 제대로 동작하는 그런 테스트를 만들면 안된다.

    • 테스트가 이유없이 실패하면 리팩터링하려는 능력과 의지가 희석되고 실패에 익숙해지게 된다. 그러다보면 기능이 고장나도 운영환경에 나가게 된다.

    • 이게 지속되면 실패한 테스트는 비활성화하게 되고, 잊혀진다.

  • 위의 경우(거짓 양성 = 기능은 제대로 동작하나 테스트는 실패하는 경우)를 없애려면 의미있는 테스트를 작성하되 구현체와 너무 결합되어있으면 안된다.

    • 인터페이스로 분리하고 최대한 결합도를 낮춰서 테스트를 작성해야 한다.

    • 의미있는 테스트의 경우, 해당 클래스의 본질적인 역할역할을 테스트한다.

  • 예를 들어서, A가 B에 의존하고 있는데 A를 테스트한다면 A에 대한 최종 결과에 대해서만 테스트해야 한다. B 의존성까지 올바른지 테스트하면 안된다. (결합도를 낮춰야 한다.)

    • 즉, 좋은 테스트는 최종 결과가 올바른지 테스트하는 것이고 좋지 않은 테스트는 모든 의존성을 포함한 것이 올바른지 테스트하는 것이다.

    • 예를 들어, html render를 테스트한다고 했을 때 좋은 테스트는 딱 html 페이지를 render하는 것만 테스트하지만 좋지 않은 테스트는 header render, body render, footer render 등을 모두 테스트한다.

A가 B에 의존하고 있는데 A를 테스트한다면 A에 대한 최종 결과에 대해서만 테스트해야 한다. B 의존성까지 올바른지 테스트하면 안된다.

테스트는 빨라야 하고 테스트 또한 유지보수가 가능해야 한다.

  • 테스트는 자주 실행할 수 있어야 한다.

  • 외부 의존성과 함께 작동하면 매우 느려지며 유지보수도 힘들어진다.

  • 테스트는 통과하지만 기능은 고장난 경우, 테스트는 실패하지만 기능은 정상인 경우, 이 두 경우를 무조건 고쳐야 한다.

그럼 이상적인 테스트는 어떤 걸까?

  • 엔드투엔드 테스트는 외부 의존성 모두 테스트한다.

    • 그러므로 회귀 방지를 훌륭히 해낸다. 어떻게 작성하냐에 따라 결합도도 충분히 낮출 수 있다.

    • 그러나 속도가 너무 느리고 유지보수도 힘들다.

  • 단위 테스트는 회귀 방지가 조금 덜한 대신 속도가 빠르다. 결합도는 작성하기에 따라 낮출 수 있고.

  • 결국 속도 or 모든 의존성을 실행 중 선택해야 한다.

테스트를 작성할 때 블랙박스 테스트 즉 코드 내부 구조를 모르는 것처럼 테스트하라. (무엇을 테스트하는지를 중심으로 작성하라)