TDD에 대해서

TDD에 대해서

TDD란 무엇인가

  • 간단히 말하자면, 테스트 코드를 먼저 짜고 그 이후에 코드를 짜는 방법
    1. 요구사항 파악
    2. 테스트 코드 작성
    3. 테스트 코드가 돌아가도록 코드 작성
    4. 리팩토링

Why

  • 장점
    1. 동작하는 코드에 대한 자신감 (Clean Code that works)
    2. 과도한 설계를 피하고, 간결성 증대
    3. 실행 가능한 문서를 가짐
      • 어떻게 그 함수와 코드를 사용해야하는지 명세서가 될 수 있음.
    4. 디자인적 유연함, 의존성 관리 편함

How

과학과 엔지니어링

도메인

  • 소프트웨어는 문제를 푸는 도구
  • 도메인은 소프트웨어가 풀어야 할 문제가 정의되는 공간 비즈니스 시스템의 도메인은 비즈니스
  • 문제를 충분히 이해하지 못하면 문제를 푸는 도구를 잘 만들 수 없다.

비즈니스 시스템의 도메인 지식 흐름

비즈니스. 분석가 프로그래머 컴퓨터 전문가

<- 목적/추상적 ——————————————– 수단/구체적 ->

  • 비즈니스 전문가
    • 문제를 가장 잘 이해
    • 시스템이 투영해야 할 핵심 지식의 원천
    • 문제 설명력 부족
    • 풀이도 가장 잘 이해한다고 착각
  • 분석가(기획자, QA)
    • 비지니스 전문가로부터 시스템 요구사항을 발굴
    • 발굴된 요구사항의 오류 탐색
    • 발견된 문제점을 구현 작업 전에 협업을 통해 해결
  • 프로그래머
    • 정제된 기능 명세를 아키텍처와 코드로 번역
    • 끊임없는 설계 결정
    • 지식 흐름 과정의 마지막 인간
  • 컴퓨터
    • 코드를 통해 프로그래머로부터 지식을 전달받음
    • 철저히 수동적
    • 융통성 없음

프로그래머와 기능 명세

  • 컴퓨터는 스스로 설계를 결정하지 않기 때문에 프로그래머가 도메인 지식을 컴퓨터에 전달할 때엔 모든 요소들이 명확히 결정될 수 밖에 없음
  • 충분히 명확한 도메인 지식을 확보하지 못한 프로그래머는 지식 흐름 상류에 지식 보강을 요청해야함
  • 하지만 어떤 프로그래머는 스스로 결정을 내림
    • 도메인 지식 투영에 오차 발생
    • 무책임하고 위험한 도박!
    • 항상 팀장,기획자에게 요청을 하자

테스트 기법

수동 테스트

  1. 품질 담당자가 UI를 사용해 기능을 검증
  2. 최종 사용자의 사용 경험과 가장 비슷하게 검증
  3. 실행 비용이 높고 결과의 변동이 큼
  4. 가장 온전한 코드 실행
  5. 인수 테스트

소프트웨어 회귀(software regression)

원래 동작하던 코드가 어느 시점 부터 동작하지 않는 현상

테스트 자동화

  1. 기능을 검증하는 코드를 작성
  2. 테스트 코드 작성 비용이 소비되지만 실행 비용이 낮고 결과의 신뢰도가 높음
  3. 테스트 코드 작성과 관리가 프로그래머 역량에 크게 영향 받음

인수 테스트

  1. 배치된 시스템을 대상으로 검증
  2. 전체 시스템 이상 여부 신뢰도가 높음
  3. 높은 비용
  4. 피드백 품질이 낮음

단위 테스트

  1. 시스템의 일부(하위 시스템)을 대상으로 검증
  2. 낮은 비용 (작성비용/관리비용/실행비용)
  3. 높은 피드백 품질
  4. 전체 시스템 이상 여부 신뢰도가 낮음

코드 분해

