본문 바로가기

단위 테스트 (Jest 사용)

Jest를 설정하고 matcher·setup·teardown·비동기 검증을 사용해 함수와 모듈의 작은 동작을 자동 확인합니다.

9장에서는 Fetch API, 웹 스토리지, History API, Canvas API, 웹 워커와 서비스 워커까지 다루며 동적인 웹 애플리케이션을 만드는 방법을 익혔습니다. 이제 기능을 만드는 것을 넘어, 만든 기능이 계속 제대로 동작하도록 지키는 단계입니다. 코드가 커질수록 모든 동작을 눈으로 확인하기 어려워지고, 한 곳을 고쳤더니 다른 곳이 깨지는 일도 잦아집니다. 이런 문제를 미리 막는 도구가 테스트(testing)입니다.

10장 테스팅과 디버깅에서는 코드를 체계적으로 검증하고 문제를 찾는 방법을 다룹니다. 첫 주제는 가장 작은 단위를 검증하는 단위 테스트(unit test)와, 자바스크립트에서 널리 쓰이는 테스트 프레임워크 Jest입니다.

코드가 잘 동작한다는 것은 오류가 나지 않는다는 뜻만은 아닙니다. 계산 함수라면 같은 입력에 같은 결과를 돌려줘야 하고, 상태를 바꾸는 함수라면 약속한 대로만 상태를 바꿔야 합니다. 현재 시각이나 서버 응답에 따라 결과가 달라지는 코드는 테스트에서 그 조건까지 통제해야 합니다. 단위 테스트는 함수, 메서드, 작은 모듈을 하나씩 따로 검사하므로, 문제가 생겼을 때 원인을 빨리 좁힐 수 있고, 고친 뒤에 예전에 되던 동작이 깨지는 회귀 버그(regression bug)도 줄여 줍니다.

예제를 따라가려면 함수의 반환값, import와 export, 배열과 객체 비교, throw, Promise와 async/await를 알고 있으면 됩니다. 테스트에서 실제 결과가 기대한 결과와 맞는지 판정하는 문장을 단언(assertion)이라고 합니다. 기대값은 구현 코드를 그대로 옮겨 계산하지 말고, 요구사항이나 손으로 계산한 결과에서 정해야 합니다.


Jest 설치와 설정

이 절은 Node.js와 npm이 설치된 별도의 연습 폴더를 기준으로 합니다. 이 교재 저장소에 Jest가 설정되어 있다는 뜻은 아니며, 기존 프로젝트에 적용할 때는 그 프로젝트의 모듈 형식과 테스트 설정부터 확인하세요. 이 글은 Jest 30.5와 Babel 8을 기준으로 하며, Babel 8은 Node.js 22.18 이상의 22.x나 24.11 이상이 필요합니다.

먼저 프로젝트를 초기화합니다. 이미 package.json이 있다면 건너뜁니다.

npm init -y

-y는 이름, 버전 같은 질문에 모두 기본값으로 답해 package.json을 바로 만듭니다.

개발 의존성으로 Jest를 설치합니다. --save-dev는 package.json의 devDependencies에 기록해 테스트할 때만 쓰는 도구임을 표시하고, jest@30처럼 주 버전을 붙이면 이 글이 기준으로 삼은 Jest 30 계열이 설치됩니다.

npm install --save-dev jest@30
# 또는 yarn add --dev jest@30

package.json의 scripts에 테스트 명령을 추가합니다. 연습용 package.json에는 "type": "module"을 넣지 않고, 뒤에서 설정할 Babel이 ES 모듈 문법을 CommonJS로 바꿔 Jest가 실행하게 합니다.

package.json
{
  "name": "my-js-app",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watchAll"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "jest": "^30.5.2"
  }
}

devDependencies의 세부 버전은 설치 명령이 기록한 값을 그대로 두면 됩니다. 예제의 import와 export를 실행하려면 Babel 변환 도구도 설치해야 합니다.

npm install --save-dev babel-jest@30 @babel/core @babel/preset-env

