이번 포스팅에서는 실전 테스트 코드를 작성하기에 앞서, 미리 알아두어야 할 사전 지식에 대해 알아보도록 하겠습니다. 실전 테스트 코드 작성법에 대해서는 다음 포스팅을 참고해주시기 바랍니다.

1. 소프트웨어 테스트

1) 구성 단계

① 단위 테스트(Unit Test)

  • 소프트웨어의 최소 단위(메서드)가 올바르게 동작하는지 확인한다.
  • 각 단위는 독립적으로 테스트되어야 하며, 다른 단위와의 의존성을 최소화해야 한다.

② 통합 테스트(Integration Test)

  • 여러 단위가 함께 동작할 때에도 올바르게 동작하는지 확인한다.
  • 데이터베이스, 외부 API 등의 인터페이스가 실제로 통합된 상태에서, 상호작용을 테스트한다.

③ 시스템 테스트

  • 전체 시스템이 설계 의도대로 동작하는지 확인한다.
  • 모든 단위가 통합된 상태에서 테스트 되며 이 때, 기능, 성능, 보안 등의 다양한 측면을 점검한다.

④ 인수 테스트

  • 사용자의 요구사항 및 비즈니스 요구사항을 충족하는지 확인한다.
  • 실제 사용자의 입장에서 시스템이 테스트 되어야 한다.

이외에도 회귀 테스트, 성능 테스트, 보안 테스트 등을 수행할 수도 있다.

2) 테스트 프레임워크

개발자들이 테스트 코드를 작성해가며 테스트를 수행하는 단계는 주로, 단위 테스트나 통합 테스트 단계에 해당된다. 이 과정에서 테스트 프레임워크가 사용되는데, 여기서 테스트 프레임워크란 소프트웨어 테스트의 자동화 및 체계화를 위한 도구를 말한다. 테스트 프레임워크를 사용했을 때의 얻을 수 있는 이점은 아래와 같다.

  • 테스트 코드 작성, 실행, 결과 보고 등의 단계가 간편해진다.
  • 반복적인 테스트 작업을 자동화함으로써, 업무 효율을 높일 수 있다.
  • 테스트 커버리지를 높여, 높은 수준의 코드 품질이 보장된다.

※ Test Coverage
테스트 커버리지란, 작성된 테스트 코드가 실제 어플리케이션의 얼마나 많은 부분을 테스트하고 있는지를 정량적으로 나타낸 지표로, 주로 단위 테스트 단계에서 사용되는 개념이다. 테스트 커버리지가 높을 경우, 잠재적 버그를 조기에 발견할 가능성이 높아지고, 이에 따라 안정성, 신뢰성, 유지보수 용이성이 향상될 수 있다. (단, 높은 커버리지가 항상 높은 코드 품질을 보장한다고 말할 수는 없다. 일례로, 테스트 커버리지가 100%일지라도 테스트 자체의 품질이 낮으면, 무의미한 테스트가 되어버릴 수도 있다.) 테스트 커버리지의 종류는 아래와 같다.

  • 라인 커버리지(Line Coverage)
    • 기준: 얼마나 많은 코드 Line이 테스트에 의해 실행되었는가?
    • 예시: (테스트 Line / 전체 Line)을 백분율로 표시
  • 문장 커버리지(Statement Coverage)
    • 기준: 함수 내의 명령문이 최소 한번씩은 실행되었는가?
    • 특징: 라인 커버리지와 유사하지만, 각 테스트 케이스가 어떤 명령문을 실행하는지에 초점이 맞추어져 있다.
    • 예시: if(num % 2 == 0) {...} else {...}라는 코드에 대해, num이 짝수인 경우와 홀수인 경우를 모두 테스트 해야 문장 커버리지가 100%가 된다.
  • 분기 커버리지(Branch Coverage)
    • 기준: 조건문에서 가능한 모든 분기가 실행되었는가?
    • 예시: if-else 문의 경우, if 분기와 else 분기가 모두 테스트되었는지 확인한다.
  • 조건 커버리지(Condition Coverage)
    • 기준: 조건문의 각 조건이 모두 True와 False로 한번씩 평가되어 실행되었는가?
    • 예시: if(num1 != 1 && num2 != 1) {...}라는 코드에 대해 num1과 num2가 각각 1인 경우와 1이 아닌 모든 경우를 테스트해야 조건 커버리지가 100%가 된다.
  • 함수 커버리지(Function Coverage):
    • 기준: 각 함수가 최소 한 번은 호출되었는가?

