우리는 어떻게 테스트를 하고 있을까요? (Front-end)

2020.10.21

안녕하세요. 반갑습니다!! 😀

입사 후 경험 한 Reactjs의 테스트와 현재 우리는 어떻게 테스트를 수행하고 있는지에 대해서 ‘우리는 어떻게 테스트를 하고 있을까요?’ 라는 제목으로 여러분께 이야기하고자 합니다.

이 이야기는 다음 세 가지공유하기 위해서 작성하였습니다.

  • 저와 같이 테스트를 경험해보지 못한 상태로 테스트를 작성해야한다면, 우선 시작은 어떻게 진행해야 할까? (이야기에 등장 하는 골격 구조테스트 방식을 활용해서 테스트를 작성해보자!)
  • 이야기에 작성된 예시는 기본적인 테스트의 감을 잡는 용도이다. 본격적으로 작업을 하려면 공식문서를 참고하자! 하단 Reference 를 꼭 확인하자!
  • 테스트가 능사는 아니다. 하지만! 테스트마저 작성하지 않는다면? 뒷 감당은 누가 할 것인가? 곁에 있는 다른 개발자가? 이건 너무 이기적이다!!(느낌표가 무려 두 개다!) TDD가 아니더라도 너무 당연한 테스트(1+1=2)를 제외하고는 내가 만든 컴포넌트와 로직의 최소한의 테스트라도 작성하자!

내용이 무겁지 않고, 기본 테스트를 진행하는 방법에 대한 이야기로 가벼운 마음으로 읽어주세요. 🙂 이 글은 테스트를 해본 적 없는 분에게 추천드립니다.

이야기의 순서는 다음과 같습니다.

서론. 테스트? 테스트 코드를 작성하는 이유는 무엇일까?
본론. 그럼 우리는 어떻게 테스트를 진행하고 있을까?
결론. 그래서 우리는 계속 테스트를 해야할까?

그럼 바로 이야기를 진행해보겠습니다!

서론. 테스트? 테스트 코드를 작성하는 이유는 무엇일까?

먼저, 테스트는 뭘까요? 위키피디아의 정의를 살펴보겠습니다.

소프트웨어 테스트(software test)는 주요 이해관계자들에게 시험 대상 제품 또는 서비스의 품질에 관한 정보를 제공하는 조사 과정이다.”

아하! 그럼 이어서 유닛테스트에 대해서도 살펴보죠!

유닛 테스트(unit test)는 컴퓨터 프로그래밍에서 소스 코드특정 모듈이 의도된 대로 정확히 작동하는지 검증하는 절차다.”

 자 그럼! 이야기의 주인공인 테스트에 대한 정의를 알게 되었습니다. 이번에는 ‘테스트 코드를 작성하는 이유는 무엇일까?’ 를 고민해보죠.

 먼저 테스트를 검색하면 나오는 이유들을 모아보았습니다.

  • 요즘 BDD (Behaviour-Driven Development), TDD (Test-Driven-Development) 하니까. 왠지 해야할 것 만 같다.
  • 내가 가진 불안감과 코드에 대한 확신을 테스트로 보증 할 수 있다.
  • 다른 개발자가 내 코드를 리팩토링 할 때 테스트만 성공해도 수정한 코드가 검증완료된다.
  • 같은 동작을 하는 로직이더라도 보다 클린한 코드로 만들어준다.
  • 모듈에 대한 버그를 빨리 찾을 수 있어 궁극적으로는 개발 속도가 빨라진다.

 테스트 주도 개발(Test-Driven-Development, 이하 TDD)을 집필한 켄트 벡은 “테스트 없이 프로그래밍하면 확신이 덜 생기고 시간은 더 걸린다.”라고 얘기한 바 있습니다.

 여러분이 생각한 이유가 저 5가지 중에 있었나요? 아니면 이미 켄트 벡과 같은 경지에 이르셨나요?!

 테스트 작성의 이유는 위에 적힌 것 외에도 충분히 많겠지만, 최근 테스트 작성을 필수로 하는 프로젝트들이 많아지는 추세만 보아도 테스트의 중요성을 충분히 깨달을 수 있습니다.

