Level 2-3. 테스트 주도 개발

soheey·2021년 5월 25일
0

디버깅: 문제 해결을 위한 방법

디버깅이 뭔가요?

모든 modern 브라우저에서 디버거 기능을 가지고 있기 때문에 브라우저에서 디버깅을 하는 것은 매우 편리합니다. 디버깅은 "버그를 잡는다는 의미"로, 예상치 못한 프로그램의 행동들에 원인을 분석하기 위한 기술입니다. 프로그래밍을 배우는 여러분들이 학습해야 하는 가장 중요한 프로그래밍 스킬중의 하나입니다.

테스트 주도 개발 - TDD

Overview

TDD(Test-driven Development)는 코드를 작성하기 전에 테스트를 쓰는 방법론입니다. 개발자 자신이 바람직하다고 생각하는 코드의 결과를 미리 정의하고, 이것을 바탕으로 코드를 작성하는 법입니다.

Pros

실제 코드보다 테스트를 먼저 작성한다는 것은, 이미 내가 바람직한 코드가 무엇이고, 어떻게 작성해야 되는지에 대한 고민이 이미 끝났다는 의미입니다. 즉, 테스트를 먼저 작성하는 개발은 필연적으로 코드를 어떻게 구성할지 고민하고, 그 과정에서 버그가 더 적은 코드를 짜게 됩니다. 테스트가 쉽도록 코드의 구조를 기획(Design)하는 것도 같은 효과를 내게 됩니다.

Cons

TDD는 프로그래머들이 자연스럽게 생각하고 일하는 방식과 일치하지 않습니다. 소프트웨어는 집을 건축하는 것과는 달리 더 유동적입니다. 아마 대부분의 프로그래머는 테스트를 작성하는 것보다 바로 뭔가 만들어내고 싶어 할 것입니다.

같은 맥락에서 또 살펴보면, 코드를 기획한다는 것은 초반에 명확하지 않을 수 있고 대신 빠르게 프로토타입을 만들어 전반적인 아웃라인을 그리는 방법이 나을 수 있습니다. 이런 경우, 기획이 지속적으로 바뀌는 과정에서 초반에 테스트를 짜다가 이후에 다시 짜야 하거나 지워야 할 경우가 높기 때문에 시간 낭비라고 느껴질 수도 있습니다.

지금까지 살펴본 단점들을 요약한 가장 큰 단점이라고 하면 바로 속도입니다. 장기적으로 피할 수 있는 문제들을 피할 수 있다는 점에서 반론의 여부가 있을 수 있겠지만 속도가 느리다는 생각이 기본 정설입니다.

Popularity

위에 살펴본 단점들 때문에 완전한 TDD를 따르는 사람들은 적습니다. 그럼에도 자동화된 테스트를 작성하는 것은 기본이며, 어느정도 프로젝트가 완성되고 나면 테스트를 작성하게 됩니다. 탄탄한 프로그래밍 팀은 테스트를 포함하지 않은 코드에 대한 pull request 요청이 들어왔을 때, 정말 사소한 부분이 아니고서는 merge하지 않습니다.

Should I use it?

당연히 시도해봐야 합니다! 완벽하게 TDD를 짜려고 하면 골머리를 앓을 것입니다. 대부분의 사람처럼 코드를 작성하는 과정 중 "가까운 시일 내에" 작성하게 되더라도, 작성하려는 코드에 대해 특정한 규칙을 설정하고 고민하면서, 코드가 큰 틀에서 어떤 의미를 갖게 되는지 살피는 것은 분명 특별한 경험이 될 것입니다.

What is Test Framework?

여러분들이 다양한 문제를 풀다보면 어느 순간, 통과하지 않은 테스트를 통과하기 위해 describe, it, assert, expect 등과 같은 다양한 키워드들을 마주쳤으리라 생각합니다. 자연스럽게 다음과 같은 질문들이 떠올랐을 것입니다.

  • 이것들은 다 무엇을 의미하나요?
  • 어디서 갑자기 이러한 키워드들이 등장한 것인가요?

