2022년 4월 16일 02:04

일상에서 겪는 일종의 테스트로 예를들어보고 테스트가 필요한 이유, 기술 요구사항에서 코드로까지 옮겨지는 과정, 마지막으로 테스트코드 작성시 더 좋은 테스트 코드를 위한 구조를 알아봅니다.

테스트 한 줄 어떠신가요

이미지

"테스트... 뒤를 부탁합니다..."

들어서며

친구1: "이번에 새로나온 초코아이스크림 맛있다는데 한번 먹어보자"
친구2: "나는 그냥 기존에 맛있던 딸기아이스크림 먹어볼래"

친구1: (초코아이스크림을 먹는다)
친구2: "맛있어?"

친구1: "야 먹어봐 진짜 맛있어"
친구2: (초코아이스크림을 먹는다)
친구1: "어때?"
친구2: "맛있네.. 나도 다음에 먹어야지"

“어떤 게임이 재밌다더라”, “어디가 명소더라" 등과 같이 위의 이야기들 일상생활에서도 흔히 볼 수 있는 대화입니다. 우리는 일상생활속에서도 항상 테스트를 해보곤합니다. 일상에서 경험해보지 않은 새로운 것을 할 때 “검증" 을 합니다. 그리고 자신의 기억에서 “경험"들을 떠올리며 스스로 품질보증의 과정을 거칩니다. 만약 좋지 않았던 경험이 있다면 자연스럽게 배제하여 생각하곤합니다.

이렇듯 제품 개발 시에도 테스트는 어느 형태로도 존재합니다. 흔히 QA(Quality Assurance: 품질보증) 를 QA팀에 요청하기도하고, 팀에서 자체적으로 진행하며 기능에 문제가 없는지 검증을합니다.

가장 쉬운 방법은 직접 경험해보며 테스트를 하는 것입니다. 일명 “손 테스트" 라고도 합니다.

왜 테스트가 필요할까?

테스트코드를 작성한다는 것은 몇가지 관점이 있다고 생각합니다.

1. 코드를 모듈단위로 작성하게 되고 읽기 쉬운 코드가 된다.

예를들어, 함수가 하나의 일을 하지 않고 여러 일을 하는 경우 최소한의 단위라고 보기 어렵습니다. 그리고 이는 재사용 가능한 코드가 아닐 확률이 높습니다. 테스트코드를 작성한다면, 코드를 “사용" 하는 사람의 입장에서 생각해보게 되어 더 좋은 인터페이스가 나올 수 있습니다. 좋은 인터페이스는 읽기 쉬운 코드가 됩니다.

2. 구현하기 전, 동작에 집중할 수 있게 된다.

코드의 구현에 집중하지 않고 동작에 집중하게 됩니다. 가령, add(1) 을 했을 때 “add 라는 함수가 1 이라는 값을 받았다” 가 중요한 것이 아니고, “더한다” 라는것이 중요하기 때문에 “이 함수가 어떤 동작을 할 지” 를 먼저 생각하게 됩니다. 구현에 집중하게 되면, 이 함수의 목적을 잃어버릴 때가 있기 때문입니다.

3. 코드의 신뢰를 통해 불필요한 시간을 아낄 수 있게 된다.

코드를 유지보수 할 때 변경사항이 나오기 마련입니다. 변경이 있을 때 기존에 유지되던 스펙들이 잘 동작하는지 테스트를 해야하는데 “손 테스트" 를 이용하게 되면 불필요한 시간낭비가 될 수 있습니다. 게다가 코드의 리팩토링이 필요할 때, 테스트코드가 있다면 마찬가지로 테스트를 위한 불필요한 시간을 아낄 수 있습니다.

4. 다른사람이 스펙을 이해할 수 있다.

테스트코드는 일종의 스펙입니다. 가령, 복잡한 비즈니스 로직이 있을 때 매번 ‘주석' 으로 처리하는것보다 테스트코드에 설명과 코드가 있다면 다른 동료가 스펙을 이해하기 쉬워집니다. 이는 장기적으로 새로운 제품을 맡아야할 때나 다른 제품을 유지보수해야할 때 좋은 가이드가 될 수 있습니다.

그렇다면 왜 테스트를 작성하기 어려울까?

1. 테스트를 작성하는 것도 리소스다.