각자 생각하는 이유야 다르겠지만, 저는 입사하고 처음에 테스트를 작성해야한다는 말을 들었을 때 정말 1번처럼 생각했습니다! 정말로요! 지금은 테스트를 하는 이유를 생각해보면 2번에 가장 가까운 것 같습니다. 테스트를 작성하고 나면 아무래도 내가 작성한 코드에 대한 확신과 게임에서 말하는 Life 하나를 비축해 둔 느낌이 듭니다!

 서론에서 우리는 테스트가 무엇인지 그리고 이야기의 주제가 될 테스트를 작성해야하는 이유에 대해서 알아보았습니다. 테스트를 작성해야하는 이유가 위에서 이야기한 5가지 모두가 될 수도 있고, 다른 이유가 있을 수도 있습니다. 대신 아무런 목적없이 테스트를 작성하지 마세요. 지옥같은 시간이 될 수도 있어요!

 이제 본격적으로 우리는 어떻게 테스트를 구성하는지, 어떤 방식을 가지고 테스트를 작성하고 있는지 살펴보겠습니다.
 아참! 이번 이야기에서는 테스트의 패키지 및 setup 파일 설정은 이번 이야기에서는 다루지 않습니다.

본론. 그럼 우리는 어떻게 테스트를 진행하고 있을까?

앞서 우리는 테스트란 무엇인지 그리고 테스트를 작성해야하는 이유에 대해서 살펴봤습니다.

본론에서는 기본적인 테스트 방법에 대해서 다음의 순서로 이야기를 진행해 나가도록 하겠습니다.

  • 우리는 어떤 을 사용하고 있을까요?
  • 컴포넌트로직은 어떻게 테스트를 작성할까요?
  • 작성한 테스트는 어떤 가치를 지닐까요?

첫번째. 우리는 어떤 툴을 사용하고 있을까요?

먼저 테스트를 위한 툴은 생각보다 많습니다. Testing library, 페이스북에서 만든 Jest, 심플하고 사용하기 좋은 mocha 그리고 unexpected 도 있습니다!

우리는 Reactjs 기반으로 프로젝트가 진행되므로, Jest로 테스트를 진행하고, 추가로 Enzyme을 사용하고 있습니다. 따라서 Linewalks FE에서는 Jest + Enzyme 을 기반으로 테스트를 수행합니다.

Jest를 선택한 가장 중요한 이유는 Reactjs를 개발한 페이스북에서 유지보수를 하기 때문에 가장 호환성이 좋을 것으로 판단되었고, DOM(Document Object Model) 조작의 원활한 테스트를 위해 airbnb에서 만든 Enzyme을 선택하게 되었습니다.

쓰고 있는 툴에 대한 언급만 있으면 아쉽지 않을까해서 우리가 사용하고 있는 Jest와 Enzyme를  간단하게 알아보겠습니다.

Jest
Jest는 간결함에 중점을 둔 즐거운 JavaScript 테스트 프레임 워크입니다. 기본 사용법은 다음과 같습니다.

expect(1+1).toBe(2)   // received 1+1, expected 2
  • 위와 같은 형태로 작성을 합니다. expect 안에는 검증을 하기 위한 대상이 되는 값인 received가 들어가고 matcher인 toBe에는 검증하려는 값인 expected가 들어가게 됩니다.
  • toBe, toEqual, toBeFalsy 등 정말 다양한 matcher들이 존재합니다!

Enzyme
Enzyme은 React 컴포넌트의 출력보다 쉽게 테스트 할 수있는 React 용 JavaScript 테스트 유틸리티입니다. Enzyme은 Reactjs를 테스트하는데 있어서 DOM 조작을 편리하게 도와주는 역할을 수행합니다.

  • 특정 버튼을 클릭하면 데이터 Fetch가 일어나 테이블에 값 할당하는 경우가 생겼다고 가정해봅시다. 해당 기능을 테스트 한다면 수행해야할 테스트는 여러가지가 있겠지만 크게는 아래와 같습니다.
    • 버튼이 눌렸는가? 그로인해 해당 데이터 Fetch가 정상적으로 수행되었는가?
    • 가져온 데이터로 테이블에 정상적으로 렌더링이 되었는가?
  • 위 경우에서 우리는 버튼을 누르는 행위를 수행해야하는데, 기존의 Enzyme이 없는 경우라면 jest 에서는 $(jquery)를 이용해서 버튼을 찾아내서 click이벤트를 수행합니다. 그러나 jquery는 많은 기능이 있어 너무 무겁습니다.. 🙁
  • Enzyme을 사용하면 테스트 대상 컴포넌트에 shallow 또는 mount를 수행하여 Wrapper Instance를 만들어서 내부의 이벤트 및 렌더링 된 형태를 파악하거나 제어할 수 있습니다! enzyme을 사용하면 보다 직관적인 테스트 뿐만 아니라, 단순히 셀렉터만 쓰기 위해 1MB를 jquery에 소비할 필요가 없어집니다! (특정 컴포넌트는 .find를 통해서 찾아낸답니다. 원리는 jquery의 셀렉터와 아주 흡사해요! 이  내용은 두번째 단락에서 더 알아보죠!)