결론부터 이야기하면 이 키워드들은 테스트 작성을 위한 도구들입니다. 이 키워드는 Test Framework라는 조금 특별한 프로그램에서 제공하고 있지요. JavaScript 내장 기능은 아닙니다. 여러 개발자들이 더 나은 테스트를 작성하기 위해 모여 많은 테스트 오픈소스 프레임워크를 제작했습니다. 이후 작성하게 될 Testbuilder 스프린트에서는 mocha, chai라는 두가지 프레임워크를 사용하고, 이후에는 jest나 supertest와 같은 정말 다양한 프레임워크를 사용하게 됩니다. 이번 레슨은 왜 우리가 이런 테스트 프레임워크에 대해서 배워야 하고, 어떻게 구성이 되어있는지 알아봅니다.

Achievement Goals

  • 테스트 프레임워크의 구성요소를 이해할 수 있다.
    • 유닛 테스트
    • Assertion
    • Matcher

유닛 테스트 - 작은 기능 검사하기

왜 테스트를 작성하나요?

우리는 지금 세상을 조금 더 효율적으로 바꾸기 위해 소프트웨어를 개발하는 방법에 대해서 배우고 있습니다. 컴퓨터 공학을 기반으로 하고 있죠. 여러분들이 어느정도 느끼셨겠지만, 수학과는 다르게, 개발은 공학과 설계에 더욱 가깝습니다.

흔히들 공대생들은 수학을 잘 해야 한다고 합니다. 자동차를 하나 만든다고 가정해봤을 때, 이 자동차가 완전히 돌아가기 위해서는 많은 부품이 필요합니다. 이 부품들이 잘 맞물려서 작동하려면, 이것이 잘 작동하는지 검증하는 과정이 필요한데, 이 과정에서 수학을 사용하곤 합니다. 자동차의 무게가 조금만 바뀌어도 곧장 사고로 이어지고, 엔진 내부의 부품이 하나만 망가져도 자동차가 굴러가지 않을테니까요. 수학으로 엄밀하게 검증하는 과정을 거쳐서, 이론적으로 완벽한 설계를 하는 것이 필요하겠죠.

그렇다면 우리 소프트웨어는 무엇을 통해서 검증을 해야 할까요? 바로 테스트 코드 작성입니다. 카카오톡 메세지가 잘 전송되는지, 로그인이 잘 되는지, 암호는 안전하게 보호되고 있는지를 미리 테스트 코드를 작성하여 시험해보고 실제 최종 유져(End User)에게 전달하는 것이 가장 바람직한 IT 회사의 모습이라 할 수 있습니다.

어떻게 테스트 코드를 작성해야 할까요?

혹시 레고 장난감에 대해서 알고 계신가요? 위 그림과 같이 레고 블록은 어떤 제품을 구매하더라도 같이 끼워서 조립할 수 있습니다. 외계인 레고 세트와 호그와트 레고 레고 세트도 서로 같이 조립할 수 있습니다. (물론, 이쁘진 않을 것 같네요.)

프로그램의 각 함수는 레고 블록처럼 작동되어야 합니다. 서로가 약속한 대로 작동하게 된다면 아주 복잡한 프로그램도 만들어낼 수 있게 됩니다. 그러지 못한다면 모든 것들은 관리가 불가능할 것입니다. 그렇다면 어떻게 (자신이 작성한) 각각의 컴포넌트들이 서로 의도대로 작동하는지 알 수 있을까요? 유닛 테스트가 바로 그 역할을 합니다. 각 "유닛"은 여러분이 작성한 함수(컴포넌트), 레고 블록입니다. 그 레고 블록이 잘 만들어졌는지 테스트를 하는 것을 유닛 테스트라고 할 수 있습니다.

즉, 앞으로 우리가 작성할 테스트 코드는 프로그램의 작은 유닛들이 잘 작동할 수 있는지 확인하기 위한 유닛 테스트로 구성되어야 합니다.

어디에 유닛 테스트를 해야 할까요?

사소한 함수를 제외한 모든 함수에 유닛 테스트를 해야 합니다. 프로그래밍 인터뷰에서는 시간 제약을 감당하기 위해 원칙을 벗어날 수밖에 없을 때도 있을 것입니다. 그러나 유닛 테스트를 안 하는 것은 아주 안 좋은 습관입니다. 마치 가드레일이 없는 도로에서 고속으로 달리는 차가 낭떠러지에서 떨어지기 쉬운 것처럼, 오히려 테스트를 안 해서 버리게 되는 시간이 더 많게 될 것입니다.

