TDD의 소개

Minjun Kim·2019년 6월 4일
70
post-thumbnail

TDD (Test Driven Development · 테스트 주도 개발) 에 대해서 알아봅시다! TDD 는 테스트가 개발을 이끌어 나가는 형태의 개발론입니다.
가장 쉽게 설명하자면, 선 테스트 코드 작성, 후 구현 인데요, 이는 총 3가지 주요 절차로 이루어져있습니다.

TDD 의 3가지 절차

실패

첫번째 절차는 실패입니다. 이는, 실패하는 테스트 케이스를 먼저 만들라는 것 입니다. 실패하는 테스트 케이스를 만들 때는 프로젝트의 전체 기능에 대하여 처음부터 모든 테스트 케이스를 작성하는 것이 아니라, 지금 가장 먼저 구현할 기능 하나씩 테스트 케이스를 작성합니다.

개발팀/상황에 따라 한꺼번에 여러 테스트 케이스를 먼저 작성하기도 합니다.

성공

두번째 절차는 성공입니다. 우리가 작성하는 실패하는 테스트 케이스를 통과시키기 위하여, 코드를 작성하여 테스트를 통과시키는 것 입니다.

리팩토링

세번째 절차는 리팩토링입니다. 우리가 구현한 코드에 중복되는 코드가 있거나, 혹은 더 개선시킬 방법이 있다면 리팩토링을 진행합니다. 리팩토링을 진행하고 나서도 테스트 케이스가 성공하는지 확인합니다. 이 절차가 끝났다면, 다시 첫번째 절차로 돌아가서 다음 기능 구현을 위하여 새로운 실패하는 테스트 케이스를 작성하세요.

TDD 의 장점

TDD 를 진행하면서 테스트 케이스를 작성할때 주로 작은 단위로 만들기 때문에, 코드를 작성 할 때 코드가 너무 방대해지지 않고, 코드의 모듈화가 자연스럽게 잘 이루어지면서 개발이 진행됩니다.

TDD 를 하면 자연스레 테스트 커버리지가 높아질 수 밖에 없습니다. 테스트를 먼저 작성을 하고 구현을 하니까요. 테스트 커버리지가 높아지면 결국 리팩토링도 쉬워지고 유지보수도 쉬워집니다. 결국 프로젝트의 퀄리티를 높이기에 좋은 환경이 구성됩니다. 추가적으로, 협업을 할때도 매우 도움이 되지요.

그리고, 버그에 낭비하는 시간도 최소한으로 할 수 있고 우리가 구현한 기능이 요구사항을 충족하는지 쉽게 확인 할 수 있습니다.

TDD 연습

이번 연습에서는 배열이 주어졌을 때 최댓값, 최솟값, 평균, 중앙값, 최빈값을 구하는 함수들을 구현해보겠습니다.

먼저 파일 두개를 만들어주세요:

  • stats.js
  • stats.test.js

최댓값 구하기

먼저 최댓값을 구하는 테스트케이스를 작성해봅시다.

stats.test.js

const stats = require('./stats');

describe('stats', () => {
  it('gets maximum value', () => {
    expect(stats.max([1, 2, 3, 4])).toBe(4);
  });
});

stats.max 함수가 존재하지 않으니 TypeError: stats.max is not a function 이런 오류가 뜰 것입니다. 그럼, max 함수를 stats.js 에 구현해봅시다.

stats.js

exports.max = numbers => {
  let result = numbers[0];
  numbers.forEach(n => {
    if (n > result) {
      result = n;
    }
  });
  return result;
};

이렇게 하고 나면 테스트 케이스가 통과하겠죠? 그 다음엔 이 코드를 어떻게 하면 리팩토링 할 수 있을까.. 고민해봅시다. JavaScript 를 잘 활용할줄 아신다면 왜 이걸 이렇게 구현했나 의아해 하실정도로, 더 쉬운 방법이 있습니다.

바로 Math.max 함수를 사용하는 것이죠.

stats.js

exports.max = numbers => Math.max(...numbers);

리팩토링 끝!

최솟값 구하기

먼저 실패하는 테스트 케이스를 작성합니다.

stats.test.js

const stats = require('./stats');

describe('stats', () => {
  it('gets maximum value', () => {
    expect(stats.max([1, 2, 3, 4])).toBe(4);
  });
  it('gets minimum value', () => {
    expect(stats.min([1, 2, 3, 4])).toBe(1);
  });
});

