Skip to content

Latest commit

 

History

History
277 lines (147 loc) · 17.4 KB

Chapter10.md

File metadata and controls

277 lines (147 loc) · 17.4 KB

10장 단위 테스트의 원칙

"딱 한줄 고쳤는데요?"

논의 사항

작업을 하다 테스트 코드를 작성하게 되는 계기가 궁금합니다.

ex) 코드를 짜 놓고 보니 문제가 될 것 같아서 테스트 코드를 만들어 둔다 / 테스트 코드를 먼저 짜고 맞게 코드를 작성한다.. 등등

책의 내용 및 정리

핵심 주제

  • 단위 테스트의 기본 사항
  • 좋은 단위 테스트가 되기 위한 조건
  • 테스트 더블
  • 테스트 철학

단위 테스트에 대한 정확한 정의는 없다.

하지만 단위 테스트를 구성하고 있는 것이 무엇인지, 그리고 정확한 정의가 없음에도 일부러 정의를 고안해 내고 자신이 자신이 작성하는 테스트가 그 정의에 정확하게 부합하는지에 대해 너무 집착하지 않는 편이 좋다.

단위 테스트 기초

  • 테스트 중인 코드(code under test): 실제 코드라고도 하고 테스트의 대상이 되는 코드를 의미한다.
  • 테스트 코드(test code): 단위 테스트를 구성하는 코드를 가리킨다.
  • 테스트 케이스(test case): 테스트 코드의 각 파일에는 일반적으로 여러 테스트 케이스가 있고, 각 테스트 케이스는 특정 동작이나 시나리오를 테스트한다.(테스트케이스가 단순한 케이스가 아니라면 세가지로 분류된다.)
    • 준비(arrange): 테스트할 특정 동작을 호출하려면 먼저 몇 가지 설정을 수행해야 하는 경우가 많다. (의존성을 설정, 인스턴스 설정 등)
    • 실행(start): 테스트 중인 동작을 실제로 호출하는 코드를 나타낸다.
    • 단언(assert): 테스트 중인 동작이 실행되고 나면 실제로 올바른 일이 발생했는지 확인한다.
  • 테스트러너(test runner): 이름에서 알 수 있듯이 테스트 러너는 실제로 테스트를 실행하는 도구다.

테스트 코드의 중요성은 다른 책들을 봐도 쉽게 알 수 있다.

요즘 대부분의 전문적인 소프트웨어 개발 환경에서는 거의 모든 실제 코드에 단위 테스트가 동반되는 것으로 생각한다.

단위 테스트라고 해서 어려운 개념이라고 생각했는데 10.1의 그림같은 구조라면 구조가 걱정될 때 비슷하게 제작한 적이 종종 있다.

훌룡한 테스트를 하기 위해서는 테스트만 있다고 되는 것이 아니라, 좋은 테스트가 필요하다.

좋은 단위 테스트는 어떻게 작성할 수 있는가?

좋은 단위 테스트가 간단해 보일일지 모른다. 실제 코드가 작동하는지 확인하기 위해 테스트 코드를 작성하기만 하면 된다고 생각할 수 있지만.. 수년 동안 많은 개발자가 쉽게 단위 테스트를 잘못된 방식으로 작성해 왔다.

이를 위해 좋은 단위 테스트가 가져야 할 5가지 주요 기능을 정의한다.

  • 훼손의 정확한 감지: 코드가 훼손되면 테스트가 실패한다. 그리고 테스트는 코드가 실제로 훼손된 경우에만 실패해야 한다.
  • 세부 구현 사항에 독립적: 세부 구현 사항을 변경하더라도 테스트 코드는 변경하지 않는 것이 이상적이다.
  • 잘 설명되는 실패: 코드가 잘못되면 테스트는 실패의 원인과 문제점을 명확하게 설명해야 한다.
  • 이해할 수 있는 테스트 코드: 다른 개발자들이 테스트 코드가 정확히 무엇을 테스트하기 위한 것이고 테스트가 어떻게 수행되는지 이해할 수 있어야 한다.
  • 쉽고 빠르게 실행: 개발자는 일상 작업 중에 단위 테스트를 자주 실행한다. 단위 테스트가 느리거나 실행이 어려우면 개발 시간이 낭비된다.
훼손의 정확한 감지

단위 테스트의 가장 명확하고 주된 목표는 코드가 훼손되지 않았는지 확인하는 것이다.

즉, 의도된 대로 수행하며 버그가 없다는 것을 확인하는 것이다.

테스트 중인 코드가 어떤 식으로든 훼손되면 컴파일되지 않거나 테스트가 실패해야 한다.

