Devin.KR

Node.js 테스트 - node:test 와 assert 로 단위 테스트와 API 통합 테스트 (Node.js API 10단원)

개발자 조회 1

이 단원에서 배우는 것

지금까지는 서버를 띄우고 요청을 보내 눈으로 결과를 확인했다. 기능이 늘면 이 확인을 매번 다 할 수 없고, 하나를 고치다 다른 것을 망가뜨려도 모른다. 테스트는 "이렇게 동작해야 한다"를 코드로 적어 두고 명령 하나로 전부 다시 확인하는 장치다. Node.js에는 18부터 테스트 러너가 들어 있어서 Jest나 Mocha를 설치하지 않아도 된다.

  • node:testtest·describe·before/afternode:assert/strict로 테스트를 쓴다.
  • 서버 없이 순수 함수를 확인하는 단위 테스트, 임시 폴더를 쓰는 저장소 테스트, 진짜 HTTP 서버를 빈 포트에 띄워 fetch로 확인하는 통합 테스트를 만든다.
  • node --test로 한꺼번에 돌리고, 실패했을 때 출력이 무엇을 알려 주는지 읽는다.

문제 상황

7단원에서 경로 변수 디코딩을 빼면서 6단원 코드를 여러 군데 고쳤다. 고친 뒤 405의 allow 헤더가 여전히 맞는지, 토큰 없이 쓰기를 막는지, 동시에 20개를 만들어도 괜찮은지 다시 확인하려면 서버를 띄우고 요청 스크립트 몇 개를 돌려 출력을 눈으로 비교해야 한다. 한 번은 하겠지만 매번은 안 한다. 그러다 언젠가 확인을 건너뛴 변경이 운영에 나간다.

6단원에서 검증 함수를 HTTP와 떼어 놓고, 7단원에서 저장소를 주입받게 하고, 8단원에서 토큰 확인을 앱 바깥의 한 겹으로 만든 것이 모두 여기서 쓸모를 보인다. 이 단원에서 memo-api에 더하는 파일은 test/ 폴더의 세 파일과 실패 예시 demo/fail.check.mjs다. 앱 코드는 바꾸지 않는다.

단위 테스트는 검증 함수만, 저장소 테스트는 임시 폴더의 실제 파일까지, 통합 테스트는 빈 포트에 띄운 진짜 서버를 fetch로 두드려 조립 전체를 확인한다.

그림 10-1. 테스트 세 종류가 확인하는 범위

완성 코드

단위 테스트: test/memos.test.mjs

// test/memos.test.mjs — 입력 규칙(순수 함수)을 확인하는 단위 테스트
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { validateMemo, parseId } from '../lib/memos.mjs';

describe('validateMemo', () => {
  test('제목 앞뒤 공백을 지우고 done 기본값은 false', () => {
    assert.deepEqual(validateMemo({ title: '  장보기 ' }), { title: '장보기', done: false });
  });

  test('모르는 필드와 빈 제목을 한꺼번에 알려 준다', () => {
    assert.throws(() => validateMemo({ title: ' ', owner: 'admin' }), {
      status: 422,
      details: ['title 은 비어 있을 수 없습니다', '모르는 필드: owner'],
    });
  });

  test('PATCH 용 검사는 보낸 필드만 돌려준다', () => {
    assert.deepEqual(validateMemo({ done: true }, { partial: true }), { done: true });
  });

  test('50자는 통과, 51자는 거절', () => {
    assert.equal(validateMemo({ title: '가'.repeat(50) }).title.length, 50);
    assert.throws(() => validateMemo({ title: '가'.repeat(51) }), { status: 422 });
  });
});

test('parseId 는 1 이상의 정수 문자열만 받는다', () => {
  assert.equal(parseId('12'), 12);
  for (const bad of ['0', '-1', '1.5', 'abc', '']) {
    assert.throws(() => parseId(bad), { code: 'INVALID_ID' }, `"${bad}" 는 거절되어야 한다`);
  }
});

저장소 테스트: test/store.test.mjs

// test/store.test.mjs — 실제 파일을 쓰는 저장소 테스트. 테스트마다 임시 폴더를 쓴다
import { test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createMemoStore } from '../lib/store.mjs';

let dir;
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'memo-store-')); });
afterEach(async () => { await rm(dir, { recursive: true, force: true }); });

