TDD 해보기

November 30, 2019 ·   12 mins to read
Red, Green, Refactor (opens new window)

1. Concept of TDD

사실 TDD에 대한 정의와 설명은 googling을 하면 좋은 글들이 많이 나오기 때문에 여기서까지 할 필요는 없다고 생각한다. 개인적으로 읽기 쉽다고 생각되는 글은 alma님의 글 (opens new window)velopert님의 글 (opens new window)이다.

TDD에 대한 개념이 와닿지 않는다면, 일단 test case를 먼저 작성한 뒤 기능을 작성한다라고 알아두면 된다. 말 그대로 testing이 기능개발을 주도하게 되는 것이다. 그렇다면 왜 test case를 먼저 작성해야 할까?

2. TDD의 절차와 이유

1. TDD의 절차

우선 그 이유를 알아보기 전에 TDD의 절차에 대해 알아보면 어렵지 않다. 위의 Red, Green, Refactor cycle에 따르면 다음과 같다.

  1. Test case를 작성한다 (Red).

    • 이때, edge case와 failure case 위주로 작성하면 도움이 된다. ::: tip Edge case Edge case란 경계조건을 의미한다. 예를들면,

      if (a >= 10) { ... }

      일때, a의 edge case는 9, 10, 11이 될 수 있다. :::

  2. Testing이 fully passed될 때 까지 기능을 작성한다 (Green).

    • Testing passed 되었다면, 기능 작성은 완료 된 것이다.
  3. Refactoring을 진행한다 (Blue).

    • 시점은 아무때나 상관없지만, 개인적으로는 refactoring 여부를 곧바로 판단하고 진행하는 편이다.
    • Refactoring을 진행할 때, 기능이 변하지 않는다면 기존의 test case가 passed될 때 까지 진행한다.
    • 기능이 변경, 추가되었다면 그에 맞추어 새로운 test case를 작성하거나 test case를 수정한다.
  4. 위의 과정을 반복한다.

생각보다 간단하다. 하지만 잘 읽어보면 무언가 의아하다. 주객이 전도된 것 같은 느낌이 들기도하고, 일이 늘어나버린것 같기도 하다. 간단히 계산했을때, test case까지 작성해야하니(당연히 test code들 역시 유지보수 대상이다) 기존보다 2배정도의 시간이 드는 것 아닌가?

그렇다면 이렇게까지 해야하는 이유가 무엇일까?

2. TDD의 이유

사실 TDD를 하는 이유 역시 googling을 통해 많은 글들을 찾을 수 있다(사실 이 글 전체가 그렇다). 그렇기때문에, 여기서는 본인의 경험에 대한 이야기를 하고자 한다.

1. 어느새 적절히 모듈화되는 코드

함수(또는 method)나 class(이하 기능)를 작성하기 전에 test case를 작성하다보면 warning signal을 받을 때가 있다. 개인적인 관점에는 다음과 같은 signal들을 경고라고 인지한다.

  • Test case를 작성하기가 까다롭다. 복잡한 dummy data를 만들어야 한다거나, 과도한 들이 필요하다. 어떠한 경우에는 다른 test suite에 있는 함수나 클래스를 통해야만 test case를 작성할 수 있다.
  • 특정 test case/suite의 크기가 거대하다.
  • Test case의 가독성이 떨어진다(주로 위의 이유로).

이럴 경우, 머릿속에서만 이루어졌던 기능의 설계가 잘못되었음을 인지할 수 있다. 이는 적당한 수준의 추상화나 모듈화가 이루어지지 않았음을 의미한다. 특히 함수의 경우 SRP(Single Resposibility Priciple; 단일책임원칙)원칙이 지켜지지않고, 하나의 함수가 많은 기능을 하고 있을수도 있다.

보통 test case는 작은 단위로 작성하게 되기 때문에, 자연스럽게 적절한 수준의 모듈화가 이루어 진다(test case가 모듈화의 모든 부분을 책임진다는 말은 절대 아니다).

2. 깨닫지 못했던 error를 빠르게 발견

기능을 작성한 후에, 생각하지도 못했던 error나 side effect를 뒤늦게 발견하는 경우도 많을 것이다. 여러 이유들이 있겠지만, 개인적으로는 edge case에서 조건을 잘못설정하는 일이 많이 발생했던 것 같다.

