TDD로 프론트엔드 개발 날로 먹기

hyeonseok Ahn
Pagecall Engineering
28 min readJan 21, 2019
TDD 흐름

개발자의 아이러니

많은 개발서적에서 컴퓨터는 멍청하다고 이야기한다. 컴퓨터가 스스로 할 수 있는 일이 없기 때문이다. 하지만 컴퓨터는 굉장하다. 사람이 할 수 없는 속도로 연산을 하기 때문에. 그래서 멍청하지만 일 하는 속도는 빠른 컴퓨터를 위해 컴퓨터에게 일을 시키는 자가 있었으니, 개발자 되시겠다.
예를 들어 누군가가 구구단 결과를 출력하고 싶을 때, 구구단 연산과 결과를 하나하나 적는 사람이 있는 반면, 개발자는 변수 선언과 반복문, 출력 함수만으로 컴퓨터가 대신 구구단 연산을 하고 결과를 출력하게 할 수 있다.

하지만 아이러니하게도 결과가 아닌 과정에서, 개발자는 컴퓨터에게 일을 시키는 자가 아닌 컴퓨터를 위해 일을 하는 사람이 된다. 컴퓨터는 항시 에러를 뱉어내고 개발자는 자기 반성과 혹시 컴퓨터 cpu를 갈아치울 때가 된 것은 아닌가 하는 번민을 반복하며 컴퓨터를 위해 불철주야 코드를 작성한다. 그 과정에서 개발자는 컴퓨터의 빠른 연산을 사용할 일 보다는, 눈보다 빠르다는 손으로 타이핑할 일이 더 많다.

이런 아이러니 사이에서, 켄트 백이라는 엄청난 개발자가 불현듯 선언했다.

태초에 테스트가 있었다.

개발자의 우로보로스: 개발과 디버깅

많은 개발자들이 개발보다는 디버깅에서 많은 시간을 소요한다고 한다. 즉 만들어내는 건 금방인데, 만들면서 생기는 문제들을 고치는 게 오래 걸린다는 것이다.

실제로 이런 일은 정말 빈번하다.

실상 개발을 하다보면 디버깅을 하게 되고, 디버깅을 하다보면 추가적인 개발을 하는 경우가 많다. 여기서 주목할 점은 개발을 (먼저) 하다보면 (그 후에) 디버깅을 하게 된다, 라는 점이다. 그런데 켄트 백은 이렇게 이야기한다.

디버깅부터 합시다. 아니, 정확히는 테스트부터 합시다!

Test Driven Development

이 엄청난 발상의 전환은, 1999년도에 첫 발상이 이루어지고 2003년도에 켄트 백으로부터 정리된 이후 아직까지 의견이 분분한 개발 방식이다.

앞서 말한 바와 같이 TDD는 테스트부터 작성하는 개발 방식이며, 작동하는 깔끔한 코드(clean code that works)를 지향한다. TDD의 기본은 다음과 같다.

  1. 실패하는 테스트를 만든다.
  2. 해당 테스트를 통과할 수 있는 코드를 만든다.
  3. 테스트에 성공한다.
  4. 작성한 코드에서 중복을 제거한다.
  5. 다시 1번으로 돌아가서 작업한다.

더 간략하게 이야기하면,

  1. red (테스트 실패)
  2. green(테스트 성공)
  3. refactor(리팩토링)
  4. 다시 1번으로.

이런 흐름을 가진다. 레드그린리팩터, 입에 착착 붙는 표현이다.

언뜻 보면 이해가 가지 않을 것이다. 그리고 TDD가 뭔지는 아는 프론트엔드 개발자라면, 프론트엔드 개발에서 이 방법론이 정말 유효한지 궁금할 것이다.

그렇기에 글쓴이는 본 포스팅에서 최근 PageCall 작업을 하며 겪었던 사례를 토대로 TDD로 angular 앱을 개발하려고 한다.

문제 설정

의외로, TDD에서 가장 중요한 것문제 설정이다.

문제 설정이란 결국 비즈니스 로직에 대한 고민이고 분석이다. 문제 설정에 따라 작성하는 테스트가 달라진다. 결국 테스트는 비즈니스 로직을 잘 반영하는 것이어야 한다. 다시 말해, 테스트는 구현에 대한 테스트가 아니라 비즈니스 로직을 위한 테스트가 되어야 한다.