복잡하게 코드를 작성해서 스스로 헷갈리게 되는 경우는 아주 빈번합니다. 반면 각각의 컴포넌트를 테스트하면 무엇을 만들고 있는지를 바로 이해할 수 있습니다.

A warning sign that you should be testing is...

코드를 작성하는 과정에서 여기저기 console.log 를 찍어 지금 도대체 무슨 일이 일어나고 있는지 고민하고 있다면, 그것보다 작은 테스트들을 통해 현재의 코드를 확인하는 것이 나을 수 있습니다. 물론, 시간이 없다면 어쩔 수 없겠지만요..

유닛 테스트를 만들고 나면...

여러분이 예를 들어, 웹앱을 하나 만들었다고 가정합시다. 테스트를 해볼까요?

  • 입력 창에 특정 값을 넣고
  • 어떤 버튼을 클릭해서
  • 특정 결과가 나오면 성공!
    이러한 과정을 반복하는 것도 테스트가 되겠지만, 우리가 이 테스트를 위해서 사람이 입력 창에 특정 값을 넣고, 어떤 버튼을 클릭하고, 특정 결과가 나오기를 기다려야 할 것입니다. 그러나 우리가 유닛 테스트를 작성하면 위 과정이 아래와 같이 바뀝니다.
  • 입력창에 알파벳을 넣을 수 있어야 합니다. 'abc'
  • 버튼을 클릭할 수 있어야 합니다.
  • 버튼을 클릭하면 입력창의 내용이 서버로 전송되어야 합니다.
  • 서버로부터 대문자로 변경된 내용을 응답 받아야 합니다. 'ABC'

이렇게 유닛 테스트가 잘 작성되어 있다면, 이를 기반으로 자동화 테스트(Automated Test)를 진행할 수도 있습니다. 오늘날 전문적으로 구축된 소프트웨어 시스템은, 누군가 코드를 작성해서 코드 관리 시스템(주로 GitHub)에 올리는 과정 중간에, 약 100개에서 많게는 1000개의 유낫 테스트가 일상적으로 작동되고 있습니다. 즉, 우리가 앞으로 만들어야 할 프로그램은 유닛 테스트로 엄밀하게 검수가 되어야 할 것입니다.

유닛 테스트의 구성 요소

주장 (Assertions)

유닛 테스트는 개발자가 생각하기에 "코드는 이렇게 짜야해"라는 Assertion(주장) 코드를 작성하며 시작합니다.

간단한 예로:

function square(x) {
  return x * x;
}

위 함수에 대한 아주 단순한 유닛 테스트는 다음과 같이 적을 수 있습니다:
(직접 boolean 값을 리턴하지 않고 console.log로 확인만 하기 때문에 일종의 unchecked assertion이라 할 수 있습니다.)

console.log(square(5) === 25);
  • 만약 square라는 함수에 5라는 값을 input 값으로 넣었을 때 output 값이 25가 나오면 true를 반환합니다.
  • 그러나 sqaure라는 함수에 5라는 값을 input 값으로 넣었을 때 output 값이 125가 나오면 false를 반환합니다.

위에 잠시 살펴본 바와 같이 테스트를 처음 구성할 때 console.log를 사용해서 유닛 테스트를 하는 방식은 문제가 되지 않습니다. 분명 테스트를 하지 않고 완성된 코드를 실행하려는 습관보다 훨씬 더 좋은 습관입니다.

하지만 보다 더 전문적인 프로그래머들이라면 Assertion을 기반으로 한 프레임워크를 사용하여, 잘 구성된 테스트를 통해 발생한 문제들을 빠르게 대처할 수 있어야 합니다.

Assertion의 한가지 예를 들면 다음과 같습니다:

var output = square(5);
expect(output).to.equal(25);

즉, square 함수의 인자를 5로 입력하여 리턴되는 값이 25가 되어야 한다는 Assertion(주장)을 담고 있는 것입니다. 만약 내가 작성한 square 함수에 오류가 있다면, 5를 리턴해야 하는 개발자의 주장과 실제 작성한 코드의 결과가 일치하지 않았기 때문에, 테스트가 통과되지 않은 것입니다.

