프로그래밍/Node.js

Node.js 애플리케이션의 테스트와 디버깅 정복하기: 실전 가이드

shimdh 2025. 2. 20. 09:25
728x90

1. 테스트: 코드의 품질을 보장하는 초석

테스트는 소프트웨어 개발에서 빼놓을 수 없는 필수 요소입니다. 코드의 정확성을 검증하고, 잠재적인 버그를 사전에 발견하여 수정함으로써, 최종 제품의 품질을 높이는 데 결정적인 역할을 합니다. 테스트는 크게 단위 테스트(Unit Testing)통합 테스트(Integration Testing) 로 나눌 수 있습니다.

1.1 단위 테스트(Unit Testing): 개별 모듈의 철저한 검증

단위 테스트는 애플리케이션의 가장 작은 단위, 즉 개별적인 함수나 모듈이 의도한 대로 정확하게 작동하는지 검증하는 과정입니다. 각 구성 요소가 독립적으로 올바르게 동작하는 것을 확인함으로써, 전체 시스템의 신뢰성을 높일 수 있습니다.

단위 테스트의 이점:

  • 빠른 피드백: 단위 테스트는 빠르게 실행되므로, 코드 변경에 따른 영향을 즉시 확인할 수 있습니다.
  • 버그 조기 발견: 작은 단위로 테스트를 진행하기 때문에, 버그를 조기에 발견하고 수정하기 용이합니다.
  • 리팩토링 용이성: 단위 테스트가 잘 구축되어 있으면, 코드 리팩토링 시에도 안심하고 변경할 수 있습니다.
  • 문서화: 테스트 코드는 해당 함수나 모듈의 사용법을 보여주는 훌륭한 문서 역할을 합니다.

예제 1: 덧셈 함수에 대한 단위 테스트

간단한 덧셈 함수 add를 예로 들어 단위 테스트를 작성해 보겠습니다.

// add.js
function add(a, b) {
    return a + b;
}
module.exports = add;

이 함수를 테스트하기 위해 assert 모듈을 사용할 수 있습니다. assert는 Node.js에 내장된 모듈로, 간단한 테스트를 작성하는 데 유용합니다.

// test/add.test.js
const assert = require('assert');
const add = require('../add');

describe('Add Function', () => {
    it('should return 5 when adding 2 and 3', () => {
        const result = add(2, 3);
        assert.strictEqual(result, 5); // 2 + 3 = 5 인지 검증합니다.
    });

    it('should return -1 when adding -1 and 0', () => {
        const result = add(-1, 0);
        assert.strictEqual(result, -1); // -1 + 0 = -1 인지 검증합니다.
    });

    it('should return 0 when adding 0 and 0', () => {
        const result = add(0, 0);
        assert.strictEqual(result, 0); // 0 + 0 = 0 인지 검증합니다.
    });
});

예제 2: 문자열 길이 반환 함수에 대한 단위 테스트

문자열의 길이를 반환하는 stringLength 함수를 위한 단위 테스트를 작성해 보겠습니다.

// stringLength.js
function stringLength(str) {
    return str.length;
}
module.exports = stringLength;
// test/stringLength.test.js
const assert = require('assert');
const stringLength = require('../stringLength');

describe('stringLength Function', () => {
    it('should return 5 for "hello"', () => {
        const result = stringLength('hello');
        assert.strictEqual(result, 5);
    });

    it('should return 0 for an empty string', () => {
        const result = stringLength('');
        assert.strictEqual(result, 0);
    });

    it('should return 7 for "Node.js"', () => {
        const result = stringLength('Node.js');
        assert.strictEqual(result, 7); // 문자열 "Node.js"의 길이는 7인지 확인합니다.
    });
});

위 코드는 stringLength 함수가 다양한 입력값에 대해 올바른 결과를 반환하는지 검증합니다.

1.2 통합 테스트(Integration Testing): 모듈 간 상호작용 검증

통합 테스트는 여러 모듈이나 시스템이 함께 작동할 때 발생할 수 있는 문제를 찾기 위해 수행됩니다. 개별 모듈이 독립적으로는 잘 작동하더라도, 서로 결합되었을 때 예상치 못한 문제가 발생할 수 있습니다. 통합 테스트는 이러한 상호작용의 문제를 사전에 발견하는 데 중요한 역할을 합니다.

통합 테스트의 이점:

  • 시스템 수준의 검증: 개별 모듈이 아닌, 시스템 전체의 동작을 검증할 수 있습니다.
  • 상호작용 문제 발견: 모듈 간의 상호작용에서 발생하는 문제를 찾을 수 있습니다.
  • 현실적인 시나리오 테스트: 실제 사용 환경과 유사한 조건에서 테스트를 진행할 수 있습니다.

