빨간색코딩

nodejs 테스트 도구와 방법론 (테스트의 중요성, 전략, mocha, chai, sinon, istanbul, 유용한 팁) 본문

node.js

nodejs 테스트 도구와 방법론 (테스트의 중요성, 전략, mocha, chai, sinon, istanbul, 유용한 팁)

빨간색소년 2019. 2. 13. 15:10

1. 테스트를 왜 해야하는가?

테스트를 안하는 개발자는 없다. 코드 작성 후 서버를 뛰워서 api url을 호출해서 응답값을 확인해보고, UI에서 버튼을 눌러보고 하는 것도 모두 테스트이다. 그러나 여기서 다루는 테스트의 정의는 '개발자가 작성한 테스트 코드에 의한 테스트'이다. 이것은 코드로 작성되었으므로 테스트 자동화가 가능해지며(CI연동 등), 다른 개발자도 이것을 통해 동일하게 반복적으로 테스트할 수 있어진다.

1-1. 테스트 코드의 중요성

  1. 반복적인 행위를 줄여준다. (비용감소) 테스트코드 없이 테스트를 하려면 계속 서버를 on/off하며 수동으로 동작시켜 줘야 할 것이다. (주로 nodemon 활용) 궁극적으로 애플리케이션이 복잡해진다면 테스트역시 복잡해진다.
  2. 빠른 오류&버그 확인과 수정, 리팩토링의 베이스
  3. bottom-up, outside-in 가능 : 예를들어 테스트코드가 없다면 DAO, Repository, Service 레이어는 Router(= Controller)가 없으면 동작 테스트할 수 없다.
  4. 심리적 안정감, 자신감 취득 : 기능추가, 스펙변경 등 많은 로직수정이 있더라도 테스트코드를 모두 통과한다면 불안하지 않아요!
  5. 코드의 올바른 동작에 대한 하나의 스펙문서 : 테스트코드만 보아도 이 함수가 어떻게 동작해야하는지를 알 수 있다.
    • 스펙명세를 테스트코드로 번역한다는 느낌
    • ex. Spring Rest Docs : 테스트코드를 작성하고 성공해야만 Docs 생성. (테스트 코드를 강제)
  6. 레거시 코드를 보는 관점 : 단위테스트로 부터 보호받지 못하는 코드들로서 시간이 지날수록 히스토리를 아무도 모르게되고, 코드는 복잡해져서 테스트가 불가능하거나 어려워진다.

1-2. 테스트 전략의 중요성

그렇다면 모든 소스코드마다 테스트 코드를 작성하고, 테스트커버리지를 100%로 유지해야하는가? 그건 아니다. 테스트 코드는 중요하지만 작성 및 유지보수에 추가 비용이 들어간다. 아래와 같은 전략적인 고민이 있을 수 있다.

  1. 테스트코드는 주요 레이어, 중요한 로직에만 해도 좋다. (테스트 코드에 들일 비용으로 더 중요한 곳에 투자)
  2. 상위메소드에서 하위메소드의 테스트에 대하여
    • 하위메소드는 자신의 단위테스트에서도 테스트될 터인데, 상위메소드에서 실제 객체로 또 다시 테스트해야할까?
    • 자체 해답 => 상황이 복잡하지 않다면 실제객체로 테스트. 아니면 mock으로!
  3. mock 객체에 대하여
    • mock을 쓰면 테스트 환경 구축이 쉬워진다. (특정 시간, 특정 경우, DB 질의, HTTP요청 등)
      • 테스트 더블로 독립성 확보
    • 그러나 mock을 많이 쓸 수록 버그 검출이 어려워진다. (ex. 성공하는 테스트만 작성)
    • 자체 해답 => DB레이어 만큼은 실제로 테스트(개발DB에 테스트가 어렵다면 sqlite3같은걸로), 이외 상위 레이어는 mock 으로 테스트

2. nodejs 테스트 도구

테스트 라이브러리들은 전역설치가 아닌 --save-dev를 통해 devDependencies 에 설치하는 것이 좋다. 이럴경우 intellij 계열에선 커맨드라인이 아닌 마우스로 편하게 특정 테스트 suite만 실행이 가능해진다.

