티스토리 뷰

인프런 김정환 님 견고한 JS 소프트웨어 만들기 강의 노트 입니다.

1. 클릭카운터 모듈 - 스펙1

어떤 모듈을 만들 때, 크게 화면에 보이는 부분, 보이지 않는 부분으로 나누어 생각할 수 있다.

지금은 화면에 보이는 부분을 먼저 만들자 보자.

ClickCounter는 카운터 데이터를 다루는 모듈이다.

counter 변수를 전역 공간이 아니라 ClickCounter 안에서 관리하자.

첫 번째 스펙

ClickCounter 모듈의 getValue()counter 값을 반환한다.

/* ClickCounter.spec.js */
// describe는 중첩해서 사용할 수 있다.
describe('App.ClickCounter', () => {
  describe('getValue()', () => {
    it('초기값이 0인 카운터 값을 반환한다', () => {
      const counter = App.ClickCounter();
      expect(counter.getValue()).toBe(0);
    })
  })
})

위 테스트 코드를 테스트 러너를 통해 실행시켜 보면 통과되지 못한다.

테스트할 코드가 작성되지 않았기 때문이다.

이것이 TDD의 첫 번째 단계, 적색 단계로 먼저 실패하는 코드를 작성하는 단계이다.

/* ClickCounter.js */
var App = App || {};

App.ClickCounter = () => {
  return {
    getValue() {
      return 0;
    }
  }
}

위와 같이 작성해주면 테스트가 성공한다. 이것이 두 번째 단계인 녹색 단계이다. 테스트를 통과하는 코드를 작성하는 단계이다.

하지만 ClickCounter로 변경시킬 counter라는 값은 계속 값이 변하기 때문에

0이라는 상수를 반환하면 안된다.

/* ClickCounter.js */
var App = App || {};

App.ClickCounter = () => {
  // counter라는 값은 계속 변하는 값이기 때문에 상수가 아니라 변수로 지정해줘야 한다.
  let value = 0;

  return {
    getValue() {
      return value;
    }
  }
}

위와 같이 리팩토링을 거치면서도 테스트를 통과했다.

이것이 세 번째 단계인 청색, 리팩토링의 단계이다.

짧은 결론

이렇게 안심하고 리팩토링할 수 있는 이유는 테스트코드 덕분이다.

이렇게 TDD는 하나의 기능에 대해 Red -> Green -> Blue(Refactor) 사이클로 개발하는 것이다.

2. 클릭카운터 모듈 - 스펙2

두 번째 스펙

ClickCounter 모듈의 increase()는 클릭할 때마다 counter를 1만큼 증가시킨다.

강의 폴더의 아래 브랜치로 이동.

$ git checkout --force ClickCounter-spec-2

describe를 이용해 increase() 함수에 대한 테스트 코드를 작성한다.

준비 -> 실행 -> 단언 단계 순서로 아래와 같이 작성한다.

describe('App.ClickCounter', () => {
  describe('getValue()', () => {
    it('초기값이 0인 카운터 값을 반환한다', () => {
      const counter = App.ClickCounter() // 코드의 중복
      expect(counter.getValue()).toBe(0)
    })
  })

  describe('increase()', () => {
    it('카운터를 1 올린다', () => {
      // 준비
      const counter = App.ClickCounter(); // 코드의 중복

      // 실행
      counter.increase();

      // 단언
      expect(counter.getValue()).toBe(1)
    })
  })
})

위 테스트코드를 보면 코드의 중복이 발생하고 있다.

코드의 중복을 없애기 위한 자스민 함수

beforeEach()는 it 함수 호출 직전에 실행되고, afterEach()는 it 함수 호출 직후에 실행된다.

이 둘은 자스민에 내장된 함수다.

실행 순서는 beforeEach -> it -> afterEach이다.

describe('App.ClickCounter', () => {
  let counter;

  beforeEach(() => {
    counter = App.ClickCounter();
  });

  describe('getValue()', () => {
    it('초기값이 0인 카운터 값을 반환한다', () => {
      expect(counter.getValue()).toBe(0);
    })
  });

  describe('increase()', () => {
    it('카운터를 1 올린다', () => {
      counter.increase();
      expect(counter.getValue()).toBe(1);
    });
  });
})