테스트 프레임워크에는 아래와 같이 다양한 종류가 있다.

① JUnit

  • Java 언어 기반의 단위 테스트 프레임워크

② PyTest

  • Python 언어 기반의 단위 테스트, 통합 테스트, 기능 테스트 지원 툴

③ Mocha

  • JavaScript 기반의 테스트 프레임워크로, 주로 Node.js 환경에서 사용

④ Jest

  • JavaScript 기반의 테스트 프레임워크로, Node.js와 React.js 환경에서 사용

2. Jest 모듈을 사용한 테스트 코드 작성법

Node.js 코드 테스팅을 위해 사용할 수 있는 프레임워크에는 Jest와 Mocha가 있다. 하지만, 대부분의 상황에서 Mocha에 비해 초기 설정이 단순하고, 사용이 편리한 Jest 프레임워크가 선호된다. 따라서, 여기서도 Jest 모듈을 사용하여 JavaScript 기반의 테스트 코드를 작성해보기로 한다.

1) Jest 모듈 사용법

① 아래의 명령을 입력하여 Jest 모듈을 설치한다.

  • 여기서는 src 디렉토리 하위에 설치하였다.
npm init -y
npm install --save-dev jest

npm test를 입력했을 때 Jest 모듈이 실행될 수 있도록, src > package.json 파일을 아래와 같이 수정한다.

"scripts": {
    ...
    "test": "jest",
},

③ src 디렉토리 하위로 test라는 이름의 디렉토리를 생성하고, test 디렉토리 하위로, isValidEmail.test.js 파일을 추가한다.

  • Jest는 현재 디렉토리와 하위 디렉토리에서, 파일의 확장자가 .test.js인 모든 파일을 찾아 실행힌다.
  • 파일의 확장자만 맞춰주면 테스트에는 전혀 지장이 없지만, test 전용 디렉토리를 따로 둘 것을 권장한다. 이 때, 디렉토리의 이름은 임의대로 지어도 상관 없다.

④ isValidEmail.test.js 파일에 아래의 내용을 입력한다.

  • 여기서는 util > ValueChecker.js 파일에 위치한 isEmail이라는 함수를 테스트해 볼 것이다.
  • 이 부분은 본인이 테스트를 원하는 함수로 대체하면 된다.
  • 코드에 대한 설명은 아래에서 다루기로 한다.
const { isEmail } = require("../util/ValueChecker")

test("이메일 유효성 평가", () => {
    expect(isEmail('gmlstjq123@naver.com')).toBe(true);
});

⑤ 터미널에 npm test를 입력하여 테스트의 결과를 확인할 수 있다.

⑥ 특정 테스트 파일만 실행해보고 싶다면 npm test {테스트 파일 경로}를 입력하면 된다.

2) 테스트 코드 작성법

위에서 사용된 코드를 분석해보자.

① test

  • Jest 모듈 제공 함수로, 하나의 테스트 케이스를 정의한다.
  • 첫번째 매개 변수로 테스트에 대한 설명을 입력받고, 두번째 매개 변수로 테스트 코드를 포함한 콜백 함수를 입력받는다.

② expect ... toBe

  • expect 메서드 안에, 특정 테스트 케이스에 대해 함수를 호출한다.
  • toBe 메서드 안에 해당 테스트 케이스에 대한 기대 값을 작성한다.
  • toBe 대신 toEqual을 사용할 수도 있다. toBe는 === 연산자와 유사하며, toEqual은 == 연산자와 유사하다.

만약 아래와 같이 테스트 케이스에 대한 기대 값이 잘못될 경우, 어떤 테스트에서 실패하였는지를 보여준다.

const { isEmail } = require("../util/ValueChecker")

test("이메일 유효성 평가", () => {
    expect(isEmail('gmlstjq123@naver.com')).toBe(true); 
    expect(isEmail('크롬의 백엔드 연구소')).toBe(true); // 기대 값이 잘못된 테스트 케이스
    expect(isEmail('hyunseop123@naver.com')).toBe(true); 
});

3) 자주 사용되는 Matcher

toBe와 toEqual 같은 메서드를 Matcher라고 하는데, 자주 사용되는 Matcher의 종류는 아래와 같다.