이것은 매우 중요한 두 가지 역할을 수행한다.

  • 코드에 대한 초기 신뢰를 준다

아무리 신중하게 코드를 작성해도 실수는 있기 마련이다.

새로운 코드나 코드 변경 사항과 함께 철저한 테스트 코드를 작성하면 코드가 코드베이스로 병합되기 전에 이러한 실수를 발견하고 수정할 수 있다.

  • 미래의 훼손을 막아준다

어느 시점에 다른 개발자가 코드를 변경또는 병합하는 과정에서 실수로 코드를 훼손할 가능성이 크다.

이것에 대한 유일한 효과적인 방어 방법은 코드가 컴파일을 중지하거나 테스트가 실패하는 것이다.

어떤 것이 고장 났을 때 코드가 컴파일을 멈추도록 하는 것은 불가능하므로 모든 올바른 동작을 테스트를 통해 확인하는 것은 절대적으로 중요하다.

코드 변경으로 인해 잘 돌아가던 기능이 동작하지 않는 것을 회귀라고 한다.

이러한 회귀를 탐지할 목적으로 테스트를 실행하는 것을 회귀 테스트라고 한다.

정확성의 또 다른 측면을 고려하는 것도 중요하다.

테스트 대상 코드가 실제로 훼손된 경우에만 테스트가 실패해야 한다.

위의 내용처럼 당연히 그렇게 될 것 같지만 실제로는 그렇지 않은 경우가 많다.

논리적 오류를 경험한 사람이라면 누구나 알겠지만, 코드가 훼손되면 반드시 실패한다는 것이 반드시 코드가 훼손될 때만 테스트가 실패한다는 것을 의미하는 것은 아니다.

테스트 대상 코드가 정상임에도 불구하고 때로는 통과하고 때로는 실패하는 테스트를 플래키(flakey)라고한다.

이것은 보통 무작위 성, 타이밍 기반 레이스조건, 외부 시스템에 의존하는 등의 테스트의 비결정적동작에 기인한다.

플래키테스트의 위험성을 피하기 위해선 코드에서 어떤 부분이 훼손될 때 그리고 오직 훼손된 경우에만 테스트가 실패하도록 하는 것은 매우 중요하다.

세부 구현 사항에 독립적

일반적으로 개발자가 코드베이스에 가할 수 있는 변경은 두 가지 종류가 있다.

  • 기능적 변화: 이것은 코드가 외부로 보이는 동작을 수행한다. 예를 들어 새로운 기능 추가, 버그 수정, 에러 처리등이 있다.
  • 리팩터링: 이것은 큰 함수를 작은 함수로 분할하거나 재사용하기 쉽도록 일부 유틸리티코드를 다른 파일로 옮기는 등의 코드의 구조적 변화를 의미한다.

첫 번째는 코드를 사용하는 모든 사람에게 영향을 미치므로 변경을 하기 전에 코드를 호출하는 쪽을 신중히 고려해야 한다.

기능적인 변경은 코드의 동작을 수정하기 때문에 테스트도 수정해야 할 것으로 기대하고 예상한다.

두 번째 경우는 코드를 사용하는 사람에게 영향을 미치지 않아야 한다.

하지만 코드의 구조만 변경하는 것인데 그 과정에서 실수로 코드의 동작을 변경하지 않았다고 확신할 수 있을까?

따라서 단위 테스트에서 리팩터링의 문제를 올바르게 발견하기 위해선 세부 구현 사항에는 독립적이어야 한다.

++ 기능 변경과 리팩터링을 같이 하지 말라: 구분하기 어려움

잘 설명되는 실패

테스트에 대한 실패가 발생했을 땐 실패가 발생되는 부분을 알아야한다.

하지만 테스트 실패가 무엇이 잘못됐는지 알려주지 않는다면 그것을 알아내기 위한 많은 시간을 낭비한다.

테스트 실패가 잘 설명되도록 하는 좋은 방법 중 하나는 하나의 테스트 케이스는 한 가지 사항만 검사하고 각 테스트 케이스에 대해 서술적인 이름을 사용하는 것이다.

이해 가능한 테스트 코드

다른 개발자는 새로운 요구 사항을 충족하기 위해 코드의 기능을 의도적으로 수정할 수 있다.

이러한 경우는 의도적이며, 변경을 수행하는 개발자는 변경된 결과가 안전한지 확인한 후에는 새로운 기능을 반영하기 위헤 테스트 코드도 수정해야 한다.

개발자가 자신이 변경한 사항이 원하는 동작에만 영향을 미친다는 확신을 가지려면 테스트의 어느 부분이 영향을 미치고 있는지, 테스트 코드에 대한 수정이 필요한지 여부를 알 수 있어야 한다.

