sungyup's.

becoming_a_better_programmer / you.write(code); / 1.11 테스트하기

1.11테스트하기

테스트 주도 개발(TDD)을 통한 개발 테스트 전략

TL;DR

TDD(Test-Driven Development, 테스트 주도 개발)는 더 나은 소프트웨어를 만들기 위한 중요한 기법 중 하나다. 하지만 이 테스트 주도, 그리고 유닛 테스트가 실제로 무엇을 의미하는가에 대한 명확한 이해를 해야 의미가 있다.

왜 테스트하는가

소프트웨어 개발자들이 자신의 새로운 코드가 작동하는지 확인해보는 것은 당연하다. 하지만 일부 프로그래머들은 지나치게 확신에 차 있어서 코드를 작성한 뒤 테스트도 하지 않고 배포한다. 이처럼 대충 작업하면 코드는 처음부터 비정상적으로 작동하고, QA 단계에서 발견되거나 사용자가 발견할때까지 방치된다.

피드백 과정 줄이기

위대한 소프트웨어를 만들기 위해선 가능하면 자주, 그리고 빨리 피드백을 받아야한다. 좋은 테스트 전략은 피드백 절차를 간소화한다.

  • 사용자의 불평을 통해서만 피드백을 받는다면 소프트웨어 개발은 매우 더딜 것이다.
  • 사용자에게 가기 전, QA팀에서 후보 버전을 테스트하면 피드백 과정이 더 줄어든다. 문제를 더 빨리 알 수 있고, 시장에 나간 뒤 발생하는 문제에 소모되는 비용을 절약할 수 있다.
  • 그 전에, 새로운 하부 시스템을 프로젝트 전체에 통합하기 전에 확인할 수 있다. 소스 관리 도구에서 브랜치하여 새로운 코드를 테스트해보는 과정을 통해 개발자가 테스트하면 코드가 의도대로 작동하는지 확인할 수 있다.
  • 하지만 더 전에, 하부 시스템의 더 작은 단위(units)에서 클래스와 함수 수준의 정확도와 품질에 대한 피드백을 얻는다면 피드백 절차는 더욱 줄어든다.

즉, 가장 작은 수준에서 테스트를 하면 가장 빠르게 피드백을 얻을 수 있다. 문제를 빨리 알수록 수정은 쉬워지고 비용이 낮아진다.

다만, QA팀이나 개발자가 수작업으로 테스트하는 것은 손이 많이 가고 느리다. 포괄적인 테스트가 되려면, 수많은 개별적 단계들을 통해 코드에 약간 수정을 가할지언정 그때그때 반복적으로 수행할 수 있어야 한다.

자동화된 테스트를 통해 피드백 과정을 줄이면, 코드 개발 과정에 도움이 되고 재활용 할 수도 있다.

테스트 코드 짜기

가장 이상적인 방법은 최대한 많은 개발자 테스트를 자동화하는 것이다. 물론 UI 테스트 도구를 통해 애플리케이션을 자동으로 테스트하거나, 브라우저 기반의 테스트 도구를 통해 웹 애플리케이션을 자동으로 테스트할 수도 있다. 하지만 대부분의 경우 개발자 테스트는 별도의 프로그램과 테스트 대상 시스템(SUT: System Under Test)에 대한 테스트를 자동으로 수행해 반응을 확인한다.

테스트를 짜는 것은 시간이 걸리는 일이지만, 적절한 테스트 전략이야말로 불량 발생률을 줄일 수 있다.

누가 테스트 코드를 짜야 할까

코더 자신이 자신의 코드에 대한 테스트 코드를 작성하는 것이 좋다.

테스트 유형

테스트의 유형엔 아래와 같은 것들이 있다.

  • 유닛 테스트(Unit Tests): 가장 작은 단위의 기능에 대한 테스트를 단독으로 수행한다. 각각의 함수가 정확하게 작동하는지 확인하고, 어떤 외부 접속(데이터베이스, 네트워크, 파일 시스템)도 하지 않는다.
  • 통합 테스트(Integration Tests): 단위 모듈을 더 큰 결합체로 통합하여 작동시키는 복합 기능을 검증한다. 통합된 단위 요소들이 정확하게 상호 동작하는 지 확인한다. 보통 유닛 테스트와 같은 프레임워크를 사용하고, 테스트 대상이 되는 범위만 다르다.
  • 시스템 테스트(System Tests): E2E(End-to-end) 테스트라고도 알려져있다. 전체 시스템에 대한 요구사항 명세를 확인하고, 통합된 소프트웨어 스택 전체에 수행되는 테스트다. 시스템 테스트는 범위가 넓어 전체 테스트를 수행하는 데 많은 시간이 걸릴 수 있다.

