Node.js/잡다 지식

Jest 와 유닛 테스트

코딩크리처 2025. 5. 5. 22:33

제목 : Jest 와 유닛 테스트


프로그래머가 되어가는 과정은 항상 험난하다.

단순히 코드의 기능을 구현하고, 품질을 유지하는데에 이어,

직접 구현한 코드가 대부분의 상황에서 동작하는지 검증해야 한다.


특히, 특정 비즈니스 로직을 작성하는 데 있어 요청된 결과물의 일관성은 매우 중요한 일이다.

프로그래머스 부트캠프에서 백엔드로서 팀 프로젝트를 진행하며 가장 크게 느낀 것 중 하나는,

코드의 재사용화가 진행됨에 따라 코드는 점점 복잡해지는데, 내가 방금 생성한 비즈니스 로직 함수 하나가

정말로 의도대로 동작하고 있는지 모를 수도 있다는 것이었다.


따라서, 웹 서버 (프론트엔드) 팀의 Next.js 도입 과정에서

EndPoint 의 데이터가 의도대로 들어오지 않았는데, 이 때 어떤 서버에서 에러가 났는지 모르므로

협력하는 양 팀이 전부 각자의 코드를 다시 훑어보게 되는 비효율적인 일이 일어났다.

결과적으로 보자면, 쿠키를 전송하고 받는 과정에서 React 와 Next 의 Receiver 환경이 달랐고,

백엔드 서버는 쿠키 전송 가능 영역을 전부 열어야 했다.

결과적으로 이 에러를 해결하기 위해 몇 일 정도가 소요되었는데,

이 과정에서 로직의 무결성을 입증하는 테스트가 존재했다면, 몇 시간 정도로 단축되었을 거라는 생각이 들었다.

물론, 위의 상황만을 가정한다면, 모든 비즈니스 로직을 거치는 E2E-Test (End To End Test) 가 적절 할 것이다.


내 프로젝트는 유저에게 API 접근권을 의미하는 쿠키를 Middleware 를 통해서 생산하고 전송했다.

이 과정에서 JWT 를 사용하여 유저의 간단한 정보를 넣었다.

문제는 쿠키를 관리하는 함수를 재사용하는 것 까지는 좋았는데,

쿠키로 인증이 필요한 장소에서 적절히 함수가 사용되었는지 제대로 확인해 보질 못했다는 것이다.

특히 POSTMAN 기본 상태는 cookie 가 만료되어도 브라우저처럼 삭제하지 않기 때문에,

브라우저 상황 뿐만 아니라, 테스팅 환경인 POSTMAN 도 인식하여 로직을 작성했다.

아마 이 과정에서 유닛 테스트를 훌륭히 작성했다면,

굳이 console.log(...) 작성할 필요 없이, 로직의 일관성을 확인했을 것이다.


Unit Test (단위 테스트) 란 무엇인가?

단위 테스트란, 코드의 가장 작은 기능적 단위를 테스트하는 프로세스이다.

소프트웨어를 작은 Unit 단위로 작성 한 다음, 코드 단위별 테스트를 작성하는 것이 소프트웨어 개발 모범 사례이다.

단위 테스트 작성 후, 소프트웨어 코드를 변경 할 때 마다, 테스트 코드를 자동으로 실행한다.

테스트 실패의 경우, 버그나 오류가 있는 코드 영역을 빠르게 분리하여 고칠 수 있다.

위의 글은 AWS 에서 가져왔는데, 좋은 시야를 가지고 작성된 글이라고 생각한다.


Unit Test 전략

1. 논리 검사

시스템이 올바른 계산을 수행하여 예상되는 입력을 출력하는가?

2. 경계 검사

주어진 입력에 대해 시스템이 예상되는 범위의 값을 출력하는가?

3. 오류 처리

주어진 입력에 대해 오류가 있을 때, 시스템은 의도한 에러가 출력되는가?

객체 지향 검사

코드를 실행했을 때, 객체의 상태가 변경되면 객체가 올바르게 업데이트되는가?


개인적인 생각

유닛 테스트를 배우게 되면 무조건 나오게 되는 개발 전략은 바로, TDD 이다.

TDD 는, Test Driven Development 의 약자로, 테스트 먼저 작성, 이후 실제 코드 작성이다.

나의 개인적인 생각은 이렇다.

"모든 코드의 Unit Test" 가 아니라, 중요 혹은 복잡 코드의 Unit Test 가 필요하다 이다.

물론, 프로그램 작성에 대해 시간이 넉넉하다면, 모든 함수에 대한 유닛 테스트를 해도 상관 없다고 생각한다.

