Mocha vs Jest (JS Testing Framework)

binimini·2022년 5월 10일
0
post-thumbnail

Spring 개발 환경에서는 테스트 프레임워크가 JUnit으로 거의 통일된 반면
JS는 아직 Mocha와 Jest로 테스트 프레임워크가 갈리는 것 같다.
각 테스트 프레임워크의 특징과 방식을 알아보자 🙌
(JS 테스트는 처음이므로 비교적 Spring 테스트 방식이 많이 등장할 수 있다)

배경지식

포스트 중간에 등장할 수 있는 용어 등의 개념과 특징 설명 등을 포함한다.
TDD, BDD, Mocking 등의 용어와 익숙한 사람들이라면 넘어가자 🙃

그래서 테스트 코드가 왜 필요할까?

실제로 동작을 확인하기 위해서는

  • 시간이 오래 걸린다.
  • 어떤 테스트가 필요한지, 무엇을 진행했는지 일일히 확인이 필요한다.
  • 재사용이 불가능하다. (테스트 케이스가 변경되면 해야할 행동을 수정하고 처음부터 다시 해야한다)
  • 사이드이펙트 파악이 어렵다. (기능 수정이 어떤 케이스에 영향을 주는지 모르고, 알아내기 어렵다)

그러므로 테스트 코드로 작성하면 위의 단점이 장점이 된다.

  • 비교적 빠르게 실행 가능하다. (특히 단위테스트의 경우)
  • 재사용이 편리하다. (케이스가 변경되도 관련 로직만 약간 수정하면 바로 테스트가 가능하다)
  • 일종의 문서로서의 기능이 가능하다.
  • 사이드 이펙트 파악이 쉽다. (기능 수정이 기존의 어떤 기능에 영향을 주는지 바로 파악이 가능하다)

그래서 테스트 종류에는 뭐가 있는데?

주로 테스트는 아래와 같이 나뉜다.

  • 단위 테스트 (Unit Test)
    클래스 하나, 함수 하나와 같이 작은 부분에 대한 테스트
    최대한 간단하고 디버깅 쉽도록 테스트 한다.
    소프트웨어 내부 구조나 구현 방식을 고려해 테스트하는 화이트박스 테스트(White-box Test)
  • 통합 테스트 (Integration Test) / 인수 테스트 (Acceptance Test)
    여러 모듈 간 의도대로 협력하는 지에 대한 테스트
    비교적 단위 테스트보다 복잡하고 시간이 많이 소요(관련 모듈 설정 등을 포함하기 때문에)된다.
    프로그램에 필요한 외부 환경(서드파티 라이브러리, DB)까지 묶어서 검증할 수 있다.
    따라서 단위 테스트에서 발견하기 어려운 버그를 찾을 수도 있다.

이 외에도 필요시 기능 테스트(Functional Test)와 같이 다양한 테스트가 추가될 수 있다.

BDD(Behavior-Driven Development)란?

사실 스프링 기반에서 테스트를 해봤던 사람이라면 BDD는 익숙한 용어일 수 있다.
TDD(Test-Driven Development)에서 파생되었으므로, 근간은 크게 다르지 않다. 행동에 기반한 TDD와 같은 느낌.
(TDD에 설명하자면 너무 내용이 지나치게 늘어나므로 이번 포스팅에서는 다루지 말자)
대표적으로 given-when-then 방식으로 정의되며, 시나리오를 기반으로 테스트한다.
따라서 간편하게 given(주어진 조건, 데이터)-when(실행될 함수, 동작)-then(결과, 검증)로
테스트를 일관된 방식으로 작성할 수 있어 많이 사용된다.

Assertion이란?

테스트에서 Assertion은 특정 요구사항에 대한 적합성을 위한 조건이다.
필요시 한 요구사항에 대해서 여러개 존재할 수 있다.
쉽게 이해하자면 테스트의 성공 / 실패를 판단하기 위한 조건을 표현한다. (Assertion을 검사해 Validation)

Mocking과 Stubbing이란?