babel-jest 30은 @babel/core 7과 8을 모두 지원하므로, 이 명령은 지금의 최신 버전인 Babel 8을 설치합니다. 그다음 프로젝트 루트에 babel.config.cjs를 만듭니다. 이 설정은 Jest 시작하기 문서의 Babel 사용 안내를 따른 것으로, Jest가 ES 모듈을 직접 실행하는 설정과는 다른 방법입니다.

// babel.config.cjs
module.exports = {
    presets: [["@babel/preset-env", { targets: { node: "current" } }]],
};

targets: { node: "current" }는 지금 실행 중인 Node.js가 이해하는 문법에 맞춰 변환하라는 뜻입니다. 설정을 마치고 터미널에서 npm test를 실행하면 Jest가 프로젝트의 테스트 파일을 찾아 실행합니다. npm run test:watch나 npm test -- --watchAll로 실행하면 파일이 바뀔 때마다 테스트를 다시 실행하는 watch 모드가 됩니다.


첫 번째 단위 테스트

간단한 함수를 만들고 Jest로 테스트해 봅니다. 여기서는 두 입력을 유한한 숫자로 한정하며, 문자열을 숫자로 바꾸거나 잘못된 타입을 거부하는 기능은 아직 만들지 않습니다. 그래서 add("1", 2) 같은 호출은 이 함수의 요구사항 밖에 있으며, 그 결과를 보고 숫자 덧셈 테스트가 충분한지 판단하지 않습니다.

먼저 테스트할 함수를 src/math.js에 만듭니다.

// src/math.js
export function add(a, b) {
    return a + b;
}

export function subtract(a, b) {
    return a - b;
}

export function multiply(a, b) {
    return a * b;
}

export function divide(a, b) {
    if (b === 0) {
        throw new Error("0으로 나눌 수 없습니다.");
    }
    return a / b;
}

그다음 테스트 파일 src/math.test.js를 만듭니다. Jest는 기본으로 이름이 .test.js나 .spec.js로 끝나는 파일과 __tests__ 폴더 안의 파일을 테스트 파일로 찾으며, 테스트할 파일과 같은 폴더에 두는 경우가 많습니다.

// src/math.test.js
import { add, subtract, multiply, divide } from './math'; // 테스트할 함수들을 임포트

// describe 블록: 관련 테스트들을 그룹화 (테스트 스위트)
describe('수학 함수 테스트', () => {

    // test 또는 it 블록: 개별 테스트 케이스
    test('add 함수는 두 숫자를 더해야 한다', () => {
        // expect(): 테스트할 값 (subject)
        // toBe(): Jest의 매처(Matcher). 예상 값과 실제 값을 비교
        expect(add(1, 2)).toBe(3);
        expect(add(0, 0)).toBe(0);
        expect(add(-1, 1)).toBe(0);
        expect(add(10, -5)).toBe(5);
    });

    test('subtract 함수는 첫 번째 숫자에서 두 번째 숫자를 빼야 한다', () => {
        expect(subtract(5, 2)).toBe(3);
        expect(subtract(2, 5)).toBe(-3);
        expect(subtract(0, 0)).toBe(0);
    });

    test('multiply 함수는 두 숫자를 곱해야 한다', () => {
        expect(multiply(2, 3)).toBe(6);
        expect(multiply(5, 0)).toBe(0);
        expect(multiply(-2, 4)).toBe(-8);
    });

    // 특정 예외(에러)가 발생하는지 테스트
    test('divide 함수는 0으로 나눌 때 에러를 발생시켜야 한다', () => {
        // toThrow(): 특정 에러가 발생하는지 확인하는 매처
        expect(() => divide(10, 0)).toThrow('0으로 나눌 수 없습니다.');
    });

    test('divide 함수는 두 숫자를 나눠야 한다', () => {
        expect(divide(10, 2)).toBe(5);
        expect(divide(7, 2)).toBe(3.5);
    });
});

describe()는 관련된 테스트를 묶는 테스트 스위트(test suite)이고, test()나 같은 뜻의 it()은 개별 테스트 케이스입니다. expect()에 실제 값을 넣고, 뒤에 이어지는 toBe() 같은 매처(matcher)로 기대값과 비교합니다.