하지만, 에 적어놓았듯이, edge case에 대한 test를 꼼꼼히 작성해 놓으면 이러한 부분을 피할 수 있다.

또한 test case를 먼저 작성하면서 기능에 대한 경우의 수를 생각할 시간이 생기기 때문에, error에 대한 대비를 어느정도 해놓을 수 있다. 이를테면, 빈문자열이나 빈 list가 들어왔을 경우에 대한 test case를 작성하여, 실제 기능개발 단계에서 오동작을 막을 수 있게 되는 것이다.

3. 명확하게 이해되는 요구사항

2의 경우와 비슷하다. 본인의 경우 복잡한 요구사항은 PM이나 디자이너와 함께 앉아 test case를 먼저 작성한다. 이러한 과정을 거치다 보면 요구사항을 명확하게 이해할 수 있게되고, 그 기능을 요구한 사람과 함께 test case를 작성했기 때문에 miscommunication의 발생 확률이 현저히 줄어든다. 어 저는 이렇게 해달라고 하지 않았는데요? 그때 그러셨잖아요

4. 자동으로 올라가는 test coverage

Test coverage (opens new window)를 신경쓸 이유가 적어진다. 기능을 만들면 그에 해당하는 test case가 미리 작성되기 때문이다.

3. TDD 예제

1. Test case 작성

Url의 query string을 object로 parsing하거나 object를 query string으로 만들기 위한 함수를 작성해 보도록 하자. 먼저, queryString.js, queryString.test.js라는 두 file을 생성한다. 그리고 다음과 같이 작성한다.

// queryString.test.js
import { queryString } from './queryString'

describe('queryString.js', () => {
  describe('queryString()', () => {
    test('should return parsed query object from received query string.', () => {})
  })
})

// queryString.js
export const queryString = () => {}

일단, 우리는 query string을 parsing하거나, object를 query string으로 만드는 두 가지 기능이 필요하다는 것을 알고 있다. 그러한 이유로, queryString()함수의 parameter와 결과값에 대해 충분히 예측할 수 있으므로, test case를 작성한다.

// queryString.test.js
import { queryString } from './queryString'

describe('queryString.js', () => {
  describe('queryString()', () => {
    test('should return parsed query object from received query string.', () => {
      expect(queryString('?q=TDD&max=100')).toEqual({
        q: 'TDD',
        max: '100',
      })
    })

    test('shoule return quert string by received object', () => {
      expect(
        queryString({
          q: 'TDD',
          max: '100',
        })
      ).toEqual('q=TDD&max=100')
    })

    test('should return empty object or empty string when received invalid parameter.', () => {
      expect(queryString('')).toEqual({})
      expect(queryString({})).toEqual('')
      expect(queryString([])).toEqual('')
      expect(queryString(0)).toEqual('')
      expect(queryString(undefined)).toThrow(Error)
      expect(queryString(null)).toThrow(Error)
    })
  })
})

2. Test가 통과할 때 까지 기능 작성

에서 말한 것 처럼, failure case를 통해 어떻게 error에 대비해야 할 지 생각해 볼 수 있다. 본인이 처음 개발했을 때 많이 하던 실수인 happy case만 생각하는 문제를 미리 막을 수 있는 것이다. 그렇다면 이 케이스를 통과시키기 위해 queryString() 함수를 작성한다.

// queryString.js
export const queryString = value => {
  if (typeof value === 'undefined' && value === null) {
    throw new Error()
  }

  if (typeof value === 'string') {
    if (value.length === 0) {
      return {}
    }
    const queryValueString = value.replace('?', '')
    const splitedQueryString = queryValueString.split('&')

    const queryObject = splitedQueryString.reduce((acc, query) => {
      const [k, v] = query.split('=')
      acc[k] = v
      return acc
    }, {})

    return queryObject
  }

  if (typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) {
    return ''
  }

  const query = Object.keys(value).reduce((acc, key) => {
    acc.push(`${key}=${value[key]}`)
    return acc
  }, [])

  return query.join('&')
}

