Vallista
미분류 프론트엔드 테스팅

테스트 코드 시작하기

· 8 min read · 4,125 words

이미지1
이미지1

소프트웨어를 개발하며 어떤 경로로든 테스트를 진행하게 된다. 테스트라는 것의 범주는 굉장히 넓다고 볼 수 있는데 “프로덕트에 녹여지는 코드에 대한 테스트”, “개발자가 결과물을 실행해보면서 체크하는 테스트”, “전문 QA 인력이 진행하는 테스트” 등 여러 방면으로 존재한다. 그 중에서 “실제 프로덕트를 동작시키는데 쓰이는 로직에 대한 테스트”에 대해 작년부터 든 생각을 꺼내보도록 하겠다.

작년부터, 코드리뷰를 진행하면서 듣거나 피드백을 주는 대다수는 테스트코드에 대한 것들이었다. 실제 로직을 피드백하다보면, 이 로직은 되지 않을것이고 잠재적으로 에러를 유발할 것으로 보이는 코드들이 존재한다. 그런 경우에도 결국 테스트 코드로 귀결되며 대다수의 피드백이 테스트코드로 이어지는 경험을 했다. 하지만 프로그래머로써 진입하여 코드리뷰를 받는 입장에선 테스트 코드는 무얼 집중해야하고 무얼 테스트해야하는지 알기가 어렵다. 또한 일정이 한정되어있는 부트캠프 특성상 테스트코드를 작성하는것도 어려운 일이다.

그래서 이번 글은 테스트 코드 작성을 어떻게 시작하고, 어디에 중점을 두어야하는지 포커스를 맞춰 이야기를 해보고자 한다.

테스트를 왜 해야하는가?

먼저, 테스트를 왜 해야할까에 대해 짚어보고자 한다.

개발하면서 테스트 코드가 필요할까?

개발을 하면서 본인이 테스트를 할 필요없다고 생각하는 김모씨가 있다. 김모씨는 여태까지 프로젝트를 진행하며 장애 낸 경험도 없고, 본인의 코드가 운영에서 잘 동작하므로 여태까지 문제가 없다 생각한다. 그렇게 1년이 지나고, 김모씨는 1년전에 개발한 리뷰 기능에 대해 상품 사진을 누르면 상품으로 이동하지 않고 바로 결제를 진행하게 해달라는 업무 요청건이 왔다. 여느때와 다름없이 김모씨는 상품의 이벤트를 결제로 이동시키는 함수로 변경했다.

그런데 운영에서 버그가 발생했다. 동일한 결제가 두 번 이상 진행되는 케이스가 생긴 것이다. 김모씨는 다급하게 해당 문제를 해결하기 위해서 코드를 확인했고, 결제로 이동시키는 함수에 쓰로틀이 적용되어있지 않는 문제를 파악했다. 그래서 쓰로틀을 적용하고 다시 배포를 진행했다. 장애를 해결했는데, 이 상황에서 테스트 코드가 있었다면 어떻게 되었을까?

  1. 테스트 코드가 있었다면, 사용자 관점에서 고민해보고 여러 케이스에 대해서 생각을 해봤을 것이다. 그렇기 때문에 “상품 사진을 누르면 상품으로 이동한다” describe를 읽어봤을 것이며, 로직에 쓰로틀이 포함되어있는걸 확인할 수 있었을 것이다.
  2. 테스트 코드는 일종의 비즈니스가 이뤄졌던 역사이다. 그 당시 상황에 맞는 비즈니스와 지금 비즈니스를 비교할 수 있다.

테스트 코드가 있었어도 장애상황은 발생할 수 있었지만, 그 확률은 낮아졌을 것이다.

협업하면서 테스트 코드가 필요할까?

박모씨는 1년째 스타트업에서 혼자 개발해온 개발자이다. 혼자 개발을 진행하다, 어느날 주니어 개발자 정모씨가 새로 합류해서 온보딩을 끝내고 실제 프로젝트에 투입되었다. 그렇게 신규 피쳐를 개발하려 두 명이서 투입되었는데, 박모씨는 문제없이 코드를 작성했다. 그런데 정모씨는 코드를 보면서 의문점이 생기는 상황이 올 때마다 박모씨에게 이야기를 했고, 결론적으로 기존 시간보다 더 많은 시간이 걸려서 프로젝트를 완성하게 되었다.