문제의 크기

  • 프로그래머가 한 번에 다룰 수 있는 문제의 크기는 한계를 가짐
  • 프로그래머는 더 큰 문제를 자주 마주함
  • 시스템의 크기는 점점 커짐
  • 큰 문제는 작은 문제로 분해할 수 있음
  • 작은 문제의 일부는 반복됨

한번 작성한 코드는 계속해서 읽히게 된다. -코드 재사용-

  • 새로운 코드를 작성할 때 기존의 코드를 읽게 된다.
  • 기존 코드를 수정할 때도 읽게 된다.
  1. 반복되는 문제의 풀이는 기존 코드를 재사용해서 해결 가능하다.
  2. 결국 가독성이 좋은 코드가 소프트웨어 개발 비용을 절감한다.

코드를 재사용할 때, 가장 먼저 고려해야 할 것은 그대로 다시 사용하는 것

  • 코드는 다른 코드와 연결되어 있기 때문에, 수정할 때는 조심해야 한다.

모듈화

  • 분해
    • 큰 시스템은 더 작은 하위 시스템으로 분해 가능
    • 교체 가능
  • 조립
    • 작은 시스템은 더 큰 상위 시스템으로 조립 가능
    • 모듈 재사용
    • 라이브러리
  • 단위 테스트

구현, 인터페이스

  • 인터페이스
    • 모듈이 어떤 기능을 제공하는지, 그 기능을 제공받기 위해서 어떻게 사용해야 하는지 알 수 있다.
  • 구현
    • 어떻게 만들어지는지에 대해 알 수 있다.

모듈을 모아 상위 시스템을 만들 때는, 인터페이스를 고려해서 새로운 시스템을 설계 해야한다.

  • 따라서 구현과 인터페이스가 잘 구분되어 있어야 한다.
  • 구현과 인터페이스가 잘 구분되어 있으면, 가독성을 높이고 실수를 줄인다.

단위 테스트

예시)

// 운영코드
function refineText(s) {
  return s.replace("    ", " ").replace("  ", " ");
}

module.exports = refineText;
  • jest –watch 를 사용해서 test 코드를 작성
const sut = require("./test");

test('sut transforms "hello  world" to "hello world"', () => {
  const actual = sut("hello  wolrd");
  expect(actual).toBe("hello wolrd");
});

jest parameterize test

  • 테스트 코드를 실행 할 때, 동일한 테스트 코드를 여러개의 테스트 데이터를 넣어주면서 테스트 해주는 기능
const sut = require("./test");

test.each`
  source              | expected
  ${"hello  world"}   | ${"hello world"}
  ${"hello   world"}  | ${"hello world"}
  ${"hello    world"} | ${"hello world"}
`('sut transforms "$source" to "$expected"', ({ source, expected }) => {
  const actual = sut(source);
  expect(actual).toBe(expected);
});

테스트 우선 개발

  • 테스트 코드를 운영 코드보다 먼저 개발하는 방법 입니다.

테스트 코드

  • 가시적이고 구체적인 목표 - 기능명세 역할을 함
  • 자가검증
  • 반복실행 - 언제든지 사용 가능
  • 클라이언트 - 운영코드 API의 클라이언트가 됨

운영 코드보다 테스트 코드를 먼저 작성

  1. 명확하고 검증 가능한 목표를 설정한 후 목표를 달성
  2. 프로세스가 코딩에 앞선 목표 설정을 강요
  3. 프로그래머는 자신이 풀어야 할 문제를 구체적으로 이해해야 함

기존에 있던 코드로 다시 돌아갑시다.

기존의 운영 코드를 테스트가 통과하도록 수정해 봅시다

  • 기존 운영 코드

    // prod 함수
    function refineText(s) {
      return s.replace("    ", " ").replace("  ", " ");
    }
    
    module.exports = refineText;
    
  • 수정 운영 코드

    // prod 함수
    function refineText(s) {
      return s.replace("    ", " ").replace("  ", " ").replace("  ", " ");
    }
    
    module.exports = refineText;
    