오! 무언가 그럴싸한 함수를 하나 만들었다. 의도하지 않은 value type에 대해 예외처리를 해주었고, 의도한 기능은 잘 돌아간다.

하지만 절대 마음에 드는 모양새는 아니다. 철저하게 개인적인 견해지만 함수가 하는 일에 비해 하나의 test case가 조금 크다는 생각이 든다. 하나의 함수가 string을 parsing하거나, object를 string으로 재조립하는 역할을 맡고있고, 여러 예외처리까지 담당하고 있어 혼란스러운 모양새다. 즉, code smell이 진하게 나는 코드인 것이다.

3. Refactoring 하기

일단 함수가 두 가지 주요한 기능을 하고 있기때문에, 함수를 분리해야 한다. 중요한 사실은 그전에 다시 test code를 작성하는 것이다.

// queryString.test.js
import { getQueryString, getQueryObject } from './queryString'

describe('queryString.js', () => {
  describe('getQueryString()', () => {
    test('should return parsed query object from received query string.', () => {
      expect(
        getQueryString({
          q: 'TDD',
          max: '100',
        })
      ).toEqual('q=TDD&max=100')
    })

    test('should return empty string when received invalid parameter or empty object.', () => {
      expect(getQueryString({})).toEqual('')
      expect(getQueryString([])).toEqual('')
      expect(getQueryString(0)).toEqual('')
      expect(getQueryString(undefined)).toEqual('')
      expect(getQueryString(null)).toEqual('')
    })
  })

  describe('getQueryObject()', () => {
    test('shoule return query string by received object', () => {
      expect(getQueryObject('?q=TDD&max=100')).toEqual({
        q: 'TDD',
        max: '100',
      })
    })

    test('should return empty object when received invalid parameter or empty string.', () => {
      expect(getQueryObject('')).toEqual({})
      expect(getQueryObject([])).toEqual({})
      expect(getQueryObject(0)).toEqual({})
      expect(getQueryObject(undefined)).toEqual({})
      expect(getQueryObject(null)).toEqual({})
    })
  })
})

// queryString.js
export const getQueryString = value => {
  //...
}

export const getQueryObject = value => {}

Failure case에 대한 부분이 반복처럼 느껴지긴 하지만, 하나의 test case 크기는 줄어들었다. 그리고 failure case의 기대값이 test case 단위별로 동일하게 설정되어, 함수의 return 값을 분기하여 생각할 필요가 줄어들었다. Test case를 다시 작성했으면, fully passed 할 때까지 다시 함수를 작성한다.

// queryString.js
export const getQueryString = value => {
  if (typeof value === 'undefined' || value === null) {
    return ''
  }
  if (typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) {
    return ''
  }

  const query = Object.keys(value).reduce((acc, key) => {
    acc.push(`${key}=${value[key]}`)
    return acc
  }, [])

  return query.join('&')
}

export const getQueryObject = value => {
  if (typeof value !== 'string' || value.length === 0) {
    return {}
  }

  const queryValueString = value.replace('?', '')
  const splitedQueryString = queryValueString.split('&')

  const queryObject = splitedQueryString.reduce((acc, query) => {
    const [k, v] = query.split('=')
    acc[k] = v
    return acc
  }, {})

  return queryObject
}

함수를 둘로 나누었다. 보다 역할이 명확해 졌고, 각각 string을 return하거나 object를 return하는 하나의 책임만을 지고 있다.

getQueryString() 함수를 유심히 살펴보면 parameter의 validation을 하는 부분이 다소 복잡해 보인다. getQueryObject()의 경우 paramter의 type만을 체크해주면 되지만, getQueryString() 의 paramter validation 로직은 크다고 느껴진다. Red, Green, Refactor cycle에 따라 다시 해야할 일을 한다.

// queryString.test.js
import { isValidObject, getQueryString, getQueryObject } from './queryString'

describe('queryString.js', () => {
  describe('isValidObject()', () => {
    test('should return true when received valid object.', () => {
      expect(
        isValidObject({
          q: 'TDD',
          max: '100',
        })
      ).toEqual(true)
    })

    test('should return false when received valid object.', () => {
      expect(isValidObject({})).toEqual(false)
      expect(isValidObject([])).toEqual(false)
      expect(isValidObject(0)).toEqual(false)
      expect(isValidObject(undefined)).toEqual(false)
      expect(isValidObject(null)).toEqual(false)
    })
  })

  describe('getQueryString()', () => {
    // ...
  })

  describe('getQueryObject()', () => {
    // ...
  })
})