터미널에서 테스트를 실행합니다.

npm test

테스트가 통과하면 Jest는 PASS와 함께 테스트마다 결과를 출력하고, 실패하면 어느 테스트에서 어떤 값이 기대와 달랐는지 자세히 보여 줍니다.

단위 테스트 작성·실행·진단 루프

FLOWCHART · WEB PLATFORM CONTRACT

단위 테스트 작성·실행·진단 루프

작은 입력으로 공개 동작을 실행하고 기대값을 단언한다. 실패하면 입력·기대값·구현을 분리해 원인을 찾고, 같은 초기 조건에서 다시 실행한다.

단위 테스트 작성·실행·진단 루프 행동 계약과 최소 입력을 정한 뒤 assertion 결과가 PASS면 종료하고, FAIL이면 원인을 진단해 구현이나 테스트를 고친 뒤 다시 실행하는 반복 흐름 행동 계약관찰할 공개 결과최소 입력한 원인만 드러내기동작 실행외부 효과 통제assertion 결과?계약과 실제 결과 비교테스트 종료계약 충족실패 진단입력 · 예상 · 구현 분리구현 / 테스트 수정원인 한 가지씩 변경PASSFAILRE-RUN
단위 테스트 작성·실행·진단 루프행동 계약과 최소 입력으로 동작을 실행하고 단언이 PASS이면 종료한다. FAIL이면 입력·예상·구현을 진단하고 수정한 뒤 최소 입력 준비로 돌아간다. 좁은 화면에서도 분기 조건과 다시 실행 화살표를 독립적으로 읽는다. PASS FAIL RE-RUN행동 계약관찰할 공개 결과 최소 입력한 원인만 드러내기 동작 실행외부 효과 통제 assertion결과?계약과 실제결과 비교 테스트 종료계약 충족 실패 진단입력 · 예상 · 구현분리 구현 / 테스트수정원인 한 가지씩변경

재실행 때 입력과 공유 상태를 다시 초기화한다. 실패를 숨기기 위해 기대값만 바꾸지 않고, 요구사항과 실제 동작을 비교해 수정 대상을 정한다.

위 그림은 실제 출력 화면이 아니라 테스트를 쓰고 실행하고 실패를 진단하는 흐름입니다. 먼저 바깥에서 확인할 공개된 동작을 정하고, 원인을 하나만 드러내는 가장 작은 입력을 준비합니다. 그다음 시간이나 네트워크 같은 바깥 영향을 통제한 채 동작을 실행하고, 단언으로 기대한 동작과 실제 결과를 비교합니다. PASS면 끝나고, FAIL이면 입력, 기대값, 구현 가운데 어디가 틀렸는지 나눠 진단합니다.

원인을 찾으면 한 번에 한 가지씩 구현이나 테스트를 고치고, 다시 최소 입력 준비로 돌아가 실행합니다. 그림 아래의 설명처럼 다시 실행할 때는 입력과 공유 상태를 다시 초기화해야 하며, 실패를 감추려고 기대값만 바꾸면 안 됩니다. 요구사항과 실제 동작을 비교해 고칠 대상이 구현인지 테스트인지 정합니다.

expect(add(1, 2)).toBe(3)은 준비(입력 1과 2), 실행(add), 판정(결과 3)의 순서로 읽습니다. 준비와 실행을 변수로 나눠 적어도 구조는 같습니다. PASS는 작성한 조건을 만족했다는 뜻일 뿐, 테스트하지 않은 모든 입력까지 검증했다는 뜻은 아닙니다. 또 예외를 검사할 때는 expect(divide(10, 0))처럼 바로 실행하지 말고 expect(() => divide(10, 0))처럼 함수를 넘겨야 합니다. 그래야 Jest가 함수를 직접 실행하면서 예외를 잡아 판정할 수 있습니다.


Jest의 주요 매처

expect(value) 뒤에 이어 붙이는 toBe(expected) 같은 메서드를 매처라고 합니다. Jest는 값을 여러 방식으로 비교하는 매처를 제공합니다.