위와 같이 테스트 코드를 개선할 수 있다.

불필요한 중복을 제거한 코드를 DRY한 코드라고 한다.

근데 초기값이 0이 아니면 increase()의 결과가 1이 아닐 수 있다. 따라서 아래와 같이 리팩토링해준다.

describe('App.ClickCounter', () => {
  let counter;

  beforeEach(() => {
    counter = App.ClickCounter();
  });

  describe('getValue()', () => {
    it('초기값이 0인 카운터 값을 반환한다', () => {
      expect(counter.getValue()).toBe(0);
    })
  });

  describe('increase()', () => {
    it('카운터를 1 올린다', () => {
      const initialValue = counter.getValue(); // 초기값 설정
      counter.increase();
      expect(counter.getValue()).toBe(initialValue + 1);
    });
  });
})

이처럼 안전하게 리팩토링을 할 수 있는 것이 TDD의 장점이다.

3. 클릭 카운트 뷰 모듈 - 스펙1

counter 데이터는 DOM에 반영되어야 한다.

이 역할을 하는 ClickCounterView 모듈을 만들자.

데이터를 출력하고 이벤트 핸들러를 바인딩하는 일을 담당한다.

$ git checkout --force ClickCountView-spec-1

첫 번째 스펙

ClickCounterView 모듈의 updateView()는 카운트값을 출력한다.

모듈 주입

근데 데이터를 조회할 ClickCounter 모듈을 어떻게 얻을까?

또 데이터를 출력할 DOM element는 어떻게 테스트할까?

정답은 모듈을 주입한다!

ClickCounter 모듈은 객체를 만들어 인자로 전달 받는다.

데이터를 출력할 DOM 엘레먼트도 만들어 전달 받는다.

TDD 방식으로 사고하다 보면 이렇게 필요한 모듈을 주입받아 사용하는 경향이 생긴다.

주입된 객체들은 하나의 역할만 수행하게 된다. 이로써 모듈을 분리할 수 있기 때문에 단일 책임 원칙을 지킬 수 있다.

ClickCountView.spec.js

describe('App.ClickCountView', () => {
  // 중복 코드 제거 -> DRY
  let clickCounter;
  let updateEl;
  let view;

  beforeEach(() => {
    clickCounter = App.ClickCounter();
    updateEl = document.createElement('span');
    view = App.ClickCounterView(clickCounter, updateEl);
  });

  describe('updateView()', () => {
    it('ClickCounter의 getValue() 값을 출력한다', () => {
      // 준비
      const counterValue = clickCounter.getValue();
      // 실행
      view.updateView(); 
      // 단언
      expect(updateEl.innerHTML).toBe(counterValue.toString()); 
    })
  })
})

ClickCountView.js

var App = App || {};

App.ClickCounterView = (clickCounter, updateEl) => {
  return {
    updateView() {
      updateEl.innerHTML = clickCounter.getValue();
    }
  }
}

4. 클릭 카운트 뷰 모듈 - 스펙1(계속)

잠깐 ClickCountView에 의존성 주입이 되었는지는 어떻게 보장할 수 있는가?

어떻게 에러를 발생시킬 수 있을까?

자스민에는 toThrowError라는 내장 함수가 있다.

expect(function() { throw new Error() }).toThrowError();

expect() 내의 콜백함수가 Error를 발생시킬 것을 기대하는 함수다.

따라서 아래와 같이 테스트 코드를 작성한다.

ClickCountView.spec.js

describe('App.ClickCountView', () => {
  let clickCounter;
  let updateEl;
  let view;

  beforeEach(() => {
    clickCounter = App.ClickCounter();
    updateEl = document.createElement('span');
    view = App.ClickCounterView(clickCounter, updateEl);
  });

  // 의존성 주입이 잘 되었는지 확인하는 코드
  it('clickCounter 주입하지 않으면 에러 발생', () => {
    // clickCounter 없는 상황 가정
    const clickCounter = null;
    const updateEl = document.createElement('span');

    const actual = () => App.ClickCounterView(clickCounter, updateEl); 
    expect(actual).toThrowError();
  });
  it('updateEl 주입하지 않으면 에러 발생', () => {
    // updateEl 없는 상황 가정
    clickCounter = App.ClickCounter();
    const updateEl = null;

    const actual = () => App.ClickCounterView(clickCounter, updateEl); 
    expect(actual).toThrowError();
  });

  describe('updateView()', () => {
    it('ClickCounter의 getValue() 값을 출력한다', () => {
      const counterValue = clickCounter.getValue();
      view.updateView(); 
      expect(updateEl.innerHTML).toBe(counterValue.toString()); 
    })
  })
})