그 다음에는 이 테스트 케이스를 통과시켜봅시다.

stats.js

exports.max = numbers => Math.max(...numbers);
exports.min = numbers => Math.min(...numbers);

테스트 코드가 통과됐나요? 여기서 딱히 리팩토링 할 방법은 없으니 리팩토링 절차는 생략하겠습니다.

평균값 구하기

이번에는 평균값을 구해봅시다! 우선 평균값을 구하는 테스트 케이스를 작성해보세요.

stats.test.js

const stats = require('./stats');

describe('stats', () => {
  it('gets maximum value', () => {
    expect(stats.max([1, 2, 3, 4])).toBe(4);
  });
  it('gets minimum value', () => {
    expect(stats.min([1, 2, 3, 4])).toBe(1);
  });
  it('gets average value', () => {
    expect(stats.avg([1, 2, 3, 4, 5])).toBe(3);
  });
});

실패하는 테스트 케이스가 잘 만들어졌나요? 그럼 구현을 시작해봅시다.

stats.js

exports.max = numbers => Math.max(...numbers);
exports.min = numbers => Math.min(...numbers);
exports.avg = numbers => {
  const sum = numbers.reduce((acc, current) => acc + current, 0);
  return sum / numbers.length;
};

테스트 케이스가 통과했지요? 여기서 조금 더 리팩토링을 하자면.. 맨 마지막 sum / numbers.length 부분을 굳이 저렇게 바깥에 넣지 않고 reduce 함수 내부에서 처리하게 할 수도 있습니다. 한번 해볼까요?

stats.js

exports.max = numbers => Math.max(...numbers);
exports.min = numbers => Math.min(...numbers);
exports.avg = numbers =>
  numbers.reduce(
    (acc, current, index, array) =>
      index === array.length - 1
        ? (acc + current) / array.length
        : acc + current,
    0
  );

음.. 해보니까 통과는 하는데 코드의 가독성이 오히려 안좋아졌습니다. 또 다시 바꿔볼까요?

stats.js

exports.max = numbers => Math.max(...numbers);
exports.min = numbers => Math.min(...numbers);
exports.avg = numbers =>
  numbers.reduce(
    (acc, current, index, array) => acc + current / array.length,
    0
  );

방금 전 보다는 괜찮아졌습니다. 여기서 또! 리팩토링 할 수 있는게 있습니다.

stats.js

exports.max = numbers => Math.max(...numbers);
exports.min = numbers => Math.min(...numbers);
exports.avg = numbers =>
  numbers.reduce(
    (acc, current, index, { length }) => acc + current / length,
    0
  );

배열의 length 를 구조 분해 문법을 사용하여 따로 추출해주었습니다.

중앙값 구하기

이번에는 중앙값을 구하는 기능을 구현해봅시다. 중앙값을 구현하기 전에 우선 배열을 정렬해야합니다. 정렬하는 함수를 먼저 구현해볼건데요, 이를 위한 테스트 케이스를 작성해보세요.

stats.test.js

const stats = require('./stats');

describe('stats', () => {
  it('gets maximum value', () => {
    expect(stats.max([1, 2, 3, 4])).toBe(4);
  });
  it('gets minimum value', () => {
    expect(stats.min([1, 2, 3, 4])).toBe(1);
  });
  it('gets average value', () => {
    expect(stats.avg([1, 2, 3, 4, 5])).toBe(3);
  });
  describe('median', () => {
    it('sorts the array', () => {
      expect(stats.sort([5, 4, 1, 2, 3])).toEqual([1, 2, 3, 4, 5]);
    });
  });
});

describe 내부에서 또 describe 를 쓸 수 있습니다. 단, it 내부에 또다른 it 이나 describe 를 쓸 수는 없습니다.

위 테스트 케이스에서는 우리가 toBe 가 아닌 toEqual 을 사용했는데요 이는 객체 또는 배열을 비교해야하는 상황에서 사용합니다.

이제 sort 를 구현해줍시다.

stats.js

exports.max = numbers => Math.max(...numbers);
exports.min = numbers => Math.min(...numbers);
exports.avg = numbers =>
  numbers.reduce(
    (acc, current, index, { length }) => acc + current / length,
    0
  );

exports.sort = numbers => numbers.sort((a, b) => a - b);

이제, 중앙값을 구현해줄건데요, 중앙값은 자료의 개수가 홀수개일때랑 짝수개일때랑 알아내는 방법이 다릅니다.