이를 위해서는 서로 다른 테스트 케이스가 무엇을 테스트하는지 그리고 어떻게 테스트하는지 이해하고 있어야 한다.

개발들이 테스트 코드를 일종의 사용설명서처럼 사용하기 때문에 이해 가능하게 작성해야 한다.

쉽고 빠른 실행

단위 테스트의 중요한 기능 중 하나는 잘못된 코드가 코드베이스에 병합되는 것을 방지하는 것이다.

따라서 많은 코드베이스에서 관련 테스트를 통괴해야만 병합이 가능한 병합 전 검사를 수행한다.

만약 단위 테스트를 실행하는 데 한 시간이 걸린다면 코드 변경 병합 요청이 작거나 사소한 것과 상관없이 최소 한 시간이 걸리기 때문에 모든 개발자의 속도가 느려진다.

퍼블릭 API에 집중하되 중요한 동작은 무시하지 말라

앞서 구현 세부사항에 구애받지 않는 것이 중요한 이유에 대해 설명했다.

코드는 퍼블릭 API와 구현 세부 사항으로 나눌 수 있다.

따라서 우리는 퍼블릭 API만을 사용해서 테스트해야 한다는 것을 의미한다.

퍼블릭 API에 초점을 맞추면 세부 구현사항이 아닌 코드 사용자가 궁극적으로 신경 쓸 동작에 집중할 수 밖에 없게 되는데, 세부 사항은 목적을 이루기 위한 수단일 뿐이다.

이렇게 하면 실제로 중요한 사항만 테스트 하는 데 도움이 되며, 테스트 과정에서 구현 세부 사항에 상관없이 테스트에 집중할 수 있다.

중요한 동작이 퍼블릭 API 외부에 있을 수 있다

처음에는 읽고 이해하기 조금 어려웠다.

테스트코드의 형태를 안봐서 그런가 했는데 그림을 보니 이해가 된다.

테스트는 가능하다면 퍼플릭 API를 사용해서만 테스트하는 것을 목표로 해야한다.

하지만 원하는 부수효과를 확인하기 위해서 테스트가 공용 API의 일부가 아닌 종속성과 상호작용해야 하는 경우가 많다.

테스트 더블

의존성을 실제로 사용하는 것에 대안으로 테스트 더블(test double)이 있다.

테스트 더블은 의존성을 시뮬레이션하는 객체지만 테스트에 더 적합하게 사용할 수 있도록 만들어진다.

세가지 유형의 테스트 더블 목, 스텁, 페이크에 대해 살펴본다.

테스트 더블을 사용하는 이유
  • 테스트 단순화

일부 의존성은 테스트에 사용하기 까다롭고 힘들다.

의존성은 많은 설정이 필요하거나 하위 의존성을 설정해야 할 수도 있다.

이러면 테스트는 복잡하고 구현 세부 사항과 밀집하게 결합될 수 있다.

의존성을 실제로 사용하는 대신 테스트 더블을 사용하면 작업이 단순해진다.

  • 테스트로부터 외부 세계 보호

일부 의존성은 실제로 부수 효과를 발생한다.

코드의 종속성 중 하나가 실제 서버에 요청을 전송하거나 실제 데이터베이스에 값을 쓰게 되면, 사용자가 비지니스에 중요한 프로세스에 나쁜 결과를 초래할 수 있다.

이러한 상황에서 테스트 더블을 사용하면 외부 세계에 있는 시스템을 테스트의 동작으로부터 보호할 수 있다.

  • 외부로부터 테스트 보호

외부 세계는 비결정적일 수 있다.

다른 시스템이 데이터베이스에 쓴 값을 의존성 코드가 읽는다면 이 값은 시간이 지남에 따라 변경될 수 있다.

이 경우 테스트 결과를 신뢰하기 어려울 수 있다.

반면 테스트 더블은 항상 동일하게 결정적 방식으로 작동하도록 설정할 수 있다.

목(mock)은 클래스나 인터페이스를 시뮬레이션하는 데 멤버 함수에 대한 호출을 기록하는 것외에는 어떠한 일도 수행하지 않는다.

함수가 호출될 때 인수에 제공되는 값을 기록한다.

테스트 대상 코드가 의존성을 통해 제공되는 함수를 호출하는지 검증하기 위해 목을 사용할 수 있다.

따라서 목은 테스트 대상 코드에서 부수 효과를 일으키는 의존성을 시뮬레이션하는 데 가장 유용하다.

스텁

스텁은 함수가 호출되면 미리 정해 놓은 값을 반환함으로써 함수를 시뮬레이션 한다.