① toBeTruthy(), toBeFalsy()

  • 반환되는 값이 boolean 타입이 아니더라도, true나 false로 간주될 수 있는 값인지 확인한다.
  • 참고로, 자바스크립트에서는 false, 0, null, undefined, NaN 등을 false로 간주하고, 그 외에는 모두 true로 간주한다.
test("toBeTruthy(), toBeFalsy() 사용법", () => {
  expect(0).toBeFalsy(); // 0은 false로 간주됨.
  expect("Hello").toBeTruthy(); // 문자열은 true로 간주됨.  
});

② toHaveLength(n), toContain(e)

  • toHaveLength(n): 배열의 길이가 n인지 확인한다.
  • toContain(e): e 원소가 배열에 존재하는지 확인한다.
test("toHaveLength(n) 사용법", () => {
  const colors = ["Red", "Yellow", "Blue"];
  expect(colors).toHaveLength(3);
  expect(colors).toContain("Yellow");
  expect(colors).not.toContain("Green");
});

③ toMatch(re)

  • 반환되는 값이 정규 표현식과 일치하는지 확인한다.
test("toMatch(re) 사용법", () => {
    expect('gmlstjq123@naver.com').toMatch(/^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*\.[a-zA-Z]{2,3}$/i);
});

④ toThrow()

  • 함수가 예외를 호출하는지 확인한다.
  • 매개변수를 전달하지 않는 경우, 아무 예외나 호출하면 성공한다.
  • 특정 예외가 호출되는지 확인하고 싶다면, 매개변수로 예외에 대한 message 값이나 예외 객체를 전달하면 된다.
  • expect 메서드 안에서 수행될 함수는 반드시 람다 함수 형태어야 한다. 람다 함수 형태가 아닐 경우, 실제로 예외가 호출되어 테스트에 실패하게 된다.
const { InvalidInputError } = require('../exception/scheduleException')

const isValidUserId = (userId) => {

    if (userId < 0 || userId > 100) { 
      throw new InvalidInputError();
    }

}

test("toThrow() 사용법", () => {
    expect(() => isValidUserId(10)).not.toThrow(); // 예외가 호출되지 않아야 성공
    expect(() => isValidUserId(1000)).toThrow(); // 예외가 호출되어야 성공
    expect(() => isValidUserId(1000)).toThrow(InvalidInputError); // InvalidInputError 예외가 호출되어야 성공
    expect(() => isValidUserId(1000)).toThrow('유효하지 않은 입력입니다.'); // 예외 객체의 메시지가 '유효하지 않은 입력입니다.'여야 성공
});

⑤ toHaveBeenCalled(), toHaveBeenCalledTimes(n)

  • toHaveBeenCalled(): 특정 메서드가 호출되었는지 확인
  • toHaveBeenCalledTimes(n): 특정 메서드가 n번 호출되었는지 확인
test('mockResolvedValue 테스트', async () => {

    const asyncMockFn = jest.fn();
    asyncMockFn.mockResolvedValue(100);
    await asyncMockFn(); // 1번째 호출
    await asyncMockFn(); // 2번째 호출
    await asyncMockFn(); // 3번째 호출

    asyncMockFn.mockRejectedValue(new Error('error'));
    await expect(asyncMockFn()).rejects.toThrow('error'); // 4번째 호출

    expect(asyncMockFn).toHaveBeenCalledTimes(4);

});

4) 테스트 커버리지 확인

Jest 모듈을 사용하여 테스트 커버리지를 확인하는 방법에 대해 알아보자.

① 먼저 packages.json 파일의 scripts에 아래의 내용을 추가한다.

  • npm run coverage 명령을 통해 테스트 커버리지를 확인할 수 있다.
"scripts": {
    ...
    "coverage": "jest --coverage",
},

② 표에 나타나는 레이블의 의미는 아래와 같다.

  • File: 디렉토리 및 하위 파일의 이름
  • % Stmts: Statements의 약자로, 문장 커버리지를 의미
  • % Branch: 분기 커버리지
  • % Func: 함수 커버리지
  • % Lines: 라인 커버리지
  • Uncovered Line #s: 커버되지 않은 코드

npm run coverage 명령을 수행하면, coverage라는 디렉토리가 생성된다.

④ coverage > Icov-report > index.html 파일을 열면, 커버리지 레포트를 웹 페이지 형태로 열람할 수도 있다.

  • 단, 커버리지 레포트에는 직접적으로 테스트되거나, require에 의해 import 된 파일에 대해서만 커버리지를 분석한다.

5) Mocking