매처설명예시
toBe(value)Object.is로 비교합니다. 객체는 같은 객체인지 확인하며, NaN과 +0, -0에서 ===와 결과가 다릅니다.expect(1).toBe(1);
toEqual(value)객체와 배열의 내용을 재귀적으로 비교합니다. undefined 속성이나 빈 칸이 있는 배열, 객체의 타입까지 구별하려면 toStrictEqual을 씁니다.expect({a: 1}).toEqual({a: 1});
toBeTruthy()값이 참으로 평가되는지 확인합니다.expect('hello').toBeTruthy();
toBeFalsy()값이 거짓으로 평가되는지 확인합니다.expect(0).toBeFalsy();
toBeNull()값이 null인지 확인합니다.expect(null).toBeNull();
toBeUndefined()값이 undefined인지 확인합니다.expect(undefined).toBeUndefined();
toBeDefined()값이 undefined가 아닌지 확인합니다.expect('str').toBeDefined();
toContain(item)배열에 항목이, 문자열에 부분 문자열이 들어 있는지 확인합니다.expect([1, 2, 3]).toContain(2);
toHaveLength(number)배열이나 문자열의 길이를 확인합니다.expect([1, 2]).toHaveLength(2);
toMatch(regexpOrString)문자열이 정규식이나 문자열과 맞는지 확인합니다.expect('hello').toMatch(/ll/);
toThrow(error)함수가 오류를 던지는지 확인합니다. 함수를 감싼 함수를 넘겨야 합니다.expect(() => throwError()).toThrow();
not매처의 뜻을 뒤집습니다.expect(1).not.toBe(2);

내용이 같은지와 같은 객체인지는 다릅니다. toBe()는 같은 객체인지를 보고, toEqual()은 내용이 같은지를 봅니다. 소수 계산에는 이진 부동소수점 오차가 있어 0.1 + 0.2가 정확히 0.3이 되지 않으므로, toBeCloseTo()로 허용할 정밀도를 밝혀 비교합니다.

test('소수의 합은 허용 정밀도로 비교한다', () => {
    expect(add(0.1, 0.2)).toBeCloseTo(0.3, 2);
});

두 번째 인수 2는 소수점 아래 둘째 자리까지 비교한다는 뜻으로, 두 값의 차이가 0.005보다 작으면 통과합니다. 돈을 다루는 앱이라면 반올림과 허용 오차의 규칙을 따로 정해야 합니다. 매처 목록은 Jest의 매처 사용 문서에서 더 볼 수 있습니다.

다른 매처도 사용해 봅니다. 먼저 사용자 목록과 조회 함수를 src/data.js에 만듭니다.

// src/data.js
export const users = [
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' },
    { id: 3, name: 'Charlie' }
];

export function getUserById(id) {
    return users.find(user => user.id === id);
}
// src/data.test.js
import { users, getUserById } from './data';

describe('사용자 데이터 테스트', () => {
    test('users 배열은 3명의 사용자를 포함해야 한다', () => {
        expect(users).toHaveLength(3);
    });

    test('users 배열은 Bob 사용자를 포함해야 한다', () => {
        // 객체는 toEqual로 깊은 비교
        expect(users).toContainEqual({ id: 2, name: 'Bob' });
    });

    test('getUserById는 올바른 사용자 객체를 반환해야 한다', () => {
        const user = getUserById(1);
        expect(user).toEqual({ id: 1, name: 'Alice' });
    });

    test('getUserById는 존재하지 않는 ID에 대해 undefined를 반환해야 한다', () => {
        const user = getUserById(99);
        expect(user).toBeUndefined();
    });
});

toContainEqual()은 배열에 내용이 같은 객체가 들어 있는지를 확인합니다. toContain()은 같은 객체인지로 비교하므로, 새로 만든 { id: 2, name: 'Bob' }로는 배열 안의 객체를 찾지 못합니다.

더 깊이: toEqual이 못 본 척하는 차이