비록 전문적인 프로그래머가 되는 첫걸음을 내딛는 과정일지라도, console.log를 찍는 것보다 assertion을 통해 테스트를 작성하는 것은 중요한 훈련이 될 것입니다. 다음과 같이 아주 쉬운 assertion function, assertEqual을 구성해보세요:

function assertEqual(actual, expected, testName) {
  if (actual === expected) {
    console.log("passed");
  } else {
    console.log(
      "FAILED " + testName + ": Expected " + expected + ", but got " + actual
    );
  }
}

위와 같이 console.log를 직접 함수 내에 사용하지 않고, assertion function 내에 사용할 수도 있습니다.

Matcher

expect(output).to.equal(25);

Assertion 예시에서 보았던 코드의 뒷 부분이 정확히 이해가 되지 않으실겁니다. 뒷 부분 to.equal을 Matcher라고 부릅니다. 우리가 주장하는 바와 실제 코드 실행 결과가 어때야 하는지에 대한 정보를 담고 있습니다. 여기서는 "같아야" 한다라고 되어있는 듯 합니다. 이 Matcher는 테스트 프레임워크마다 다르지만, 우리는 우선 chai의 Matcher를 사용하게 될 것입니다. 다양하게 Assertion과 실제 실행 결과에 대한 관계가 어떻게 "Match"되는지에 대해 설정할 수 있습니다.

이외에 유닛 테스트를 구성하는 요소들

우리는 하나의 유닛 테스트를 하기 위해서 하나의 Assertion이 있을 수 있고, 여러 Assertions가 있을 수도 있습니다. 하나의 기능테스트를 하기 위해서 하나의 테스트가 있는 경우는 별로 없습니다. 유져가 회원가입을 한다고 가정할 때, 아이디의 길이, 비밀번호 구성 규칙, 통신 보안 요소를 모두 테스트 해야 할 것입니다.

테스트 코드를 작동시키려면 여러 준비과정 또한 필요합니다. 테스트를 위한 input 값을 준비하고 예상되는 expected 결과값을 준비합니다. Class를 포함하는 경우, 그 class에 대한 예시를 명시해 놓아야 합니다.

테스트를 하고 나서, 실제 배포된 프로그램을 망치는 일이 없어야 하기에 이를 위해서 원상복귀를 시키거나, 아예 따로 테스트 환경을 만들어야 할 것입니다. 그렇기 때문에 테스트 실행 후 초기화를 시키는 코드도 준비해야 합니다. 이러한 Assertion을 진행하기 전에 하는 모든 세팅도 테스트의 일부라고 볼 수 있습니다.

Unit test와 assertion을 일부러 구분해서 소개하는 이유도 이 때문입니다. 하나의 유닛 테스트를 위해서 여러 Assertions와 그 이외의 구성 요소들이 필요하기 떄문입니다.

테스트 작성 무작정 따라하기

How do I decide what to test?

제곱을 구하는 square 함수에 대한 더 구체적인 assertion을 한번 살펴봅시다:

  1. -5가 input 값일 때, 25가 output 값으로 반환한다.
  2. 0이 input 값일 때, 0이 output 값으로 반환한다.
  3. 5가 input 값일 때, 25가 output 값으로 반환한다.
  4. 0.25가 input 값일 때, 0.0625가 output 값으로 반환한다.

위 assertion들이 잘 작성되었다고 느껴졌을 것입니다. 왜 그렇게 느껴졌을까요? 물론, 위 assertion들은 그냥 작성된 것이 아닙니다. 잘 만들어졌는지 여부를 확인하기 위해 square(제곱)의 특징을 한번 살펴봅시다:

  1. 음수를 제곱하면 양수가 된다.
  2. 0을 제곱하면 0이된다.
  3. 숫자의 제곱은 그 숫자 자신을 곱하는 것이다.
  4. 분수를 곱하면 그 숫자는 더 작은 분수를 반환한다.

어느 때든지 함수에 대한 테스트를 작성할 때에는 "범주적 추론(Categorical Reasoning)"을 통해 작성하고자 하는 코드가 그 기능을 충실히 이행할 수 있도록 모든 범주를 찾아야 합니다.