테스트코드를 작성하는것도 결국 코드를 작성하는 것이기 때문에 절대적으로 코드를 작성하는 양과 시간은 늘어나기 마련입니다. 팀 내 비즈니스로직이 급하게 돌아가게 되어 배포 일정이 촉박하거나 급한 장애상황이 왔을 때에는 작성하기 어렵고, “AB 테스트”가 진행되는 부분은 추후 한쪽은 휘발되는 코드이기 때문에 작성하는데 심리적 허들이 있을 수 있습니다.

👉 하지만 앞서 말씀 드렸던것처럼 제품이 커지고 비즈니스로직이 추가되다보면 예기치 않은 버그가 나올 수 있고, 디버깅하는데 오랜시간이 걸립니다. 그때 비로소 테스트코드가 빛을 발하게 되는데요. “손 테스트" 했던 순간들을 모두 합친다면 장기적으로 유지보수는 더 편리해지지 않을까 싶습니다.

2. 익숙치 않으면 계속 작성하지 않게된다.

테스트코드에 대한 환경을 설정하기란 쉽지 않을 뿐더러, 처음 접하는 테스트코드들과 테스트코드에서 발생하는 에러들을 해결하고 있는 자신을 보고 있으면 ‘이런 시간에 그냥 비즈니스로직이나 기능을 개발할 텐데...’ 라는 심리적 허들도 작용합니다.

👉 누구나 처음부터 작성하기란 쉽지 않습니다. 먼저 간단한 유닛테스트 부터 작성해보는 것은 어떨까요? 가령 가장 간단한 함수라도 하나씩 붙여나가다보면 테스트 코드 블럭들이 모여 보다 안전한 제품을 만드는데 기여하지 않을까 싶습니다.

그렇다면 간단한 예제를 통해 테스트코드를 어떻게 작성하는지 생각해봅니다

생각해보기

아래는 예시로 회원가입을 위해 필요한 기능들을 정의했습니다.

회원가입 기능 정의1

  • 아이디

    • “아이디”는 영문과 숫자로 이뤄진 4자리 이상이어야 합니다.
  • 비밀번호

    • “비밀번호”는 영문 + 숫자 + 특수문자로 이뤄진 8자리 이상이어야 합니다.
    • “비밀번호확인”은 입력한 비밀번호와 동일한 값 이어야 합니다.
  • 가입

    • “아이디”나 “비밀번호" 가 올바르지 않다면 “가입”버튼은 누를 수 없습니다.
    • “아이디"와 “비밀번호" 가 올바르다면 “가입”버튼이 눌립니다.

기능 정의를 보고 구체화 되지 않은 부분을 다시 한번 생각해봅시다.

  • ⚠️ “아이디”는 영문과 숫자로 이뤄진 4자리 이상이어야 합니다.

    • 아이디형식이 맞지 않는다면 어떻게 되는걸까?
  • ⚠️ “비밀번호”는 영문 + 숫자 + 특수문자로 이뤄진 8자리 이상이어야 합니다.

    • 비밀번호 형식에 맞지 않는다면 어떻게 처리되는걸까?
  • ⚠️ “비밀번호확인”은 입력한 비밀번호와 동일한 값 이어야 합니다.

    • 비밀번호확인이 비밀번호와 다르면 어떻게 처리되는 걸까?

이를 반영해서 기능정의서를 다시 고쳐봅니다.

회원가입 기능 정의2

  • 아이디

    • “아이디”는 영문과 숫자로 이뤄진 4자리 이상이어야 합니다.
    • ❗️“아이디"가 형식에 맞지 않다면 “영문과 숫자를 포함해 4자리 이상으로 만들어주세요" 문구가 나옵니다.
  • 비밀번호

    • “비밀번호”는 영문 + 숫자 + 특수문자로 이뤄진 8자리 이상이어야 합니다.
    • ❗️ “비밀번호"가 형식에 맞지 않다면 “영문과 숫자와 특수문자를 포함해 8자리 이상으로 만들어주세요" 문구가 나옵니다.
    • “비밀번호확인”은 입력한 비밀번호와 동일한 값 이어야 합니다.
    • ❗️ “비밀번호확인" 이 입력한 비밀번호와 값이 다르다면 “입력한 비밀번호와 달라요" 문구가 나옵니다.
  • 가입

    • “아이디”나 “비밀번호" 가 올바르지 않다면 “가입”버튼은 누를 수 없습니다.
    • “아이디"와 “비밀번호" 가 올바르다면 “가입”버튼이 눌립니다.