만약 이 상황에서 테스트 코드가 있었다면 어떻게 되었을까?

  1. 테스트 코드가 있었다면, 사용자 관점에서 해당 코드에 대한 해설이 들어간 코드를 보면서 파악을 했을 것이다. 그러므로 절대적으로 박모씨의 작업이 블락되는 상황이 적었을 것이며, 정모씨는 해당 작업을 쉽게 해석할 수 있었을 것이다.
  2. 또한 테스트 코드가 있었다면, 앞으로 또 올 신규 입사자를 위해 설명의 시간을 만들지 않아도, 정모씨는 신규 피처에 대해 기존 내용을 바탕으로 히스토리를 작성했을 것이다.

테스트 코드가 있으면 협업 시 어떤 코드에 대한 설명을 테스트 케이스로 하여금 설명이 되기 때문에, 추후 올 인원들에 대해서도 효율적으로 시간을 사용할 수 있다.

QA를 하는데 테스트 코드가 필요할까?

개발 업계에 반농담삼아 말하는 개발 방법론 중, QA Develop Driven(QDD) 이라는게 있다. 개발하면 QA 환경에서 테스트를 진행해서 인력으로 하여금 비용을 더 써서 개발만을 집중하겠다는 의도이다. 그런데, 여기서 중요한 점은 개발에 “테스팅”이 함께 들어가야하는 범주라는 사실이다. 사례를 하나 들어보자.

신모씨는 QDD를 적극적으로 사용하는 프로그래머이다. 개발을 하고 얼추 본인이 개발자 테스트 후 베타 환경에 배포해서 QA에게 체크를 해달라고 요청하였다. QA 파트는 베타에서 TC 테스트를 통해 문제가 없음을 체크했고, 운영에 배포되었다.

그런데, 운영에 배포되고 장애가 발생했다. TC에서 빠진 테스트가 존재했고, 그렇게 장애가 발생했다. 여기서 온전히 TC에서 빠졌다고 QA의 탓일까?

개발자는 유저 관점에서 테스트 코드를 필수적으로 작성해야 이러한 문제를 조기에 잡을 수 있다. 요구사항에 맞게 기획서나 QA에서 다 걸러주거나 하면 좋겠지만, 그것은 언제까지나 한계가 존재한다.

그렇기 때문에 테스트 코드는 필요하고, 전사적으로 해당 테스트 케이스를 관리하여 필수적으로 추가될 수 있도록 제공해야한다. 웹 프론트라는게, 비즈니스마다 로직이 다르긴 하겠지만 결국 테스트 케이스는 비슷할 것이다.

테스트 코드 작성

테스트와 테스트 코드가 중요하다는 걸 알게 되었다. 하지만 시작할때 테스트 코드를 어떻게 작성해야할까 고민이 될 것이다. 몇 가지 쉽게 진입할 수 있는 방법을 알아보자

처음 테스트 코드를 마주할때

대표적으로 AAA, Given-When-Then 패턴으로 테스트 영역 내에 주석의 영역을 미리 작성해두고, 해당 형태대로 코드를 작성하면 편하다.

AAA 패턴

AAA 패턴은 “Arrange-Act-Assert”의 약자이다. 결과 테스트를 기준으로 AAA 패턴을 적용해 작성한 걸 확인하면 다음과 같다.

describe('계산 테스트', () => {
  const calculator = new Calculator()

  test('0 값을 받았을때 1값을 반환한다.', () => {
    const value = 0 // Arrange

    const result = calculator.increment(value) // Act

    expect(1).toBe(result) // Assert
  })
})
  • Arrange: 필요한 모든 조건과 입력값을 작성한다.
  • Act: 테스트 방법을 작성한다.
  • Assert: 예상한 결과가 발생한 것을 확인한다.

작성시 3가지 영역을 미리 작성해놓고 하나의 테스트 케이스를 제작한다. 테스트 케이스는 하나의 행동에 대해 정상적으로 작동하는지와 여러 문제가 있는 케이스를 만들면 된다. 이 방법을 사용했을때 장점은 다음과 같다.

  • 테스트 대상과 설정값 그리고 단계를 명확하게 구분하여 작성할 수 있다.
  • 역사적으로 많은 성공의 사례들이 존재한다.

Given-When-Then 패턴

Given When Then 패턴은 링크한 마틴파울러의 글을 읽어보면 잘 이해가 된다. 쉽게 이야기하면 준비 - 실행 - 검증 케이스를 분리하라는 의미이다. 위에서 언급한 AAA 패턴과 동일하게 세가지로 구분하며, 조금 다르지만 비슷하다.

