01. Unit Test Basic

개인의 생각이 강하게 들어가 있는 글 입니다. 더 좋은 생각이 있으시면 lucas.han.public@gmail.com으로 메일 주세요. 언제나 환영합니다.

1. Unit Test?

유닛 테스트(unit test)는 컴퓨터 프로그래밍에서 소스 코드의 특정 모듈이 의도된 대로 정확히 작동하는지 검증하는 절차다. 즉, 모든 함수와 메소드에 대한 테스트 케이스(Test case)를 작성하는 절차를 말한다(출처-위키백과).

좀 더 개발자의 입장에서 와닿게 이야기 하자면, 각 개별 Function / Method(이하 함수 로 줄입니다)에 대한 테스트 케이스를 작성하여 검증하고, 지속적인 테스트를 수행하여 기초단계에서 부터 오류를 줄이는 행위이다. 언뜻생각하면 개발자가 제품을 직접 테스트하면 될텐데, 왜 제품 개발에도 부족한 시간을 쪼개어 테스트 코드를 작성해야 할까?

2. Unit Test를 왜 하는가

개인적인 경험에 의하면 제품 소스에 Unit Test code가 존재했을 때 이런 부분들이 편했던 것 같다.

  • 함수의 동작에 대해, 가끔은 복잡한 동작을 명세한 코드보다 테스트 코드가 좀 더 이해가 빠를 때도 있다.
  • 오래전에 작성한, 혹은 복잡한 로직을 담고 있는 함수라도 테스트 코드가 있기 때문에 과감히 수정할 수 있다.
  • 다른 사람이 쓴 코드를 refactoring 하였을 때 테스트 코드를 돌려보는 것만으로도 1차적인 검증은 가능하다(테스트 코드가 잘 쓰여있다는 전제 하에).
  • 잘 짜여진 테스트를 모두 통과했다는 사실이, 수정 후 merge할 때 심리적 안정감을 준다(진짜 중요합니다).
  • 테스트 코드 작성시 소모되는 시간과 테스트 코드 없이 제품을 디버깅하는 시간을 비교해 보면 전자의 시간이 압도적으로 적게 소모된다.
  • 테스트 코드를 잘 작성하려다 보면, 테스트 대상 코드가 자연스럽게 refactoring되는 경우가 빈번하다.

3. 테스트 코드 작성

글쓴이가 선호하는 의사코드는 다음과 같다 (mocha 기준).

describe('[파일 이름]', () => {
  describe('[함수 이름]', () => {
    context | describe('[케이스 설명]', () => {
      it('[케이스 설명]', () => {
        // arrange
        const userId = 14
        // act
        const res = fakeReturn(getUser(14))
        // assert
        expect(res.fakeUserName).is.equal('lucas')
        expect(res.fakeUserId).is.equal(14)
      })
    })

    context | describe('...', () => {
      ...
    })
  })
})
  1. 맨 처음은 파일 이름을 작성한다. 모든 테스트를 실행할 시 알아보기 편하다.
  2. 다음은 해당 파일 내에서 테스트하려는 함수의 이름을 작성한다. 파일 이름을 적는 이유와 동일하다.
  3. context | describe는 각자의 취향에 관한 부분인데, 아래에서 조금 설명하도록 하겠다. 이곳에는 테스트하려는 케이스에 대한 명세를 한다. 길어도 좋고, 짧아도 좋다. 쉽게 알아 볼 수만 있으면 된다.
  4. it에는 이 함수에게 기대하는 동작을 명세한다. 보통 should로 시작하는 문장을 적어주는 것이 일반적이다.
  5. it 내부에는 실제 함수가 동작을 하기위한 조건을 구성해주고(arrange), 실행하고(act), 실행의 결과를 검증한다(assert). 글쓴이의 경우 반드시 이렇게 세 부분으로 나눌 필요가 없는 간단한 함수(e.g. getSum) AAA원칙을 따르기 보다는 아래처럼 읽기 쉽게 한줄에 작성해 버린다.
expect(getSum(2, 3)).is.equal(5)
  1. 테스트 케이스 작성 시 arrow function을 사용하면 Mocha context에 접근할 수 없는 문제가 발생한다. lifecycle hook을 사용하거나 this에 접근할 일이 있다면 function declaration을 사용할 것.
  • context vs describe 참고

    • context는 describe의 alias이다.
    • when 조건이라고 생각하면 편하다.
    • 보통 동일 조건으로 테스트하는 케이스들을 묶어줄 때 사용한다. 이렇게 하면 가독성과 케이스에 대한 의미부여를 쉽게 할 수 있다.
    describe('launch the rocket', () => {
      context('when all ready', () => {
        it('should rocket launch when put launch button', () => { ... })
      })
    
      context('when not ready', () => {
        it('should alert that impossible to rocket launch', () => { ... })
      })
    })
    
  • Alias for BDD and TDD

describe context it before after
BDD describe context it before after
TDD suite --- test suiteSetup suiteTeardown

4. Test Double