어처구니 없이 replace 를 추가해주니 모든 테스트를 성공했습니다.

  • 테스트 케이스를 추가해 보았습니다.

    const sut = require("./test");
    
    test.each`
      source                 | expected
      ${"hello  world"}      | ${"hello world"}
      ${"hello   world"}     | ${"hello world"}
      ${"hello    world"}    | ${"hello world"}
      ${"hello     world"}   | ${"hello world"}
      ${"hello      world"}  | ${"hello world"}
      ${"hello       world"} | ${"hello world"}
    `('sut transforms "$source" to "$expected"', ({ source, expected }) => {
      const actual = sut(source);
      expect(actual).toBe(expected);
    });
    

    마지막 케이스에서 실패 했습니다.

탭 문자가 포함 되어 있을때 케이스를 개발하기

  • 우선 테스트에 tab케이스가 포함된 경우를 작성해줍니다.

    const sut = require("./test");
    
    test.each`
      source                 | expected
      ${"hello  world"}      | ${"hello world"}
      ${"hello   world"}     | ${"hello world"}
      ${"hello    world"}    | ${"hello world"}
      ${"hello     world"}   | ${"hello world"}
      ${"hello      world"}  | ${"hello world"}
      ${"hello       world"} | ${"hello world"}
    `('sut transforms "$source" to "$expected"', ({ source, expected }) => {
      const actual = sut(source);
      expect(actual).toBe(expected);
    });
    
    test.each`
      source             | expected
      ${"hello\t world"} | ${"hello world"}
    `(
      'sut transforms "$source" that contains tab character to  "$expected"',
      ({ source, expected }) => {
        const actual = sut(source);
        expect(actual).toBe(expected);
      }
    );
    
  • 그리고 운영코드를 수정합니다.

    // prod 함수
    function refineText(s) {
      return s
        .replace("     ", " ")
        .replace("    ", " ")
        .replace("  ", " ")
        .replace("  ", " ")
        .replace("\t ", " ");
    }
    
    module.exports = refineText;
    

    이러한 과정을 반복하며 개발합니다.

    • 테스트 케이스 추가 -> 운영 코드 수정 -> 테스트 케이스 추가 -> 운영 코드 수정

새로운 기능 추가

특정 단어가 오면 마스킹 처리를 하는 기능을 개발 해봅시다.

  • 테스트 코드 개발

    test.each`
      source             | bannedWords              | expected
      ${"hello mockist"} | ${["mockist", "purist"]} | ${"hello *******"}
    `(
      'sut transforms "$source" to "$expected"',
      ({ source, bannedWords, expected }) => {
        const actual = sut(source, { bannedWords });
        expect(actual).toBe(expected);
      }
    );
    
  • 운영 코드 수정

    // prod 함수
    function refineText(s) {
      return s
        .replace("    ", " ")
        .replace("\t", " ")
        .replace("  ", " ")
        .replace("  ", " ")
        .replace("  ", " ")
        .replace("\t ", " ")
        .replace("mockist", "*******");
    }
    
    module.exports = refineText;
    

언제까지 이럴게 할 것인가…

random test를 해봅시다

  • test code

    describe("given banned word", () => {
      const bannedWord = faker.lorem.word();
      const source = "hello " + bannedWord;
      const expected = "hello " + "*".repeat(bannedWord.length);
    
      test("${bannedWord} when invoke sut then it returs ${expected", () => {
        const actual = sut(source, { bannedWords: [bannedWord] });
        expect(actual).toBe(expected);
      });
    });
    
  • 상용 code

    // prod 함수function refineText(s, options) {  s = s    .replace("    ", " ")    .replace("\t", " ")    .replace("  ", " ")    .replace("  ", " ")    .replace("  ", " ")    .replace("\t ", " ")    .replace("mockist", "*******")    .replace("purist", "******");  if (options) {    for (const bannedWord of options.bannedWords) {      s = s.replace(bannedWord, "*".repeat(bannedWord.length));    }  }  return s;}module.exports = refineText;
    

리팩토링

리팩토링은 작업 환경 정리다

  • 생상선 : 정리된 환경과 어지럽혀진 확녕에서의 작업 생산성 차이
  • 지속성 : 작업 환경의 생산성이 일정 수준 미만으로 떨어지면 더 이상 그 환경에서 작업 진행은 불가능
  • 코드는 작업 환경이자 작업 결과물