Note: Your tests go outside the code that you're testing

테스트할 코드는 절대 테스트를 진행하는 코드 안에 넣으시면 안 됩니다.

각 테스트는 각 함수를 "블랙 박스"처럼 다뤄야 합니다.

각 테스트는 단순히 input 값을 제공하고 assertion과 expectation만을 output 값으로 반환하는 역할을 하는 것입니다.

아래는 좋지 않은 예입니다:

function decorateClassListWithAges(classList) {
  var classListWithAges = classList.map(function(student) {
    return { name: student, age: getRandomIntInclusive(10, 15) };
  });
  var checkAge = assertRange(
    classListWithAges[0].age,
    10,
    15,
    "check age is between 10 and 15"
  );
  return classListWithAges;
}

Read more: A note on testing object equality

우리는 다음과 같이 두 배열, [1, 2, 3] === [1, 2, 3]을 비교할 수 없다는 것을 압니다. 각 배열에 해당하는 내용을 비교하는 것이 아니라 각 배열에 할당된 메모리 주소를 비교하기 때문입니다.

아래와 같은 방식으로, 간단한 값인 숫자와 문자열로만 이루어진 배열이라는 가정하에 두 배열을 비교해주는 함수를 작성할 수 있습니다 (예를 들어, object가 들어있거나 또 다른 배열이 들어있는 배열은 아니라는 말입니다). 이러한 간단한 값을 Primitive type은 scalar type 이라고도 합니다.

function assertArraysEqual(actual, expected, testName) {
  var areEqualValues = actual.every(function(item, i) {
    return item === expected[i];
  });
  var areEqualLength = actual.length === expected.length;

  if (areEqualLength && areEqualValues) {
    console.log("passed");
  } else {
    console.log(
      "FAILED [" +
        testName +
        '] Expected "' +
        expected +
        '", but got "' +
        actual +
        '"'
    );
  }
}

간단한 방법으로 배열 안에 값을 다 문자열로 만든 후 비교하는 방법이 있습니다.

JSON.stringify()와 같은 helper 함수를 사용하여 해결할 수 있습니다. JSON에 관해서는 JSON format 을 통해 더 자세히 살펴볼 수 있지만, 이 함수만 사용한다면 굳이 볼 필요는 없을 것입니다.

function assertObjectsEqual(actual, expected, testName) {
  actual = JSON.stringify(actual);
  expected = JSON.stringify(expected);
  if (actual === expected) {
    console.log("passed");
  } else {
    console.log(
      "FAILED [" + testName + "] Expected " + expected + ", but got " + actual
    );
  }
}

그런데도 이 테스트 방식은 두 object를 비교하는 모든 경우의 수를 감지하기에 완벽한 테스트 방식은 아닙니다. object의 경우, key 값은 같을 수 있지만 순서가 다르게 될 수 있기 때문입니다. 키의 순서는 동일한지 여부를 파악할 때 비교의 대상이 되지 않아야 하지만 stringify() 을 하면 그 순서에 의해 둘이 다른 값으로 인지되기 때문입니다.

JSON.stringify({ foo: 1, bar: 2 });
// "{"foo":1,"bar":2}"
JSON.stringify({ bar: 1, foo: 2 });
// "{"bar":1,"foo":2}"

이 말은즉슨, 완전한 "deep equality"를 확인하는 방법을 살펴보는 것은 이 코스에서 다루기에 너무 먼 이야기 입니다.

관심이 있으신 분들은 다음 사이트에 작성된 코드를 확인해보실 수 있습니다

기술 면접 중에 유닛 테스트를 할 때 여러분(당사자)만 "expected" 값에 대해 관리를 하게 됩니다. 그렇기 때문에, JSON.stringify를 사용하여 빠르게 비교를 한다고 하더라도 큰 문제가 되지 않을 것입니다. 단지, 인터뷰를 진행하는 사람에게 이러한 방법이 production 코드에서는 안전하지 않은 방법임에도 불구하고 왜 그렇게 작성하게 되었는지를 설명할 준비는 하셔야 할 것입니다.

  • IIFE에 대해 어느정도 숙지해야 합니다.

0개의 댓글