test('동시에 20개를 만들어도 id 가 겹치지 않고 모두 저장된다', async () => {
  const file = join(dir, 'memos.json');
  const store = createMemoStore(file);
  const made = await Promise.all(
    Array.from({ length: 20 }, (_, i) => store.create({ title: `t${i}`, done: false })),
  );
  assert.equal(new Set(made.map((m) => m.id)).size, 20);

  const reopened = createMemoStore(file);          // 새 저장소로 파일을 다시 읽어 확인
  assert.equal((await reopened.list()).length, 20);
});

test('없는 메모를 고치거나 지우면 null / false', async () => {
  const store = createMemoStore(join(dir, 'memos.json'));
  assert.equal(await store.update(99, { done: true }), null);
  assert.equal(await store.remove(99), false);
});

test('list 가 준 객체를 바꿔도 저장소는 그대로다', async () => {
  const store = createMemoStore(join(dir, 'memos.json'));
  await store.create({ title: '원본', done: false });
  const [copy] = await store.list();
  copy.title = '바꿈';
  assert.equal((await store.get(1)).title, '원본');
});

통합 테스트: test/api.test.mjs

// test/api.test.mjs — 진짜 HTTP 서버를 빈 포트에 띄워 fetch 로 두드리는 통합 테스트
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createApp } from '../app.mjs';
import { createMemoStore } from '../lib/store.mjs';
import { withToken } from '../lib/auth.mjs';

const TOKEN = 'test-token-0123456789';
let dir, server, base;

before(async () => {
  dir = await mkdtemp(join(tmpdir(), 'memo-api-'));
  const app = createApp({ store: createMemoStore(join(dir, 'memos.json')), onError: () => {} });
  server = createServer(withToken(app, TOKEN)).listen(0, '127.0.0.1');   // 0 = 비어 있는 포트 아무거나
  await once(server, 'listening');
  base = `http://127.0.0.1:${server.address().port}`;
});

after(async () => {
  server.close();
  await rm(dir, { recursive: true, force: true });
});

function post(path, body, token = TOKEN) {
  const headers = { 'content-type': 'application/json' };
  if (token) headers.authorization = `Bearer ${token}`;
  return fetch(base + path, { method: 'POST', headers, body: JSON.stringify(body) });
}

test('토큰 없이 POST 하면 401', async () => {
  const res = await post('/memos', { title: '몰래 쓰기' }, null);
  assert.equal(res.status, 401);
  assert.equal((await res.json()).error.code, 'UNAUTHORIZED');
});

test('만들고 → 읽고 → 고치고 → 지운다', async () => {
  const created = await post('/memos', { title: '통합 테스트' });
  assert.equal(created.status, 201);
  const memo = await created.json();
  assert.equal(created.headers.get('location'), `/memos/${memo.id}`);

  const read = await fetch(`${base}/memos/${memo.id}`);
  assert.deepEqual(await read.json(), { id: memo.id, title: '통합 테스트', done: false });

  const patched = await fetch(`${base}/memos/${memo.id}`, {
    method: 'PATCH',
    headers: { 'content-type': 'application/json', authorization: `Bearer ${TOKEN}` },
    body: JSON.stringify({ done: true }),
  });
  assert.equal((await patched.json()).done, true);

  const removed = await fetch(`${base}/memos/${memo.id}`, {
    method: 'DELETE', headers: { authorization: `Bearer ${TOKEN}` },
  });
  assert.equal(removed.status, 204);
  assert.equal((await fetch(`${base}/memos/${memo.id}`)).status, 404);
});

test('허용하지 않는 메서드는 405 와 allow 헤더', async () => {
  const res = await fetch(`${base}/memos`, { method: 'DELETE', headers: { authorization: `Bearer ${TOKEN}` } });
  assert.equal(res.status, 405);
  assert.equal(res.headers.get('allow'), 'GET, POST');
  await res.body?.cancel();
});

일부러 실패하는 테스트: demo/fail.check.mjs

// demo/fail.check.mjs — 실패하는 테스트가 무엇을 보여 주는지 확인하려고 일부러 틀리게 적었다
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { validateMemo } from '../lib/memos.mjs';

test('제목을 그대로 돌려준다고 잘못 기대한 테스트', () => {
  assert.deepEqual(validateMemo({ title: ' 장보기 ' }), { title: ' 장보기 ', done: false });
});

줄별 해설

테스트 하나 = 이름 + 함수

