icon
10장 : 테스팅과 디버깅

단위 테스트 (Jest 사용)

우리는 지난 9장을 통해 Fetch API, 웹 스토리지, History API, Canvas API, 웹/서비스 워커 등 자바스크립트 언어의 최신 기능들을 넘어 브라우저가 제공하는 다양한 웹 API를 활용하여 동적이고 기능이 풍부한 웹 애플리케이션을 만드는 방법을 학습했습니다. 이제 여러분은 단순히 웹 페이지를 만드는 것을 넘어, 사용자에게 더 나은 경험을 제공하고 복잡한 상호작용을 구현할 수 있는 수준에 이르렀습니다.

하지만 코드가 복잡해지고 규모가 커질수록, 개발한 기능이 의도대로 정확하게 동작하는지 확인하는 것은 점점 더 어려워집니다. 또한, 새로운 기능을 추가하거나 기존 코드를 수정할 때 예상치 못한 버그가 발생하여 다른 부분이 오작동하는 '사이드 이펙트'가 생길 수도 있습니다. 이러한 문제들을 미연에 방지하고, 코드의 품질과 안정성을 보장하며, 자신감 있게 기능을 개발하고 배포하기 위한 필수적인 과정이 바로 테스팅(Testing) 입니다.

10장 "테스팅과 디버깅"에서는 여러분이 작성한 자바스크립트 코드를 효율적으로 검증하고 문제를 해결하는 방법을 다룹니다. 그 첫걸음으로, 코드의 가장 작은 단위(함수, 모듈)를 개별적으로 검증하는 단위 테스트(Unit Testing) 의 개념을 배우고, 현대 자바스크립트 개발에서 가장 널리 사용되는 테스트 프레임워크 중 하나인 Jest를 활용하여 단위 테스트를 작성하는 실질적인 방법을 학습하겠습니다.

웹 개발에서 "코드가 잘 작동한다"는 것은 단순히 에러가 나지 않는 것을 넘어, 특정 입력에 대해 항상 예상된 출력을 반환하고, 특정 상황에서 올바른 부수 효과(side effect)를 일으키는 것을 의미합니다. 이러한 '올바름'을 체계적으로 검증하기 위한 첫 번째 단계가 바로 단위 테스트(Unit Testing) 입니다.

단위 테스트는 애플리케이션을 구성하는 가장 작은 단위, 즉 개별 함수나 클래스의 메서드, 작은 모듈 등이 독립적으로 올바르게 동작하는지 검증하는 테스트입니다. 각 단위가 제대로 작동하는지 확인하면, 문제가 발생했을 때 어떤 부분에서 오류가 시작되었는지 빠르게 파악할 수 있어 디버깅 시간을 단축하고 코드의 신뢰도를 높일 수 있습니다.

이번 장에서는 Facebook(Meta)에서 개발하여 React 생태계와 함께 폭넓게 사용되고 있는 Jest라는 자바스크립트 테스트 프레임워크를 사용하여 단위 테스트를 작성하는 방법을 배웁니다.


Jest 설치 및 기본 설정

Jest를 사용하기 위해서는 먼저 프로젝트에 Jest를 설치해야 합니다. Node.js 환경에서 개발이 진행된다고 가정합니다.

1. 프로젝트 초기화 (아직 안 했다면)

npm init -y

2. Jest 설치

개발 의존성으로 Jest를 설치합니다.

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

3. package.json 설정

package.json 파일의 scripts 섹션에 테스트 명령어를 추가합니다.

{
  "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": "^29.x.x"
  }
}

이제 터미널에서 npm test를 실행하면 Jest가 프로젝트 내의 테스트 파일을 찾아 실행합니다. npm test --watchAll 또는 npm run test:watch를 실행하면 파일 변경을 감지하여 자동으로 테스트를 재실행하는 watch 모드로 실행됩니다.


첫 번째 단위 테스트 작성하기

간단한 함수를 만들고, 이 함수가 올바르게 작동하는지 Jest를 이용해 테스트해 보겠습니다.

1. 테스트 대상 함수 파일 생성 (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;
}

2. 테스트 파일 생성 (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);
    });
});

3. 테스트 실행

터미널에서 다음 명령어를 실행합니다.

npm test

성공적으로 테스트가 실행되면 다음과 유사한 결과가 출력됩니다.

PASS  src/math.test.js
  수학 함수 테스트
    ✓ add 함수는 두 숫자를 더해야 한다 (2 ms)
    ✓ subtract 함수는 첫 번째 숫자에서 두 번째 숫자를 빼야 한다 (1 ms)
    ✓ multiply 함수는 두 숫자를 곱해야 한다
    ✓ divide 함수는 0으로 나눌 때 에러를 발생시켜야 한다
    ✓ divide 함수는 두 숫자를 나눠야 한다

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