위 테스트코드만 작성한다면 function() { throw new Error() } 가 error를 발생시키지 않기 때문에 테스트는 실패한다.

(Expected function to throw an Error.)

따라서 아래와 같이 코드를 작성한다.

ClickCountView.js

var App = App || {};

App.ClickCounterView = (clickCounter, updateEl) => {
  // error 처리
  if (!clickCounter) {
    throw Error('clickCounter is wrong');
  }
  if (!updateEl) {
    throw Error('updateEl is wrong');
  }

  return {
    updateView() {
      updateEl.innerHTML = clickCounter.getValue();
    }
  }
}

그러면 error상황에서의 테스트도 완료된다.

5. 클릭 카운트 뷰 모듈 - 스펙2

ClickCounterView 모듈의 increaseAndUpdateView()count 값을 증가하고 그 값을 출력한다.

$ git checkout --force ClickCountView-spec-2

테스트 코드 작성

아래와 같이 테스트 코드를 작성해야 할까?

describe('App.ClickCountView 모듈의', () => {
  describe('increaseAndUpdateView()는', () => {
    it('카운트 값을 증가시키고 그 값을 출력한다.', () => {
      // todo
    })
  })
})

하지만 increaseAndUpdateView()는 카운트값을 증가하고, 그 값을 출력한다. 즉 기능이 두 개다.

기능을 두 개로 쪼개서 테스트하는 게 좋다. (1. ClickCounter의 increase함수를 실행한다. 2. updateView 함수를 실행한다.)

describe('increaseAndUpdateView()는', ()=> {
    it('ClickCounter의 increase 를 실행한다', ()=> {
      // todo
    })

    it('updateView를 실행한다', ()=> {
      // todo 
    })
  })

테스트 더블

단위 테스트 패턴으로 테스트하기 곤란한 컴포넌트를 대체하여 테스트하는 것이다.

특정한 동작을 흉내만 낼 뿐이지만 테스트하기에는 적합하다.

다음 5가지를 통칭한다.

  1. 더미(dummy) - 인자를 채우기 위해 사용.
  2. 스텁(sturb) - 더미를 개선하여 실제 동작하게끔 만든 것. 리턴값을 하드코딩한다.
  3. 스파이(spy) - 스텁과 유사. 내부적으로 기록을 남기는 추가기능.
  4. 페이크(fake) - 스텁에서 발전한 실제 코드. 실제 값을 반환함. 운영에서는 사용할 수 없음.
  5. 목(mock) - 더미 스텁 스파이를 혼합한 형태.

자스민에서는 테스트 더블을 스파이스(spies)라고 부른다.

spyOn(), createSpy() 함수를 사용할 수 있다.

여기선 spyOn() 사용하는데, 용례를 살펴보면,

// spyOn(감지할 객체, 그 객체의 함수)
spyOn(MyApp, 'foo');
// 특정행동을 한 후
bar();
// 감시한 함수가 실행되었는지 체크
expect(MyApp.foo).toHaveBeenCalled()

위와 같이 작성하며 bar()함수가 MyApp.foo() 함수를 실행하였는지 검증하는 코드이다.

이를 토대로 테스트 코드 및 실제 코드를 작성하면 아래와 같다.

ClickCountView.spec.js

describe('increaseAndUpdateView()는', ()=> {
    it('ClickCounter의 increase 를 실행한다', ()=> {
      spyOn(clickCounter, 'increase');
      view.increaseAndUpdateView();
      expect(clickCounter.increase).toHaveBeenCalled()
    })

    it('updateView를 실행한다', ()=> {
      spyOn(view, 'updateView');
      view.increaseAndUpdateView();
      expect(view.updateView).toHaveBeenCalled()
    })
  })

ClickCounterView.js