toBe()는 ===가 아니라 Object.is()로 비교합니다. 두 비교는 대부분 같지만, Object.is()는 NaN을 자기 자신과 같다고 보고 0과 -0을 다르다고 봅니다. 그래서 expect(NaN).toBe(NaN)은 통과하고, 계산 결과가 -0인 함수를 expect(result).toBe(0)으로 검사하면 실패합니다. Jest 문서도 이 방식이 ===보다 테스트에 더 알맞다고 설명합니다.

toEqual()은 내용을 재귀적으로 비교하지만, 몇 가지 차이는 일부러 무시합니다. 값이 undefined인 속성, 배열의 undefined 항목, 배열의 빈자리, 객체의 타입이 다른 것은 같다고 봅니다. 그래서 expect({ a: undefined, b: 2 }).toEqual({ b: 2 })는 통과하고, 클래스로 만든 인스턴스와 같은 필드를 가진 일반 객체도 같다고 판정합니다. API 응답의 모양처럼 이런 차이가 의미 있는 경우에는 테스트가 실수를 놓칠 수 있습니다.

이 차이까지 검사하려면 toStrictEqual()을 씁니다. toStrictEqual()은 undefined인 속성의 유무, [2]와 [2, undefined]의 차이, 빈자리와 undefined의 차이, 그리고 클래스의 타입까지 비교합니다. 매처마다 무엇을 비교하는지는 Jest의 expect 문서에 정리되어 있습니다.


테스트 훅

각 테스트를 실행하기 전이나 후에 같은 코드를 실행하고 싶을 때가 있습니다. 테스트마다 같은 초기 상태를 만들거나, 테스트가 끝난 뒤 정리할 때 씁니다. Jest는 이를 위한 훅(hook)을 제공합니다.

beforeAll(fn)은 그 범위의 모든 테스트가 시작되기 전에 한 번, afterAll(fn)은 모든 테스트가 끝난 뒤에 한 번 실행됩니다. beforeEach(fn)은 각 테스트가 실행되기 전마다, afterEach(fn)은 각 테스트가 끝난 뒤마다 실행됩니다. 파일의 맨 위에 둔 훅은 파일 안의 모든 테스트에, describe() 안에 둔 훅은 그 묶음의 테스트에만 적용됩니다.

// src/calculator.js
export class Calculator {
    constructor() {
        this.value = 0;
    }

    add(num) {
        this.value += num;
    }

    subtract(num) {
        this.value -= num;
    }

    clear() {
        this.value = 0;
    }
}
// src/calculator.test.js
import { Calculator } from './calculator';

describe('Calculator 클래스 테스트', () => {
    let calculator; // 각 테스트마다 새로운 계산기 인스턴스를 생성할 변수

    // 각 테스트가 시작되기 전에 실행 (초기화)
    beforeEach(() => {
        calculator = new Calculator();
        console.log('Calculator 인스턴스 초기화됨');
    });

    // 각 테스트가 끝난 후에 실행 (정리)
    afterEach(() => {
        console.log('테스트 후 정리 (만약 필요하다면)');
    });

    test('add 메서드는 값을 올바르게 더해야 한다', () => {
        calculator.add(5);
        expect(calculator.value).toBe(5);
        calculator.add(10);
        expect(calculator.value).toBe(15);
    });

    test('subtract 메서드는 값을 올바르게 빼야 한다', () => {
        calculator.add(10); // beforeEach에서 초기화된 값에 10을 더함
        calculator.subtract(3);
        expect(calculator.value).toBe(7);
    });

    test('clear 메서드는 값을 0으로 초기화해야 한다', () => {
        calculator.add(100);
        calculator.clear();
        expect(calculator.value).toBe(0);
    });
});

beforeEach()에서 테스트마다 새 계산기를 만들므로, 한 테스트에서 calculator.value를 바꿔도 다음 테스트에 영향을 주지 않습니다. 그래서 두 번째 테스트의 add(10)은 첫 테스트가 남긴 15가 아니라 0에서 시작합니다.