[1,2,3,4,5] 처럼 숫자가 5개면, 중앙값은 3이 됩니다.
[1,2,3,4,5,6] 처럼 숫자가 6개면, 중앙에 있는 값 3 + 4 / 2 의 결과인 3.5가 중앙값이 됩니다.

그럼 위 요구사항에 맞춰 테스트 케이스를 작성해볼까요?

stats.test.js

const stats = require('./stats');

describe('stats', () => {
  it('gets maximum value', () => {
    expect(stats.max([1, 2, 3, 4])).toBe(4);
  });
  it('gets minimum value', () => {
    expect(stats.min([1, 2, 3, 4])).toBe(1);
  });
  it('gets average value', () => {
    expect(stats.avg([1, 2, 3, 4, 5])).toBe(3);
  });
  describe('median', () => {
    it('sorts the array', () => {
      expect(stats.sort([5, 4, 1, 2, 3])).toEqual([1, 2, 3, 4, 5]);
    });
    it('gets the median for odd length', () => {
      expect(stats.median([1, 2, 3, 4, 5])).toBe(3);
    });
    it('gets the median for even length', () => {
      expect(stats.median([1, 2, 3, 4, 5, 6])).toBe(3.5);
    });
  });
});

테스트 케이스들을 만들었으면, 구현을 해봅시다.

stats.js

exports.max = numbers => Math.max(...numbers);
exports.min = numbers => Math.min(...numbers);
exports.avg = numbers =>
  numbers.reduce(
    (acc, current, index, { length }) => acc + current / length,
    0
  );

exports.sort = numbers => numbers.sort((a, b) => a - b);
exports.median = numbers => {
  const middle = Math.floor(numbers.length / 2);

  if (numbers.length % 2) {
    // 홀수
    return numbers[middle];
  }
  return (numbers[middle - 1] + numbers[middle]) / 2;
};

리팩토링을 조금 해볼까요?

stats.js

exports.max = numbers => Math.max(...numbers);
exports.min = numbers => Math.min(...numbers);
exports.avg = numbers =>
  numbers.reduce(
    (acc, current, index, { length }) => acc + current / length,
    0
  );

exports.sort = numbers => numbers.sort((a, b) => a - b);
exports.median = numbers => {
  const { length } = numbers;
  const middle = Math.floor(length / 2);
  return length % 2
    ? numbers[middle]
    : (numbers[middle - 1] + numbers[middle]) / 2;
};

테스트 케이스가 여전히 잘 통과하고 있나요?

최빈값 구하기

최빈값은 배열에서 가장 빈도가 높은 값 입니다. 이 값은 배열 안에 어떤 숫자들이 있느냐에 따라 형태가 다릅니다.

  1. 주어진 값들 중에서 가장 자주 나타난 값이 결과가 됩니다.
    • [1,2,2,2,3]2
  2. 모든 값들의 빈도가 똑같다면 최빈값은 없습니다.
    • [1,2,3], [1,1,2,2,3,3]null
  3. 빈도가 똑같은 값이 여러개라면, 결과값도 여러개입니다.
    • [1,2,2,3,3,4][2,3]

그럼, 위 요구사항에 맞춰서 테스트 케이스들을 만들어볼까요?

stats.test.js

const stats = require('./stats');

describe('stats', () => {
  it('gets maximum value', () => {
    expect(stats.max([1, 2, 3, 4])).toBe(4);
  });
  it('gets minimum value', () => {
    expect(stats.min([1, 2, 3, 4])).toBe(1);
  });
  it('gets average value', () => {
    expect(stats.avg([1, 2, 3, 4, 5])).toBe(3);
  });
  describe('median', () => {
    it('sorts the array', () => {
      expect(stats.sort([5, 4, 1, 2, 3])).toEqual([1, 2, 3, 4, 5]);
    });
    it('gets the median for odd length', () => {
      expect(stats.median([1, 2, 3, 4, 5])).toBe(3);
    });
    it('gets the median for even length', () => {
      expect(stats.median([1, 2, 3, 4, 5, 6])).toBe(3.5);
    });
  });
  describe('mode', () => {
    it('has one mode', () => {
      expect(stats.mode([1, 2, 2, 2, 3])).toBe(2);
    });
    it('has no mode', () => {
      expect(stats.mode([1, 2, 3])).toBe(null);
    });
    it('has multiple mode', () => {
      expect(stats.mode([1, 2, 2, 3, 3, 4])).toEqual([2, 3]);
    });
  });
});