구현에 대한 테스트는 무엇이고, 비즈니스 로직을 위한 테스트란 무엇일까?
1부터 10까지의 수를 순차적으로 입력하여 홀수를 넣는 경우는 odd라고 출력하고 짝수를 넣는 경우 even이라고 출력해야 한다고 가정하자. 쉬운 요구사항이기 때문에 비즈니스 로직도 간단하다. 주어진 숫자가 홀수라면 odd를, 짝수라면 even을 출력한다.

비즈니스 로직과 구현

이 경우 구현하는 방법은 여러가지가 있다. 고전적인 반복문과 조건문을 사용할 수도 있고, 함수형 명세를 지원하는 언어라면 map 메소드와 3항 연산자를 조합할 수도 있다. 조건을 분기할 때 플래그 역할을 하는 변수를 만들 수도 있다. 하지만 이렇게 구현한 코드마저 테스트 케이스를 작성한다면? 반복문을 map 메소드로 대체하는 것만으로도 테스트는 깨지고, 개발자는 허겁지겁 해당 코드를 살펴보거나 테스트를 (눈을 질끈 감고) 수정하여야 한다. 물론 비약이 심한 예시이다. 반복문을 테스트하겠다고 index 값이 순차적으로 1씩 증가하는지 확인하는 테스트를 작성할 일은 없을테니까.

이런 세부적인 구현 사항까지 테스트를 하는 경우 TDD의 비용은 높아지고 생산성은 낮아지며, 코드에 대한 유지보수 뿐만 아니라 테스트에 대한 유지보수까지 신경써야 하는 상황이 발생한다. 컴퓨터를 부려먹어 개발을 쉽게 하려는 시도에서 점점 멀어지고, 퇴근시간마저 멀어지는 것이 느껴진다. 요즘 유행하는 드라마를 제시간에 챙겨보기 위해서라도 비즈니스 로직만을 테스트하는 것이 좋을 것 같다.

슈퍼히어로도 TV 보는 시간은 마련하니깐

그렇다면 단순히 홀수를 넣었을 때 odd가 나오기만 하면 되는 것일까? 11이란 값을 테스트 해서 odd를 출력한다면? 이것은 미묘한 문제이다. 11은 비즈니스 로직 상 12와 다를 것이 없는 숫자이고 그것이 의미하는 것은 특별하게 다룰 필요가 없는 숫자라는 것이다. 요구 사항에서는 11 이상의 숫자, 또는 0 이하의 실수에 대해서는 언급하지 않았다.

하지만 코드가 자신의 예상 범위 밖에서 뛰어 노는 것을 질색하는 개발자라면 예외처리를 하고 어떤 방식으로든 해당 상황에 대한 응답을 이끌어낼 것이다. 설령 그것이 최종 사용자에게 보이지 않는 응답이라도 말이다.
이 경우 11은 요구사항에는 없었지만 특별한 의미를 지닌 숫자가 되고 비즈니스 로직에서 다루어야 하는 숫자가 된다. 갑자기 index 값이 순차적으로 1로 증가하는 것을 테스트 할 일은 없다고 결론 지은 것이 후회가 된다. index값이 어떻게 증가하는지는 여전히 관심이 없지만 어디까지 증가하는지 관심을 가져야 할 것 같기 때문이다.
그럼에도 불구하고 여전히 테스트가 index 값을 다루는 것은 비즈니스 로직과 거리가 멀다. 앞서 말했듯 map을 사용하면 index에 연연할 필요가 없으니깐.

index나 반복문을 쓰지 말라는 것이 아니다. 테스트에서 숫자를 다룰 때 그 숫자가 index 역할을 하는 숫자인지 알 필요가 없다는 것이다. 비즈니스 로직에 집중하라는 것은 1을 입력할 땐 odd가 나오고 2를 입력할 땐 even이 나오고 0 이하나 11 이상을 입력할 땐 적절한 예외처리 문구가 나오는지 테스트만 하면 된다. 테스트가 대상이 되는 코드의 index 정보를 가져와서 index가 혹시라도 null은 아닌지, 범위 설정은 잘 되었는지 테스트할 필요는 없다.

결합도는 낮게, 응집력은 높게, 관심은 하나만

쉽게 말했지만 매우 어려운 문제이다.
일단 구현하는 코드와 비즈니스 로직을 나타내는 코드를 구분하는 것부터가 어렵다. 필연적으로 결합도는 낮고 응집력이 높으며 하나의 관심을 가진 코드를 작성해야지 가능한 일이다. 비즈니스 로직과 구현 코드가 결합 상태가 높다면? 위의 예시대로 말한다면 index 값의 유무 및 범위가 비즈니스 로직에도 큰 영향을 미친다면? 그럼 테스트 단계에서도 index의 정보를 알아야만 한다. 그러나 홀수 짝수 구분을 하는데 index의 정보를 알아야 하는 당위성이 없는 것 같다.