App.ClickCountView = (clickCounter, updateEl) => {
  if (!clickCounter) throw new Error(App.ClickCountView.messages.noClickCounter)
  if (!updateEl) throw new Error(App.ClickCountView.messages.noUpdateEl)

  return {
    updateView() {
      updateEl.innerHTML = clickCounter.getValue()
    },
    // increase와 updateView 실행
    increaseAndUpdateView() {
      clickCounter.increase();
      this.updateView();
    }
  }
}

6. 클릭 카운트 뷰 모듈 - 스펙3

클릭 이벤트가 발생하면 increaseAndUpdateView()를 실행한다.

$ git checkout --force ClickCountView-spec-3

카운터 값을 출력할 돔 엘레먼트를 주입했듯이 클릭 이벤트 핸들러(increaseAndUpdateView)를 바인딩할 DOM 요소(triggerEl)을 주입받자

ClickCountView.spec.js

describe('App.ClickCountView 모듈', () => {
  let updateEl, triggerEl, clickCounter, view;

  beforeEach(()=> {
    updateEl = document.createElement('span');
    triggerEl = document.createElement('span');
    clickCounter = App.ClickCounter(); 
    // 인자가 많아지는 것은 좋지 않으니 네임드 파라미터를 이용
    view = App.ClickCountView(clickCounter, { updateEl, triggerEl });
  })

  describe('네거티브 테스트', ()=> {
    it('ClickCounter를 주입하지 않으면 에러를 던진다', ()=> {
      const actual = () => App.ClickCountView(null, { updateEl, triggerEl })
      expect(actual).toThrowError(App.ClickCountView.messages.noClickCounter)
    })

    it('updateEl를 주입하지 않으면 에러를 던진다', ()=> {
      const actual = () => App.ClickCountView(clickCounter, { triggerEl })
      expect(actual).toThrowError(App.ClickCountView.messages.noUpdateEl)
    })
  })

  describe('updateView()', () => {
    it('ClickCounter의 getValue() 실행결과를 출력한다', ()=> {
      const counterValue = clickCounter.getValue()
      view.updateView()
      expect(updateEl.innerHTML).toBe(counterValue.toString())
    })
  })

  describe('increaseAndUpdateView()는', ()=> {
    it('ClickCounter의 increase 를 실행한다', ()=> {
      spyOn(clickCounter, 'increase')
      view.increaseAndUpdateView()
      expect(clickCounter.increase).toHaveBeenCalled()
    })

    it('updateView를 실행한다', ()=> {
      spyOn(view, 'updateView')
      view.increaseAndUpdateView()
      expect(view.updateView).toHaveBeenCalled()
    })
  })

  it('클릭 이벤트가 발생하면 increaseAndUpdateView 실행한다', ()=> {
    // 준비
    spyOn(view, 'increaseAndUpdateView');
    // 실행
    triggerEl.click();
    // 단언
    expect(view.increaseAndUpdateView).toHaveBeenCalled();
  })
})

ClickCounterView.js

var App = App || {}

App.ClickCountView = (clickCounter, options) => {
  if (!clickCounter) throw new Error(App.ClickCountView.messages.noClickCounter)
  if (!options.updateEl) throw new Error(App.ClickCountView.messages.noUpdateEl)
  // 반환할 객체 미리 선언 및 할당
  const view = {
    updateView() {
      options.updateEl.innerHTML = clickCounter.getValue()
    },

    increaseAndUpdateView() {
      clickCounter.increase()
      this.updateView()
    }
  }

  options.triggerEl.addEventListener('click', () => {
    view.increaseAndUpdateView();
  });

  return view;
}

App.ClickCountView.messages = {
  noClickCounter: 'clickCount를 주입해야 합니다',
  noUpdateEl: 'updateEl를 주입해야 합니다'
}

언제나 같다.

1. 테스트 코드 작성(준비 - 실행 - 단언) -> 2. 테스트 실패 -> 3. 테스트를 성공하는 로직 작성

반응형

'Programming > TDD' 카테고리의 다른 글

05-정리  (0) 2022.01.16
04-추가 요구사항도 쉽게 받을 수 있는 코드 만들기  (0) 2022.01.16
03-중간정리  (0) 2022.01.16
01-TDD 이론 및 패턴 소개  (0) 2022.01.16
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2025/07   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
글 보관함