QA 를 한다는것은 “기능정의서대로 기능이 올바르게 동작하는가?” 부터 시작할 수 있습니다. 기능정의서가 있는곳도 있고 없는곳도 있지만 스펙에 대한 논의는 이루어지고 제품개발을 시작하니 “생각해보기" 처럼 개발에 들어가기 앞서 예상되는 결함이 있는지 확인해 볼 필요는 있습니다.

이제 코드에서는 이를 어떻게 테스트 할 수 있을까요?

테스트 작성하기 (TDD)

흔히 TDD(Test Driven Development) 라고 하는 “테스트 주도 개발” 방법론이 있습니다. 아래와 같은 흐름으로 이루어집니다.

  1. 기능정의서처럼 돌아가는 "테스트 케이스" 를 작성하고
  2. “테스트 케이스" 가 모두 OK 될 때 까지 제품코드를 수정 및 작성
  3. 새로운 기능이나 결함이 생기면 1번으로 되돌아감

테스트 케이스 작성은 Jest 를 이용해 작성합니다.

1. 테스트케이스 작성하기

먼저, 테스트케이스처럼 돌아가야하니 각 케이스에 대해 “설명" 을 작성합니다.

describe('회원가입', () => {
  describe('아이디', () => {
    it.todo('영문과 숫자포함 4자리 이상이면 "영문과 숫자를 포함해 4자리 이상으로 만들어주세요" 문구가 나오지 않습니다.')
    it.todo('영문과 숫자포함 4자리 이상이 아니면 "영문과 숫자를 포함해 4자리 이상으로 만들어주세요" 문구가 나옵니다.')
  })

  describe('비밀번호', () => {
    it.todo('영문 + 숫자 + 특수문자로 이뤄진 8자리 이상이면 "영문과 숫자와 특수문자를 포함해 8자리 이상으로 만들어주세요" 문구가 나오지 않습니다.')
    it.todo('영문 + 숫자 + 특수문자로 이뤄진 8자리 이상이 아니면 "영문과 숫자와 특수문자를 포함해 8자리 이상으로 만들어주세요" 문구가 나옵니다.')
    it.todo('비밀번호확인의 값과 비밀번호 값이 다르다면 "입력한 비밀번호와 달라요" 문구가 나옵니다.')
  })

  describe('가입', () => {
    it.todo('아이디나 비밀번호가 올바르지 않다면 "가입" 버튼은 disabled 입니다.')
    it.todo('아이디와 비밀번호가 올바르다면, "가입" 버튼은 enabled 입니다.')
  })
})

코드를 모르더라도, 위의 기능 정의서에 써있는 부분과 매우 유사한 글로 설명되어있는것을 확인할 수 있습니다.

1-1. 테스트케이스 작성하기: 아이디

describe('아이디', () => {
  it('영문과 숫자포함 4자리 이상이면 "영문과 숫자를 포함해 4자리 이상으로 만들어주세요" 문구가 나오지 않습니다.', () => {
    render(<SignUpPage />)

    // 키보드 타이핑으로 'heecheolman123' 을 입력한다.
    userEvent.type(input, 'heecheolman123')
    // 올바른 값이므로 "영문과 숫자를 포함해 4자리 이상으로 만들어주세요" 는 나오지 않는다.
    expect(
      screen.queryByText(/영문과 숫자를 포함해 4자리 이상으로 만들어주세요/)
    ).not.toBeInTheDocument()
  })

  it('영문과 숫자포함 4자리 이상이 아니면 "영문과 숫자를 포함해 4자리 이상으로 만들어주세요" 문구가 나옵니다.', () => {
    render(<SignUpPage />)

    // 키보드 타이핑으로 'heecheolman' 을 입력한다.
    userEvent.type(input, 'heecheolman')
    // 올바른 값이 아니므로 "영문과 숫자를 포함해 4자리 이상으로 만들어주세요" 문구가 나온다.
    expect(
      screen.getByText(/영문과 숫자를 포함해 4자리 이상으로 만들어주세요/)
    ).toBeInTheDocument()
  })
})

1-2. 테스트케이스 작성하기: 비밀번호

