본문 바로가기
source-code/FrontEnd

TDD기반 TODO 애플리케이션

by mattew4483 2023. 8. 17.
728x90
반응형

1. 웹 프론트엔드 개발과 TDD

테스트 주도 개발.

약 1년 전 누군가 나에게 TDD에 대해 얘기했다면... 한 귀로 듣고 한 귀로 흘렸을 게 뻔하다. 왜? 

 

첫째, 공감가지 않았다.

하면 좋다. 여기에는 당연히 공감했다.

하지만 지금, 이 순간에 도입해야 하는가? 하는 질문에는 항상 회의적이었다.

시장에서 살아남을지도 불확실한 제품을 테스트까지 해가면서! 개발해야 한다는 점이 못마땅했다.

그 시간에 더 많은 아이디어들을 구현하고, 가설을 검증하는 것이 나아 보였다.

(TDD = 귀찮고, 많은 시간을 잡아먹는 괴물 처럼 느껴졌다)

 

둘째, 프론트엔드의 테스트코드라는 게 이해되지 않았다.

사용자가 이러이러한 액션을 취했을 때 → 이러이러한 결과가 나타나야 한다 를 테스트하는 거라면...

그냥 개발하면서 해당 액션을 직접 확인하면 되지 않나?하는 의문이 있었다.

 

테스트만 했더라면...?

그리고 얼마 전, ReactNative의 KeyboardAvoidingView 관련 이슈를 해결하며 생긴 일.

기존 작성된 반려동물 등록 페이지에, KeyboardAvoidingView 관련 custom HOC(higher order component)를 적용해야 했다.

const KeyboardAvodingViewHOC = (WrappedComponent) => {
  const KeyboardAvodingComponent = () => {
  	{...}
    return (
      <KeyboardAvoidingView
        {...}
      >
        <WrappedComponent />
      </KeyboardAvoidingView>
    );
  };

  return KeyboardAvodingComponent;
};

컴포넌트를 전달받아 새로운 컴포넌트를 반환하는 평범한 HOC.

해당 기능을 적용한 후 반려동물과 관련된 정보를 입력하고 - 저장하는 (나름의) 테스트도 진행했다.

아무런 문제도 없어보였고, 그대로 main develop branch에 병합까지 마친 상태.

 

그런데...

다른 기능을 개발하며 반려동물을 등록하다 무심코! 품종 선택 버튼을 눌렀고,

그제야 문제를 깨달았다.

 

const KeyboardAvodingViewHOC = (WrappedComponent) => {
  const KeyboardAvodingComponent = (props) => {
  	{...}
    return (
      <KeyboardAvoidingView
        {...}
      >
        <WrappedComponent {...props} />
      </KeyboardAvoidingView>
    );
  };

  return KeyboardAvodingComponent;
};

앱의 페이지 전환을 위해 react-navigation을 사용하고 있었는데...

각 Screen에 props로 전달되는 navigation 객체가 HOC 반환 컴포넌트에는 전달되지 못하고 있었던 것!

 

문제 해결에는 아무런 어려움이 없었지만, 꽤나 큰 자괴감이 몰려왔다.

아무런 문제가 없다 생각한 기능에서 결함을 찾아낸 방법이... 버튼을 무심코!!! 누른 것이란 게 실망스러웠다.

만약 그날 따라 누르고 싶지 않아 그냥 지나쳤더라면?

더 큰 규모의 서비스에서 이런 일이 발생했다면? 혹은 개발 환경에서 당장 눈에는 보이지 않는 버그가 발생했더라면?

 

새로운 기능 개발은 필연적으로 기존 기능과 서비스에 영향을 줄 수밖에 없다.

그렇다면 그에 따른 사이드 이펙트를 방지하기 위해서는...

새로운 기능을 개발할 때마다 기존 기능을 일일히, 손수 개발자가 확인해야 하나? 만약 그게 몇 십 개, 몇 백개를 넘어가더라도???

 

그리고 이 모든 생각은 '테스트 코드만 작성되어 있었다면'이란 후회로 이어졌다.

 

2. TDD로 TODO 앱 개발하기

TDD의 필요성은 공감했지만 아직 어떻게, 특히 프론트엔드에서의 테스트 코드가 무엇인지에 대한 의문이 남아있었다.

https://www.youtube.com/watch?v=L1dtkLeIz-M 

그리고 귀신 같은 영상은 발견했다.

 

TDD의 궁극적 목표

Clean Code that works

동작하는 깔끔한 코드를 작성하기 위해 → 테스트 주도 개발이 등장한 것!

실패 → 통과 → 중복 및 개선 의 Red-Green-Refactor 사이클을 돌며 개발이 진행된다.

 

왜 TDD가 어려울까?

머리를 탁! 칠 정도로 명료한 답변을 제시한다.

 테스트하기 어려운 이유는, 코드가 testable 하지 않기 때문이다!

그러므로 testable한 코드를 작성하면, 테스트하기 쉬워질 테다.

 

엥? 그럼 testable한 코드를 어떻게 작성할 수 있을까?

 각 코드들의 관심사를 분리한다! (Separation of Concerns)

(이쯤 되면 어떤 진리처럼 느껴지기도 한다)

 

3. Jest 사용하기

https://github.com/megaptera-kr/frontend-tdd-feconf2020

 