홀수 짝수를 구분하는 코드의 index가 있고 없고를 따지는 것이 와닿지 않는다면, index라는 단어를 자신이 사용하는 라이브러리나 프레임워크의 이름으로 바꿔보자. 비즈니스 로직과 angular를 철저히 분리하는 것이 가능할까? 완전히 분리할 수 없다면 어느 정도의 결합도가 이상적인 것일까? 개발자의 자유를 제약하고 엄격한 코딩 스타일을 강조하는 angular는 best practice가 존재한다고들 하지만, 사실 이런 문제에 정답은 없다.

[OKKYCON: 2018] 이혜승 — 테알못 신입은 어떻게 테스트를 시작했을까? (24:21부터)

[OKKYCON: 2018] 이규원 — 당신들의 TDD가 실패하는 이유 (전반적인 내용)

두 영상은 이와 관련된 보다 구체적인 insight를 제공해준다.

그러나, 다시 TDD

정답은 없는 개발자의 이 영원한 난제를, TDD를 통해 최대한 정답에 가깝게 다가갈 수 있다.

TDD는 적절한 때에 번뜩이는 통찰을 보장하지 못한다. 그렇지만 확신을 주는 테스트와 조심스럽게 정리된 코드를 통해, 통찰에 대한 준비와 함께 통찰이 번뜩일 때 그걸 적용한 준비를 할 수 있다.

켄트 벡 — 테스트 주도 개발 p115(인사이트)

그럼 이제 시작해보자!

요구사항 분석

PageCall은 데스크탑의 브라우저뿐만 아니라, 태블릿 및 모바일 브라우저에서도 그 어떤 추가적인 플러그인 설치없이 바로 사용이 가능하다. 마법처럼 보이는 이 상황은 WebRTC라는 기술 덕분인데, 아쉽게도 모든 브라우저가 이 WebRTC를 지원하지 않는다.

따라서 PageCall의 모든 기능을 쾌적하게 사용할 수 있는 브라우저를 안내하는 페이지가 필요하다.

이 페이지가 가지고 있어야 하는 기능을 나열하면 다음과 같다.

  • 앱을 띄우기 전에 가장 먼저 보이는 화면 만들기
  • 현재 브라우저의 정보를 가져오기
  • 브라우저의 정보에 따라 안내 문구 동적으로 보여주기
  • 브라우저의 정보에 따라 버튼의 역할을 나누기
  • 영어와 한글 로케일 설정

음, 뭔가 굉장히 많은데 차근차근 해보자. 그럼 가장 먼저 보여주어야 하는 화면에 대해 실패하는 테스트를 작성하자. 아차, 그 이전에 환경설정부터 해야겠다. 환경설정은 테스트 실패가 필요하지 않다(맞다, 이건 농담이다)

jest 테스트 환경 설정

angular는 친절한 프레임워크이기 때문에 테스트 도구도 미리 준비하고 있다. 따라서 개발자는 테스트를 하기 위해 이것저것 설정해야하는 수고를 덜 수 있다.

하지만, angular 테스트 도구인 jasmine + karma 는 느리며 테스트를 진행하는데 브라우저가 필요하다. 단발성 유닛 테스트라면 이런 느린 속도와 브라우저 의존성을 감당할 수 있겠지만, 테스트와 코드 작성이 혼연일체인 TDD에서는 테스트가 느리다는 것은 그만큼 개발을 더디게 한다는 것을 의미한다. 따라서 본 포스팅에서는 react 라이브러리를 만든 페이스북에서 만든 테스트 라이브러리인 jest를 사용한다.

설치가 필요한 모듈은 다음과 같다.

npm i -D jest jest-preset-angular @types/jest

그 후 src 디렉토리에 setupJest.ts라는 파일을 생성하여 다음 라인을 삽입한다.

import 'jest-preset-angular';

이 후 package.json 에 다음 내용을 추가한다.

"jest": {
"preset": "jest-preset-angular",
"setupTestFrameworkScriptFile": "<rootDir>/src/setupJest.ts"
}

다음은 실제 적용한 코드들이다.

이제 설정이 끝났다! 생각보다 어렵지 않다. jest-preset-angular 덕택일 것이다. 추가적인 설정이 궁금하다면 위 링크에 가면 자세히 나와있다.