describe('계산 테스트', () => {
  test('0 값을 받았을때 1값을 반환한다.', () => {
    // given
    const calculator = new Calculator()
    const value = 0

    // when
    const result = calculator.increment(value)

    // then
    expect(1).toBe(result)
  })
})
  • given은 시나리오에서 필요한 값을 미리 추출하여 선언한다. 이 데이터들은 테스트의 전제 조건이다.
  • when은 지정한 동작(행동)을 의미한다.
  • then은 지정한 동작(행동)으로 인해 예쌍되는 변화에 대해 설명한다.

일반적으로 테스트를 작성한다고 하면 Given-When-Then 패턴으로 작성하게 된다. mockito 개발자가 사용하는 테스트 템플릿으로 소개되기도 했고, 한국의 java-spring 커뮤니티에서 주로 많이 사용되어 널리 퍼져있다. 또한 TDD 실천법과 도구 - 채수원 책에서도 나와있기도 하다.

유닛 테스트 작성하기

유닛 테스트를 작성하기에 앞서, 우리는 “결과 테스트 패턴”, “상태 테스트 패턴”, “긍정에 대한 테스트보다 부정에 대한 테스트를 작성하기” 세 가지를 확인해보도록 한다.

결과 테스트 패턴

결과 테스트 패턴은 결과값이 무엇인지에 대한 테스트를 진행하는 패턴이다.

describe('커피 가격 검증 테스트', () => {
  test('우유가 들어있지 않은 경우 할인된 가격으로 라떼를 제공한다.', () => {
    // given
    const name = 'americano latte'
    const originCoffeePrice = 3000
    const expectedPrice = 2500

    const coffee = new Coffee(name, originCoffeePrice)

    // when
    coffee.sub('milk')
    const actualPrice = coffee.getDiscountedPrice()

    // then
    expect(expectedPrice).toBe(actualPrice)
  })
})

쉽지 않은가? 정상적으로 어떤 행동이 이루어졌을때 해당 값에 대해서 체크하여 값을 비교하면 된다.

상태 테스트 패턴

상태 테스트 패턴은 위의 결과 테스트 패턴과 비슷하다. 조금 다른 점은 결과 테스트 패턴의 경우 들어가는 값과 나오는 값의 원시 유형이 동일하지만, 상태 테스트 패턴은 다를 수 있다는 점이다. 현재 상태에서 어떤 행위를 했을때 이후 상태가 변경될 것이고, 변경된 상태가 의도한 상태와 같은지 체크하는 게 상태 테스트 패턴이다.

describe('Array.prototype.reverse 테스트', () => {
  test('배열의 요소가 뒤바뀌어 적용되어야 한다.', () => {
    // given
    const originValue = ['hello', 'world']
    const expectedValue = ['world', 'hello']

    // when
    originValue.reverse()

    // then
    expect(expectedValue).toBe(originValue)
  })
})

결과 테스트 패턴과 별 다른게 없다. 일반적으로 테스트할 때 결과/상태 테스트 패턴의 구분없이 대부분의 깔끔하게 구조화된 메소드들은 이러한 테스팅 과정을 거치게 되며, 상대적으로 간단히 작성된다. 그럼에도 이러한 값이 맞는지에 대한 테스트는 필수적으로 해줘야 할 요소들이 있는데, 정수의 값을 테스트 한다면, 입력값을 음수, 0, 양수, 크거나, 작은 값들에 대해서 의도적으로 에러를 만드는 테스트를 진행하여 안전한 코드 바운더리를 만들어야한다.

긍정에 대한 테스트보다 부정에 대한 테스트를 작성하기

아래는 얼마전, 코드리뷰를 남긴 피드백이다.

로직의 “정상적으로 작동” 하는 결과는 하나뿐입니다. 하지만 오작동하는 케이스는 수십가지가 넘죠.

예를들자면 결과값이 “2”가 되어야 하는 케이스가 있다고 합시다.

결국 어떤 시나리오, 2+0, 1+1, 0+2 등, 결국 2가 되면 맞는 게임입니다. 하지만 그 외에 아닌 케이스는 정말 많겠죠?

서비스의 개발도 위와 동일합니다. 결국 어떻게 되었던 이 로직이 제대로 도는 결과는 하나이고 오작동하는 케이스는 수십가지가 넘을 것입니다.