GitHub - megaptera-kr/frontend-tdd-feconf2020: FEconf2020 `프론트엔드에서 TDD가 가능하다는 것을 보여드립니

FEconf2020 `프론트엔드에서 TDD가 가능하다는 것을 보여드립니다` 라이브코딩 예제 - GitHub - megaptera-kr/frontend-tdd-feconf2020: FEconf2020 `프론트엔드에서 TDD가 가능하다는 것을 보여드립니다` 라이브코딩

github.com

해당 발표자료 코드를 클론 받아 진행했다.

 

jest라는 테스트 라이브러리를 사용해 자동화된 테스트 실행 환경을 구축했으며,

--watchAll 옵션을 통해 변경사항 발생시 모든 테스트를 다시 실행하고 있다.

 

https://jestjs.io/docs/getting-started

 

Getting Started · Jest

Install Jest using your favorite package manager:

jestjs.io

역시 친절한 공식 문서.

 

기본적으로 sum.js 파일을 테스트하고 싶으면 → sum.test.js 파일을 생성하면 된다.

// sum.js
function sum(a, b) {
  return a + b;
}
module.exports = sum;

// sum.test.js
const sum = require('./sum');

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
});

yarn test라는 예약 명령어에 jest를 등록해 두면

// package.json
{
  "scripts": {
    "test": "jest"
  }
}

yarn test시 해당 테스트를 진행할 수 있다. 와아!

 

발표자료에서 사용한 jest method들을 몇 가지 살펴보면...

 

describe(name, fn)

관련된 여러 test들을 하나의 block으로 묶는다.

describe를 사용하지 않고, 최상위에 여러 test block을 작성해도 되지만... 

해당 test들을 그룹 별로 묶어주고 싶을 때 유용할 테다!

const binaryStringToNumber = binString => {
  if (!/^[01]+$/.test(binString)) {
    throw new CustomError('Not a binary number.');
  }

  return parseInt(binString, 2);
};

describe('binaryStringToNumber', () => {
  describe('given an invalid binary string', () => {
    test('composed of non-numbers throws CustomError', () => {
      expect(() => binaryStringToNumber('abc')).toThrow(CustomError);
    });

    test('with extra whitespace throws CustomError', () => {
      expect(() => binaryStringToNumber('  100')).toThrow(CustomError);
    });
  });

  describe('given a valid binary string', () => {
    test('returns the correct number', () => {
      expect(binaryStringToNumber('100')).toBe(4);
    });
  });
});

계층 테스트를 위해 describe 끼리 묶어두는 것도 가능하다.

 

it(name, fn, timeout)

it이 뭘까..? 싶었는데, 대부분 예제에 등장하는 test(name, fn, timeout)의 별칭(alias)이었다!

 

test file은 요 test란 method가 필요하며, 해당 method에 우리가 수행하고자 하는 테스트를 작성한다.

테스트 이름, 테스트 결과를 포함한 함수 를 인자로 받으며,

세 번째 인자인 timeout은 테스트 중단 전까지 대기한 milliseconds를 의미한다(default = 5 seconds)

 

만약 test method가 Promise를 return 할 경우?

→ Jest는 promise가 resolve 될 때까지 테스트 완료를 대기한다.

test('has lemon in it', () => {
  return fetchBeverageList().then(list => {
    expect(list).toContain('lemon');
  });
});

test method가 곧바로 return 되더라도, 해당 테스트는 promise가 resolve 될 때까지 완료되지 않는다.

 

callback

Jest는 test함수에 done이란 인자를 제공할 경우에도, 위와 유사하게 테스트 완료를 대기한다.

데이터 fetching과 관련된 상황에서 사용할 수 있을 텐데...

// Don't do this!
test('the data is peanut butter', () => {
  function callback(error, data) {
    if (error) {
      throw error;
    }
    expect(data).toBe('peanut butter');
  }

  fetchData(callback);
});

위와 같이 작성할 경우, callback을 호출하기도 전에(fetchData가 완료되자마자) 테스트가 완료돼버리고 만다!

 

test('the data is peanut butter', done => {
  function callback(error, data) {
    if (error) {
      done(error);
      return;
    }
    try {
      expect(data).toBe('peanut butter');
      done();
    } catch (error) {
      done(error);
    }
  }

  fetchData(callback);
});

 

 

test method의 콜백 함수에 done이라 불리는 인자를 넘겨줄 경우,

Jest는 done callback이 호출되기 전까지 테스트를 완료를 대기한다.

만약 done이 호출되지 않으면? → 해당 테스트는 실패!

 

이때, 만약 expect에서 테스트가 실패할 경우... 이는 error를 throw 하고, done은 호출되지 않는다!

따라서 실패 log를 살펴보기 위해서는 expect를 try-catch문으로 감싼 후, catch문에서 done에 error를 전달해 주면 될 테다.

(try-cath문이 없다면? log에는 단순 timeout 에러만 남게 된다. expect에서 반환된 에러 데이터가 아니라!!!)

 

expect

expect를 통해 테스트 작성 시 특정 조건을 만족하는 values를 체크할 수 있다.

test('the best flavor is grapefruit', () => {
  expect(bestLaCroixFlavor()).toBe('grapefruit');
});

이를 통해 다양한 matchers에 access 할 수 있으며, 요 matchers로 다양한 값들에 대한 validate가 가능해진다.

 

https://jestjs.io/docs/expect

 

Expect · Jest

When you're writing tests, you often need to check that values meet certain conditions. expect gives you access to a number of "matchers" that let you validate different things.

jestjs.io

공식문서에서 다양한 matchers를 확인할 수 있다!

728x90
반응형