두번째. 컴포넌트와 로직은 어떻게 테스트를 작성할까요?

위에서 어떤 툴을 사용하고 있는지에 대해서 이야기를 나눠보았습니다. 이번 주제에서는 실제로 테스트를 어떻게 수행하는지에 대해서 이야기를 나눠볼까 합니다.

Linewalks FE에서는 TDD를 권장하고 있습니다. 따라서 테스트를 먼저 작성하고 작성한 테스트를 기반으로 컴포넌트 및 로직을 개발하는 것을 원칙으로 하고 있습니다.

다만, 저 역시도 아직 TDD를 명확하게 이해하고 원칙에 맞게 TDD를 잘 적용하진 못하고 있습니다! 🙁

아! 사담은 여기까지하고 그럼 테스트를 어떻게 작성하는지 한 번 살펴보도록 하죠!
우리가 수행하는 테스트 구성은 크게 세 가지로 나뉩니다. 하나씩 알아보죠!

  • 테스트의 구성(골격)
  • 컴포넌트 테스트(.find와 테스트 방법)
  • 로직 테스트(단계별 테스트)

테스트의 기본 구성

컴포넌트 및 로직 테스트를 진행하기에 앞서 테스트의 기본 구성을 이야기하려고 합니다! 다양한 방법으로 테스트의 기본 구성을 작성할 수 있지만, 우리는 기본적으로 아래와 같은 구성을 따릅니다.

/*
  골격을 살펴봅시다!
  먼저 describe는 기본 골격을 구성하는 요소입니다.
  보통은 컴포넌트이름이나 파일이름이 들어갑니다.
*/
describe('TableComponent', () => {
  beforeEach(() => {
    /*
        각 하위 테스트 수행 전에 공통으로 호출되는 로직을 미리 설정할 수 있습니다.
        여기서 설정된 내용은 해당 block scope 내에서 진행되는 테스트가
        시작되기 전에 호출됩니다.
    */
  })

  afterEach(() => {
    /*
        각 하위 테스트 수행 후에 공통으로 호출되는 로직을 미리 설정할 수 있습니다.
        여기서 설정된 내용은 해당 block scope 내에서 진행되는 테스트가
        완료된 후에 호출됩니다. (Clean up을 진행한다고 보면 좋습니다.)
    */
  })

  // it는 테스트의 실제 구현이 들어가는 부분입니다.
  it('Table Header', () => {
    // test logic
  })


  // describe 안에 describe가 중첩될 수 있습니다.
  describe('Table Body', () => {
    it('Table body length', () => {
      // test logic
    })
  })
})

컴포넌트 테스트

우리가 만든 컴포넌트를 테스트합니다. 테이블이 잘 그려지는지? 아니면 버튼을 클릭하면 원하는 동작이 잘 수행되는지 등을 말이죠. 그럼 어떻게 테스트하는지 순서대로 살펴보겠습니다!
(위에서 언급했던 it 내부에 들어가는 내용들입니다!)

/*
    1. 먼저 Wrapper Instance를 생성해보죠!(App이라는 컴포넌트가 있다고 가정합니다.)
    mount는 하위 컴포넌트까지 모두 생성합니다.
    그래서 보통의 Nested 된 컴포넌트를 테스트 할 때는 mount를 사용하는 편입니다!
 */
const mountWrapper = mount(<App />)
/*
    shallow는 대상 컴포넌트만 생성합니다.(일반적인 컴포넌트 테스트 시에는 잘 쓰지 않습니다.)
    이유는 대상 컴포넌트만을 ShallowWrapper로 만들기 때문에 Nested 되어있는
    하위 컴포넌트와의 연계를 테스트할 수 없기 때문입니다.
    (* 그래서 보통은 Pure Component 처럼 렌더링만 하는 컴포넌트를 테스트 할 때 사용합니다!)
 */

const shallowWrapper = shallow(<App />)
/*
    2. .find를 통해서 테스트하고자 하는 ReactElement를 찾아봅시다!
    .find는 tag, class, component 등을 대상으로 할 수 있으며,
    더 자세한 설명은 enzyme docs를 참고하는게 좋습니다.
    다양한 방법으로 선택이 가능합니다!
    (https://enzymejs.github.io/enzyme/docs/api/selector.html)
 */