예제 1: Express.js API 엔드포인트 통합 테스트

Express.js로 작성된 간단한 API 서버를 예로 들어 통합 테스트를 작성해 보겠습니다.

// app.js (Express 애플리케이션)
const express = require('express');
const app = express();
app.use(express.json());

let items = [];

app.post('/items', (req, res) => {
    const item = req.body;
    items.push(item);
    res.status(201).send(item);
});

app.get('/items', (req, res) => {
    res.send(items);
});

module.exports = app;

이 API는 /items 경로로 POST 요청을 보내면 새로운 아이템을 생성하고, GET 요청을 보내면 생성된 모든 아이템 목록을 반환합니다.

이 API를 테스트하기 위해 supertest 라이브러리를 사용할 수 있습니다. supertest는 HTTP 요청을 쉽게 생성하고, 응답을 검증할 수 있는 기능을 제공합니다.

// test/app.test.js
const request = require('supertest');
const app = require('../app');

describe('Items API', () => {
    it('should create a new item', async () => {
        const response = await request(app)
            .post('/items')
            .send({ name: 'item1' });

        expect(response.status).toBe(201);
        expect(response.body.name).toBe('item1');
    });

    it('should retrieve all items', async () => {
        const response = await request(app).get('/items');

        expect(response.status).toBe(200);
        expect(response.body.length).toBeGreaterThan(0); // 최소한 하나 이상의 아이템이 있어야 합니다.
    });

    it('should retrieve all items after multiple additions', async () => {
        await request(app).post('/items').send({ name: 'item2' });
        await request(app).post('/items').send({ name: 'item3' });

        const response = await request(app).get('/items');

        expect(response.status).toBe(200);
        expect(response.body.length).toBe(3); // 아이템이 3개인지 확인합니다.
        expect(response.body[1].name).toBe('item2');
        expect(response.body[2].name).toBe('item3');
    });
});

예제 2: 로그인 API 통합 테스트

사용자 인증을 처리하는 로그인 API의 통합 테스트를 작성해 보겠습니다.

// auth.js (Express 애플리케이션)
const express = require('express');
const authRouter = express.Router();

let users = {
    'testuser': 'password123'
};

authRouter.post('/login', (req, res) => {
    const { username, password } = req.body;
    if (users[username] && users[username] === password) {
        res.status(200).send({ message: 'Login successful' });
    } else {
        res.status(401).send({ message: 'Invalid credentials' });
    }
});

module.exports = authRouter;
// test/auth.test.js
const request = require('supertest');
const express = require('express');
const authRouter = require('../auth');

const app = express();
app.use(express.json());
app.use(authRouter);

describe('Auth API', () => {
    it('should return 200 for valid credentials', async () => {
        const response = await request(app)
            .post('/login')
            .send({ username: 'testuser', password: 'password123' });

        expect(response.status).toBe(200);
        expect(response.body.message).toBe('Login successful');
    });

    it('should return 401 for invalid credentials', async () => {
        const response = await request(app)
            .post('/login')
            .send({ username: 'wronguser', password: 'wrongpassword' });

        expect(response.status).toBe(401);
        expect(response.body.message).toBe('Invalid credentials');
    });

    it('should return 401 for invalid password', async () => {
        const response = await request(app)
            .post('/login')
            .send({ username: 'testuser', password: 'wrongpassword' });

        expect(response.status).toBe(401);
        expect(response.body.message).toBe('Invalid credentials');
    });
});

2. Mocha와 Chai: 테스트를 위한 최강 콤비

단위 테스트와 통합 테스트를 작성하고 실행하는 데는 여러 도구가 사용됩니다. 그중에서도 MochaChai는 Node.js 개발자들에게 널리 사용되는 강력한 조합입니다.

2.1 Mocha: 유연하고 강력한 테스트 프레임워크

Mocha는 JavaScript 테스트 프레임워크로, 테스트 코드를 구성하고 실행하는 데 필요한 기능을 제공합니다. 비동기 코드 테스트를 지원하며, 유연하고 직관적인 문법을 제공하여 테스트 작성을 용이하게 합니다.