분명 단위 테스트는 모듈 별로 독립적으로 실행되야하는데,
테스트하고자 하는 모듈이 특정 모듈을 필요로 하면 어떻게 해야할까?
Mocking과 Stubbing은 주로 단위테스트에서 모듈이 필요로하는 다른 모듈을 처리할 때 사용한다.

  • Mock

    받을 호출에 대한 기대를 가진 미리 프로그래밍된 객체.
    행위 검증을 사용한다. (구현된 메서드의 호출 여부, 횟수, 발생한 예외 등 검증)
    행위 검증은 상대적으로 특정 메서드의 호출과 같이 구현에 의존한다.
    하지만 비교적 복잡한 협력에서도 작성이 간편하다.
  • Stub

    미리 정해진 호출에 대해 정해진 응답을 제공한다.
    테스트시에 프로그래밍된 것 외에는 응답하지 않는다.
    상태 검증을 사용한다. (결과에서의 상태의 값 확인)
    상태 검증을 위해 상태가 노출되는 메서드가 추가될 수 있다.

길고긴 서론이 끝나고 드디어 본론으로 들어가보자 😅

Mocha

테스트 러너를 포함한 테스트 프레임워크.
초기에 NodeJs로 실행되는 애플리케이션의 테스트를 위해서 설계되었다.
Assertion, Mocking, Stubbing 등의 라이브러리를 포함하지 않으므로
작동하기 위해서 다른 라이브러리의 설치 및 설정 작업이 필요하다.
단위 테스트, 통합 테스트 E2E 테스트 등 다양한 테스트를 지원한다.

특징으로는

  • 다른 라이브러리의 설치가 필요
  • 비교적 높은 러닝 커브
  • 유연성 제공

주로 Assertion ⇒ Chai, Mocking ⇒ Sinon 라이브러리를 사용하는 게 대표적이다.

Jest

Facebook에 의해 개발된 테스트 프레임워크다.
초기에는 주로 React를 사용한 웹 애플리케이션의 JS 테스트를 위해서 개발되었다.
주로 단순함(Simplicity)에 집중한다. 현재는 Angular, Vue, NodeJs 등도 원활하게 지원한다. (심지어는 TS까지도!)
최근에는 JS 테스트 프레임워크 중 가장 많이 사용되는 추세인듯 하다.

특징으로는

  • 잘되어있는 문서화
  • preconfiguration 필요하지 않음 (추가적인 의존성 필요하지 않음)
  • 쉬운 러닝 커브
  • customization 어려울 수 있음

사용해보기

특징도 알았으니 직접 사용해보자 😼
테스트는 /api/info/{id} 경로를 받은 id를 my id is {id}로 비동기적으로 돌려주는
간단한 API를 기준으로 진행했다.
기본적인 단위 테스트와 간단한 Mocking 처리까지만 해보도록 하자!
(Service Unit Test - Service Mocking 통한 Controller Unit Test)

공통 코드

// node app.js와 같이 직접 실행해줄 경우 설치 필요 X
npm i -D nodemon 

package.json

// 필요 라이브러리 모두 설치된 상태
{
  "name": "node-test",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "mocha --verbose", // jest의 경우 jest --verbose
    "start": "nodemon app.js"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "chai": "^4.3.6",
    "jest": "^28.1.0",
    "mocha": "^10.0.0",
    "nodemon": "^2.0.16",
    "sinon": "^14.0.0"
  },
  "dependencies": {
    "express": "^4.18.1"
  }
}

npm test를 통해 테스트가 실행될 수 있도록 package.json에 명시해주자.

app.js

const express = require("express");
const testRouter = require("./test.router");

const app = express();
const port = process.env.PORT || 3000;

app.use("/api", testRouter);

app.listen(port, () => {
  console.log(`server is listening at localhost:${port}`);
});

test.router.js

const express = require("express");
const TestController = require("./test.controller");

const testRouter = express.Router();

testRouter.get("/info/:id", TestController.getInfo);

module.exports = testRouter;

test.controller.js

const TestService = require("./test.service");

const TestController = {
  async getInfo(req, res) {
    const { id } = req.params;
    const result = await TestService.getOneAsync(id);
    res.send(result);
  },
};

module.exports = TestController;

test.service.js

const getOne = (id) => {
  return `my id is ${id}`;
};