describe('비밀번호', () => {
  it('영문 + 숫자 + 특수문자로 이뤄진 8자리 이상이면 "영문과 숫자와 특수문자를 포함해 8자리 이상으로 만들어주세요" 문구가 나오지 않습니다.', () => {
    render(<SignUpPage />)

    // 키보드 타이핑으로 'heecheolman123!' 을 입력한다.
    userEvent.type(input, 'heecheolman123!')
    // 올바른 값이므로 "영문과 숫자와 특수문자를 포함해 8자리 이상으로 만들어주세요" 는 나오지 않는다.
    expect(
      screen.queryByText(
        /영문과 숫자와 특수문자를 포함해 8자리 이상으로 만들어주세요/
      )
    ).not.toBeInTheDocument()
  })

  it('영문 + 숫자 + 특수문자로 이뤄진 8자리 이상이 아니면 "영문과 숫자와 특수문자를 포함해 8자리 이상으로 만들어주세요" 문구가 나옵니다.', () => {
    render(<SignUpPage />)

    // 키보드 타이핑으로 'heecheolman' 을 입력한다.
    userEvent.type(input, 'heecheolman')
    // 올바른 값이 아니므로 "영문과 숫자와 특수문자를 포함해 8자리 이상으로 만들어주세요" 는 나온다.
    expect(
      screen.getByText(
        /영문과 숫자와 특수문자를 포함해 8자리 이상으로 만들어주세요/
      )
    ).toBeInTheDocument()
  })

  it('비밀번호확인의 값과 비밀번호 값이 다르다면 "입력한 비밀번호와 달라요" 문구가 나옵니다.', () => {
    render(<SignUpPage />)

    // 키보드 타이핑으로 'heecheolman123!' 을 패스워드에 입력한다.
    userEvent.type(passwordInput, 'heecheolman123!')
    // 키보드 타이핑으로 'heecheolman123!@#' 을 패스워드 확인에 입력한다.
    userEvent.type(passwordConfirmInput, 'heecheolman123!@#')

    // 서로 값이 다르기 때문에 "입력한 비밀번호와 달라요" 문구가 나온다.
    expect(screen.getByText(/입력한 비밀번호와 달라요/)).toBeInTheDocument()
  })
})

1-3. 테스트케이스 작성하기: 가입

describe('가입', () => {
  it('아이디나 비밀번호가 올바르지 않다면 버튼은 disabled 입니다.', () => {
    render(<SignUpPage />)

    // 키보드 타이핑으로 각 값을 입력한다.
    userEvent.type(idInput, 'heecheolman123')
    userEvent.type(passwordInput, 'heecheolman')
    userEvent.type(passwordConfirmInput, 'heecheolman')

    // 비밀번호가 조건에 맞지 않아 버튼이 disabled 상태가 된다.
    expect(screen.getByRole('button', { name: /가입/ })).toBeDisabled()
  })

  it('아이디와 비밀번호가 올바르다면, 버튼이 enabled 입니다.', () => {
    render(<SignUpPage />)

    // 키보드 타이핑으로 각 값을 입력한다.
    userEvent.type(idInput, 'heecheolman123')
    userEvent.type(passwordInput, 'heecheolman123!@')
    userEvent.type(passwordConfirmInput, 'heecheolman123!@')

    // 만족하는 값이기 때문에 버튼은 enabled 상태이다.
    expect(screen.getByRole('button', { name: /가입/ })).toBeEnabled()
  })
})

2. 테스트코드가 성공할 때 까지 코드를 작성

pseudo code 로 작성합니다.

1번에서 작성한 테스트코드가 “OK” 가 될 때 까지 코드를 고쳐갑니다.

function SignUpPage() {
  const [id, setId] = useState('')
  const [password, setPassword] = useState('')
  const [passwordConfirm, setPasswordConfirm] = useState('')

  const isSamePassword = password === passwordConfirm
  const validId = isValidId(id)
  const validPassword = isValidPassword(password)

  const canSubmit = validId && validPassword && isSamePassword

  return (
    <>
      <input type="text" value={id} onChange={e => setId(e.target.value)} />
      {!validId && '영문과 숫자를 포함해 4자리 이상으로 만들어주세요'}

      <input
        type="password"
        value={password}
        onChange={e => setPassword(e.target.value)}
      />
      {!validPassword &&
        '영문과 숫자와 특수문자를 포함해 8자리 이상으로 만들어주세요'}

      <input
        type="password"
        value={passwordConfirm}
        onChange={e => setPasswordConfirm(e.target.value)}
      />
      {!isSamePassword && '입력한 비밀번호와 달라요'}

      <button disabled={!canSubmit}>가입</button>
    </>
  )
}