Mocha의 주요 특징:

  • 다양한 환경 지원: 브라우저와 Node.js 환경 모두에서 실행 가능합니다.
  • 비동기 테스트 지원: async/await, Promise, callback 등 다양한 비동기 패턴을 지원합니다.
  • 다양한 리포터: 테스트 결과를 다양한 형식(spec, dot, list 등)으로 출력할 수 있습니다.
  • 풍부한 기능: before, after, beforeEach, afterEach 훅을 통해 테스트 전후 처리를 할 수 있고, skip, only를 통해 특정 테스트를 건너뛰거나 선택적으로 실행할 수 있습니다.

2.2 Chai: 가독성 높은 Assertion 라이브러리

Chai는 Assertion 라이브러리로, 테스트 코드에서 예상 결과를 표현하는 데 사용됩니다. 즉, 특정 조건이 참인지 거짓인지 확인하는 다양한 방법을 제공합니다. Mocha와 함께 사용되어 테스트 코드를 더욱 간결하고 가독성 좋게 만들어 줍니다.

Chai의 주요 특징:

  • 다양한 스타일 지원: should, expect, assert의 세 가지 스타일을 지원하여 개발자의 선호에 맞게 선택할 수 있습니다.
  • BDD/TDD 스타일: BDD(Behavior Driven Development)와 TDD(Test Driven Development) 스타일에 맞는 assertion을 제공합니다.
  • 플러그인 확장: 다양한 플러그인을 통해 기능을 확장할 수 있습니다.

2.3 Mocha와 Chai 설치 및 설정

Mocha와 Chai는 npm을 통해 쉽게 설치할 수 있습니다.

npm install --save-dev mocha chai

--save-dev 옵션은 개발 의존성으로 설치한다는 것을 의미합니다. 테스트 관련 라이브러리는 개발 과정에서만 필요하고, 실제 배포되는 애플리케이션에는 포함되지 않기 때문입니다.

2.4 Mocha와 Chai를 활용한 테스트 작성

예제 1: 덧셈 함수 테스트 (Mocha와 Chai 사용)

// test/sum.test.js
const chai = require('chai');
const expect = chai.expect;
const sum = require('../sum'); // 위에서 작성한 덧셈 함수를 가져옵니다.

describe('Sum Function', () => {
    it('should return the correct sum of two numbers', () => {
        const result = sum(2, 3);
        expect(result).to.equal(5); // Chai의 expect 스타일을 사용하여 2 + 3 = 5 인지 검증합니다.
    });

    it('should return a negative number when summing negative values', () => {
        const result = sum(-1, -1);
        expect(result).to.equal(-2); // -1 + (-1) = -2 인지 검증합니다.
    });

    it('should handle large numbers correctly', () => {
        const result = sum(1000000, 2000000);
        expect(result).to.equal(3000000); // 큰 숫자 더하기 테스트를 추가합니다.
    });
});

예제 2: 비동기 함수 테스트 (Mocha와 Chai 사용)

데이터베이스에서 사용자를 조회하는 비동기 함수 findUserById를 테스트해 보겠습니다. (가상의 데이터베이스 함수를 사용합니다.)

// database.js (가상의 데이터베이스 함수)
const users = {
    1: { name: 'Alice' },
    2: { name: 'Bob' }
};

function findUserById(id) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (users[id]) {
                resolve(users[id]);
            } else {
                reject(new Error('User not found'));
            }
        }, 100);
    });
}

module.exports = { findUserById };
// test/database.test.js
const chai = require('chai');
const expect = chai.expect;
const { findUserById } = require('../database');

describe('Database Functions', () => {
    it('should find user by id', async () => {
        const user = await findUserById(1);
        expect(user).to.be.an('object');
        expect(user.name).to.equal('Alice');
    });

    it('should return an error for non-existent user', async () => {
        try {
            await findUserById(3);
            expect.fail('Expected an error');
        } catch (error) {
            expect(error.message).to.equal('User not found');
        }
    });

    it('should find user with id 2', async () => {
        const user = await findUserById(2);
        expect(user).to.be.an('object');
        expect(user.name).to.equal('Bob');
    });
});

예제 3: 배열에서 최댓값 찾기 함수 테스트

// max.js
function findMax(arr) {
  if (arr.length === 0) {
    return undefined;
  }
  return Math.max(...arr);
}

module.exports = findMax;
// test/max.test.js
const chai = require('chai');
const expect = chai.expect;
const findMax = require('../max');

describe('findMax Function', () => {
  it('should return the maximum number in an array', () => {
    const result = findMax([1, 5, 2, 9, 3]);
    expect(result).to.equal(9);
  });

  it('should return undefined for an empty array', () => {
    const result = findMax([]);
    expect(result).to.be.undefined;
  });

  it('should handle negative numbers', () => {
    const result = findMax([-1, -5, -2]);
    expect(result).to.equal(-1);
  });
});