단위 테스트를 수행할 때, 테스트하려는 코드가 의존하는 부분을 직접 생성하기 어렵거나, 테스트 데이터를 실제 DB에 삽입하기 부담스러운 경우에 Mocking을 사용할 수 있다. 여기서 Mocking은 실제 객체와 동일한 동작을 수행할 수 있는 가짜 객체를 생성하는 것을 의미한다. Mocking을 사용함으로써 얻을 수 있는 이점은 아래와 같다.

  • 데이터베이스 트랜잭션에 소요되는 시간 등 불필요한 연산의 수행 시간이 테스트에 포함되지 않는다.
  • 테스트 코드의 의존성을 완전히 배제할 수 있기 때문에, 오직 테스트 코드 자체에 결함이 있을 때에만 예외가 발생한다. 이로써, 진정한 의미의 단위 테스트를 진행할 수 있게 된다. (네트워크 에러, DB 연결 실패 등의 상황이 단위 테스트에 영향을 줄 수 없다.)

Jest 모듈에서는 가짜 함수를 생성할 수 있는 jest.fn 메서드를 제공한다. jest.fn의 종류는 아래와 같다.

① mockReturnValue(value)

  • 반환 값을 지정한다. 이 때 숫자, 문자, 불리언 등 모든 유형의 값을 지정할 수 있다.
  • mockReturnValueOnce를 사용하여 호출된 각 순서마다 다른 값을 출력할 수도 있다.
test('mockReturnValue 테스트', () => {

	const mockFn = jest.fn();

    // 항상 100을 반환하도록 설정
    mockFn.mockReturnValue(100);
    
    console.log(mockFn()); // 100 출력
    
    // 한 번만 반환값을 설정
    mockFn.mockReturnValueOnce('First Call');
    mockFn.mockReturnValueOnce('Second Call');
    
    console.log(mockFn()); // First Call 출력
    console.log(mockFn()); // Second Call 출력
    console.log(mockFn()); // 100 출력
    
 })

② mockImplemetation(value)

  • 익명 함수를 생성한다.
test('mockImplementation 테스트', () => {

	const mockFn = jest.fn();
    mockFn.mockImplementation( (name) => `Hello ${name}!` ); 

    // 또는 아래와 같은 방식을 사용할 수도 있다.
    // const mockFn = jest.fn( (name) => `Hello ${name}!` );

    console.log(mockFn("Chrome"));
      
})

③ mockResolvedValue(value), mockRejectedValue(value)

  • 비동기 함수의 반환 값을 모킹하기 위해 사용한다.
  • mockResolvedValue(value): 성공 상태의 Promise 객체 반환
  • mockRejectedValue(value): 실패 상태의 Promise 객체 반환
  • mockResolvedValueOnce를 사용하여 호출된 각 순서마다 다른 상태의 Promise 객체를 반환할 수도 있다.
test('mockResolvedValue 테스트', async () => {
    
    const asyncMockFn = jest.fn();
  
    asyncMockFn.mockResolvedValue(100);
    const result1 = await asyncMockFn();
    expect(result1).toBe(100);
  
    asyncMockFn.mockRejectedValue(new Error('error'));
    await expect(asyncMockFn()).rejects.toThrow('error');
  
    asyncMockFn.mockResolvedValueOnce(200).mockResolvedValueOnce(300);

    const result2 = await asyncMockFn(); 
    expect(result2).toBe(200);
  
    const result3 = await asyncMockFn(); 
    expect(result3).toBe(300);
  
    const result4 = await asyncMockFn(); 
    expect(result4).toBe(100);

});

3. Supertest 모듈을 사용한 테스트 코드 작성법

1) 개념

Jest는 기본적으로 API 호출을 지원하지 않는다. 따라서, API 테스트를 수행하기 위해서는 추가 라이브러리가 필요한데, 그중에서 가장 자주 사용되는 라이브러리가 바로 Supertest이다.

Supertest는 실제 서버를 실행하지 않고도, 요청을 보내고 응답을 검증할 수 있는 테스트 환경을 제공한다. Supertest 모듈을 활용한 통합 테스트는 일반적으로, HTTP 요청을 생성 및 전송한 후, 응답 객체의 상태 코드 및 본문을 예상 값과 비교하는 방식으로 구성된다.

2) 내장 메서드

Supertest 모듈을 설치하는 명령은 아래와 같다. (Jest 모듈을 설치한 경로와 동일한 곳에서 아래의 명령을 입력한다.)

npm i -D supertest