그러나, 시간이 촉박한 상황에서, 과연 모든 테스트를 작성할 수 있는가에 대해서 생각이 든다.

자신에게 주어진 "시간" 이라는 자원에 따라, 유닛 테스트의 범위와 양은 달라진다고 생각한다.


Jest 란 무엇인가?

Jest 는 JavaScript, 혹은 TypeScript 환경에서 사용하는 Unit 혹은 E2E 테스팅 도구라고 생각하면 된다.

React, Express, NestJS 와 같은 프로그램에서 자주 사용하는 테스팅 도구인데,

JavaScript 로 실행되는 어떠한 형태의 프로그램이던 모두 사용할 수 있는 라이브러리이다.


우선, jest 테스팅을 위한 새로운 폴더를 만들고,

$ npm install --save-dev jest

위의 명령을 해당 폴더에서 실행한다.

이후, 생성된 package.json 에, 밑의 옵션을 넣는다 :

{
  "scripts" : {
    "test": "jest"
  }
}

이후, jest 가 잘 동작하는지 확인하기 위해, 제공되는 2 개의 파일을 먼저 생성 해 보자 :

sum.js

function sum (a, b) {
  return a + b;
}

module.exports = sum;

sum.test.js

const sum = require("./sum");

test("1 + 2 는 3이다!", () => {
  expect(sum(1, 2)).toBe(3);
})

그리고 나서, npm run test 혹은 npm test 를 실행시켜 보자 :

➜  jest npm run test