2-1. 테스트 프레임워크 mocha

javascript 진영에서 테스트 러너를 지원하는 테스트 프레임워크이다.

2-1-1. 테스트 블록

  • describe = context : 테스트 suite
  • it = specify : 단위테스트
    • specify는 intellij 에서 실행버튼이 안생겨서 불편
  • 테스트 블록을 통해 가독성 좋게 코드를 작성하는 방법은 3-1에서 다룬다.

2-1-2. 훅 메소드

  • before() : 블록 범위 내 전체 테스트 전에 실행
  • after() : 블록 범위 내 전체 테스트 후에 실행
  • beforeEach() : 블록 범위 내 각 단위테스트 직전에 실행
  • afterEach() : 블록 범위 내 각 단위테스트 직후에 실행

2-1-3. 유용한 팁

  • mocha의 context를 사용할 일이 있다면 화살표함수를 사용하면 안된다. this에 접근할 수 없게되기 때문이다.
  • 타임아웃은 개별 테스트 케이스의 describe레벨에서 this.timeout(5000); 설정. 전역적으로 하려면 mocha --timeout 5000
  • 특정 조건에서 런타임에 테스트를 무시하고 싶다면 this.skip(); 을 쓰자.
    • if (condition) this.skip();
    • ex. 로컬에서는 성공할수 없는 테스트이거나, 특정 os에서 동작하지 않는 코드인 경우 등
  • npm스크립트 등록하고 쓰는 것을 추천 : npm test = "mocha --exit --timeout 5000 test/**/*.spec.js"
    • 내부적으로 ./node_modules/mocha 를 실행
  • #태그를 달고 mocha grep으로 태그가 달린 것만 테스트해볼 수도 있다. (cold-test)

2-2. 단언 라이브러리 chai

assertion library이다. 풍부한 API를 갖고 있다. API문서를 보고 익히며 다양하게 사용해도 좋지만, 가볍게 몇개만 알아서 일관되게 사용하는 것을 추천한다. (API를 익혀써야하는 개념적 무게 감소)

2-2-1. 자주쓰는 단언문

  • expect(결과값).to.equal(기대값);
    • to.not.equal
  • expect(결과값_객체).to.deep.equal(기대값_객체);
  • expect(결과값).to.be.a(기대되는 타입);
    • ex. expect(result.size).to.be.a("number");
  • expect(예외던지는 함수).to.throw(예외);
  • 이외 경우에도 native 함수들로도 충분히 조합이 가능하다.
    • ex. NaN인지 test할 경우 : expect(isNaN(result)).to.equal(true);

2-3. mocking 라이브러리 sinon

test double library이다. spy, stub, fake 등을 지원해준다. (java진영으로 치면 mockito같은 친구)

2-3-1. sinon의 spy VS stub VS mock 간단 비교

  • spy : spy로 감싸진 함수는 모든 것이 기록된다. 인자, 반환 값, 호출횟수 등
  • stub : 실제 함수를 바꿔쳐서 반환 값 등을 조작할 수 있다. 첫번째 반환값과 두번째 반환값, N번째 반환값을 다르게 설정할 수도 있다.
  • mock : 위의 spy와 stub을 섞어두었다.

2-3-2. 유용한 팁

  • 예외를 던지도록 mocking하고 싶다면 sinon.stub(userDAO, "insert").throws("DB이슈 발생!");
  • Promise를 반환해야한다면 sinon.stub(userDAO, "update").resolves(1); 아니라면 returns를 쓰면 된다.
  • 위 세가지 모두 wrap을 하는 방식이므로 unwrap을 해야 다른 단위테스트에 영향이 안 간다. mocha의 훅 메소드를 통하여 다음과 같이 구현할 수 있다.
    // stubHelper.js
    function unwrapMethod(stub) {
        for (let method in stub) {
            stub[method].restore();
            delete stub[method];
        }
    }
    
    // userService.spec.js
    describe("UserService", () => {
        const stub = {};
        afterEach("stub method restore", () => stubHelper.unwrapMethod(stub));
    
        it("update_성공", async () => {
            //생략...
            stub.update = sinon.stub(userDAO, "update").resolves(1);
            //생략...
        });
    }):
    
  • (추가) 문서보니 sinon sandbox 나 sinon test 래핑함수를 쓰면 unwrap을 자동으로 해주는 듯 하다.