const getOneAsync = (id) => {
  return new Promise((res, rej) => {
    setTimeout(() => {
      res(`my id is ${id}`);
    }, 500); // 비동기 로직 처리 위해 500ms 이후 실행
  });
};

module.exports = { getOne, getOneAsync };

Jest

간단한 API를 작성했으니 먼저 비교적 심플한 Jest로 단위 테스트를 해보자.
개발 의존성으로 설치해준다.

npm i -D jest

Jest는 자동으로 디렉토리 내의 테스트 파일을 테스팅한다.
이때 파일명이 test.js 와 같은 형식도 가능한 것 같지만
이미 TestSerivice와 같이 작성했으므로 파일명.spec.js 형식을 통해 테스트 파일을 명시해주자.

test.service.spec.js

const TestService = require("./test.service");

describe("Service Test", () => {
  it("getOne() Test", () => {
    const id = 324;

    expect(TestService.getOne(id)).toBe(`my id is ${id}`);
  });

  it("getOneAsync() Test", async () => {
    const id = 524;

    const result = await TestService.getOneAsync(id);

    expect(result).toBe(`my id is ${id}`);
  });
});

describe() 통해 테스트 단위를 그룹화하고 it() 또는 test() 로 각각의 테스트를 작성할 수 있다.
expect() 뒤의 toBe()와 같은 비교 구문을 통해 값을 검증할 수 있었다. (toBe() 외에도 다양한 함수를 제공한다)

비동기 함수 테스트도 기존에 익숙한 asybc/await 구문으로 바로 검증할 수 있다.

test.controller.spec.js

const TestController = require("./test.controller");
const TestService = require("./test.service");

jest.mock("./test.service");

describe("Controller Test", () => {
  it("getInfo() Test", async () => {
    const id = 2349;
    const tReq = { params: { id } };
    const tRes = { send: jest.fn() };
    TestService.getOneAsync.mockResolvedValue(`my id is ${id}`);

    await TestController.getInfo(tReq, tRes);

    expect(TestService.getOneAsync).toHaveBeenCalledWith(id);
    expect(tRes.send).toHaveBeenCalledWith(`my id is ${id}`);
  });
});

TestController의 테스트를 위해서 TestService를 Mocking한다.
jest.fn()은 한 함수를 모킹, jest.mock()은 명시된 모듈 내 모든 함수를 모킹한다.
mockResolvedValue()를 통해 서비스의 getOneAsync() 함수의 resolve시의 값을 미리 지정(Stubbing)한다.

JS 테스트에서 특히 신기했던 것은 request의 response를 검증하기 위해
만들어둔 response 객체의 send() 함수를 모킹하는 것이었다.
이를 통해 res.send()로 전송되는 결과값(send의 파라미터) 검증을 할 수 있었다.

테스트가 Pass되면 다음과 같은 화면으로 확인할 수 있다.

Unit Test까지는 정말 깔끔하게 작성할 수 있어서 너무 좋았다.
하지만 객체 내부의 함수처럼 복잡하게 Mocking 해야하는 경우나
Express와 같이 외부의 라이브러리와 연결될 경우 처리가 복잡해졌다.
특히 Jest는 Mocking을 함수 단위로 하는 느낌이 강했고
(주로 Serivce를 객체 취급하고 내부 함수로 구현하는 방식을 선호하는 내게는 아쉬운 점이었다)
Mocking 대상을 설정하는 부분과 모킹될 함수, 결과값을 설정하는 부분이 나뉘어있어 헷갈리기 쉬웠다.

Mocha

이제 Mocha로 넘어가보자.

npm i -D mocha chai sinon

Mocha는 test/ 디렉토리 내부의 파일을 테스트한다.
test/test.service.js

const chai = require("chai");
const TestService = require("../test.service");

describe("Service Test", () => {
  it("getOne() Test", () => {
    const id = 324;

    chai.expect(TestService.getOne(id)).to.equal(`my id is ${id}`);
  });

  it("getOneAsync() Test", async () => {
    const id = 7824;

    const result = await TestService.getOneAsync(id);

    chai.expect(result).to.equal(`my id is ${id}`);
  });
});