위 package.json 에 나와있듯, 글쓴이는 테스트 실행을 다음 명령어로 수행한다.

$ npm run test:jest

아직 jasmine, karma 등의 모듈을 삭제하지 않았고, 따라서 test 스크립트 명령어가 해당 모듈을 사용하도록 지정되어있기 때문이다. jasmine과 karma를 삭제했다면 npm run test나 ng test로 테스트를 수행해도 상관 없을 것이다.

드디어 진짜 테스트를 작성한다!

BrowserCheckComponent

우선, 많은 시간 소요를 피하고 번거로운 과정을 줄이기 위해서 이번 테스트에서 html 요소에 대한 테스트는 제외하려고 한다. 곧바로 컴포넌트를 대상으로 테스트를 시작한다. 간단하게 테스트가 존재하는지 확인한다.

아직 컴포넌트를 작성하지 않았기 때문에 테스트는 실패한다.

테스트가 실패했으니(red) 이제 테스트를 성공할 수 있도록(green) BrowserCheckComponent 컴포넌트를 작성한다.

아무것도 없는 빈 컴포넌트지만 테스트를 성공했으니 커다란 진전이다! 그럼 이제 리팩토링을 수행할 차례이다. 그러나 아직까지 어떤 중복도 찾지 못했다. 그럼 테스트를 작성하기 위해 우리가 해결해야 하는 과제들을 다시 보자.

  • 앱을 띄우기 전에 가장 먼저 보이는 화면 만들기
  • 현재 브라우저의 정보를 가져오기
  • 브라우저의 정보에 따라 안내 문구 동적으로 보여주기
  • 브라우저의 정보에 따라 버튼의 역할을 나누기
  • 영어와 한글 로케일 설정

여기서 매우 흥미롭고 두근거리는 것은, 이 문제 설정 단계에서 무엇을 먼저 시작할지 결정하는 것은 매순간마다 다르다는 것이다. 당장 봤을 때에 필요하다고 생각되는 것은 현재 브라우저의 정보를 가져오는 것이다. 브라우저 정보에 대한 문제가 선결되어야 로케일을 제외한 다른 문제들을 해결할 수 있기 때문이다. 앱을 띄우기 전 가장 먼저 보이는 화면 역시 브라우저 정보를 필요로 한다.

그렇다면 브라우저 정보를 먼저 가져오도록 할까? 하지만 브라우저 정보를 가져오고 그 정보로 화면을 가장 먼저 보이게 하는 과정은 쉬워보이지 않는다. 현재 만들고 있는 component만으로는 다른 컴포넌트보다 우선해서 화면을 보이도록 할 수 없기 때문이다. 자연스레 여기서 브라우저 정보를 다루는 것은 논리적으로 컴포넌트보다 상위에서 다루어야 하는 것은 아닌가 생각이 스쳐지나간다. angular에 익숙한 개발자라면 여기서 곧바로 service를 떠올릴 수 있을 것이다. 하지만 이번에는 좀 더 돌아가는 길을 택하려고 한다. 다음 번에 같은 작업을 할 때에는 service에 대한 테스트를 먼저 작성할 수도 있을 것이다. 이번에는 컴포넌트 테스트를 좀 더 붙잡고 있으려고 한다.

문제 설정을 좀 더 세분화 시켜서 다음과 같이 작성하고 나머지 문제들은 일단 무시하도록 한다.

  • 브라우저 정보를 얻어온다.
  • 화면에 안내 문구를 보여준다.

다행히도 문제 설정을 세분화시켰는데도 가장 중요하다고 생각할 수 있는 문제 설정은 가져올 수 있었다. 바로 브라우저 정보를 얻어온다는 점이다. 그렇다면 브라우저 정보를 테스트해보자. 우선 어떤 브라우저인지 이름을 string 형태로 보내주고, 브라우저를 데스크탑에서 사용하는지 확인하는 플래그 역할의 boolean 변수를 테스트한다.

it('should be on desktop', () => {
fixture = TestBed.createComponent(BrowserCheckComponent);
component = fixture.componentInstance;
expect(component.isDesktop).toBeTruthy(); // isDesktop의 값 확인
});

it('should be on Chrome', () => {
fixture = TestBed.createComponent(BrowserCheckComponent);
component = fixture.componentInstance;
expect(component.browserName).toEqual('Chrome'); // 브라우저 확인
});

그리고 테스트는 실패한다.