// queryString.js
export const isValidObject = value => {
  if (typeof value === 'undefined' || value === null || typeof value !== 'object') {
    return false
  }
  if (Array.isArray(value) || Object.keys(value).length === 0) {
    return false
  }

  return true
}

export const getQueryString = value => {
  if (!isValidObject(value)) {
    return ''
  }

  const query = Object.keys(value).reduce((acc, key) => {
    acc.push(`${key}=${value[key]}`)
    return acc
  }, [])

  return query.join('&')
}

export const getQueryObject = value => {
  // ...
}

전체적으로 코드의 양은 늘어났지만, 함수는 관심사 별로 잘 분리되었고, test case들을 통해 refactoring이 용이하게 되었다. 아마 lodash (opens new window)를 이용해 이런식으로 완전히 함수를 뜯어고쳐도 test case들만 passed되면 문제 없으니 코드를 개선하는 시간 자체는 줄어들게 된다.

export const getQueryObject = value => {
  if (!isString(value) || value.length === 0) {
    return {}
  }

  return flow(
    replace('?', ''),
    splitFp('&'),
    map(x => {
      const slicePoint = indexOf(x, '=')
      return [x.slice(0, slicePoint)].concat(decodeURI(x.slice(slicePoint + 1)))
    }),
    fromPairs
  )(value)
}

4. Conclusion

TDD를 통해, 하나의 함수를 개선해 세 개의 함수로 나눠보고, 그 중 하나는 lodash를 이용해 완전히 다른모습으로 바꿔 보았다. 사실 글만 읽어서는 잘 와닿지 않을 것이다(본인 역시 처음에는 그랬으니까). 하지만 sample code보다 조금 더 복잡한 함수들을 다루게될 때 TDD를 하게된다면 생각이 조금 달라질지도 모른다.

1. TDD는 생각보다 합리적이다.

Test code를 작성해야한다는 생각때문에 당장은 일이 두 배로 늘어난것 같은 기분이 들어 불합리해 보이기도 한다. 비즈니스 로직을 작성 하기도 바쁜데 test code까지 상황에 맞추어 유지보수 해야한다는 압박감이 들기 때문이다. 이러한 자신의 경험을 근거로 test code에 부정적인 사람들을 많이 보았다(사실 제일 설득하기 힘든 케이스다).

하지만 개인적인 경험으로는, 프로젝트 생명주기 전체를 놓고 보았을때 오히려 개발시간이 단축 되는 효과가 있었다. 위에 써놓은 많은 장점들 덕분인데, 대표적인 것 몇가지만 뽑아보자면 다음과 같다.

  1. Miscommunication 확률의 현저한 감소(복잡한 로직은 PM과 test case를 먼저 작성한다).
  2. Product가 운영중일 때 기존 기능에 크게 영향을 주지 않는 선에서 빠르게 code 개선이 가능(미리 작성된 test case만 fully passed하면 된다).
  3. Test case를 먼저 작성하면서, parameter의 타입체크나 edge case에 대해 놓칠 수 있는 부분을 먼저 생각해 볼 수 있다.

이러한 몇가지 장점들 덕분에 기능의 오동작, 혹은 요구사항 분석의 오류를 짧은 시간 안에 수정 할 수 있었다. 특히 코드를 개선하는 시간이 압도적으로 짧아지는 좋은 경험을 했다.

2. 하지만 생각보다 쉽지 않다.

하지만 생각보다 TDD는 쉽지않다. 여태까지의 경험과 주변의 말을 들어보았을 때 보편적으로 나오는 의견들이 있다.

1. TDD가 모든 상황에 맞는 방법은 아니다.

전적으로 동의하는 말이다. 개발에 오답과 해답은 있지만, 정답은 없다고 생각하는 편이다. TDD 역시 수많은 방법론 중 하나일 뿐이고, 때에 따라서는 TDD가 생산성을 감소시킬 수도 있다고 생각한다. 예를들어, 다음과 같은 함수들은 TDD를 하는게 맞는 것일까?