test('이름', 함수)가 테스트 하나다. 함수가 끝까지 오류 없이 실행되면 통과, 도중에 예외가 나면 실패다. async 함수를 넘기면 돌려준 Promise가 끝날 때까지 기다린다. describe는 관련된 테스트를 묶어 출력에서 한 덩어리로 보여 준다. 이름은 "무엇을 하면 어떻게 되어야 한다"는 문장으로 적는다. 실패했을 때 이름만 보고 무엇이 깨졌는지 알 수 있어야 한다.

assert/strict의 세 가지

이 단원에서 쓰는 assert 함수
함수통과 조건쓰는 곳
equal(실제, 기대)===로 같다숫자 · 문자열 · 불리언
deepEqual(실제, 기대)내용이 끝까지 같다객체 · 배열
throws(함수, 기대)예외를 던지고 속성이 맞다검증 실패 확인
rejects(Promise, 기대)거부되고 속성이 맞다비동기 실패 확인
ok(값)참으로 판정된다조건 여러 개 조합

기대 자리에 객체를 주면 던진 오류가 그 속성들을 가졌는지만 확인한다. { status: 422, details: [...] }처럼 필요한 것만 적는다.

node:assert가 아니라 node:assert/strict를 가져오는 이유는, 엄격하지 않은 옛 모드에서는 equal(1, '1')이 통과하기 때문이다. 항상 /strict를 쓴다.

경계값을 확인한다

50자는 통과, 51자는 거절 테스트처럼 규칙이 바뀌는 바로 그 지점을 확인한다. > 50을 실수로 >= 50으로 바꾸면 50자가 거절되어 이 테스트가 잡아낸다. parseId 테스트는 거절해야 할 값 다섯 개를 반복문으로 확인하고, 세 번째 인자로 실패 메시지를 줘서 어느 값에서 실패했는지 알려 준다.

테스트는 서로 기대지 않는다

beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'memo-store-')); });
afterEach(async () => { await rm(dir, { recursive: true, force: true }); });

저장소 테스트는 테스트마다 새 임시 폴더를 만들고 끝나면 지운다. 앞 테스트가 만든 메모가 뒤 테스트에 남아 있으면, 순서가 바뀌거나 하나만 따로 돌릴 때 결과가 달라진다. mkdtemp는 운영체제의 임시 폴더 아래에 겹치지 않는 이름의 폴더를 만든다. 실제 data/memos.json은 건드리지 않는다. 7단원에서 저장소를 "파일 경로를 받아 만드는 함수"로 설계했기 때문에 가능한 일이다.

첫 번째 저장소 테스트는 저장소를 새로 하나 더 만들어 같은 파일을 읽게 한다. 메모리에 들고 있는 값이 아니라 파일에 실제로 20개가 쓰였는지 확인하기 위해서다.

빈 포트에 진짜 서버 띄우기

before(async () => {
  dir = await mkdtemp(join(tmpdir(), 'memo-api-'));
  const app = createApp({ store: createMemoStore(join(dir, 'memos.json')), onError: () => {} });
  server = createServer(withToken(app, TOKEN)).listen(0, '127.0.0.1');   // 0 = 비어 있는 포트 아무거나
  await once(server, 'listening');
  base = `http://127.0.0.1:${server.address().port}`;
});

통합 테스트는 server.mjs를 쓰지 않고, 같은 부품(createApp, createMemoStore, withToken)을 테스트용으로 조립한다. 저장소는 임시 폴더를, 토큰은 테스트용 값을, onError는 아무것도 하지 않는 함수를 받는다. 포트 0은 "운영체제가 비어 있는 포트를 아무거나 골라 달라"는 뜻이다. 실제로 받은 번호는 server.address().port로 읽는다. 3700번을 고정해 두면 개발 서버를 켜 둔 채로 테스트할 수 없고, 여러 테스트 파일이 동시에 돌 때 서로 부딪친다. once(server, 'listening')listening 사건이 한 번 일어날 때까지 기다리는 Promise다.

before에서 임시 폴더를 만들고 포트 0으로 서버를 띄워 운영체제가 고른 빈 포트를 읽는다. 테스트는 그 주소로 fetch하고, after에서 서버를 닫고 폴더를 지운다.

그림 10-2. 통합 테스트의 준비와 정리

after에서 서버를 닫고 임시 폴더를 지운다. 서버를 닫지 않으면 열린 포트 때문에 테스트 프로세스가 끝나지 않을 수 있다.