undefined를 받아서 true / false 를 확인할 수 없다.
‘Chrome’이라는 string을 받아야 하는데 undefined를 받았다.

이제 테스트를 실패했으니 테스트를 성공하기 위해 코드를 수정하자. 앞서 깜빡한 부분이 있었는데, 테스트를 실패한 뒤 테스트를 성공하기까지 걸리는 시간이 오래 걸려서는 안 된다는 점이다. 가장 쉽고 간단한 방법으로 테스트를 성공시켜야 한다는 이야기이다. 따라서 여기서 테스트를 성공하는데 거창한 방법을 사용하지 않는다. 테스트가 undefined라고 이야기했다는 건 값을 초기화하지 않았거나 해당 변수조차 존재하지 않는 경우인데 우리도 알다시피 현재 상황은 후자이다. 그렇다고 굳이 여기서 변수의 존재 여부를 확인하는 테스트를 따로 만들고 그 테스트를 통과하는 일은 하지 않겠다. 해당 테스트가 필요없다는 것은 아니다. 오히려 나중에 비동기 통신이 들어가는 코드의 경우 변수의 존재 여부를 판별하는 방어 코드 테스트가 절실할 것이다. 하지만 지금의 상황은 좀 더 스피드를 올려도 좋을 것 같다. 이건 어디까지나 글쓴이의 직관이며, 좀 더 천천히 가고 싶다면 해당 변수의 존재 여부를 확인해도 좋다.

속도를 내서 컴포넌트에 변수를 추가하고 값을 초기화 한다. 다음과 같은 모양이 될 것이다.

import { Component, OnInit } from '@angular/core';

@Component({
selector: 'browser-check',
templateUrl: './browser-check.component.html',
styleUrls: ['./browser-check.component.scss']
})
export class BrowserCheckComponent implements OnInit {
isDesktop = true;
browserName = 'Chrome';


constructor() {
}

ngOnInit() {
}
}

다시 테스트를 수행한다. 좋다, green이다.

그럼 이제 중복을 제거하고 리팩토링의 시간이다. 글쓴이는 여기까지 진행하면서 곧 다가올 중복의 조짐을 발견했다(미리 생각한 것이 아니라 테스트를 실패하고 성공하는 단계에서 퍼뜩 떠올랐다. 켄트 벡의 말이 맞았다!). 그런데 아쉽게도 현 코드 상으로는 중복이 없다.

무슨 의미일까? 브라우저 상태를 체크한다는 것은 브라우저의 이름 뿐만 아니라 브라우저가 작동하는 디바이스의 상태도 체크하는 것이 보통이다. 보통 디바이스의 상태라면 desktop(또는 laptop)이거나, tablet이거나, mobile 이다. 이 세가지 상태 뿐이며 두 가지 상태가 공존하는 경우는 단언컨대 아직까진 없다. 물론 M모사의 S모 기계같은 경우는 desktop이자 tablet라고 우길 수 있겠지만 여기선 편의상 desktop으로 분류하겠다. 먼훗날 접혀있는 스크린을 피면 태블릿이 되고 노트북이 되는 획기적인 스마트폰이 나올 때까지 지금의 분석은 유효하다. 따라서 isDesktop 에 뒤이어 올 isTablet, isMobile 이라는 플래그는 무의미하다. 이 세가지 플래그는 항상 어느 하나만 true 값을 가질 것이기 때문이다. 그렇다면 세가지 플래그를 가지는 대신 하나의 string 상태값을 가지는 변수가 더 나은 선택일 것이다. 그렇다면 바로 코드를 작성할까? 리팩토링 단계이지만 테스트를 먼저 작성해야 하는 상황이기도 하다. 항상 기억해야 할 것은 테스트가 먼저라는 점이다.

it('should be desktop device', () => {
fixture = TestBed.createComponent(BrowserCheckComponent);
component = fixture.componentInstance;
expect(component.deviceStatus).toEqual('desktop');
});

실패했으니 코드를 추가한다. 수정이 아니라 추가하는 이유는, 해당 리팩토링은 많은 단계를 건너 뛴 직관으로부터 온 것이기 때문이다. 아직 해당 리팩토링이 유효하다는 어떠한 보증도 없다. 결정적으로, deviceStatus 라는 변수를 추가하고 isDesktop 플래그 변수를 삭제한다는 것은 앞서 작성한 테스트가 깨진다는 것을 의미한다. 해당 테스트가 불필요한 것을 알기 전까지 테스트를 삭제할 수 없다. 앞서 말한 세부 구현 사항은 테스트하지 않는다를 어긴 것일까? 일부는 그렇다. 같은 목적(비즈니스 로직)을 가진 다른 코드가 있다는 것부터가 어느 한쪽은 같은 비즈니스 로직의 다른 구현이라는 것을 의미하기 때문이다. 하지만 아직은 시작 단계이며, 두 코드 모두 비즈니스 로직 또한 반영한다. 따라서 당분간은 지켜보려고 한다. deviceStatus 의 역할에 대해 백프로의 신뢰를 벌써부터 보내는 것은 섣부른 결정일 것 같다.