import _ from 'lodash'

const getMaxValue = (xs: number[]) => _.max(xs)

const getFirstAndSecondValue = (xs: any[]) => _.take(xs, 2)

판단은 모두가 다르겠지만, 본인의 경우에는 test coverage를 올리기 위해서가 아니라면 굳이 test code를 작성하지 않을 것 같다. Lodash의 max와 take 함수는 이미 라이브러리 내에서 test case가 passed된 함수이기 때문이다. 그렇기 때문에, TDD를 하는 상황은 본인이 판단하여 취사선택 해도 괜찮다고 생각한다.

2. Test도 중요하지만 정말 중요한 것은 제품의 delivery이다.

개인적인 가치판단이라 말을 꺼내는 것이 조심스럽지만, (그럼에도 불구하고) test case작성과 제품의 delivery 중 하나를 고르라면 주저 없이 후자 를 고를 것 같다. 한창 test code 작성에 빠져있을 때 영감님 (opens new window)에게 혼난 부분이기도 하다. 혹시 hotfix를 적용하고 있다거나, 빠르게 prototyping을 해야하는 상황에서 TDD를 고집하고 있지는 않은가?

TDD는 결국 내가 만들고 있는 제품을 delivery하기 위한 많은 수단들 중 하나이다. Test case 작성때문에 일정이 늦어진다면 그것은 수단목적으로 착각하는 큰 실수이다. 제품을 개발했었던 기억을 되살려보면, 항상 이상적인 상황만을 맞이하는 것은 아니었다. 되돌아보면 TDD를 고집하면 안되는 순간이 꽤나 많았었다. 그럴때는 언제나, 빠르게 문제점을 수정하고 그 뒤에 test case를 작성했던 것 같다.

제품을 delivery한다는 중요한 목적을 잊고 수단에만 집중한다면 좋은 프로세스를 따르고 있다고 할 수 있을까?

3. 습관을 바꾸는 것은 쉽지 않다.

TDD가 쉽지 않은 이유중 제일 큰 것은 역시 본인의 습관 이라고 생각한다. 사실 TDD는 그렇게 대단한 것이 아님에도, 본인이 개발하는 스타일을 의식적으로 신경쓰고 바꾸지 않으면 실천하기 어려운 방법이다. (어떤 경험을 했는지는 모르겠지만) ‘내 경험상 test case를 작성하는 것은 쓸모없었다’라는 생각이 깊이 박혀 있는 사람들에게는 더욱 그러할 것이다.

본인의 경우에, 처음부터 개발팀 대부분이 TDD를 하는 문화 속에서 일했었다. 그랬기 때문에 의식적으로 TDD를 한다기 보다는, 자연스럽게 test case부터 작성하게 되는 것 같다.

3. 그럼에도 TDD를 지향하자.

사실 대부분의 사람들이 test code의 중요성에 대해서는 대부분이 동감한다. 하지만 늘 다양한 이유로 test code를 작성하지 못한다. 바빠서, 방법을 몰라서, 귀찮아서, 계속 작성을 미루고 있어서 등등.

개인적인 생각으로 TDD의 가치는, test case를 작성하는 것이 프로세스에 포함되어, 자연스럽게 test code를 작성하게 된다는 것 에 있다고 생각한다. 결국 test code를 작성하지 못하는 수많은 이유들이 TDD를 하게되는 순간 무의미하게 변하는 것이다.

기간은 짧지만, 개발을 하는 동안 TDD의 덕을 본 적이 수도 없이 많다. 막연한 편견이나 두려움으로 test code를 작성하지 않고, console.log()로만 debugging을 하고 있다면, 어렵지 않으니 당장 test code부터 작성하는것이 좋다. TDD는 그 다음에 해도 늦지 않는다.

또 한가지, TDD는 수많은 개발 방법론 중 하나이다. 이것이 반드시 정답일수도 없고, 그래서도 안된다.
그렇기때문에, 스스로 TDD가 합리적인 수단이라고 생각이 들었을 때 의식적인 수련을 시작하면 된다.


참고


© 2021, Built with Gatsby