테스트 종류 비교
종류대상준비물잡는 문제
단위검증 함수없음규칙 · 경계값
저장소createMemoStore임시 폴더파일 저장 · 동시성
통합조립한 서버임시 폴더 · 포트 0라우팅 · 상태 코드 · 토큰

한 흐름을 끝까지 따라가는 테스트

만들고 → 읽고 → 고치고 → 지운다는 사용자가 실제로 하는 순서대로 API를 부른다. 상태 코드뿐 아니라 location 헤더, 읽은 본문 전체(deepEqual), 지운 뒤의 404까지 확인한다. 단위 테스트가 부품을, 통합 테스트가 조립을 확인한다. 둘 중 하나만 있으면 "부품은 다 맞는데 연결이 틀린" 문제나 "어디가 틀렸는지 모르는" 문제가 남는다.

node --test는 무엇을 찾아 돌리는가

파일 이름을 주지 않고 node --test만 실행하면, 현재 폴더 아래에서 test 폴더 안의 파일과 이름이 *.test.mjs(.js, .cjs도 같다)처럼 생긴 파일을 찾아 각각 별도 프로세스로 실행한다. demo/fail.check.mjs는 이 규칙에 맞지 않아 평소에는 실행되지 않는다. 실행 결과를 원고에 싣기 위해 --test-reporter=spec으로 보기 좋은 형식을 골랐다. 터미널에서 직접 실행하면 기본값도 대개 이 형식이다.

실제 실행 결과

memo-api 폴더에서 실행했다. 괄호 안의 시간은 실행할 때마다 조금씩 다르다.

$ node --test --test-reporter=spec
✔ 토큰 없이 POST 하면 401 (20.692667ms)
✔ 만들고 → 읽고 → 고치고 → 지운다 (14.920083ms)
✔ 허용하지 않는 메서드는 405 와 allow 헤더 (2.28875ms)
▶ validateMemo
  ✔ 제목 앞뒤 공백을 지우고 done 기본값은 false (1.512167ms)
  ✔ 모르는 필드와 빈 제목을 한꺼번에 알려 준다 (0.29ms)
  ✔ PATCH 용 검사는 보낸 필드만 돌려준다 (0.073833ms)
  ✔ 50자는 통과, 51자는 거절 (0.101834ms)
✔ validateMemo (2.637125ms)
✔ parseId 는 1 이상의 정수 문자열만 받는다 (0.112792ms)
✔ 동시에 20개를 만들어도 id 가 겹치지 않고 모두 저장된다 (13.942375ms)
✔ 없는 메모를 고치거나 지우면 null / false (0.696959ms)
✔ list 가 준 객체를 바꿔도 저장소는 그대로다 (1.189125ms)
ℹ tests 11
ℹ suites 1
ℹ pass 11
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 122.790375

11개 모두 통과했다. 파일 세 개가 따로 실행되므로 출력 순서는 파일 이름 순서와 다를 수 있다. 이제 일부러 틀린 테스트를 돌린다.

$ node --test --test-reporter=spec demo/fail.check.mjs
✖ 제목을 그대로 돌려준다고 잘못 기대한 테스트 (2.064083ms)
ℹ tests 1
ℹ suites 0
ℹ pass 0
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 57.470708

✖ failing tests:

test at demo/fail.check.mjs:6:1
✖ 제목을 그대로 돌려준다고 잘못 기대한 테스트 (2.064083ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
  + actual - expected
  
    {
      done: false,
  +   title: '장보기'
  -   title: ' 장보기 '
    }
  
      at TestContext.<anonymous> (file:///home/me/node-book/memo-api/demo/fail.check.mjs:7:10)
      at Test.runInAsyncScope (node:async_hooks:226:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.start (node:internal/test_runner/test:1242:17)
      at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: { title: '장보기', done: false },
    expected: { title: ' 장보기 ', done: false },
    operator: 'deepStrictEqual',
    diff: 'simple'
  }

종료 코드가 1이다. 출력에서 볼 곳은 세 군데다. test at demo/fail.check.mjs:6:1은 실패한 테스트의 위치, + actual - expected 아래는 실제 값과 기대 값의 차이, at TestContext...fail.check.mjs:7:10은 실패한 assert 줄이다. 실제 값은 공백이 지워진 '장보기'였다. 이번에는 테스트가 틀렸다. 코드는 5단원에서 정한 대로 공백을 지웠다. 테스트가 실패하면 "코드가 틀렸나, 기대가 틀렸나"를 먼저 따진다.

실무에서 자주 틀리는 것

1. 테스트가 실제 데이터와 고정 포트를 쓴다

data/memos.json과 3700번 포트를 그대로 쓰는 테스트는 개발 중인 데이터를 지우고, 서버를 켜 둔 상태에서는 실패한다. 임시 폴더와 포트 0을 쓴다.

2. await 없는 비동기 확인

assert.rejects(...)Promise를 돌려준다. 앞에 await를 빠뜨리면 확인이 끝나기 전에 테스트 함수가 끝나 버린다. 비동기 확인 앞에는 항상 await를 붙인다.

3. 통과하는 것만 확인한다

"잘 만들어진다"만 확인하고 "잘못된 입력은 거절된다", "토큰 없이는 안 된다"를 빠뜨리기 쉽다. 사고는 대부분 실패 쪽에서 난다. 이 단원 테스트의 절반은 거절을 확인한다.

4. 테스트하기 어려운 구조를 테스트로 억지로 푼다

server.mjs 한 파일에 설정 읽기, 저장소, 라우팅, listen이 다 들어 있으면 테스트에서 부품만 꺼내 쓸 수 없다. 테스트가 어렵다는 것은 구조를 나누라는 신호인 경우가 많다. 이 책이 app.mjsserver.mjs를 처음부터 나눈 이유다.

연습 문제

  1. api.test.mjs에서 listen(0, ...) 대신 listen(3700, ...)을 쓰면 어떤 상황에서 테스트가 실패하는가?
  2. 저장소 테스트에서 beforeEach/afterEach 대신 파일 맨 위에서 임시 폴더를 하나만 만들어 세 테스트가 같이 쓰게 하면 어떤 테스트가 어떻게 영향을 받는가?
  3. 검색 기능(GET /memos?q=)에 대한 통합 테스트를 하나 추가하라. 제목이 다른 메모 두 개를 만들고, 한 단어로 검색하면 하나만 나오는지 확인한다.

정답과 해설

  1. 개발 서버(node server.mjs)를 켜 둔 채로 테스트를 돌리면 3700번을 이미 쓰고 있어서 EADDRINUSEbefore가 실패한다. 테스트 파일 여러 개가 같은 포트를 쓰면, node --test가 파일들을 동시에 실행할 때 서로 부딪친다. CI 서버처럼 다른 프로그램이 그 포트를 쓰는 환경에서도 실패한다. 포트 0은 이런 충돌을 원천적으로 없앤다.
  2. 세 번째 테스트(list 가 준 객체를...)가 영향을 받는다. 첫 번째 테스트가 만든 memos.json이 남아 있으므로 새 저장소가 이미 20개를 읽고, 새로 만든 메모의 id는 1이 아니라 21이 된다. store.get(1)은 첫 테스트의 t0을 돌려주므로 '원본'과 비교가 실패한다. 테스트를 하나만 골라 돌리면 통과하고 같이 돌리면 실패하는, 가장 찾기 어려운 형태의 실패가 된다.
  3. 아래 테스트를 api.test.mjs에 더한다. 검증 스크립트가 이 테스트를 같은 파일에 붙여 node --test로 실행해 통과를 확인했다. 앞 테스트들이 만든 메모가 남아 있을 수 있으므로 "개수가 정확히 1"보다 "찾은 제목이 모두 검색어를 포함하고, 다른 쪽 제목은 없다"를 확인하는 편이 튼튼하다.
    test('검색어가 들어간 제목만 돌려준다', async () => {
      await post('/memos', { title: '장보기 목록' });
      await post('/memos', { title: '스터디 발표' });
      const res = await fetch(`${base}/memos?q=${encodeURIComponent('장보기')}`);
      const { items } = await res.json();
      assert.ok(items.length >= 1);
      assert.ok(items.every((m) => m.title.includes('장보기')));
      assert.ok(!items.some((m) => m.title === '스터디 발표'));
    });

이제 기능, 설정, 로그, 테스트가 모두 갖춰졌다. 마지막 단원에서는 이 서버를 실제로 운영에 올리기 전에 확인할 것들, 특히 "끌 때 제대로 꺼지는가"를 다루고, 프레임워크로 옮긴다면 무엇이 바뀌는지 정리한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.