각각의 테스트는 각자의 위치에서 모두 중요하므로, 성숙한 소프트웨어 프로젝트에는 모두 존재해야 한다.

언제 테스트를 작성할까

TDD는 테스트 우선 개발(Test-First Development)이라는 용어와 차이가 있지만 같이 쓰이곤 한다. 왜냐면 테스트 작성을 미룰수록 테스트의 효과가 떨어지기 때문이다. 코드가 어떻게 작동하는지 잊어버리거나, 극단적인 경우에 대한 테스트를 작성하지 못할 수도 있다. 혹은 아예 테스트 코드 작성을 잊어버릴 수도 있다.

테스트 우선 TDD는 주로 XP라고 불리는 익스트림 프로그래밍(Extreme Programming, 애자일 개발 프로세스라 불리는 개발 방법 중 하나다. 프로그래머가 코딩할때 테스트 코드를 작성하게 만들고 그것을 기반으로 프토젝트를 완성한다) 진영에서 종종 보인다.

실패 테스트를 작성하기 전에 어떤 구현 코드도 작성하지 말라.(익스트림 프로그래밍의 지침 중 하나)

테스트 우선 TDD는 아래와 같이 진행된다.

  1. 다음으로 구현해야 하는 기능을 결정한 뒤, 해당 기능에 대한 테스트를 작성한다.(테스트는 물론 실패할 것이다.)
  2. 테스트를 작성하고 최대한 간단한 방법으로 기능을 구현하라. 테스트를 통과했다면 적절히 기능을 구현한 것이다. 각 단계에서 작은 기능을 추가하면서 테스트도 추가한다.
  3. 코드를 정리하고, 이상한 공통분모를 리팩토링한다. 테스트 대상 시스템을 재구조화해서 더 나은 내부 구조로 만든다.
  4. 첫 단계로 돌아가서, 요구사항에 대한 테스트 케이스 전체를 통과할 때까지 테스트를 반복한다.

짧은 피드백 과정의 일례로, 레드-그린-리팩토링(red-green-refactor) 주기라고도 불린다.

설령 "테스트 우선"을 하지 않더라도, 피드백 절차를 줄이고, 특정 범위에 대해 코드를 작성하며 해당 범위에 대한 유닛 테스트 코드를 작성하라. 모든 것이 기능적으로 정확성을 보장하고 퇴행을 방어하고, 클래스 API가 실제로 어떻게 사용될지 그리고 얼마나 간결한지 확인할 수 있는 최고의 방법이다.

테스트 작성을 위한 또 하나의 적절한 시기는 바로 출시 코드에 대한 버그를 수정할 때다. 코드 수정에 무작정 달려들지 말고 우선 버그의 원인을 설명하는 실패 유닛 테스트를 작성하라. 이 테스트 작성 과정에서 다른 관련 버그를 찾을 수도 있다.

언제 테스트를 실행하는가

테스트 코드를 TDD를 사용해 개발하면 각각의 기능을 구현하면서 테스트를 계속 실행할 수 있다. 정확하고 충분하게 구현되었는지 지속적으로 검증할 수 있다.

버전 관리 도구에 구현 코드와 테스트 코드 모두를 추가하라. 테스트는 사라지지 않고 이전부터 존재해온 테스트들과 함께한다. 만약 이후에 누가 코드를 잘못 수정한다면, 너무 늦기 전에 그에 대한 경고를 받게 된다.

모든 테스트는 지속적 통합(CI: Continuous Integration) 도구의 일부인 빌드 서버에서 실행해야 한다. 유닛 테스트는 개발 머신에서 자주 실행해야 한다.

테스트를 빠르게 자주 수행하라. 빌드 과정에 테스트를 통합하라.