Supertest에서 주로 사용되는 메서드는 아래와 같다.

① request()

  • 가상의 서버를 실행한 후, API 요청을 보내는 역할을 수행한다.
  • Server 모듈을 매개변수로 입력받는다.
  • HTTP 메서드와 API 엔드포인트를 지정한 후, 이에 대한 예상 결과를 입력한다.

② agent()

  • request 메서드가 매번 새로운 요청을 생성하는 것과 달리, agent 메서드는 여러 HTTP 요청 간에 쿠키 등의 상태 정보를 공유하는 기능을 제공한다.
  • 주로 인증 정보를 저장해야하는 로그인 API에서 사용된다.

3) 테스트 코드 작성법

이해를 돕기 위해 아래와 같은 코드를 가정하자.

① 테스트할 API

const request = require('supertest');
const express = require('express');

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

app.post('/login', (req, res) => {
    if (req.body.username === 'user' && req.body.password === 'password') {
        res.cookie('session_id', '123456');
        res.status(200).send('Login successful');
    } else {
        res.status(401).send('Login failed');
    }
});

app.get('/protected', (req, res) => {
    if (req.cookies.session_id === '123456') {
        res.status(200).send('Access to Protected Content');
    } else {
        res.status(401).send('Unauthorized');
    }
});

② 테스트 코드

const request = require('supertest');
const app = require('./app'); // 위에서 정의한 Express 앱

const agent = request.agent(app); // supertest agent 생성

describe('인증 정보 저장 여부 확인', () => {

    test('Protected content에 접근 성공', async () => {
        await agent
            .post('/login')
            .send({ username: 'user', password: 'pass' })
            .expect(200);

        await agent
            .get('/protected')
            .expect(200)
            .then(response => {
                console.log(response.text); // Protected content
            });
    });

    test('Protected content에 접근 실패', async () => {
        await request(app)
            .get('/protected')
            .expect(401);
    });
    
});

request로 생성된 요청에 대해서는, 인증이 수행되지 않는 것을 확인할 수 있다.

4. 테스트 케이스 작성법

모든 경우를 고려하여 테스트 케이스를 작성하는 것은 불가능에 가깝다. 그러므로, 테스트 케이스를 효율적으로 작성하기 위한 기준이 필요하다. 이 때, 아래와 같은 기준을 고려해볼 수 있다.

① 동치 분할 검사

  • 전략: 일반적으로 입력 값이 특정 범위 내에 있을 때, 입력 영역을 분할하여 여러 테스트 케이스를 골고루 적용한다.
  • 예시: 90점 이상은 A, 80점 이상은 B, 70점 이상은 C, 그 미만은 D를 주는 프로그램이 있다고 할 때, 90 이상인 경우와 80 이상인 경우, 70 이상인 경우, 70 미만인 경우에 대해 각각 여러 개의 테스트를 진행해야 한다.

② 경계값 분석

  • 전략: 주로 동치 분할 검사와 함께 사용되어, 테스트 케이스를 특정하는 데에 도움을 준다. 일반적으로 입력 범위의 중간 값보다 경계값에서 오류 확률이 높기 때문에 입력 범위의 경계 값을 테스트 케이스로 선정한다.
  • 예시: 위 프로그램의 테스트 케이스로 69, 70, 71, 80, 81 등을 선정한다.

③ 오류 예측 검사

  • 전략: 과거 경험이나 개발자의 감각에 의존하여 테스트를 진행한다.
  • 예시: 과거에 로그인 기능을 구현할 때, 경험했던 보안 취약점들에 대해 집중적으로 테스트한다.

④ 원인-효과 그래프 검사

  • 전략: 입력 데이터와 출력 사이의 관계를 분석하여, 효용성이 높은 테스트 케이스를 선정한다.
  • 예시: 이메일 없이 로그인 시도, 잘못된 이메일 형식으로 로그인 시도, 등록되지 않은 이메일로 로그인 시도 모두 로그인에 실패하는 결과는 같지만, 그중에서도 "잘못된 이메일 형식으로 로그인 시도"에 대한 부분이 예기치 않은 동작을 일으킬 가능성이 제일 높다. 따라서 이 부분을 집중적으로 테스트하기에 적합한 테스트 케이스를 선정해야 한다.
profile
LG전자 VS R&D Lab. Connected Service 1 Unit 연구원 변현섭입니다.

0개의 댓글

Powered by GraphCDN, the GraphQL CDN