그럼, 요구사항에 맞춰서 하나하나 구현해봅시다! 이번에 충족해야하는 조건들이 꽤 많은데, 순서대로 하나씩 처리해봅시다.

stats.js

exports.max = numbers => Math.max(...numbers);
exports.min = numbers => Math.min(...numbers);
exports.avg = numbers =>
  numbers.reduce(
    (acc, current, index, { length }) => acc + current / length,
    0
  );

exports.sort = numbers => numbers.sort((a, b) => a - b);
exports.median = numbers => {
  const { length } = numbers;
  const middle = Math.floor(length / 2);
  return length % 2
    ? numbers[middle]
    : (numbers[middle - 1] + numbers[middle]) / 2;
};
exports.mode = numbers => {
  const counts = new Map();
  numbers.forEach(n => {
    const count = counts.get(n) || 0;
    counts.set(n, count + 1);
  });
  const maxCount = Math.max(...counts.values());
  const result = [...counts.keys()].find(
    number => counts.get(number) === maxCount
  );
  return result;
};

여기까지 구현하면, mode 함수의 첫번째 테스트 케이스가 만족된것을 볼 수 있습니다.

나머지 테스트 케이스들도 충족시켜봅시다.

stats.js

exports.max = numbers => Math.max(...numbers);
exports.min = numbers => Math.min(...numbers);
exports.avg = numbers =>
  numbers.reduce(
    (acc, current, index, { length }) => acc + current / length,
    0
  );

exports.sort = numbers => numbers.sort((a, b) => a - b);
exports.median = numbers => {
  const { length } = numbers;
  const middle = Math.floor(length / 2);
  return length % 2
    ? numbers[middle]
    : (numbers[middle - 1] + numbers[middle]) / 2;
};

exports.mode = numbers => {
  const counts = new Map();
  numbers.forEach(n => {
    const count = counts.get(n) || 0;
    counts.set(n, count + 1);
  });
  const maxCount = Math.max(...counts.values());
  const modes = [...counts.keys()].filter(
    number => counts.get(number) === maxCount
  );

  if (modes.length === numbers.length) {
    // 최빈값이 없음
    return null;
  }

  if (modes.length > 1) {
    // 최빈값이 여러개
    return modes;
  }

  // 최빈값이 하나
  return modes[0];
};

구현을 하고나면, 모든 테스트 케이스가 통과 할 것입니다!

이제 리팩토링을 좀 시도해볼까요?

stats.js

exports.max = numbers => Math.max(...numbers);
exports.min = numbers => Math.min(...numbers);
exports.avg = numbers =>
  numbers.reduce(
    (acc, current, index, { length }) => acc + current / length,
    0
  );

exports.sort = numbers => numbers.sort((a, b) => a - b);
exports.median = numbers => {
  const { length } = numbers;
  const middle = Math.floor(length / 2);
  return length % 2
    ? numbers[middle]
    : (numbers[middle - 1] + numbers[middle]) / 2;
};

exports.mode = numbers => {
  const counts = numbers.reduce(
    (acc, current) => acc.set(current, acc.get(current) + 1 || 1),
    new Map()
  );

  const maxCount = Math.max(...counts.values());
  const modes = [...counts.keys()].filter(
    number => counts.get(number) === maxCount
  );

  if (modes.length === numbers.length) {
    // 최빈값이 없음
    return null;
  }

  if (modes.length > 1) {
    // 최빈값이 여러개
    return modes;
  }

  // 최빈값이 하나
  return modes[0];
};

기존에 forEach 로 처리하던 부분을 reduce 를 사용하여 구현해주었습니다.

정리

이번 실습을 통하여 여러분은 TDD 를 맛보기식으로 체험해보았습니다. 테스트 코드가 존재하니까 리팩토링을 하기가 훨씬 편하다는 것을 경험하셨나요? 추가적으로, 요구사항을 제대로 만족시키는것을 시각적으로 확인 할 수도 있고, 개발을 하게 될 때 각 테스트 케이스에 맞춰서 할 수 있다는 점에서 우리가 해결하고자 하는 문제에 조금 더 집중을 할 수도 있었습니다.

profile
CEO @ Chaf Inc. 사용자들이 좋아하는 프로덕트를 만듭니다.

8개의 댓글

comment-user-thumbnail
2019년 6월 16일