2.5 테스트 실행

Mocha 테스트는 다음 명령어로 실행할 수 있습니다.

npx mocha test/sum.test.js

테스트가 성공적으로 통과하면 다음과 같은 결과를 볼 수 있습니다.

  Sum Function
    ✓ should return the correct sum of two numbers
    ✓ should return a negative number when summing negative values
    ✓ should handle large numbers correctly

  3 passing (10ms)

3. 디버깅: 문제 해결의 열쇠

테스트를 통해 코드의 문제를 발견했다면, 이제 그 원인을 찾아 해결해야 합니다. 이 과정을 디버깅이라고 합니다. Node.js는 다양한 디버깅 도구와 기법을 제공하여 개발자가 효율적으로 문제를 해결할 수 있도록 돕습니다.

3.1 console.log()를 활용한 디버깅: 간편하지만 강력한 방법

가장 기본적이면서도 강력한 디버깅 방법은 console.log()를 사용하는 것입니다. 코드의 특정 부분에 console.log()를 추가하여 변수의 값이나 함수의 실행 흐름을 추적할 수 있습니다.

console.log()의 장점:

  • 간편함: 별도의 도구 없이 코드에 간단히 추가할 수 있습니다.
  • 빠른 확인: 코드 실행 결과를 즉시 확인할 수 있습니다.

console.log()의 단점:

  • 코드 수정 필요: 디버깅을 위해 코드를 수정해야 합니다.
  • 복잡한 디버깅의 어려움: 복잡한 로직이나 비동기 코드의 경우 console.log()만으로는 디버깅이 어려울 수 있습니다.

예제 1: 기본적인 console.log() 사용

function add(a, b) {
    console.log(`a: ${a}, b: ${b}`); // 입력 파라미터 a와 b의 값을 출력합니다.
    return a + b;
}

const result = add(5, 10);
console.log(`Result: ${result}`); // 함수의 실행 결과값을 출력합니다.

예제 2: 조건문 디버깅

function checkNumber(num) {
  console.log(`Input number: ${num}`);
  if (num > 10) {
    console.log(`${num} is greater than 10`);
    return "Greater than 10";
  } else {
    console.log(`${num} is not greater than 10`);
    return "Not greater than 10";
  }
}

checkNumber(5);
checkNumber(15);

예제 3: 객체 속성 디버깅

function processUser(user) {
  console.log('User object:', user);
  if (user.isActive) {
    console.log(`User ${user.name} is active`);
  } else {
    console.log(`User ${user.name} is not active`);
  }
}

const user1 = { name: 'Alice', isActive: true };
const user2 = { name: 'Bob', isActive: false };

processUser(user1);
processUser(user2);

3.2 Node.js 내장 디버거: 단계별 코드 실행 및 변수 검사

Node.js는 내장된 디버거를 제공하여 보다 체계적인 디버깅을 지원합니다. node inspect 명령어를 사용하여 디버거를 실행할 수 있습니다.

node inspect app.js

이 명령어를 실행하면 Chrome DevTools와 유사한 디버깅 인터페이스가 시작됩니다. debugger 키워드를 코드에 삽입하면 해당 지점에서 코드 실행이 일시 중지되고, 개발자는 변수 값을 확인하고 코드를 한 단계씩 실행하며 문제를 찾을 수 있습니다.

Node.js 내장 디버거의 장점:

  • 브레이크포인트 설정: debugger 키워드를 통해 원하는 지점에 브레이크포인트를 설정할 수 있습니다.
  • 단계별 실행: 코드를 한 줄씩 실행하며 변수 값을 확인할 수 있습니다.
  • 호출 스택 확인: 현재 함수의 호출 스택을 확인할 수 있습니다.

Node.js 내장 디버거의 단점:

  • 별도의 창 사용: 디버깅을 위해 별도의 브라우저 창을 사용해야 합니다.
  • 명령어 입력: 디버깅 명령어를 직접 입력해야 합니다.

예제 1: debugger 키워드를 사용한 디버깅

function multiply(a, b) {
    debugger; // 이 지점에서 코드 실행이 멈춥니다.
    return a * b;
}
multiply(3, 4);

예제 2: 반복문 디버깅

function sumArray(arr) {
  let sum = 0;
  for (let i = 0; i < arr.length; i++) {
    debugger; // 반복문 내부에서 멈춥니다.
    sum += arr[i];
  }
  return sum;
}

sumArray([1, 2, 3, 4, 5]);

예제 3: 비동기 코드 디버깅