이를 통해 테스트 대상 코드는 특정 멤버 함수를 호출하고 특정 멤버 함수를 호출하고 특정 값을 반환하도록 의존성을 시뮬레이션할 수 있다.

그러므로 스텁은 테스트 대상 코드가 의존하는 코드로부터 어떤 값을 받아야 하는 경우 그 의존성을 시뮬레이션 하는 데 유용하다.

목과 스텁은 문제가 될 수 있다
  • 목이나 스텁이 실제 의존성과 다른 방식으로 동작하도록 설정되면 테스트는 실제적이지 않다.
  • 구현 세부 사항과 테스트가 밀접하게 결합하여 리펙터링이 어려워질 수 있다.
페이크

페이크(fake)는 클래스의 대체 구현체로 테스트에서 안전하게 수용한다.

페이크는 실제 의존성의 공개 API를 정확하게 시뮬레이션하지만 구현은 일반적으로 단순한데, 외부 시스템과 통신하는 대신 페이크 내의 멤버 변수에 상태를 저장한다.

페이크의 요점은 3장에서 다룬 코드 계약에 관련된 내용으로 실제 의존성이 동일하기 때문에 실제 클래스가 특정 입력을 받아들이지 않는다면 페이크도 마찬가지라는 것이다.

따라서 실제 의존성에 대한 코드를 유지보수하는 팀이 일반적으로 페이크 코드도 유지보수해야 하는데, 실제 의존성에 대한 코드 계약이 변경되면 페이크의 코드 계약도 동일하게 변경되어야 하기 때문이다.

목에 대한 의견
  • 목 찬성론자: 개발자는 단위 테스트 코드에서 의존성을 실제로 사용하는 것을 피해야 하고 대신 목을 사용해야 한다고 주장한다.
  • 고전주의자: 목과 스텁은 최소한으로 사용되어야 하고 개발자는 테스트에서 의존성을 실제로 사용하는 것을 최우선으로 해야한다.

목을 사용한 테스트는 상호작용을 하는 반면, 고전주의 방법을 사용한 테스트는 코드의 결과 상태와 의존성을 테스트하는 경향이 있다는 점이다.

이러한 의미에서 목 접근법은 대상 코드를 어떻게 하는가를 확인하는 반면, 고전 주의 접근법은 코드를 실행하는 최종 결과가 무엇인지 확인하는 경향이 있다.

테스트 철학으로부터 신중하게 선택하라

책의 앞부분에서도 계속 나온 내용처럼 테스트에 대한 철학과 방법론의 내용처럼 이런 내용에 정답은 없다.

스스로 판단하여 옳다고 생각하는 바를 실천할 자유가 있다.

  • 테스트 주도 개발(test-development, TDD): TDD는 실제 코드를 작성하기 전에 테스트 케이스를 먼저 작성하는 것을 지지한다.

실제 코드는 테스트만 통과하도록 최소한으로 작성하고 이후에 구조를 개선하고 중복을 없애기 위해 리팩터링을 한다.

  • 행동 주도 개발(Behavior-driven devlopment, BDD): BDD는 사람마다 조금씩 다른 의미를 가질 수 있지만 이 철학의 핵심은 사용자, 고객, 비즈니스의 관점에서 소프트웨어가 보야야 할 행동을 식별하는 데 집중하는 것이다.

  • 수용 테스트 주도 개발(Acceptance test-driven developnent ATDD): 고객의 관점에서 소프트웨어가 보여줘야 하는 동작을 식별하고 소프트웨어가 전체적으로 필요에 따라 작동하는지 검증하기 위해 자동화된 수락 테스트를 만드는 것을 수반한다.

느낀점

뒤의 테스트 더블 부분은 정말 정말 이해하기 어려웠다..

테스트코드자체를 작성해본적이 없어서 뭐라도 해볼려고 UnityTestCode를 작성해보고 공부했지만.. 테스트 더블은 이해하기 어려웠다.

Unity TestRunner

Unity에서는 TestRunner를 통해 TDD를 가져가는 것 같다.

게임쪽에서 한번도 자동으로 테스트되는 경우를 만들어보지 못해서 한번 만들어 보고 싶다.

코드베이스에 병합을 시도하면 병합에 문제가 없다면 -> 테스트 전부 실행, 문제가 없다면 -> 자동 빌드

Game Ci

젠킨스처럼 CI/CD를 위한 github action을 활용한 기능도 있다.

이런 흐름을 가져가면 개발할 맛도 나고 좋을 것 같은데 CI/CD의 대해서 한번 알아보고 간단하게 미니프로젝트에 도입해볼 생각..