벨로퍼트님 정말 최고입니다...

답글 달기
comment-user-thumbnail
2019년 8월 7일

TDD 막연히 어렵게만 생각했었는데, 쉽게 정리해주셔서 감사합니다!

답글 달기
comment-user-thumbnail
2019년 8월 22일

이론은 알겠는데, 실제 업무에서 tdd를 어떻게 적용해야 할지는 찾아보기 힘드네요.
실제 웹앱 개발을 할 때, 최댓값구하는 로직을 테스팅 해야하는지 의문스럽습니다.
저라면 lodash를 믿고 _.max([4, 2, 8, 6]); // => 8 로 최대값을 구하겠습니다. 테스트 코딩은 따로 안하고요.
리액트로 웹앱 개발하면서 테스트가 필요하다고 느끼는 부분이, 컴포넌트로 데이터를 넣어주는 작업을
할 때, 그 데이터가 변하면 그로인해 해당 컴포넌트는와 그 컴포넌트에 주입된 데이터를 의존하고 있는 다른
컴포넌트가 제대로 작동하는 것을 알고 싶을때, 테스트가 필요하다고 느꼇습니다. 제가 말주변이 부족해서 제대로 말했는지 모르겠네요. 문제는, 개발을 하다보면 컴포넌트를 너무 자주변경하게 되고, 그걸 일일이 테스트 한다는 것은 도무지 상상할 수 없다는 것입니다.
jest에서 보면 snapshot test라는게 있는데, 어차피 리액트로 개발할 때, 핫 리로딩이 적용되서 개발하면서 고친 코드를 실시간으로 렌더링 되는지 보면서 개발할 수 있는데, 제대로 렌더링 되는지 테스트 해야만 하는지도 의문스럽습니다.
프로젝트 규모가 뭐 쇼핑몰을 제작한다거나 엄청나게 큰 프로젝트가 아니면, 혹은 리액트 라이브러리를 만들어 깃헙에 공개한다든지가 아니면, 테스트 코드를 작성해야 할 필요가 있을까 싶습니다.

답글 달기
comment-user-thumbnail
2019년 9월 18일

안녕하세요. :) 테스트에 대해 공부를 시작해보려던 중에 이 글을 만나게 되어 정말 다행입니다. 그 어떤 글 보다 이해가 쉽게 잘 되었어요. 🙏

벨로퍼트님의 글을 참고하여 전 mocha로 테스트 코드를 짜보았는데요, 그 과정을 개인 블로그(https://leehwarang.github.io/2019/09/18/contributhon(1).html)에 정리해보았습니다. 출처는 명시해두었지만, 혹시 이런식으로 오버라이드(?) 하여 작성하는 것이 실례가 되지 않을까 싶어서 댓글로 여쭈어 봅니다. :) 댓글 확인을 언제 하실지 몰라서.. 미리 업로드한 점 양해 부탁드려요 ㅎㅎ 문제시 곧바로 수정/삭제 하겠습니다. 항상 좋은 자료 남겨주셔서 큰 도움을 받고 있어요. 감사합니다.

답글 달기
comment-user-thumbnail
2019년 12월 16일

깔끔하네요 ! 멋있습니다 ㅎㅎ

답글 달기
comment-user-thumbnail
2019년 12월 17일

velopert 님 항상 글 잘 읽고 있습니다. 좋은 글 써주셔서 감사합니다!.

저도 tdd 연습을 위해서, 코드를 그대로 따라 읽다보니 문제가 하나 있는 것 같아서 한번 확인해주시면 좋을 것 같습니다.

최빈값 체크 시
modes.length === numbers.length 부분은 지금 테스트에 딱 맞춰져 있어서
만약 테스트 값이 [1,1,2,2,3,3] 같이 최빈 값이 없는 복수의 어레이로 들어오면 맞지 않아서
modes.length === counts.size 로 변경하는 것은 어떨까요?
혹시 맞지않다면 창피하지만 ㅋㅋㅋ 한번 확인해주십사 합니다!

답글 달기
comment-user-thumbnail
2019년 12월 25일

크게 도움 됐습니다 감사합니다~!

답글 달기
comment-user-thumbnail
2020년 10월 12일

뭔가 제가 하고 있는 연구랑 흡사한 부분이 많은 것 같네요.
TDD 라는 방법론은 다른 분야에도 충분히 사용히 가능한 것 같아요.

답글 달기