deviceStatus를 추가하자.

import { Component, OnInit } from '@angular/core';

@Component({
selector: 'browser-check',
templateUrl: './browser-check.component.html',
styleUrls: ['./browser-check.component.scss']
})
export class BrowserCheckComponent implements OnInit {
isDesktop = true;
browserName = 'Chrome';
deviceStatus = 'desktop';

constructor() {
}

ngOnInit() {
}
}

테스트는 통과하지만 기분은 끔찍하다. 다시 한 번 TDD 흐름을 상기하자.

  1. red (테스트 실패)
  2. green(테스트 성공)
  3. refactor(리팩토링, 중복의 제거)
  4. 1번으로

그런데 중복을 제거해도 모자를 마당에 너무나도 명백한 중복을 추가해버렸다. 지금 이 순간 영어가 모국어가 아니라는 사실에 감사하다. 이 글이 영어였다면 켄트 벡이 신랄하게 비난의 댓글을 달 수도 있으니까.

안심하기는 이르다. 구글의 번역기는 점점 좋아지고 있고, 국내에도 TDD에 능통한 분들은 많으니까. 어떻게 하면 저 끔찍한 중복을 제거할 수 있을까?

아무리 고민해도 중복을 제거할 수 있는 방법이 떠오르지 않는다(실제로 5분 간 고민을 했다). 그럼 일단 앞으로 나아가자.

  • 브라우저 정보를 얻어온다.(하드 코딩으로 해결, 중복 존재)
  • 화면에 안내 문구를 보여준다.

화면에 안내 문구를 보여줄 차례이다. 실제로 작성해야하는 안내 문구는 꽤 긴편에 속하지만 포스팅의 편의를 위해서 다음과 같은 안내 문구를 보여주려고 한다.

This is Chrome

안내문구에 대한 테스트가 필요하겠군!

it("has a guide message", () => {
fixture = TestBed.createComponent(BrowserCheckComponent);
component = fixture.componentInstance;
expect(component.guideMessage).toEqual("This is Chrome");
});

실패했으니 코드를 작성한다. 글쓴이는 이 글을 작성하면서 끊임없이 실제로 테스트를 수행한다. 얼마나 경이로운가! 테스트 자동화가 없었다면 실제로 앱을 개발 서버 상에 구동시켜서 확인해야만 한다. 그런데 명령어 스크립트 하나만으로 컴퓨터가 귀찮은 일을 알아서 다 수행해주다니!

그리고 역시 하드코딩을 한다.

import { Component, OnInit } from '@angular/core';

@Component({
selector: 'browser-check',
templateUrl: './browser-check.component.html',
styleUrls: ['./browser-check.component.scss']
})
export class BrowserCheckComponent implements OnInit {
isDesktop = true;
browserName = 'Chrome';
deviceStatus = 'desktop';
guideMessage = 'This is a Chrome';

constructor() {
}

ngOnInit() {
}
}

테스트를 돌려보면 성공…하지 않았다. 어째서?

오타보다 더 무안한 실수이다

곧바로 수정해주자. 지금은 할 게 많은 상황이다.

테스트를 통과하고, 이제 refactor의 시간이다. 중복이 보인다. 물론 내가 만든 죄악으로 가득찬 중복을 이야기한 것이 아니다.

browserName = 'Chrome';
guideMessage = 'This is Chrome';

굉장히 초보적인 중복이다. 뿐만 아니라 너무나 쉬운 중복이어서 뭔가 함정이 있는 것이 아닌가 싶을 정도이다. 그래서 문제 설정 리스트를 다시 한 번 살펴본다.

  • 현재 브라우저의 정보를 가져오기
  • 브라우저의 정보에 따라 안내 문구 동적으로 보여주기

문제 설정 리스트에서 보다시피 현재 정적으로 하드코딩 된 안내 문구는 사실 브라우저 정보에 따라 동적으로 바뀌는 값이다. 브라우저 정보에 의존하고 있다고 볼 수도 있다. 그렇기 때문에 다음처럼 리팩토링할 수도 있겠지만,