afterEach()에서 로그를 남기는 것 자체가 정리 작업은 아닙니다. 이 계산기는 바깥 자원을 만들지 않아 따로 정리할 것이 없지만, 실제 테스트에서 타이머나 파일, 연결을 열었다면 여기서 닫는 코드를 작성해야 합니다. 비동기로 준비하거나 정리할 때는 훅에서도 Promise를 돌려주거나 await해야 Jest가 끝날 때까지 기다립니다. 자세한 규칙은 Jest의 준비와 정리 문서에 있습니다.


비동기 코드 테스트

웹 개발에서는 서버 통신이나 시간 지연이 들어간 비동기 코드를 테스트할 일이 많습니다. 다음 fetchData()는 이름과 달리 실제 HTTP 요청을 보내지 않고, 타이머로 성공과 실패를 흉내 내는 연습용 함수입니다. 그래서 이 테스트는 성공과 실패 분기, Promise가 끝나는지를 검증할 뿐 서버나 네트워크의 동작은 검증하지 않습니다. 100은 타이머에 요청한 지연 시간으로, 정확히 100ms에 끝난다는 보장은 아닙니다.

// src/api.js
export function fetchData(shouldSucceed) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (shouldSucceed) {
                resolve({ data: '데이터 로드 성공!' });
            } else {
                reject(new Error('데이터 로드 실패!'));
            }
        }, 100); // 0.1초 지연
    });
}

resolves와 rejects

Promise를 테스트할 때는 .resolves와 .rejects로 이행한 값이나 거부한 오류를 검사합니다. expect()에 Promise를 그대로 넘기고 둘 중 하나를 붙이면, 뒤의 매처는 Promise 자체가 아니라 Promise가 끝난 뒤의 이행 값이나 거부 이유에 적용됩니다. 다음 테스트에서 성공 쪽은 toEqual()로 객체의 내용을, 실패 쪽은 toThrow()로 오류 메시지를 비교합니다.

// src/api.test.js
import { fetchData } from './api';

describe('비동기 데이터 fetch 테스트', () => {
    test('fetchData는 성공적으로 데이터를 가져와야 한다', () => {
        // resolves: Promise가 성공적으로 이행되는지 확인
        return expect(fetchData(true)).resolves.toEqual({ data: '데이터 로드 성공!' });
    });

    test('fetchData는 에러를 반환해야 한다', () => {
        // rejects: Promise가 에러로 거부되는지 확인
        return expect(fetchData(false)).rejects.toThrow('데이터 로드 실패!');
    });
});

테스트 함수에서 expect()를 return해야 Jest가 Promise가 끝날 때까지 기다립니다. return을 빠뜨리면 테스트 함수가 먼저 끝나 버려 비동기 판정을 기다리지 못합니다. 실패를 검사할 때는 .catch() 안에만 단언을 두지 말고, 거부되는 것 자체가 요구사항이라는 뜻을 .rejects로 드러냅니다.

async와 await

async/await를 쓰면 비동기 테스트도 동기 코드처럼 읽히게 쓸 수 있습니다. 다음 코드는 앞의 두 테스트를 async 함수로 다시 쓴 것으로, 성공 쪽은 결과를 변수에 받아 일반 단언으로 검사하고, 실패 쪽은 await와 .rejects를 함께 씁니다.

// src/api.test.js (async/await 버전)
import { fetchData } from './api';

describe('비동기 데이터 fetch 테스트 (async/await)', () => {
    test('fetchData는 성공적으로 데이터를 가져와야 한다 (async/await)', async () => {
        const data = await fetchData(true);
        expect(data).toEqual({ data: '데이터 로드 성공!' });
    });

    test('fetchData는 에러를 반환해야 한다 (async/await)', async () => {
        // rejects 매처로 Promise 거부와 오류 메시지를 검증
        await expect(fetchData(false)).rejects.toThrow('데이터 로드 실패!');

        // 또는
        // try {
        //     await fetchData(false);
        // } catch (e) {
        //     expect(e.message).toBe('데이터 로드 실패!');
        // }
    });
});