chai를 사용해 assertion한다.
기본적인 테스트 문법은 Jest와 유사하지만 값을 검증하는 부분에서 약간의 문법 차이가 있었다.
(toBe()와 같은 역할의 to.equal())

test/test.controller.js

const TestController = require("../test.controller");
const TestService = require("../test.service");
const sinon = require("sinon");

describe("Controller Test", () => {
  it("getInfo() Test", async () => {
    const id = 824;
    const result = `my id is ${id}`;
    const tReq = { params: { id } };
    const tRes = { send: sinon.stub() };
    sinon.stub(TestService, "getOneAsync").resolves(result);

    await TestController.getInfo(tReq, tRes);

    sinon.assert.calledWith(TestService.getOneAsync, id);
    sinon.assert.calledOnce(TestService.getOneAsync);
    sinon.assert.calledWith(tRes.send, result);
  });

  afterEach(() => {
    sinon.restore();
  });
});

Jest와 동일하게 비동기 문법의 테스트는 async/await를 지원한다.

Mock/Stub을 위해서 sinon을 필요로 한다.
sinon.stub(대상, 함수명).resolves(결과값)과 같이 Mocking 대상의 결과값을 지정할 수 있다.
함수의 호출은 sinon.assert.calledOnce()와 같이, 파라미터 검증은 sinon.assert.calledWith()와 같이 검증할 수 있었다.

단 주의할 점은 각 테스트가 완전히 분리되어있는 Jest와 달리
Mocha에서 사용하는 sinon의 stub() mock() spy()는 초기화 해주지 않으면 다른 테스트에도 영향을 줄 수 있다.
따라서 이후에도 필요한 경우가 아니라면 sinon.restore()와 같은 초기화 함수를 통해 사용 이후 꼭 초기화해주자.

(이것 때문에 컨트롤러 테스트 이후 서비스 테스트를 돌리면 모킹했던 결과값이 나와서 Fail을 겪었다 😵 )
테스트의 순서는 원하는 대로 보장되지 않을 수 있으므로 꼭 초기화하자!

결과 화면은 이렇게! report 형식은 다양하게 존재하는 것 같았는데 변경해보지는 않았다.

개인적으로는 Mocking이 Mocha가 더 직관적이라는 느낌은 있었다.
하지만 sinon에서 mock() stub()의 구분이 뚜렷하지 않은 느낌이었고,
Jest에 비해서 초기화 처리 등 신경 써줘야할 부분이 더 존재한다.
(물론 잘 쓸 수 있다면 공통된 모킹 처리를 편하게 할 수 있을 것 같다. 사람에 따라 장점이자 단점일 수 있을 것 같다.)

결론

그래서 Jest / Mocha 중 무엇을 써야할까?

개인적 생각으로는

  • 빠른 설치 (단순한 설치)
  • 깔끔한 테스트
  • React 테스트
  • 모듈별로 독립적 테스트가 많음 (Mocking 많이 사용하지 않는 Unit Test)

⇒ Jest

  • 다양한 확장 (Customization) 필요한 경우
  • 필요 라이브러리만 설치하고 싶은 경우 (Jest 제공 모든 라이브러리 포함)
  • 복잡한 Mocking을 해야할 경우 (객체 내 함수 Mocking, 여러개의 Mocking을 여러 테스트에서 지속적 사용 등)

⇒ Mocha 인 것 같다.

Jest에서 속도와 TS 컴파일에 대한 이슈가 있는 것 같기도 한데 확실하지 않아서 포스팅 내용에 넣지 않았다.
사용해봐야 확실히 할 수 있는 부분인 것 같다.

궁금해서 검색해본 npm-trend를 마지막으로 포스팅을 마무리한다! 🖐

Reference

테스트 코드를 작성하는 이유
화이트박스 테스트 vs 블랙박스 테스트
Mocks Aren't Stubs
[tdd] 상태검증과 행위검증, stub과 mock 차이
Mocha vs. Jest: comparison of two testing tools for Node.js
Sinon.js의 spy, stub, mock 의 Best Practice
Sinon.JS
[번역] Jest Mocks에 대한 이해

0개의 댓글