browserName = 'Chrome';
// 템플릿 문자열을 이용하여 browserName을 메시지에 삽입한다.
guideMessage = `This is ${this.browserName}`;

이 리팩토링이 아쉬운 부분이 몇가지 있다. 문제 설정을 보자.

  1. 안내 문구는 동적으로 변하며 그 변화는 브라우저 정보에 의존한다.
  2. 동적으로 값이 변한다면 그것은 함수 연산에 의한 것일 것이다.
    (물론 위 리팩토링처럼 참조 타입을 사용한다면 함수 연산에 의존하지 않아도 되겠지만 참조 타입을 사용하는 것은 위험부담이 크다. 부수효과를 기억하자)

따라서 함수를 하나 만든다. 응? 잠깐, 데자뷰가 느껴진다. 아까도 리팩토링 단계에서 오히려 뭔가를 만들지 않았는가. 설마 이번에도 중복을 만드는 건가? 그렇다. 하지만 이 중복은 곧 사라질 중복이다. 곧바로 코드를 작성한다. 천천히.

import { Component, OnInit } from '@angular/core';

@Component({
selector: 'browser-check',
templateUrl: './browser-check.component.html',
styleUrls: ['./browser-check.component.scss']
})
export class BrowserCheckComponent implements OnInit {
isDesktop = true;
deviceStatus = 'desktop';
browserName = 'Chrome';
guideMessage: string; // 값을 초기화하지 않는다.

constructor
() {
}

// angular 의 라이프 사이클을 이용한다
// angular 앱을 처음 초기화 하는 시점에 내부 scope의 연산을 수행한다.
ngOnInit() {
// _browserName은 내부 scope에서만 의미를 가진다
// _browserName은 가독성을 위해서 만든 변수이다.
const
_browserName = this.getBrowserName(this.browserName);
this.guideMessage = `This is ${_browserName}`;
}

// 사실 angular 는 class 문법이기 때문에
// 이 경우 매개변수를 사용하지 않고 클래스 변수에 접근해도 된다.
// 하지만 함수의 부수효과를 줄이기 위해 매개변수를 가지며
// 함수의 연산 값 또한 반환한다.
getBrowserName(browserName: string): string {
if (browserName === 'Chrome') {
return 'Chrome';
}
return 'Unknown';
}
}

주석까지 함께 작성하니 양이 무척 많다. 일단 해명부터 해야겠다. 테스트부터 실패한다는 TDD의 첫번째 원칙을 위배하고 코드부터 작성한 것에 대한 해명 말이다.

함수를 작성할 정도의 큰 수정이면 당연히 테스트부터 작성해야 한다. 그럼에도 불구하고 이 정도의 코드 수정을 테스트 없이 만든 것은 나름의 이유가 있다.

  1. 리팩토링 단계에서 추가한 코드이다.
    이것이 가장 큰 이유이다. red — green — refactor — red 라는 단계를 기억하자. red — green — red (for refactor)— green(for refactor) — refactor — red가 아니다.
  2. 비즈니스 로직만을 테스트한다.
    과연 getBrowserName()은 비즈니스 로직을 담당하는 함수인가? 비즈니스 로직과 전혀 무관하다고 할 수는 없지만 지금의 getBrowserName()은 부수효과가 전혀 존재하지 않는 순수함수이기 때문에 getBrowserName() 함수를 통과한 뒤 어떤 테스트가 깨졌다면 그것은 매개변수로 전달한 browserName 이 오염되었다고 보는 것이 타당하다. 순수함수라는 확신을 얻기 위해서 불필요한 if문을 추가하고 새로운 string을 생성해서 return한다. 사실 string은 immutable한 성격을 가지고 있기 때문에 if문 내부에서browserTest를 그대로 return 해도 상관없다.
    그리고 우리는 이미 browserName에 대한 테스트를 작성했다. 물론 충분하다고 볼 수는 없다. 하지만 getBrowserName()의 테스트를 추가할 정도로 테스트가 부족한 상황은 아니다.

여기서 얻은 교훈이 몇가지 있다. 지금까지는 변수만을 선언하고 변수값만을 테스트했었다. 하지만 어떤 action 을 통해 event를 발생시킨다면 그 역할은 함수가 해야할 것이다. 이제부터는 비즈니스 로직을 테스트로 구현할 때 변수 뿐만 아니라 함수도 고려해야 한다는 생각이 들 것이다.