test()에 넘기는 함수에 async를 붙이고, await로 Promise가 끝나기를 기다립니다. 주석의 try...catch 방식을 쓴다면 try 앞에 expect.assertions(1)을 두어야 합니다. 그래야 Promise가 뜻밖에 성공해 catch의 단언이 한 번도 실행되지 않은 채 통과하는 경우도 실패로 잡을 수 있습니다. 비동기 테스트의 규칙은 Jest의 비동기 코드 테스트 문서에서 확인할 수 있습니다.


정리

  • 단위 테스트는 함수나 작은 모듈을 따로 검사해 회귀 버그를 줄이고, 문제가 생긴 위치를 빨리 좁혀 줍니다.
  • Jest는 describe()로 테스트를 묶고 test()로 케이스를 만들며, expect()와 매처로 실제 값과 기대값을 비교합니다.
  • toBe()는 같은 값인지나 같은 객체인지를, toEqual()은 내용이 같은지를 비교하고, 소수는 toBeCloseTo()로 비교합니다.
  • 예외는 expect(() => fn())처럼 함수를 넘겨 toThrow()로 검사합니다.
  • beforeEach()와 afterEach() 같은 훅으로 테스트마다 상태를 새로 만들고 자원을 정리해, 테스트끼리 영향을 주지 않게 합니다.
  • 비동기 테스트는 expect()를 return하거나 await해야 하며, .resolves와 .rejects로 이행과 거부를 검사합니다.

연습 문제

1. 단계마다 검사하기

새 Calculator를 만든 뒤 add(4), subtract(6), clear()를 차례로 실행하는 테스트를 작성하려고 합니다. 각 단계의 기대값을 먼저 적어 보세요. 마지막 값만 검사하면 어떤 버그를 놓칠 수 있을까요?

해설 보기

기대값은 차례로 4, -2, 0입니다. 마지막 0만 검사하면 subtract()가 고장 나도 clear()가 값을 0으로 덮어써 결함이 가려집니다. 그래서 단계마다 단언을 둡니다.

test('계산과 초기화는 단계마다 기대값을 만족한다', () => {
    const calculator = new Calculator();
    calculator.add(4);
    expect(calculator.value).toBe(4);
    calculator.subtract(6);
    expect(calculator.value).toBe(-2);
    calculator.clear();
    expect(calculator.value).toBe(0);
});

2. 문자열 ID로 조회하기

getUserById('1')의 기대값은 무엇일까요? 숫자 1과 문자열 '1'을 같은 ID로 받아야 한다는 요구사항은 아직 없다고 가정합니다.

해설 보기

undefined입니다. 구현의 user.id === id는 타입까지 비교하므로 숫자 ID만 일치합니다.

문자열 ID도 받아야 한다면 먼저 그 요구사항과 변환 규칙을 정하고, 구현과 테스트를 함께 바꿉니다. 지금 결과에 맞춰 기대값을 정하거나, 반대로 요구사항 없이 구현만 바꾸면 테스트가 무엇을 지키는지 흐려집니다.

3. return을 빠뜨린 비동기 테스트

resolves 예제의 성공 테스트에서 return을 지우고, 기대값을 일부러 틀리게 적었습니다. 테스트 결과에 PASS가 보이더라도 믿을 수 없는 이유는 무엇이고, 어떻게 고쳐야 할까요?

해설 보기

return이 없으면 테스트 함수는 Promise가 끝나기 전에 반환되고, Jest는 비동기 단언을 기다릴 신호를 받지 못합니다. 그래서 단언이 실패하더라도 이미 끝난 테스트로 처리되어 PASS로 보이거나, 다른 테스트를 실행하는 중에 엉뚱한 곳에서 오류가 보고될 수 있습니다.

return expect(...).resolves...로 되돌리거나, async 테스트에서 await expect(...).resolves...로 씁니다. 고친 뒤에는 틀린 기대값을 넣었을 때 실제로 실패하는지도 확인해야, 테스트가 정말 무언가를 검사하고 있다고 믿을 수 있습니다.

다음 글 통합 테스트와 E2E 테스트 소개에서는 여러 모듈을 함께 검사하는 통합 테스트와, 실제 브라우저에서 사용자처럼 화면을 조작하는 E2E 테스트를 살펴봅니다.