3. 새로운 Case 가 생기거나 변경이 생긴다면 1번으로 돌아가 수정

만약 스펙이 수정되거나, 추가되면 1로 되돌아가서 다시 작성합니다.

테스트 코드 구조

테스트 코드를 작성할 때에도 더 읽기 쉽다면 좋을 것 같습니다. 이러한 구조를 잡은 패턴을 알아봅니다.

AAA 패턴

AAA 패턴은 Assignment(준비), Act(실행), Assert(단언) 의 단계를 거칩니다.

  1. Assignment(준비): 테스트 코드를 실행하기 전, 필요한 상황을 사전 준비하는 과정
  2. Act(실행): 테스트 할 목적이 되는 코드를 실행하는 과정
  3. Assert(단언): 의도한 결과값이 나온다는것을 확인하는 과정

위의 예시 코드에서는 AAA 가 다음과 같이 적용되었습니다.

it('영문과 숫자포함 4자리 이상이면 "영문과 숫자를 포함해 4자리 이상으로 만들어주세요" 문구가 나오지 않습니다.', () => {
  // 1. Assignment: 화면에 그려서 테스트 단계 준비
  render(<TestPage />)

  // 2. Act: 테스트할 목적이 되는 코드. 타이핑한 결과 값이 목적임
  // 키보드 타이핑으로 'heecheolman123' 을 입력한다.
  userEvent.type(input, 'heecheolman123')

  // 3. Assert: 기대하는 결과값으로 안내 문구가 나오지 않기를 기대함
  // 올바른 값이므로 "영문과 숫자를 포함해 4자리 이상으로 만들어주세요" 는 나오지 않는다.
  expect(
    screen.queryByText(/영문과 숫자를 포함해 4자리 이상으로 만들어주세요/)
  ).not.toBeInTheDocument()
})

Given-When-Then 패턴

Given-When-Then 패턴도 AAA 패턴과 유사하지만, 의미상 약간의 차이가 있는데요.

BDD(Behaviour Driven Development) 에서 유래된 패턴으로 “행동" 에 집중합니다. 그래서 테스트 코드의 설명도 유저관점에서 작성하게 된다는 차이가 있습니다.

어떤 패턴에 얽매이기보다는 “준비-실행-단계” 의 순서만 기억하면 더 읽기 좋은 테스트코드가 되지 않을까 싶습니다.

  1. Given(준비): 테스트 코드를 실행하기 전, 필요한 상황을 준비
  2. When(실행): “무엇을 했을 때” 라는 말로, 테스트의 목적이 되는 코드를 실행
  3. Then(결과): “그러면~” 이라는 말로, 기대하는 결과값을 검증

위의 예시 코드에서 Given, When, Then 이 다음과 같이 적용되었습니다.

it('영문과 숫자포함 4자리 이상이면 "영문과 숫자를 포함해 4자리 이상으로 만들어주세요" 문구가 나오지 않습니다.', () => {
  // 1. Given: 화면에 그려서 테스트 단계 준비
  render(<TestPage />)

  // 2. When: 테스트할 목적이 되는 코드. 'heecheolman123' 을 입력할 때,
  // 키보드 타이핑으로 'heecheolman123' 을 입력한다.
  userEvent.type(input, 'heecheolman123')

  // 3. Then: 기대하는 결과값으로 안내 문구가 나오지 않아야 함
  // 올바른 값이므로 "영문과 숫자를 포함해 4자리 이상으로 만들어주세요" 는 나오지 않는다.
  expect(
    screen.queryByText(/영문과 숫자를 포함해 4자리 이상으로 만들어주세요/)
  ).not.toBeInTheDocument()
})

맺으며

일상에서 겪는 일종의 테스트로 예를들어보고, 테스트가 필요한 이유, 기술 요구사항에서 코드로까지 옮겨지는 과정, 마지막으로 테스트코드 작성시 더 좋은 테스트 코드를 위한 구조를 알아보았습니다.

테스트코드 환경 구성하는 부분부터, 비즈니스가 급박하게 돌아가면 작성을 고민하게 되는 지점들이 있기 마련이지만 최근들어 테스트 코드의 효용성을 깨닫고 의도적으로 작성해보려고 노력하고 있습니다.

“오늘부터 테스트 한 줄 어떠신가요?”

©2022 heecheolman

Built with Gatsby