어쨌든 리팩토링을 완료했고, 테스트를 실행한다. 여기서 실행하는 테스트는 실패하기 위한 테스트가 아닌, 리팩토링 과정에서 실수나 오타는 없었는지 점검하기 위한 목적의 테스트이다. 즉, 일반적인 유닛 테스트 수행 상황이다. 그런데, 충격적이게도 테스트가 실패한다.

guidMessage가 string 타입의 값을 가지고 있는 게 아닌 undefined라고 한다.

낙심하지 말자. 매우 예외적인 상황으로, 코드가 아닌 테스트를 수정해야 하는 상황이다. 지금까지는 컴포넌트의 내부 변수를 초기화 시킨 상태 그대로 사용했었다. 하지만 angular의 라이프 사이클을 이용하면서 컴포넌트의 값 변화를 테스트가 반영해야 하는 상황이 되었다. 다음 코드를 테스트에 추가해주자.

fixture.detectChanges();

위의 코드를 추가한 테스트 코드는 다음과 같다.

it("has a guide message", () => {
fixture = TestBed.createComponent(BrowserCheckComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component.guideMessage).toEqual("This is Chrome");
});

일단은 마무리

글이 너무 길어진 관계로, TDD로 코딩을 진행하는 것은 일단 여기까지 마무리 지으려고 한다. PageCall에서는 ngrx를 사용하지만, 특별히 ngrx의 store를 이용할 정도로 복잡하고 전역적인 state의 변화가 필요한 컴포넌트가 아니었기 때문에 TDD 입문에 매우 적합한 사례였음에도 불구하고 굉장한 양의 텍스트와 코드량이 필요했다. 사실 원래 의도했던 것은 ngrx의 테스트까지 아우르는 것이었지만, ngrx는 테스트 컴파일부터가 굉장히 난해했기 때문에 며칠을 고민하고 공부하다가 일단은 좀 더 익혀보고 공부한 뒤에 다시 도전하기로 결정했다.

이 글의 제목이 TDD로 날로 먹기이기 때문에, 어떻게 날로 먹을 수 있었는지 분석하며 글을 마무리 짓고자 한다.

  1. 높은 코드 커버리지를 통한 유지 보수 및 기능 추가의 두려움을 없애준다.
    가장 버그가 없는 코드는 작성하지 않은 코드라는 말이 있다. 가까스로 기능들이 큰 문제없이 맞물려서 작동하는 서비스가 있다면, 뭔가를 추가하거나 수정하는 것은 굉장한 용기를 필요로 한다. 추가하고 수정하는 부분에서만 문제가 생기는 것이 아니기 때문이다.
    하지만 TDD를 수행하게 되면 코드 커버리지는 상당히 높은 수준을 유지하게 되며, 이것은 새로운 기능을 추가한 뒤 부수효과가 발생하여 해당 테스트 케이스가 깨지게 되면 바로 개발자가 기민하게 대응할 수 있게끔 한다.
  2. 도메인 모델 문서의 대체
    테스트는 비즈니스 로직의 반영이다. 앞서 본 것처럼, device 정보를 체크하는데 boolean 형태의 플래그보다는 string 형태의 변수값이 필요했음을 알 수 있다(여기까지 진행하지 않았기 때문에 너무 단정적인 결론일 수 있지만). angular의 테스트 케이스 작성 컨벤션은 확장자 앞에 .spec 이라고 적어주는 것이다. spec이라는 단어의 의미를 이해한다면, 비즈니스 로직을 잘 반영하여 잘 작성된 테스트 케이스가 어떤 위치를 가지고 있는지 짐작할 수 있다.
  3. 실제 코드에서 낮은 결합도와 높은 응집도를 가지고 관심의 분리를 할 수 있다.
    red-green-refactor 라는 단계를 잘 지킨다면, 개개인마다 정도의 차이는 있을 지언정 과거보다 좀 더 추상화가 잘 이루어진 코드를 작성할 수 있다. 고수준의 디자인 패턴을 익히지 않더라도 단순히 중복된 변수를 추상화하고 두번 이상 반복하는 함수 패턴을 추상화하는 것만으로도 한결 보기 좋은 코드가 될 것이다.
  4. 자동화 테스트를 통해 테스트에 시간적, 금전적 비용을 들이지 않는다.
    뿐만 아니라 테스트 시나리오까지 자연스레 얻을 수 있다. 단, TDD가 모든 종류의 테스트(예: 스트레스 테스트)를 대체하는 것은 아니다.

--

--