Re-factoring

  • 리팩토링은 의미를 유지하며 코드베이스를 정리하는 것
  • 하지만 많은 개발자들이 의미가 변하지 않는 것을 보장하는 작업을 하지 않는다.
  • 이 보장하는 작업은 테스트이다.

테스트 주도 개발

tdd1

  • RED
    • 테스트 우선개발
    • 운영코드를 작성하기 전에 테스트를 추가하는 과정
  • GREEN
    • 운영 코드를 수정해서 테스트를 통과 시키는 과정
  • REFACTOR
    • 테스트를 통과하면서 작성한 코드를 리팩토링하는 과정

테스트 실패

  1. 구체적인 하나의 요구사항을 검증하는 하나의 테스트를 추가
  2. 추가된 테스트가 실패하는지 확인

테스트 성공

  1. 추가된 테스트를 비롯해 모든 테스트가 성공하도록 운영 코드를 변경
  2. 테스트 성공은 요구사항 만족을 의미 - 코딩의 가장 중요한 임무
  3. 테스트 성공을 위한 최소한의 변경

리팩토링

  1. 코드베이스 정리
  2. 구현 설계 개선
    • 가독성
    • 적응성
    • 성능
  3. 모든 테스트 성공을 전제

켄트 벡의 설계 규칙

  • Passes the tests : 빠르게 테스트 성공을 해야함
  • Reveals intention : 의도를 노출함, 가독성을 높임
  • No duplication : 중복 제거
  • Fewest elements : 테스트 성공에 필요하지 않은 코드를 제거해라

테스트 주도 개발 세부 흐름

  1. 단위 테스트 작성
  2. 단위 테스트 실행
  3. (실패하면) 운영 코드 작성
  4. 단위테스트 실행
  5. 설계 계선
  6. 단위테스트 실행

테스트 주도 개발은 낯설지 않다.

  • 버그 리포트 -> 버그 재현 -> 오류 발생 -> 코드 수정 -> 버그 재현

프로그래머 피드백

기대 출력 피드백

  • 사용자 피드백
    • 사용자가 직접 코드를 사용한 후 경험한 버그나 불만을 제보
  • Quality Assurance(QA)
    • 전문 인적 자원에 의한 인수 테스트
  • 프로그래머 테스트
    • 프로그래머가 직접 피드백 장치를 준비
  • 도구 피드백
    • 컴파일 오류, 정적 검사 등 프로그래머가 사용하는 도구가 제공하는 피드백

오버 엔지니어링

  • 프로그래머는 요구사항 명세에 명확히 지정되지 않은 성능 달성이나 구현 설계 품질 개선에 빠져드는 경향을 가짐
  • 이런 목표는 그 자체로 나쁜 것이 아니지만 지나치면 더 중요한 목적, 기능 요구사항에 써야 할 자원을 불필요하게 낭비하게 됨
  • 테스트 주도 개발은 가장 중요한 목표를 우선 달성하도록 유도하며 오버엔지니어링에 빠졌음을 느낄 때 안심하고 다음으로 나아갈 수 있도록 피드백을 제공

핵심은 피드백

  • 테스트 주도 개발의 핵심은 정해진 절차가 아니라 짧은 주기로 지속되는 피드백
  • 피드백에 기반해 안정적으로 지식과 코드를 늘려 나가는 것이 목적

연습하기 1

게임 설계

  • 1부터 100까지의 임의의 정수를 맞추는 게임
  • 플레이어가 숫자를 입력하면
    • 입력한 숫자가 정답보다 작으면 작다고 출력
    • 입력한 숫자가 정답보다 크면 크다고 출력
    • 입력한 숫자가 정답보다 일치하면 라운드 종료
  • 단일 플레이어 모드와 다중 플레이어 모드 지원
    • 단일 플레이어 모드 라운드가 종료되면 총 시도를 출력
    • 다중 플레이어모드 라운드가 종료되면 승자를 출력

Comments

comments powered by Disqus