통합 테스트와 시스템 테스트는 개발 머신에서 컴파일 때마다 수행되면 너무 오래 시간이 걸린다. 이런 경우는 CI 빌드 서버에서만 적절히 수행할 수 있다.

코드 수준의 자동화 테스트를 하더라도, 출시 전 사람에 의한 QA 검수 과정은 필요하다. 진정한 테스트 전문가에 의한 탐색적 테스트는 매우 유용하다.

무엇을 테스트할 것인가

애플리케이션에서 중요한 부분모두 테스트하라.

만약 애플리케이션의 성능이 주요 요구사항이라면, 코드 성능을 모니터링하는 테스트도 수행한다. 서버에서 특정 시간대에 어떤 응답을 해야 한다면 그 조건에 대한 테스트도 포함하라.

좋은 테스트

나쁜 테스트는 짐이 된다. 하지만 걱정하지 말고 테스트를 계속 쓰고, 쓸모없을까 걱정하여 무기력해서는 안된다.

좋은 테스트의 특징은 다음과 같다:

  • 이름이 짧고 명확해 실패했을 때 무엇이 문제인지 쉽게 알 수 있다.
  • 유지 보수가 가능해서, 작성/읽기/수정하기가 모두 쉽다.
  • 수행에 오랜 시간이 걸리지 않는다.
  • 최신 구현 코드와 싱크가 맞다.
  • 특별한 머신 설정이 필요 없다. 파일 시스템 경로를 조정하거나 데이터베이스를 설정할 필요가 없다.
  • 다른 테스트에 대한 의존성이 없어서 특정 테스트를 실행하고 해야한다거나 할 필요가 없다.
  • 실제 구현 코드를 테스트한다. 즉, 구현 코드의 복제본에 대한 테스트가 아니다.

나쁜 테스트는 다음의 특징이 있다:

  • 때로는 성공하고 때로는 실패한다. 발생 요인으로는 스레드 사용, 특정 시점에 발생하는 경쟁 조건, 외부 의존성, 테스트 간 순서, 공유 상태 등이 있다.
  • 이상해보이고, 읽거나 수정하기 어려움
  • 지나치게 큰 테스트 코드
  • 하나의 테스트 케이스에서 둘 이상을 수행
  • 직접 작성하지 않은 서드파티 코드에 대한 테스트(이걸 못 믿으면 쓰면 안된다)
  • 클래스의 주요 기능이나 행태에 대해 실제로 테스트를 하지 않는 코드
  • 하나의 머신에섬나 수행 가능한 테스트
  • 화이트박스식 테스트. 즉, SUT의 내부 구현에 대한 지식이 있어야 할 수 있는 테스트. 테스트는 블랙박스 식으로 수행해야만 입력과 결과를 통해 추상적 요구사항을 구현하는지 확인할 수 있다.

테스트는 어떠해야 하는가

테스트 프레임워크에 따라 테스트 코드의 형태가 결정된다.

일반적으로 각 테스트는 준비 과정이 필요하고, 준비가 되면 실제 실행하게 되며, 마지막으로 실행 결과를 검증한다. 이것이 배치-실행-확인(arrange-act-assert) 패턴이다. 예를 들면, 아래는 Java로 만든 유닛 테스트다.