특히 복잡한 유저관점에서 해석을 한다면 유저가 어떻게 행동할지 미리 생각해서 유저가 어떤 행동을 했을때 오작동을 하면 “유저에게 이렇게 사용하지마!” 라는 피드백이 필요할 것입니다.

이는 기능관점에서도 유효합니다. “버튼을 누른다”의 경우 정상적인 케이스는 “버튼이 눌려서 어떤 액션이 실행되었는가?” 겠죠.

그런데 버튼을 0.1초 안에 200번을 누르는 케이스가 존재한다면 (실제로 dom 이벤트를 이렇게 증식 시켜서 디도스를 할 수도 있겠죠.)

이런 문제 케이스에 대해서 대응을 해야겠죠? 왜냐하면 버튼에서 “어떤 액션”의 범주는 굉장히 넓고, 거기엔 비동기 처리도 있을테니까요.

결론적으로 안되는 케이스를 주요로 작성하라는건 다음과 같습니다.

  1. 애자일 철학에서도 나오는 “유저스토리 관점으로 생각하라” 처럼, 이 메소드/함수와 같은 행동을 사용하는 유저 관점에서 생각하고, 테스트를 작성하라
  2. 유저 관점에서 생각하면 유저가 정상적으로 사용할 수 있는 케이스는 “하나” 이고, 그 외에 오작동 하는 케이스에 대한 “수많은” 문제를 정상적으로 사용할 수 있도록 유도하는 코드가 등장하게 된다.
  3. 테스트 코드는 협업하는 사람들에게 어떤 문제에 대해 코드가 보완되어 있는지 공유하는 성격도 갖고있습니다. 특히 현업에선 QA TC등이 결국 테스트 코드와 매칭되는 부분이 많습니다. 에러 상황에 대한 테스트 코드가 많을수록 당연히 QA에서 커버되는 커버리지를 코드단위로 이행시켜서 효율성을 증대 시킬 수 있겠죠?

결론적으로 이야기를 하면.

  1. 로직은 정상 작동 결과는 하나지만, 오작동하는 케이스는 수십가지가 넘기 때문에 어떤 행동에 대해 잘못되었다면 그에따른 피드백을 코드로 반영해야한다. 수많은 문제를 정상적으로 사용할 수 있도록 유도를 해야하기 때문이다.
  2. 테스트 코드는 오작동 문제 상황에 대한 코드 보완 여부를 협업하는 사람들과 공유하는 성격을 갖는다. 그러므로 히스토리가 차곡차곡 쌓이니 코드 단위로 효율성이 증대된다.

위의 커피 테스트를 가져와서 올바르게 테스트 하는 경우를 체크해보도록 하면,

describe('커피 가격 검증 테스트', () => {
  // 성공한 경우
  test('우유가 들어있지 않은 경우 할인된 가격으로 라떼를 제공한다.', () => {
    // given
    const name = 'americano latte'
    const originCoffeePrice = 3000
    const expectedPrice = 2500

    const coffee = new Coffee(name, originCoffeePrice)

    // when
    coffee.sub('milk')
    const actualPrice = coffee.getDiscountedPrice()

    // then
    expect(expectedPrice).toBe(actualPrice)
  })

  test('할인된 가격으로 제공될 때, 제공되는 금액이 할인된 가격보다 적을 경우 무료로 제공한다.', () => {
    // given
    const name = 'americano latte'
    const originCoffeePrice = 200
    const expectedPrice = 0

    const coffee = new Coffee(name, originCoffeePrice)

    // when
    coffee.sub('milk') // milk의 가격은 500원이다.
    const actualPrice = coffee.getDiscountedPrice()

    // then
    expect(expectedPrice).toBe(actualPrice)
  })

  // ... 그 외 부정한 경우
})

우유가 들어있지 않은 경우 할인된 가격으로 라떼를 제공할 때, 예상치 못한 케이스를 유저관점에서 생각해보면 커피가 이미 있는데로 할인이 들어간 상황에서 milk의 가격보다 낮을때가 있을 것이다. 이러한 경우 0이 되어야 하는데, 이런 테스트를 작성해주면 된다.

다시 한 번 말하지만, 테스트는 올바르게 정상 작동하는지 보는 것보다 “어느 상황에 어떻게 동작하면 안되는지”를 볼 수 있는 히스토리 저장소라고 보면 옳다.

끝으로

오늘은 테스트코드를 시작함에 있어 어떻게 짜는게 시작하기에 좋을지 알아보았다. 다음 챕터는 비동기 테스트 작성에 대해 알아보도록 할 것이다.