const button = mountWrapper.find('button')
/*
    3. 그럼 찾은 button의 클릭 테스트를 수행할까요?
    아래와 같이 simulate를 돌리면 아주 간단하게 Click 이벤트를 수행할 수 있습니다!
 */
button.simulate('click')// 만약 event에 값을 전달해야한다면 아래와 같이 수행하면 됩니다!
input.simulate(‘change’, { target: { value: 'linewalks' } })
/*
    4. 그럼 만약 App에 내가 만든 SessionList 라는 컴포넌트가 있다면 어떻게 찾아서
    데이터를 검증할까요?
    두 가지 방법이 있어요! SessionList가 받는 props를 검사해볼 수 있고, 해당 컴포넌트의
    내부에 존재하는 SessionItem 의 개수를 파악할 수도 있습니다.
    그럼 둘 다 해봅시다!
 */

// 첫번째 방법! props의 값을 테스트 해봅시다.
const sessionList = wrapper.find(SessionList)
const expected = [{ id: 1, title: 'title 1' }, { id: 2, title: 'title 2' }]
expect(sessionList.prop('data')).toEqual(expected)

// 두번째 방법! SessionItem의 개수를 세어봅시다!
const sessionItems = wrapper.find(SessionItem)
expect(sessionItems).toHaveLength(expected.length)

아래는 테스트를 작성할 때 적용했던 몇 가지 규칙입니다.

/*
    1. .at으로 3단계 이상은 접근하지 않는 것이 좋습니다 
    .at 또는 .find가 3단계 이상 깊이를 가지게 되면 다른 개발자가 테스트 코드를 
    보았을 경우 불가피하게 debug, html 을 통해서 해당 컴포넌트의 구조를 다시 확인해야 하는 
    경우가 자주 발생했던 문제로 지양하는 것 중 하나입니다.
*/
const button = wrapper.find('div').at(0).find('span').at(2).find('button') // X
// 부득이한 경우를 제외하고는 예시에서 버튼을 styled-components로 작성해봅시다!
const closeButton = wrapper.find(CloseButton); // O

/*
    2. 테스트에서 비동기에 대한 테스트를 할 경우 되도록 마지막에 done()을 호출해주세요.
    비동기 호출이 정상적으로 완료 된 것을 테스트에서 확인하는 것이 done() 호출입니다.
    If done() is never called, the test will fail(with timeout error),
    which is what you want to happen.
    (https://jestjs.io/docs/en/asynchronous)
*/
it('async test', (done) => {
  // async logic & test
  done()
})

/*
    3. 테스트에서 비동기 테스트를 수행할 경우 일정 시간이 지연이 필요한 경우 아래와 같이해보세요
    해당 함수는 비동기로 인한 컴포넌트 변경이 테스트에 반영되는 시간을 제공할거에요!
*/
const flushPromises = () => new Promise((resolve) => setImmediate(resolve))
it('async test', async (done) => {
  // async logic  const result = await fetchApi()
  flushPromises()
  // test logic  expect(result).toEqual(expected)  done()
})

/*
    4. 너무 단순한 로직은 테스트를 작성하지 않는 것이 좋습니다.
    누가봐도 명확하다면 굳이 테스트를 작성하는 시간을 허비할 필요가 없습니다.
    function makeYear(year) { return `${year}년`; }
*/
it('makeYear', () => {
  expect(makeYear(2019)).toBe('2019년') // X
})

// 5. foo.js 파일 하나에는 상응하는 테스트 foo.test.js 작성을 지향합니다.

로직테스트

그럼 로직은 어떻게 테스트를 수행할까요?

/*
    우리는 TDD로 구현을 한다고 했으니! 한 번 적용 해보면서 로직테스트를 작성해보죠!
    예제는 숫자로 이루어진 배열을 받아 배열의 원소 중 하나의 값을 +1을 했을 때
    배열의 전체 원소들의 곱이 가장 커지는 경우를 찾아내고 싶습니다.
*/
// 1. 먼저 로직을 작성하기 전에 내부 로직이 없는 함수와 테스트를 작성해보죠!
export default function findValue() { // 대상 함수
  return 0 // 그저 리턴 0을 할 뿐입니다.
}
describe('Find the value that makes the largest value', () => {
  // 테스트 로직
  it('default', () => {
    const numbers = [1, 2, 3]
    const expected = 12
    expect(findValue(numbers)).toBe(expected)
    // 위 테스트는 실패! Expected: 12, Received: 0 기대값과 다르다는 군요!
    // TDD에서는 이 단계를 'Red' 라고 합니다!
  })
})