java
@Test public void stringsCanBeCapitalised() { // 1. 배치(Arrange): 입력을 준비한다. String input = "This string should be uppercase"; String expected = "THIS STRING SHOULD BE UPPERCASE"; // 2. 실행(Act): 실제로 실행한다. String result = input.toUpperCase(); // 3. 확인(Assert): 실행 결과를 확인한다. assertEquals(result, expected); }

테스트 이름

하나의 기능 검증에 집중하는 테스트에는 명확한 이름이 붙어서 간단한 문장처럼 읽힌다. 테스트 케이스에 이름 붙이는게 너무 어렵다면, 아마 요구사항이 모호하거나 여러 가지를 한번에 테스트하려고 해서 그럴 것이다.

테스트 메소드에는 @Test와 같은 특성이 지정되므로 메소드 이름에 다 test라는 단어를 붙일 필요는 없다.

테스트들이 코드에 대한 명세 사항으로써 읽힌다고 가정하라. 각 테스트의 이름은 테스트 대상 시스템(SUT)을 실행하는 것에 대한 설명, 즉 하나의 정의에 대한 설명이다. 'should'나 'must' 같이, 실제 행위에 대한 것이 아닌 단어를 쓰지 마라.

테스트 구조

테스트가 코드의 중요 기능을 모두 다룬다는 것을 보장하라. 정상적인 입력 값들은 물론, 일반적 실패 케이스 및 빈 값, 0과 같은 경곗값에서 발생할 수 있는 모든 경우의 수를 고려하라.

테스트는 중복해서 수행하지 않는다. 중복 수행은 노력과 혼란, 유지 보수 비용을 가중시킨다. 각 테스트 케이스는 하나의 정의만을 검증해야 한다.

테스트 유지 보수

테스트 코드는 구현 코드만큼 중요하므로, 테스트 코드의 외관과 구조를 다듬어야 한다. 엉망이 되었다면 깔끔하게 다듬어라.

클래스의 행태를 변경한 탓에 테스트에 실패했다고 테스트를 막아버리고 도망치면 안된다. 테스트 코드도 유지 보수해야 한다. 완료 일정을 맞추려고 테스트 코드를 무시할 수 있는데, 서두르는 과정에서 세심하지 못하면 결국 뒤통수를 맞게 된다.

테스트 프레임워크 고르기

대부분의 테스트 프레임워크는 스몰토크(smalltalk)를 위해 켄트 백(Kent Beck)이 만든 오리지널 SUnit 프레임워크로부터 유래한 'xUnit' 모델을 따른다.

어떤 프레임워크는 예쁜 GUI를 제공하는데, 녹색과 빨간 막대를 통해 테스트의 성공과 실패를 명확히 알 수 있다. 하지만 피트 구들리프의 생각엔 굳이 이런 UI는 필요하지 않고, 그냥 빌드 과정에 포함시키는 것을 이상적으로 여긴다.

어떤 코드도 혼자가 아니다

유닛 테스트 코드를 작성할 때는 독립화된 유닛의 코드를 테스트 대상 시스템에 넣는 것을 목표로 한다. 즉, 이 유닛은 나머지 시스템 없이도 존재할 수 있어야 한다. 유닛은 다른 것에 의존적이어서는 안된다.

하나의 클래스와 상호 작동하는 객체들은 생성자의 매개 변수로서 전달되어야 한다. 이를 '상위로부터의 매개 변수화(parameterize from above)'라고 부른다. 이를 통해 클래스의 다른 코드에 대한 의존성을 줄이고, 그것들이 간접적으로 호출되도록 할 수 있다. 클래스와 상호 작동하는 객체들의 자료형에 대해 직접적으로 의존하기보다 객체들이 특정 인터페이스를 통해 간접적으로 연결됨으로써, 클래스와 객체들 간의 상호 작동에 대한 테스트를 수행할 수 있다.

외부 인터페이스에 의존하는 객체를 테스트할 때, 테스트 케이스에서 해당 인터페이스에 대한 샘플을 제공할 수 있다. 보통 테스트 대역(test double)이라고 부른다. 테스트 대역엔 이런 것들이 있다:

  • 견본(Dummies): 견본 객체는 보통 빈 껍데기로, 테스트에서 견본을 실행하지는 않지만 인자 목록을 채우기 위해 쓴다.
  • 짝(Stubs): 인터페이스의 단순화된 구현체로서, 미리 정의된 응답을 반환하고 자신에 대한 호출과 관련된 정보를 저장한다.
  • 모조(Mocks): 여러 모조 객체 지원 라이브러리의 기능을 한다. 모조 객체는 인터페이스에서 자동으로 생성될 수 있고, 테스트 대상 시스템에서 어떻게 사용될지에 대해 미리 전달받을 수 있다. 모조 객체는 테스트를 더 간단하고 쉬워지게 하지만, 남용될 경우 테스트가 엉켜버려 문제를 파악하기 어렵고 유지보수도 쉽지 않다.

마치며

테스트는 코드 작성에 도움을 준다. 테스트를 통해 좋은 코드를 작성할 수 있고 코드 품질을 유지할 수 있다.

물론 어떤 테스트도 완벽하진 않다. 하지만 테스트의 존재를 통해 작성 중인 코드나 유지 보수하는 코드에 대한 확신을 키울 수는 있다. 다만 테스트 모음이 얼마나 쓸모있는지는 포함한 테스트의 퀄리티에 달렸다. 구현 코드만큼이나 테스트 코드를 주의 깊게 살펴봐야 한다.

핵심은 이렇다. 작성해야 할만큼 중요한 코드라면 테스트해야 할만큼 중요한 것이다. 그러므로 구현한 코드에 대해 개발자 테스트를 작성하라. 코드의 설계를 개선하기 위해 테스트를 이용하고, 구현 코드를 작성하면서 테스트 코드를 작성하라. 테스트의 실행을 자동화하고, 피드백 과정을 줄여라.


🥸 생각해보기

1. 얼마나 많은 종류의 테스트를 보거나 사용해보았는가?

테스트의 개념은 들어 알고 있었지만, 아직까지는 보거나 사용해본적이 없다. 이번 기회에 이 블로그 프로젝트 및 개인적인 간단한 사이드 프로젝트에 유닛, 통합, 시스템 테스트를 도입해 보려는 생각이 든다.

2. 테스트 우선 방식과 코드 작성 직후의 테스트 방식 중에 가장 좋은 개발자 테스트 기법은 무엇인가? 그 이유는 무엇이며 어떤 경험을 통해 그 결론을 내렸는가?

아직까지 테스트를 도입해본 적이 없기에 한번 생각을 해보자면, 둘은 상황에 따라 쓰임새가 다를 것 같다. 아주 크고 복잡한 프로젝트라면 테스트 우선 방식으로 모든 것을 조심조심하며 나아가는게 좋을것 같고, 빠르게 움직여야 하는 조직이라면 코드를 쓰고 그 코드에 대한 유닛 테스트를 붙여보는 식으로 진행하는 것도 좋을 것 같다.

3. 고품질의 테스트를 작성하기 위해 유닛 테스트를 작성하는 전문 개발자를 고용하는 것은 좋은 생각인가?

책에서 내용이 나왔지만, 대부분의 경우 코딩을 한 개발자 본인이 스스로 테스트를 작성하는것이 더 효율적일 것이다. 물론, 테스트 전략을 설계하고 가이드할 수 있는 테크 리드나 경험자가 있다면 더 도움이 될 것은 분명하다.

4. 왜 QA 부서에서 많은 테스트 코드를 작성하지 않고, 테스트 스크립트와 탐험적 테스트를 수행하는데 집중하는가?

테스트 코드는 개발자의 몫이고, QA는 사용자 관점에서 예외 상황을 체크하거나 탐색적으로 테스트하는 역할이기 때문이다.

5. 한 번도 자동화 테스트를 하지 않은 코드베이스에 어떻게 하면 TDD를 가장 잘 적용할 수 있을까? 이때 어떤 문제에 직면하게 될까?

새로 추가하는 작은 모듈부터 테스트 코드와 함께 개발하는 것이 하나의 방법이라고 생각한다. 일단 개발된 함수들에도 유닛 테스트를 적용하고 차차 늘려가는 것이 방법일 것이다.

처음에 테스트를 염두에 두지 않고 코드를 작성하였기에 테스트하기 어렵게 코드가 작성되었을 수도 있다고 생각한다.(당장 이 블로그 예시) 하지만 테스트를 작성하며 코드 구조가 더 나아질 수 있다는 말을 믿고 한번 해보려고 한다.

6. 행동 기반 개발(BDD: Behavior-Drive Development)은 전통적인 TDD와 어떻게 다른가? 어떤 문제를 해결해주는가? TDD를 보충하는가? 아니면 대체하는가? 행동 기반 개발이 테스트가 나아갈 방향인가?

행동기반개발은 검색해보니 TDD를 보완하는 접근으로, 사용자/비즈니스 요구를 자연어에 가까운 문장으로 테스트에 녹여내어 의사소통에 강점을 가진다고 한다. 예를 들어 showValidationErrorWhenInputEmpty같은 문장은 비개발자가 봐도 가독성이 좋다.