function fetchData(callback) {
  setTimeout(() => {
    debugger; // 비동기 작업 완료 후 멈춥니다.
    callback({ data: 'Sample data' });
  }, 1000);
}

fetchData((result) => {
  console.log(result);
});

3.3 VS Code 디버거: 통합 개발 환경에서의 강력한 디버깅

Visual Studio Code(VS Code)는 강력한 디버깅 기능을 내장하고 있어, Node.js 애플리케이션을 효율적으로 디버깅할 수 있습니다.

VS Code 디버거 사용 방법:

  1. launch.json 파일 생성: VS Code 좌측 사이드바에서 "Run and Debug" 아이콘을 클릭하고 "create a launch.json file"을 선택한 후 Node.js를 선택합니다.
  2. 디버깅 구성 설정: .vscode/launch.json 파일에 디버깅 구성을 설정합니다.
  3. 브레이크포인트 설정: 코드 에디터에서 디버깅하려는 코드 줄 왼쪽에 마우스를 클릭하여 브레이크포인트를 설정합니다.
  4. 디버깅 시작: F5 키를 누르거나 "Run and Debug" 패널에서 "Start Debugging" 버튼을 클릭하여 디버깅을 시작합니다.

launch.json 예시:

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "name": "Launch Program",
            "program": "${workspaceFolder}/app.js"
        }
    ]
}

VS Code 디버거의 장점:

  • 편리한 UI: 직관적인 UI를 통해 디버깅을 쉽게 수행할 수 있습니다.
  • 강력한 기능: 브레이크포인트, 단계별 실행, 변수 검사, 호출 스택 확인 등 다양한 디버깅 기능을 제공합니다.
  • 통합 개발 환경: 코드 편집, 실행, 디버깅을 모두 VS Code 내에서 수행할 수 있습니다.

예제 1: VS Code에서 중단점을 사용하여 디버깅

app.js 파일의 multiply 함수에 중단점을 설정하고 디버깅을 시작합니다.

//app.js
function multiply(a, b) {
  let result = a * b; // 이 줄 왼쪽에 중단점을 설정합니다.
  return result;
}

const x = 5;
const y = 10;
const z = multiply(x, y);

console.log(z);

예제 2: VS Code에서 "단계별 실행"을 사용하여 디버깅

// app.js
function calculate(a, b) {
  const sum = a + b;
  const difference = a - b;
  const product = a * b;
  return { sum, difference, product };
}

const result = calculate(10, 5);
console.log(result);

3.4 테스트 프레임워크를 활용한 디버깅: 효율적인 문제 해결

Mocha와 같은 테스트 프레임워크는 테스트 코드를 실행하고 결과를 보여주는 기능 외에도, 디버깅을 위한 유용한 기능을 제공합니다. --inspect-brk 옵션을 사용하면 테스트 코드를 디버거 모드로 실행하여, 테스트 케이스가 실패하는 원인을 쉽게 찾을 수 있습니다.

npx mocha --inspect-brk test/app.test.js

테스트 프레임워크를 활용한 디버깅의 장점:

  • 테스트와 디버깅의 통합: 테스트 코드를 디버깅하여 테스트 실패 원인을 쉽게 찾을 수 있습니다.
  • 자동화된 테스트 환경에서의 디버깅: 자동화된 테스트 환경에서 발생하는 문제를 효과적으로 디버깅할 수 있습니다.

예제 1: Mocha 테스트 디버깅

// test/app.test.js
const chai = require('chai');
const expect = chai.expect;
const app = require('../app');

describe('App Tests', () => {
  it('should return the correct result', () => {
    const result = app.someFunction(5); // someFunction은 app.js에 정의된 함수라고 가정합니다.
    debugger; // 이 지점에서 디버깅을 시작합니다.
    expect(result).to.equal(10);
  });
});

예제 2: 실패한 테스트 케이스만 디버깅

npx mocha --inspect-brk --grep "should fail" test/failing.test.js

결론

이 가이드에서는 Node.js 애플리케이션 개발에 필수적인 테스트와 디버깅에 대해 자세히 살펴보았습니다. 단위 테스트와 통합 테스트로 코드의 품질을 확보하고, Mocha와 Chai를 활용하여 효율적으로 테스트를 작성 및 실행하는 방법을 알아보았습니다. 또한, console.log(), Node.js 내장 디버거, VS Code 디버거, 테스트 프레임워크를 활용한 디버깅 등 다양한 디버깅 기법을 통해 문제 해결 능력을 향상시키는 방법을 제시하였습니다.

728x90