단위 테스트 케이스는 테스트 대상이 의존하는 것에 대해 독립적으로 작성 되어야 한다. 대상이 의존하는 것에 대한 독립성이라는 언뜻 이해가 가지 않는 개념에 대해서, 실용주의 프로그래머를 위한 단위 테스트 with JUnit이라는 책에서는 이렇게 설명하고 있다.

Independent(독립적)
테스트는 깔끔함과 단정함을 유지해야 한다. 즉, 확실히 한 대강에 집중한 상태여야 하며, 환경과 다른 개발자들(명심하라. 다른 개발자들이 동시에 같은 테스트를 실행해 볼 수도 있다)에게서 독립적인 상태를 유지해야 한다.
또한 독립적이라는 것은 어떤 테스트도 다른 테스트에 의존하지 않는다는 것을 의미한다. 어느 순서로든, 어떤 개별 테스트라도 실행해 볼 수 있어야 한다. 처음 것을 실행할 때 그 밖의 다른 테스트에 의존해야 하는 상황을 원하지는 않을 것이다.

모든 테스트는 섬이어야 한다.

이 말이 의미하는 것은 언제, 어디에서, 누군가가 테스트를 실행하는것에 상관없이 테스트 수행이 가능해야한다는 뜻이다. 그렇다면 이런 독립성을 방해하는 요소가 무엇이 있을까?
글쓴이가 겪은 몇가지 케이스는 다음과 같다.

  • DB, 파일 I/O
  • 다른 곳으로의 네트워크 연결
  • 경로를 Absolute path로 지정한 경우

DB, 파일 I/O에 의존한 테스트는 DB에 연결할 수 없거나(DB점검, 보안 이슈, 네트워크 등의 문제로), 필요한 파일이 정확하게 포함되어 있지 않은 경우 문제를 일으킨다. 특히 DB에 의존하는 경우 db.save()등의 method가 실제로 작동하면서 DB에 필요없는 데이터가 insert되거나, 파일에 의존하는 경우 단지 테스트만을 위해 프로젝트 내에 불필요한 파일이 필요하게 되는 문제점이 발생한다.

네트워크 연결 역시 문제가 있다. 특정 서버가 특정 IP대역에서만 접근이 가능한 경우가 그렇다. 접근 가능한 IP대역을 추가하는 등의 방법으로 해결이 가능하지만, 단지 테스트때문에 보안 이슈나 정책을 무시할 수는 없다.

그렇기 때문에 진짜 DB나 파일에 접근하지 않고 이런 문제들을 해결해 줄 대역들이 자연스레 필요하게 된다(API를 호출하는 함수의 테스트 코드를 작서한다면 더욱!).

5. Stub, Fake, Spy, Mock

위의 문단 마지막에 대역(Stunt Double)이 필요하다고 했었는데, Unit test를 위한 대역들도 존재한다. 이를 테스트 더블(Test Double)이라고 한다. 테스트 더블은 보통 개념적으로 아래의 종류로 나눠볼 수 있다.

  • Stub
    간단하게 표현하자면 return void or hard coded value이다. 단순한 return이나 단순하게 하드 코딩된 value로 검증하는 것이 충분할 때 가볍게 쓸 수 있다.
  • Fake
    진짜 object를 흉내내지만, 그에 따른 부수효과나 연쇄작용이 일어나지 않는 가짜 object를 의미한다.
  • Spy
    테스트 대상 object에 테스트 결과를 확인할 수 있는 내부 상태 확인용 함수가 없는 경우 사용한다.
  • Mock
    특정 조건에서 object가 취해야 할 action을 사전 정의해 놓은 object로, 동작을 동적으로 재설정하여 사용한다.

테스트 더블의 사용에 대해 설명하면 길어질 것이 뻔하기 떄문에, 다음 글에서 설명하는 것이 좋을 것 같다. 지금은 이런것이 있다는 것을 알고 지나가도록 하자.

6. Unit Test code 작성 시 생각하면 좋은 것들

  • Stub은 질문하고 Mock은 행동한다.
  • Mock 사용시에는 핵심동작만 설정한다. 부수적인 구현은 Stub으로 충분하다.
    즉, 의도한 동작한 검증할 수 있으면 된다.
  • 테스트를 실패하는 이유는 오직 하나뿐이어야만 한다.
    그렇지 않다면 함수 작성 시 단일 책임 원칙(SRP; Single Responsibility Principle)을 위반한 것이다.
  • 테스트 실패 시 '왜 실패하였는지' 가 명확해야 한다.
  • 테스트 코드에서 반복되는 부분이 있다면 library의 lifecyle hook의 사용을 고려한다.
  • 테스트 코드도 유지보수 대상이라는 것을 잊으면 안된다.
  • 성공하는 케이스 보다는 실패 혹은 edge 케이스 작성에 공을 들인다 (글씨가 진한데에는 이유가 있습니다).

뭔가 Unit test에 대한 설명을 급하게 마무리 하는 느낌이 있지만, 어렴풋이 개념을 잡기에는 충분하다고 본다. 이 글에서는 Unit test 코드의 필요성과 작성 요령, 기본적인 개념에 대해서만 알게 되어도 성공적이라고 생각한다.


참고