> test
> jest

 PASS  ./sum.test.js
  ✓ 1 + 2 은 3 과 동일하다! (2 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.194 s, estimated 1 s
Ran all test suites.

동작을 실행한다면, npmpackage.json 에 적혀 있는 test 라는 스크립트를 읽어 실행한다.

내가 만들어 둔 1 개의 test 메서드에 반응하여, 1 개의 테스트가 통과함을 잘 보여주고 있다.


그런데, 나는 궁금증이 들기 시작했다.

node 에 탑재된 기본 라이브러리가 아니라면, jest 전용인 expect, toBe 메서드는 명시적으로 가져와야 한다.

test 는 이미 node 에 탑재되어 있으므로, 나머지 두 개의 메서드의 행방을 찾기 시작했다.

node_modules 를 뒤진 결과,

jest --> @jest 경로로 유틸 메서드가 존재했다.

또 다른 궁금증도 들었는데, node_modules 에 존재하는 expect, toBe 메서드는 왜 가져오지 않는가? 였다.

이에 대해 결국 GPT 에게 물어보았는데,

npm run testjest 의 cli 를 실행하며, 테스팅 환경에 필요한 global 변수 및 함수들을

주입해 준다는 것이었다.

따라서, 따로 expect, toBe 메서드를 라이브러리에서 명시적으로 가져오지 않아도,

실행 시 코드는 전역 함수 혹은 변수를 참조하므로 코드가 정상적으로 실행된다는 것이었다.

또한, 기존의 node:test 를 사용하는 것이 아니라, jest 라이브러리의 test 를 사용한다는 것이었다.


결과적으로, 테스팅 라이브러리를 사용하면서 명시적으로 메서드를 가져오지 않는 이유는,

실행하면서 자동으로 글로벌 메서드로 test, expect, toBe 를 주입하기 때문이었다.

명시적으로 가져오고 싶다면, 아래와 같이 코드를 작성하면 된다 :

const sum = require("./sum");
const {test, expect} = require("@jest/globals")

test("1 + 2 은 3 과 동일하다!", () => {
  expect(sum(1, 2)).toBe(3);
})

Jest 와 TypeScript

우선, TypeScript 의 2 가지 실행 방법 에 대해서 알고 있어야 한다.

TypeScript 는 실행 타겟 언어가 아니다.

TypeScript 는 결국 JavaScript 로 번역되어, 다시 실행 될 뿐이다.

그렇다면, TypeScript 파일은 어떻게 실행해야 하는가?


1. JS 로 컴파일 후, 실행

결국 타입스크립트는 자바스크립트로 컴파일 되므로,

tsc sum.ts, tsc sum.test.ts 를 각각 입력하여 JavaScript 파일로 내보내거나,

tsconfig.json 을 생성하여 각자 원하는 옵션을 기입 한 후, tsc 명령어 하나로

sum.ts, sum.test.ts 를 동시에 컴파일 할 수 있다.

이후,

$ npm run test

를 통해 .js 로 변경된 테스트를 진행 할 수 있다.

그런데, 여기엔 치명적인 오류가 있다.

jest 의 기본 설정은 .ts 파일을 실행할 수 없다.


일단, 아주 기본적인 작업으로 JS 변환 후, jest 를 실행 해 보자 :

➜  ts npm run test

> test
> jest

 PASS  js/sum.test.js
 PASS  ts/sum.test.js
 FAIL  ts/sum.test.ts # 따로 "ts" 폴더로 구분하였는데, `.ts` 테스팅까지 들어가 버리는 현상.
  ● Test suite failed to run

    Jest encountered an unexpected token

    Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to support such syntax.

    .....


      at Runtime.createScriptFromCode (node_modules/jest-runtime/build/index.js:1505:14)

# js/sum.test.js 통과, ts.sum.test.js 통과, ts.sum.test.ts ("실패")
Test Suites: 1 failed, 2 passed, 3 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.352 s, estimated 1 s
Ran all test suites.

즉, jest 는 컴파일 된 JS 코드를 타겟으로 하므로, .ts 확장자는 적용될 수 없다.

바로 밑에 ts-jest 를 통해 .ts 파일 또한 실행이 가능하지만,

자바스크립트와 타입스크립트 테스팅을 구분하고 싶은 사람을 위해 밑에 작성하겠다.


2. ts-node 와 같은 환경처럼, ts-jest 자체를 실행

위에서 말했듯, TS 는 결국 JS 로 실행된다.

하지만, ts-node 환경을 이용한다면, .ts 확장자를 굳이 .js 로 컴파일 하지 않고도,

타입스크립트 특유의 환경 변수를 그대로 가져와 사용할 수 있다.

여기서 일어나는 문제는, .js 확장자는 적용이 되지 않는다는 것이다.

jest 를 타입스크립트 환경에서도 동일하게 사용하고 싶다면, ts-jest 를 install 해야 한다.

ts-jest 를 사용한다면, .js.ts 확장자도 실행 할 수 있다.

ts-jest.js 를 만난다면 자바스크립트 그대로 실행하며,

.ts 를 만난다면, 굳이 자바스크립트 출력물 없이 그대로 실행이 가능하다. (하이브리드)

$ npm i -D ts-jest # 혹은 npm i --save-dev ts-jest

package.json

{
  "scripts": {
    "test": "jest"
  },
  "devDependencies": {
    "@types/jest": "^29.5.14",
    "jest": "^29.7.0",
    "ts-jest": "^29.3.2"
  },
  "jest": {
    "preset": "ts-jest"
  }
}

"scripts" 속성을 통해, 우리는 jest 를 로컬로 직접 설치하지 않고도 테스팅을 수행 할 수 있다.

그리고, package.json 파일의 하단에 "jest" 를 적어놓았는데,

이는 CLI 로서 사용될 jest 라이브러리의 디폴트 설정을 바꿀 수 있게 만들어주는 것이다.

정말 수 많은 옵션들이

https://jestjs.io/docs/configuration

이곳에 존재한다.

그 중에서, 우리는 preset 이란 속성을 통해,

jest 의 실행 환경을 TS 와 JS 모두 실행 가능하도록 변경 한 것이다.


그렇다면, 한번 실행 해 보자 :

➜ npm run test

> test
> jest

ts-jest[config] (WARN) message TS151001: If you have issues related to imports, you should consider setting `esModuleInterop` to `true` in your TypeScript configuration file (usually `tsconfig.json`). See https://blogs.msdn.microsoft.com/typescript/2018/01/31/announcing-typescript-2-7/#easier-ecmascript-module-interoperability for more information.
 PASS  ts/sum.test.ts
 PASS  js/sum.test.js
 PASS  ts/sum.test.js

Test Suites: 3 passed, 3 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        2.239 s
Ran all test suites.

ts-jest 에서 실행 한 테스팅 메서드는 총 3 개이다.

그런데, 2개는 같은 테스트를 의미한다. (ts --> js 컴파일)

ts-jest 는 JS, TS 동시 실행 환경을 지원하지만, 각 확장자 별 분리도 필요하다.

예를 들면, 유닛 테스팅과 e2e-테스팅 간의 실행 환경 차이가 있을 수도 있기 때문이다.

(EX - ts-node 환경 or node 환경)

이럴 때, 우리는 package.json"jest" 환경을 override 한 것 처럼,

각 실행 환경을 분리 할 수도 있다.


어떻게 실행 환경을 분리할 것인지는 각자의 판단이 있지만,

나는 package.json 을 통해 명령어 스크립트로 나누었다.

package.json

{
  "scripts": {
    "test:ts": "jest --testRegex='\\.test.ts'",
    "test:js": "jest --testRegex='\\.test.js'"
  },
  "devDependencies": {
    "@types/jest": "^29.5.14",
    "jest": "^29.7.0",
    "ts-jest": "^29.3.2"
  },
  "jest": {
    "preset": "ts-jest"
  }
}

위에 보면 2 개의 스크립트로 나뉜 것을 알 수 있다.

사용법은 npm run test:ts or npm run test:js 이다.

여기서, 내가 테스팅을 시작 할 정확한 파일 extension 을 정해 놓은 것이다.

2 개의 script 는 모두 jestpreset=ts-jest 이지만,

각자 가진 testRegex 속성은 다른 것이다.

참고로, .test 를 넣어주지 않으면, .ts or .js 파일 또한 실행되기 때문에,

정확한 테스팅 파일 확장자를 넣어주어야 한다.

jest 가 자동으로 찾아주기를 원한다면, testPathPattern 옵션을 각자 넣어주면 된다.

npm run test:js 결과

➜ npm run test:js

> test:js
> jest --testRegex='\.test.js'

 PASS  ts/sum.test.js
 PASS  js/sum.test.js

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.361 s, estimated 1 s
Ran all test suites.

npm run test:ts 결과

➜  ts npm run test:ts

> test:ts
> jest --testRegex='\.test.ts'

ts-jest[config] (WARN) message TS151001: If you have issues related to imports, you should consider setting `esModuleInterop` to `true` in your TypeScript configuration file (usually `tsconfig.json`). See https://blogs.msdn.microsoft.com/typescript/2018/01/31/announcing-typescript-2-7/#easier-ecmascript-module-interoperability for more information.
 PASS  ts/sum.test.ts
  ✓ TS 버전 1 + 2 = 3 (3 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.57 s, estimated 2 s
Ran all test suites.

결과를 보면, 일단 타입스크립트 테스팅이 통과하긴 했지만, 여전히 무언가 경고가 뜨고 있다.

이는, 내가 import 구문을 어떻게 처리 할 지에 대해서 tsconfig.json 에 지정하지 않았기 때문이다.

즉, 내 tsconfig.json 은 텅 비어있다.

따라서, 설정 파일을 이렇게 바꿔준다.

tsconfig.json

{
  "compilerOptions": {
    "module": "CommonJS"
  }
}

정말 간단하게 1 개의 옵션만 적어놓았다.

나는 tsconfig.json 의 옵션에 대해서 상세하게 적어놓은 글이 있다.

https://codecreature.tistory.com/196

여기서 상단 부분에 적어놓은 "module" 을 읽으면, 왜 ESMcommonjs 간의

영역 정리가 필요한지 알게 될 것이다.

그리고, 바뀐 tsconfig.json 과 함께, npm run test:ts 를 실행해 보자 :

➜  ts npm run test:ts

> test:ts
> jest --testRegex='\.test.ts'

 PASS  ts/sum.test.ts
  ✓ TS 버전 1 + 2 = 3 (5 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.488 s, estimated 2 s
Ran all test suites.

이제 어떠한 경고도 뜨지 않고, 정상적으로 실행되는 것을 볼 수 있다.



Jest 로 본격적인 테스팅하기 (Matcher 사용하기)

주로 프레임워크에서 Jest 의 형태를 미리 갖춰놓는 경우가 있는데,

이 때, describe, it 이라는 메서드가 존재한다.

describe 는 일정 테스트 메서드들을 Grouping(그룹화) 하는 역할을 한다.

ittest 의 별칭으로 사용된다. 둘의 행동은 동일하다.

주로, describe 로 테스팅 하고자 하는 도메인 영역을 설명하고,

각각의 테스팅 유닛들을 내부에 it 혹은 test 로 작성한다.

먼저, 가장 작은 유닛인 test 부터 살펴보자.

아 그리고, TypeScript 를 이용하여 테스팅하겠다.

그리고, 내가 원하는 파일만 따로 jest or ts-jest 테스팅을 하기 위해,

package.jsonscripts 에 따로 명령어를 넣어주자 :

"scripts": {
  "test" : "jest", // (기입함) 기존의 일반 명령어
  "test:ts": "jest --testRegex='\\.test.ts'",
  "test:js": "jest --testRegex='\\.test.js'"
},
...

이런 식으로, 모든 js 실행을 원한다면 npm run test:js 를,

모든 ts 실행을 원한다면, npm run test:ts 를 실행하면 된다.


Matcher 1 - toBe

우리는 sum 이라는 파일을 따로 제작해서 sum 메서드를 송출시켰지만,

결국 이러한 코드로 만들어진다. :

to-be.test.ts :

test("2 더하기 2 는 4 입니다.", () => {
  // "toBe" 라는 Matcher 를 사용했다!
  expect(2 + 2).toBe(4);
})
➜ npm run test -- to-be.test
# 이는 "jest to-be.test" 명령어와 동일하다.

> test
> jest to-be.test

 PASS  ts/to-be.test.ts
  ✓ 2 더하기 2 는 4 입니다. (3 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.988 s, estimated 2 s
Ran all test suites matching /to-be.test/i.

방금 위에서 npm run test 라는 지정 스크립트 외, -- 라는 키워드를 추가했다.

이는, npm run test 를 입력하면 실행될 "실제" 프로그램에 원하는 옵션 혹은 명령을 보내는 역할을 한다.

따라서, 내가 만들어 둔 폴더 산하에 여러 테스팅 파일이 존재하는 가운데,

이 중 to-be.test 라는 키워드가 들어가는 파일만 실행하겠다는 것이다.

이를 통해, 굳이 모든 테스팅 결과를 보지 않고도, 하나 혹은 그 이상의 파일들만 골라서 실행 할 수 있다.


일단, expect() 메서드는 expectation 이라는 객체를 반환한다.

이 객체는 matcher 에 반응하여 이 결과가 옳은지, 틀린지를 자동으로 판별 해 준다.

만약에 toBe(5) 를 입력했다면,

➜ npm run test -- to-be.test

> test
> jest to-be.test

 FAIL  ts/to-be.test.ts
  ✕ 2 더하기 2 는 4 입니다. (5 ms)

  ● 2 더하기 2 는 4 입니다.

    expect(received).toBe(expected) // Object.is equality

    Expected: 5
    Received: 4

      1 | test("2 더하기 2 는 4 입니다.", () => {
    > 2 |   expect(2 + 2).toBe(5);
        |                 ^
      3 | })
      4 |

      at Object.<anonymous> (ts/to-be.test.ts:2:17)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        2.233 s
Ran all test suites matching /to-be.test/i.

위의 결과를 보다시피, jest 라이브러리의 훌륭한 출력과 더불어,

왜 실패했는지를 자세히 보여주는 것을 확인 할 수 있다.


Matcher 2 - toEqual

toBe 메서드는 값의 동일성을 취급한다.

그런데, 클래스 타입 (객체) 의 값 동일성은 어떻게 취급할 것인가?

일반적인 원시 값과 다르게, 객체의 값 동일성은 쉽게 판단할 수 있는 것이 아니다.

객체는 자신만의 고유 주소값을 가지므로, 내부 필드의 값이 모두 동일하더라도,

서로 다른 객체라면, 값의 동일성을 따질 수 없다.

따라서, 값이 동일한지 내부 필드들을 모두 "재귀적으로" 확인하는 toEqual 메서드가 존재한다.

to-equal.test.ts

test("객체의 동일성은 판단하는 매처가 존재한다?", () => {
  const obj = {
    test1: ["배열로 넣어보는 테스팅 값!"],
    test2 : {
      first : 1,
      second : "two",
    }
  }

  expect(obj).toEqual({
    test1: ["배열로 넣어보는 테스팅 값!"],
    test2 : {
      first : 1,
      second : "two",
    }
  })
})
➜ npm run test -- to-equal

> test
> jest to-equal

 PASS  ts/to-equal.test.ts
  ✓ 객체의 동일성은 판단하는 매처가 존재한다? (3 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.156 s
Ran all test suites matching /to-equal/i.

보다시피, 둘의 객체 주소값은 다르지만, 내부의 값을 재귀적으로 파악하여

동일한 필드와 값을 가지고 있다는 것을 알 수 있다.


Matcher 3 - not

toBe 와, toEqual 까지는 "일치하는 값" 을 의미하지만,

어떠한 테스트에서는, "일치해서는 안되는 값" 을 사용해야 할 수도 있다.

not.test.ts

// 2 + 3 은, 4 가 "되어서는 안된다"
test("not 을 이용한 테스팅!", () => {
  expect(2 + 3).not.toBe(4);
})
➜ npm run test -- not

> test
> jest not

 PASS  ts/not.test.ts
  ✓ not 을 이용한 테스팅! (2 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.148 s
Ran all test suites matching /not/i.

이러한 not 은, 당연히 .not.toEqual(...) 로도 사용할 수 있다.


Matcher 4 - 여러가지 타입 판별 매처들

물론, 위에서 다룬 내용들은 굉장히 자주 사용되지만,

현재 expect 에 들어간 값 혹은 객체가 어떠한 상태인지 "바로 판별 가능한" 매처들도 굉장히 유용하다.

  • toBeNull() - null 값이다.
  • toBeUndefined() - undefined 값이다.
  • toBeDefined() - expect 내부의 내용이 "정의되어 있다" (값이 있다.)
  • toBeTruthy() - expect 내부 결과가 if 정의문으로 판별했을 때, true 의 값이다.
  • toBeFalsy() - expect 내부 결과가 if 정의문으로 판별했을 때, false 의 값이다.

expect 의 내부 결과 타입을 예상 할 수 있는 메서드들이다.


Matcher 5 - 숫자 관련 매처들

숫자, 타입, 일치 matcher 들이 있다보니, 사실상 toBe 만 치면, 관련 메서드들이 전부 나오긴 한다.

이렇게 다양한 메서드들이 보여지는 것이 jest 가 쉽게 접근할 수 있는 상황이 아닌가 생각이 든다.

  • toBeGreaterThan(x) - x 값 보다는 크다 (expect > x)
  • toBeGreaterThanOrEqual(x) - x 값 보다는 크거나 같다 (expect >= x)
  • toBeLessThan(x) - x 값 보다는 작다 (expect < x)
  • toBeLessThanOrEqual(x) - x 값 보다는 작거나 같다 (expect <= x)

Matcher 6 - 문자열 매처

나는 사실, regex 에 대해서는 잘 아는 편은 아니다!

정규표현식을 사용하여 판별하는데, 공식문서는 이렇게 표현되어 있다.

test("Team 문자열에 'I' 는 포함되어 있지 않다!", () => {
  expect("Team").not.toMatch(/I/);
});

test("Iterable 문자열에는 'rable' 이 포함되어 있다!", () => {
  expect("Iterable").toMatch(/rable/);
})
➜ npm run test -- string

> test
> jest string

 PASS  ts/string.test.ts
  ✓ Team 문자열에 'I' 는 포함되어 있지 않다! (2 ms)
  ✓ Iterable 문자열에는 'rable' 이 포함되어 있다! (1 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        2.081 s
Ran all test suites matching /string/i.

Matcher 7 - toThrow 에러를 예상한다

이는 특히, Back 사이드의 어플리케이션이나, Front 사이드의 어플이나 동일하게

정말 자주 사용되는 매처라고 생각한다.

바로, 내가 일부로 잘못된 정보를 주었으니, 내가 설정한 예외가 반출되어야 한다는 것이다.

반출된 예외의 클래스 타입, 혹은 내부에 담긴 내용도 toThrow 를 통해 일치시킬 수 있다.

이는 TypeScript, JavaScript 에서 throw new Error("....") 를 통해 트리거를 걸 수 있으며,

어플리케이션의 특성상 의존성 주입 과정의 에러와 비즈니스 로직의 무결성을 증명하는

백엔드 프로그램에서 유용하게 사용할 수 있었다.

공식 문서를 참고 및 변형한 예제

// isError 변수를 통해, 에러를 반환하거나, 일반 문자열을 반환 할 수 있게 만들었다.
function compileLogicCode(isError : boolean) : string {
  if(isError) {
    throw new Error("return Error");
  } else {
    return "Pass"
  }
}

test("toThrow 매처를 통해 에러를 잡아내는 예제 작성!", () => {
  // 어떤 형식의 에러이던 잡아 낼 수 있다.
  expect(() => compileLogicCode(true)).toThrow();
  expect(() => compileLogicCode(false)).not.toThrow();

  // 특정 형태의 오류인지 특정 할 수 있다.
  expect(() => compileLogicCode(true)).toThrow(Error);

  // 에러의 메세지를 특정 할 수 있다.
  expect(() => compileLogicCode(true)).toThrow("return Error");
  // Regex (정규표현식) 을 통해, "Error" 라는 메세지가 내부에 있는지 확인 할 수 있다.
  expect(() => compileLogicCode(true)).toThrow(/Error/);
})
➜  ts npm run test -- exception

> test
> jest exception

 PASS  ts/exception.test.ts
  ✓ toThrow 매처를 통해 에러를 잡아내는 예제 작성! (11 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.314 s
Ran all test suites matching /exception/i.

위의 에러를 판별하는 matcher 는 추후 백엔드 프로그램에서 특정 HttpException 을 잡아내는 데에 매우 탁월하다.

Matcher else - 이 곳을 참조하세요. (Array 나 Iterable 등등)

https://jestjs.io/docs/expect


흔히 보이는 describeit 는 무엇일까?

describe 는 테스트를 묶어 주는 일종의 Grouping 메서드이다.

it 은 위에서 주구장창 사용했던 testalias 이다.

한번 NestJS 에서 nest cli 를 사용하여 생성된 기본 형태의 xxx.controller.spec.ts 를 살펴보자 :

import { Test, TestingModule } from '@nestjs/testing';
import { ReportsController } from './reports.controller';

describe('ReportsController', () => {
  let controller: ReportsController;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [ReportsController],
    }).compile();

    controller = module.get<ReportsController>(ReportsController);
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });
});

이 글에서 내가 직접 다루지 않은 메서드들이 많이 보일 테지만,

이에 대해 간단한 설명을 들으면 이해가 될 것이다.

  • beforeEach(() => {...}) --> let controller 에 의존성 주입된 객체를 만들어 주기 위함.
  • import { Test, TestingModule } from "@nestjs/testing"
    --> NestJS 에서 만들어진 컴포넌트가 jest 라이브러리와 상호작용 하기 위한 도구 라이브러리

그래서, 이 테스팅 파일은 이렇게 묘사 될 수 있다.

describe 는, ReportsController 클래스 유닛을 테스팅하기 위한 그룹이다.

it 은, ReportsController 클래스에 존재하는 유닛을 하나씩 테스팅한다.

참고로, 파일이 나누어지지 않았다면, 위에서 아래로 차례로 실행된다.

이 때, beforeEachdescribe 내부에서 원활한 테스트를 위한 준비 작업이라고 생각하면 된다.


그런데, 이 파일로 테스팅 결과를 보여주려고 하지만, ReportsController 에는 하나의 의존성이 필요하다.

바로, ReportsService 이다.

만약에, 내가 직접 만든 ReportsService 를 넣는다면, 또 서비스에 필요한 repo 도 넣어주어야 한다.

그렇게 되면, 유닛 테스트에 적합하지 않고, e2e 테스팅에 적합하게 된다.

따라서, 테스팅 모듈 생성 과정에서 ReportsService 를 Mocking 해 보자!

import { Test, TestingModule } from '@nestjs/testing';
import { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service';
import { Report } from './reports.entity';

describe('ReportsController', () => {
  let controller: ReportsController;
  let mockReportsService: Partial<ReportsService>;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [ReportsController],
      providers : [
        {
          provide : ReportsService,
          useValue : mockReportsService,
        }
      ]

    }).compile();

    controller = module.get<ReportsController>(ReportsController);
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });
});

위에서 달라진 것은, providers 라는 옵션이 추가되었다.

이는, NestJS 에서 ReportsController 에 필요한 의존성을 주입 하기 위해 존재하는 옵션이다.

그 과정에서 ReportsService 의존성 토큰을 필요로 한다.

Partial<> 기호 내부에 사용된 모든 타입들을 "기본적으로" 가진다는 이야기이다.

이를 통해, ReportsService 가 완벽하게 Stub 으로 구현되지 않더라도,

Partial<ReportsService> 를 선언함으로서, ReportsController 의 의존성을 해결 할 수 있는 것이다.

어쨋든, ReportsServiceReportsController 에 필요한 의존성이기 때문에,

ReportsControllerTestingModule 메셔드를 통해 의존성이 충족 된 상태로 나오게 된다.

따라서, it("should be defined", () => expect(conteroller).toBeDefined())

위에서 요구하는 ReportsController 가 정의되어 있는가? 에 대한 질문은

defined 라고 말할 수 있는 것이다.


실제 stub 까지 구현된 유닛 테스팅의 경우

밑의 코드는 실제로 Stub 까지 구현 한 유닛 테스트인데,

훑어보면서 참고만 해도 충분할 것이다.

import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { User } from './users.entity';
import { UsersService } from './users.service';
import { AuthService } from './auth.service';
import { BadRequestException, NotFoundException } from '@nestjs/common';

describe('UsersController', () => {
  let controller: UsersController;
  let fakeUsersService: Partial<UsersService>;
  let fakeAuthService: Partial<AuthService>;

  let users: User[] = [];
  let currentUser: User;

  beforeEach(async () => {
    // UsersService Mocking 하기
    fakeUsersService = {
      findOne: (id: number): Promise<User> => {
        const findUser: User = users.find((user: User) => user.id === id);
        // TypeORM 에서는 검색 유저가 없더라도, 에러를 보내지 않는다는 것을 감안함.
        return Promise.resolve(findUser);

        // return findUser
          // ? Promise.resolve(findUser)
          // : Promise.reject(findUser);
      },
      find: (email: string): Promise<User[]> => {
        const findUsers: User[] = users.filter(
          (user: User) => user.email === email,
        );

        // TypeORM 의 특성을 고려.
        return Promise.resolve(findUsers);
        // return findUsers.length
          // ? Promise.resolve(findUsers)
          // : Promise.reject(NotFoundException);
      },
      update: (id: number, attrs: Partial<User>) => {
        const findUser: User = users.find((user: User) => user.id === id);

        // 수정하려는 유저의 id 가 존재하지 않다면.
        if (!findUser) {
          return Promise.reject(NotFoundException);
        }

        Object.assign(findUser, attrs);

        users = users.map((user: User) => {
          return user.id !== id ? user : findUser;
        });

        return Promise.resolve(findUser);
      },
      remove: (id: number): Promise<User> => {
        let findUser: User;
        users = users.filter((user: User) => {
          findUser = user;
          return user.id !== id;
        });

        // 삭제가 성공한다면 해당 유저 정보를 반환, 아니면 에러
        return findUser
          ? Promise.resolve(findUser)
          : Promise.reject(NotFoundException);
      },
    };
    // AuthService 모킹하기 (단, 해싱은 제외했음.)
    fakeAuthService = {
      signUp: (email, password) => {
        const isAlreadyUser: User = users.find(
          (user: User) => user.email === email,
        );

        // 이미 이메일이 존재한다면, 회원가입 하지 못한다.
        if (isAlreadyUser) {
          return Promise.reject(BadRequestException);
        }

        currentUser = {
          id: Math.floor(Math.random() * 9999),
          email : email,
          password : password,
        } as User

        users.push(currentUser as User);

        return Promise.resolve(currentUser as User);
      },
      signIn: (email, password) => {
        const findUser = users.find((user: User) => user.email);

        if (findUser.password === password) {
          return Promise.resolve(findUser);
        } else {
          return Promise.reject(NotFoundException);
        }
      },
    };

    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        {
          provide: AuthService,
          useValue: fakeAuthService,
        },
        {
          provide: UsersService,
          useValue: fakeUsersService,
        },
      ],
    }).compile();

    controller = module.get<UsersController>(UsersController);
  });

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });

  it("회원가입", async () => {
    await expect(
      controller.createUser(
        {
          email: "test@gmail.com",
          password: "1234"
        }
      )
    ).resolves.toBeDefined();

    console.log(currentUser);
  });

  it("로그인", async () => {
    await expect(
      controller.signIn(currentUser, {})
    ).resolves.toBeDefined();
  });

  it("특정 유저 id 로 찾기", async () => {
    await expect(
      controller.findUser(currentUser.id)
    ).resolves.toBeDefined();
  });

  it("없는 유저 id 로 에러 일으키기", async () => {
    await expect(
      controller.findUser(0)
    ).rejects.toThrow(NotFoundException)
  });

  it("이메일로 유저 정보 얻기", async () => {
    await expect(
      controller.findAllUsers(currentUser.email)
    ).resolves.toBeDefined();
  });

  it("사용자 정보 수정하기", async () => {
    await expect(
      controller.updateUser(currentUser.id, {
        email: "new@gmail.com",
        password: currentUser.password
      })
    ).resolves.toBeDefined();
  })

  it("사용자 계정 이메일로 삭제하기", async () => {
    await expect(
      controller.removeUser(currentUser.id)
    ).resolves.toBeDefined();
  });

  it("사용자 계정 삭제 후 조회해 보기", async () => {
    await expect(
      controller.findUser(currentUser.id)
    ).rejects.toThrow(NotFoundException);
  });
});

이 블로그 문서를 정리하면서 배운 것

유닛 테스트는 하나의 메서드 일 수도, 프로그램을 구성하는 어플리케이션의 일부 클래스일 수도 있다.

다양한 유닛 테스트 종류가 존재하지만, 주로 사용되는 유닛 테스트 경향은 굉장히 간단하게 사용된다는 편이라고 한다.

그리고 특히, 특정 프레임워크에서 사용되는 데코레이터는 유닛 테스트 과정에서 직접적으로 적용되지 않는다.

따라서, 실제로 들어갈 수 있는 JWT 혹은 암호화 과정은 직접 구현해야 한다는 점이 있다.



참고 사이트

AWS 문서

https://aws.amazon.com/ko/what-is/unit-testing/

Jest 공식 문서

https://jestjs.io/docs