만약 테스트가 실패하면, Jest는 어느 테스트에서 어떤 이유로 실패했는지 상세하게 알려주므로 디버깅에 큰 도움이 됩니다.


Jest의 주요 매처(Matchers)

expect(value).toBe(expected)와 같이 expect() 뒤에 체이닝되는 메서드를 매처(Matcher) 라고 합니다. Jest는 다양한 매처를 제공하여 여러 가지 방법으로 값을 비교하고 검증할 수 있도록 합니다.

매처 메서드설명예시
toBe(value)원시 값(Primitive value)의 동일성(===) 확인. 객체는 참조 동일성만 확인.expect(1).toBe(1);
toEqual(value)객체나 배열의 깊은 동일성(deep equality) 확인. 모든 프로퍼티와 요소가 재귀적으로 동일한지 확인.expect({a: 1}).toEqual({a: 1});
toBeTruthy()값이 논리적으로 true인지 확인 (truthy).expect('hello').toBeTruthy();
toBeFalsy()값이 논리적으로 false인지 확인 (falsy).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매처를 부정할 때 사용. (예: not.toBe, not.toContain)expect(1).not.toBe(2);

예시: 다른 매처 사용하기

// 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();
    });
});

테스트 훅 (Hooks)

테스트 스위트(describe 블록) 내의 각 테스트 케이스(test 블록)가 실행되기 전이나 후에 특정 코드를 실행해야 할 때가 있습니다. 예를 들어, 각 테스트마다 동일한 초기 상태를 설정하거나, 테스트 후 정리 작업을 수행할 때 유용합니다. Jest는 이를 위한 훅(Hook)을 제공합니다.

  • beforeAll(fn, timeout): 현재 describe 블록 내의 모든 테스트가 시작되기 전에 한 번 실행됩니다.
  • afterAll(fn, timeout): 현재 describe 블록 내의 모든 테스트가 끝난 후에 한 번 실행됩니다.
  • beforeEach(fn, timeout): 현재 describe 블록 내의 각 테스트가 실행되기 전에 매번 실행됩니다.
  • afterEach(fn, timeout): 현재 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를 변경해도 다음 테스트에는 영향을 주지 않습니다.


비동기 코드 테스트

웹 개발에서는 서버와의 통신이나 시간 지연을 포함하는 비동기 코드를 테스트하는 것이 매우 중요합니다. Jest는 비동기 코드 테스트를 위한 여러 방법을 제공합니다.

1. Promises (.resolves, .rejects)

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

return을 사용하여 Jest에게 Promise가 완료될 때까지 기다리라고 알려줘야 합니다.

2. Async/Await

async/await 문법을 사용하면 비동기 테스트 코드를 동기 코드처럼 작성할 수 있어 가독성이 더욱 좋습니다.

// 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 () => {
        // try-catch 블록을 사용하여 에러를 잡고 테스트
        await expect(fetchData(false)).rejects.toThrow('데이터 로드 실패!');

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

async 키워드를 test 함수 앞에 붙이고, await를 사용하여 Promise가 해결되기를 기다립니다.


마무리하며

이번 장에서는 코드의 안정성과 신뢰성을 높이는 데 필수적인 단위 테스트(Unit Testing) 의 개념과, 이를 구현하기 위한 강력한 자바스크립트 테스트 프레임워크인 Jest 의 사용법을 심도 있게 학습했습니다.

여러분은 Jest를 프로젝트에 설치하고 package.json에 스크립트를 추가하는 초기 설정을 완료했습니다. 그리고 describe 블록으로 테스트 스위트를 그룹화하고, test 또는 it 블록으로 개별 테스트 케이스를 정의하며, expect().toBe(), toEqual(), toThrow() 등 다양한 매처를 사용하여 예상 값과 실제 값을 비교하는 방법을 익혔습니다. 또한, beforeEach, afterEach와 같은 테스트 훅을 활용하여 테스트 환경을 효율적으로 설정하고 정리하는 방법을 배웠고, Promiseasync/await를 통해 비동기 코드를 테스트하는 방법도 살펴보았습니다.

단위 테스트는 개발 과정에서 버그를 조기에 발견하고, 리팩터링 시 코드의 안정성을 보장하며, 팀원들과의 협업 시 코드의 의도를 명확히 전달하는 데 큰 도움이 됩니다. 처음에는 테스트 코드 작성에 시간이 걸리는 것처럼 느껴질 수 있지만, 장기적으로는 디버깅 시간을 줄이고 개발 생산성을 향상시키는 가장 효과적인 방법 중 하나입니다. 이 장의 내용을 바탕으로 여러분이 작성하는 모든 기능에 대해 단위 테스트를 작성하는 습관을 들이시길 바랍니다.