// 2. 테스트 로직 성공을 위해서 이제 구현을 해봅시다!
// 2.1 테스트를 먼저 성공시켜 봅시다!
// (* 단순한 함수에 굳이 왜 단계를 나눠서 테스트를 해야하지라고 생각할 수 있습니다. 그런 경우 당연히 2.1과 2.2는 동시에 구현하여도 지장 없습니다.)
export default function findValue() {
  // 먼저 정답이 12인 것을 알고 있으니 테스트를 먼저 통과 시키려면 return을 12로 바꿉니다.
  return 12
}

// 2.2 전달받은 매개변수를 사용해서 테스트를 통과시켜봅시다!
export default function findValue(numbers = []) {
  // 방법은 여러가지지만, 배열 내 가장 작은 수를 +1 하면 전체를 곱했을 때 가장 큰 수가 됩니다.
  const newNumbers = [...numbers]
  newNumbers.sort((a, b) => a - b)
  newNumbers[0] += 1
  const answer = newNumbers.reduce((prev, cur) => prev * cur, 1)
  return answer
}

// 3. 다시 테스트를 돌려볼까요?
describe('Find the value that makes the largest value', () => {
  ...
    expect(findValue(numbers)).toBe(expected) // ✓ test (2ms) 성공입니다!
    // TDD에서 이 단계는 'Green' 이라 합니다.
  ...
})

// 4. 그 다음! 함수를 리팩토링합니다!
export default function findValueByEachMultiply(numbers = []) {
  // refactoring this function!! TDD에서 이 단계를 'Blue' 라고 합니다!
}

세번째. 작성한 테스트는 어떤 가치를 지닐까요?

우리가 고생(익숙해지기 전까지는 말이죠!)하여 작성한 테스트는 다양한 가치를 지닙니다.

첫번째, 테스트 커버리지 자체는 프로젝트에 작성되어 있는 코드에 안정성신뢰도보증합니다.
두번째, 리팩토링도와줍니다.(TDD 기반이라면 물론 “Blue”의 단계를 거치며 리팩토링을 수행할 수도 있습니다.) 리팩토링 시에 대상의 테스트가 통과된다면, 내부 로직의 리팩토링은 어떤 방법으로 작성 되어도 됩니다. (어떤 방법이라는 말을 잘못 이해하면 안됩니다!!  당연히 더 나은 방향으로 리팩토링이 되어야겠죠?)
세번째, 중복코드제거합니다. 작성한 테스트를 기반으로 향후 다른 테스트를 작성 하다보면 기존에 존재하는 테스트와 동작 방식(즉, 내부 로직)이 동일한 형태를 지니는 코드를 만나는 경우가 있습니다. 이런 경우는 해당 함수를 공통으로 이동하고, 사용하는 곳에서 공통 함수를 호출하여 사용하는 경험을 할 수도 있습니다.

이런 가치를 창출하는 테스트를 익숙해질때까지 꾸준히 작성하도록 노력합시다! 내가 작성한 코드는 내가 완벽하게 검증해야합니다.

결론. 그래서 우리는 계속 테스트를 해야할까?

서론과 본론을 지나 이야기의 결론에 다다랐습니다. 우선 결론부터 이야기하면, “우리는 계속 테스트를 작성해야한다.”라고 생각합니다. 테스트를 작성한 후 얻게 되는 코드 검증에 대한 불안감 해소와 작성한 코드를 다른 개발자가 보았을 때 주석 외에 경계 조건에 대한 이해 관점에서 보았을 때 장기적으로 테스트 코드의 중요성이 부각됩니다. 따라서 결론은 “우리는 계속 테스트를 작성해야한다” 입니다.

현재 Linewalks 의 프로젝트들은 전반적으로 80 ~ 82% 수준의 커버리지를 유지하고 있습니다. 코드 커버리지를 설명하는 문서에서 기준으로 제시하는 70 – 80%를 기준삼아 최대치80%를 기준으로 잡고 더 나아가 90%를 달성하는 것이 목표입니다.

따라서, 80% 를 유지하며 지속가능한 목표인 90% 달성을 위해 나아갈 계획입니다.

읽어주셔서 감사합니다.

Reference

Seonyeong Jang

Linewalks에서 Front-end 를 개발하고 있습니다.

Seonyeong Jang