2-4. 테스트커버리지 도구 nyc (feat. istanbul)

js코드 커버리지 측정 및 분석 도구이다. 리포트를 html로 생성해서 보는게 편하다. npm스크립트는 "coverage": "nyc --reporter html npm test"

2-4-1. 커버리지 측정

  • Statements : 실행된 명령문 / 전체 명령문
  • Branches : 실행된 분기문 / 전체 분기문
  • Functions : 실행된 함수 / 전체 함수
  • Lines : 실행된 라인 수 / 전체 코드라인 수

2-4-2. 커버리지 분석

이를 통해, 자신이 어떤 것을 테스트하지 않았는지 쉽게 캐치할 수 있다. (위 사진에서는 실패에 대한 테스트가 빠졌음을 확인할 수 있음)

3. nodejs 테스트 방법론

3-1. 테스트코드의 가독성

  • given, (mocking), when, then 패턴
    • 단순하지만 흐름이 명확해진다
    • 이 중에서 given은 길어질 수 있어서, given의 끝에 mocking을 일관되게 하는 것을 추천
    • 적절히 hook을 이용하여 중복을 제거 : before, beforeEach 등
  • 테스트 그룹핑
    • describe, context, it 를 적절히 활용하여 분류
    // 대분류
    describe("파일명/클래스명 등", () => {
        // 소분류
        context("유사 테스트군", () => { // 상위 describe와 구분을 주기위해 context 사용. describe 사용해도 무관함
            // 단위테스트
            it("메소드명_성공", () => {
                // ...
            });
    
            // 단위테스트
            it("메소드명_실패_원인", () => {
                // ...
            });
        }); 
    });
    
    // 예제
    describe("UserService", () => {
        context("Read", () => {
            it("findBy_성공", () => {...});
    
            it("findBy_실패_없는유저", () => {...});
    
            it("findListBy_성공", () => {...});
        });
    
        context("Create", () => {...});
    });
    

3-2. 비동기에서는?

describe 레벨에서는 async/await를 할 수 없다. 만약 쓰고싶다면 hook 메소드나 단위테스트에서 사용하면 된다.

beforeEach(async () => {
    // await ...
});

it("delete 성공test", async () => {
    // given
    const id = "devljh";

    // mocking
    sinon.stub(userDAO, "delete").resolves(1);

    // when
    const result = await userService.delete(id);

    // then
    expect(result.success).to.equal(true);
});

3-3. 예외를 테스트하는 2가지 방법

  1. 예외를 던지는 함수를 assertion
    const fn = () => testHelper.throwError();
    expect(fn).to.throw(Error);
    
  2. 예외를 잡아서 assertion
    // given
    const id = "없는 아이디";
    
    // mocking
    stub.selectById = sinon.stub(userDAO, "selectById").resolves(undefined);
    
    // when
    const result = await userService.findBy(id).catch(e => e);
    
    // then
    expect(result).to.be.an("error");
    expect(result.constructor).to.equal(NoDataError); // 프로토타입 체이닝으로 constructor를 호출하여 비교
    

3-4. 테스트 코드에 유용한 함수

  • Object.prototype.constructor
    • typeof, instanceof 보다 constructor를 통해 확인하는 것을 추천
    • 주로 커스텀 에러를 던질 때, 확인하는 용도로 사용
  • Array.prototype.every : boolean을 반환하며, 배열 안의 모든 요소가 주어진 판별 함수를 통과하는지 테스트한다.
    // when
    const resultList = await service.parallel();
    
    // then
    expect(resultList.every(result => result.success)).to.equal(true); // 이렇게 하지않았다면 for루프를 돌리며 기대값과 비교
    
  • Array.prototype.includes : es6에 추가됨. boolean을 반환한다. 기존의 indexOf와 비슷하나 반환값 등에 있어서 좀 더 직관적이다.


Comments