6월 1주차. Jest 모듈 실전 활용법

변현섭·2024년 6월 18일
0

다우데이타 인턴십

목록 보기
16/17
post-thumbnail

이번 포스팅에서는 async/await을 활용하는 비동기 코드에 대한 테스트를 진행해보기로 하겠습니다.

1. 단위 테스트

비동기 코드에 대한 단위 테스트는 아래의 두 가지 경우로 분류할 수 있다.

① Mocking 해야 하는 메서드가 모두 외부 모듈에 정의된 경우

  • 테스트 코드에서 해당 모듈을 import 하여 Mocking을 수행한다.

② Mocking 해야 하는 메서드 중 하나 이상이 내부에 정의된 경우

  • 테스트하고자 하는 메서드의 매개변수로, 테스트 코드에서 Mocking 한 메서드를 전달한다.

위 두 가지 상황에 대해 자세히 알아보자.

1) Mocking 해야 하는 메서드가 모두 외부 모듈에 정의된 경우

테스트 메서드 예시를 보고, 이에 대한 테스트 코드를 직접 작성해보자.

① 테스트 메서드 정의

  • 아래의 코드는 Company DB의 부서 정보에 맞추어 Nubo DB를 동기화하기 위해, 생성 또는 삭제해야 할 그룹의 이름을 찾아내는 메서드이다.
const groupService = require("./groupService");
const { isEmpty } = require("../util/valueChecker");

/**
 * 새롭게 생성되어야 할 그룹과 삭제되어야 할 그룹의 이름 반환
 */
const findCreateOrDeleteGroups = async (departmentNames) => {

  const data = await groupService.getGroups(); // Nubo 그룹 조회 API 호출
  const nuboGroups = data.message; // API의 결과를 groups 변수에 할당
  
  if(isEmpty(global.exisitngdepartmentNames)) { // 기존에 존재하던 그룹 정보
    ...
  }

  const createGroups = departmentNames.filter((x) => !nuboGroups.includes(x)); // 회사 DB에는 존재하지만 Nubo에 등록되지 않은 부서 -> 생성해야 할 그룹
  const deleteGroups = nuboGroups.filter((x) => !departmentNames.includes(x)); // Nubo에 등록은 되어있지만, 회사 DB에 존재하지 않는 그룹 -> 삭제되어야 할 그룹

  return { nuboGroups, createGroups, deleteGroups };

}

② 테스트 코드

const { findCreateOrDeleteGroups } = require('../service/userService'); 

const groupService = require('../service/groupService');
const valueChecker = require('../util/valueChecker');

jest.mock('../service/groupService'); // groupsService 모듈 전체 Mocking
jest.mock('../util/valueChecker'); // valueChecker 모듈 전체 Mocking
    
describe('findCreateOrDeleteGroups 테스트', () => {

  beforeEach(() => { 
    jest.clearAllMocks();
  });

  test('테스트 시나리오 1', async () => {

	// 테스트 메서드의 매개변수를 임의로 조작
    const departmentNames = ['임원실', '경영지원본부', '재무기획본부', '리스크관리팀', '경영지원팀', 'DT사업팀', '스마트워크사업팀']; 
    
    groupService.getGroups.mockResolvedValueOnce({ // groupService.getGroups() 메서드 Mocking
      // 실제 API 결과 대신 임의로 조작된 결과 값을 사용
      message: ['마케팅팀', '임원실', '재무기획본부', 'SW사업본부', '리스크관리팀', '솔루션사업부', '스마트워크사업팀', '기술팀'] 
    });

    // isEmpty 함수 Mocking 
    valueChecker.isEmpty.mockReturnValueOnce(false); // 테스트의 주된 목적과 거리가 먼 isEmpty 관련 조건문이 실행되지 않도록 조작

    // 테스트 메서드 호출
    const result = await findCreateOrDeleteGroups(departmentNames);

    expect(groupService.getGroups).toHaveBeenCalled(); // getGroups 메서드가 호출되는지 여부만 검사 (실제 api를 호출하지는 않음) 
    expect(result.nuboGroups).toEqual(['마케팅팀', '임원실', '재무기획본부', 'SW사업본부', '리스크관리팀', '솔루션사업부', '스마트워크사업팀', '기술팀']); // getGroups.message가 nuboGroups에 잘 할당되는지 검사
    expect(result.createGroups).toEqual(['경영지원본부', '경영지원팀', 'DT사업팀']); // 생성해야 하는 부서를 잘 감지하는지 검사
    expect(result.deleteGroups).toEqual(['마케팅팀', 'SW사업본부', '솔루션사업부', '기술팀']); // 삭제해야 하는 부서를 잘 감지하는지 검사 

  });
  
});

외부 모듈의 메서드만 Mocking 하면 되는 경우, 이와 같은 방식으로 Mocking 하여 단위 테스트를 수행하면 된다.

2) Mocking 해야 하는 메서드 중 하나 이상이 내부에 정의된 경우

하지만, 테스트할 메서드와 동일한 모듈 안에 정의되어 있는 함수는 위와 같은 방식으로 Mocking이 불가능하다. 예를 들어 아래와 같은 메서드가 있다고 가정해보자.

① 테스트 할 메서드

  • 아래의 코드에서 getAppList, getAppGroups, installApp은 모두 transferAppsByGroup 메서드와 동일한 모듈 안에 정의되어 있다.
/**
 * 그룹 단위 App 이전 설치
 */
const transferAppsByGroup = async (modifiedGroups, prevGroups) => {

  try {

    let appList = await getAppList(); // 전체 앱 목록 조회
    const groupNames = []; 

    for(let app of appList) {
      groupNames.push(await getAppGroups(app)); // 각 앱이 배포된 그룹 목록
    }

    for(let i in groupNames) {
    
      for(let groupName of groupNames[i]) {
     
        const index = prevGroups.indexOf(groupName); 
        if (index !== -1) { 
          await installApp(appList[i], modifiedGroups[index]); // 변경된 부서명으로 앱 이전 설치
        } 
        
      }
      
    }

  } catch(e) {
    ...
  }

}

const getAppList = async () => {
	...
}

const getAppGroups = async (appId) => {
	...
}

const installApp = async (appId, groupName) => {
	...
}

② 테스트 코드

const { transferAppsByGroup, getAppList, getAppGroups, installApp } = require('../service/userService');

// 모듈 일부 Mocking → getAppList, getAppGroups, installApp 함수를 가짜(mock) 함수로 대체
jest.mock('../service/userService', () => ({
  ...jest.requireActual('../service/userService'),
  getAppList: jest.fn(),
  getAppGroups: jest.fn(),
  installApp: jest.fn(),
}));

describe('transferAppsByGroup 테스트', () => {

  let installedApps = [];

  beforeEach(() => {
    jest.clearAllMocks(); 
    installedApps = [];
  });

  test('테스트 시나리오 1', async () => {

    // getAppList의 반환 값을 조작
    getAppList.mockResolvedValue([
        'com.android.calendar',
        'com.android.camera',
        'com.android.contacts',
        'com.android.dialer',
        'com.android.email',
        'com.android.exchange',
        'com.android.nubotest'
    ]);

    // getAppGroups의 반환 값을 조작
    getAppGroups
        .mockResolvedValueOnce(['임원팀', '재무기획본부'])
        .mockResolvedValueOnce(['위기관리팀', 'SW사업본부'])
        .mockResolvedValueOnce(['솔루션사업팀', 'DX사업팀'])
        .mockResolvedValueOnce(['SW사업본부', '기술팀', '마케팅팀'])
        .mockResolvedValueOnce(['임원팀', '위기관리팀'])
        .mockResolvedValueOnce(['재무기획본부', 'DX사업팀'])
        .mockResolvedValueOnce(['DX사업팀']);


    installApp.mockImplementation((app, group) => {
    	// 앱을 실제로 설치하는 방식 대신, 어떤 그룹에 어떤 앱이 설치되었는지 저장하는 방식으로 변경
        installedApps.push({ app, group }); 
    });

    const modifiedGroups = ['임원실', '리스크관리팀', '솔루션사업부', 'DT사업팀'];
    const prevGroups = ['임원팀', '위기관리팀', '솔루션사업팀', 'DX사업팀'];

    await transferAppsByGroup(modifiedGroups, prevGroups);

    // 함수 호출을 검증
    expect(getAppList).toHaveBeenCalled();
    expect(getAppGroups).toHaveBeenCalled();
    expect(installApp).toHaveBeenCalledTimes(8); // getAppGroups의 반환 값 중 부서명이 변경된 개수만큼 호출되어야 함.
    
    expect(installedApps).toContainEqual({ app: 'com.android.calendar', group: '임원실' });
    expect(installedApps).toContainEqual({ app: 'com.android.camera', group: '리스크관리팀' });
    expect(installedApps).toContainEqual({ app: 'com.android.contacts', group: '솔루션사업부' });
    expect(installedApps).toContainEqual({ app: 'com.android.contacts', group: 'DT사업팀' });
    expect(installedApps).toContainEqual({ app: 'com.android.email', group: '임원실' });
    expect(installedApps).toContainEqual({ app: 'com.android.email', group: '리스크관리팀' });
    expect(installedApps).toContainEqual({ app: 'com.android.exchange', group: 'DT사업팀' });
    expect(installedApps).toContainEqual({ app: 'com.android.nubotest', group: 'DT사업팀' });

  });
  
});

위의 테스트 코드를 실행해보면, 아래와 같은 에러가 발생하는 것을 볼 수 있다.

위 에러는 Mocking 되지 않은 실제 getAppList 메서드가 실행되면서, 발생한 에러이다. 그러므로 동일한 모듈 내부에 정의된 메서드를 Mocking 해야 할 때에는, 해당 메서드를 테스트 메서드의 입력 인자로 전달해주어야 한다.

③ 테스트 할 메서드 수정

// Mocking이 필요한 getAppList, getAppGroups, installApp를 Callback 메서드의 형태로 전달
const transferAppsByGroup = async (modifiedGroups, prevGroups, getAppList, getAppGroups, installApp) => {
	...
}

④ 테스트 코드 수정

...

describe('transferAppsByGroup 테스트', () => {
	...
    
    test('테스트 시나리오 1', async () => {
    
    	// getAppList의 반환 값을 조작
    	getAppList.mockResolvedValue([
          'com.android.calendar',
          'com.android.camera',
          'com.android.contacts',
          'com.android.dialer',
          'com.android.email',
          'com.android.exchange',
          'com.android.nubotest'
    	]);

      	// getAppGroups의 반환 값을 조작
      	getAppGroups
          .mockResolvedValueOnce(['임원팀', '재무기획본부'])
          .mockResolvedValueOnce(['위기관리팀', 'SW사업본부'])
          .mockResolvedValueOnce(['솔루션사업팀', 'DX사업팀'])
          .mockResolvedValueOnce(['SW사업본부', '기술팀', '마케팅팀'])
          .mockResolvedValueOnce(['임원팀', '위기관리팀'])
          .mockResolvedValueOnce(['재무기획본부', 'DX사업팀'])
          .mockResolvedValueOnce(['DX사업팀']);

		// installApp 메서드의 정의를 조작
        installApp.mockImplementation((app, group) => {
            installedApps.push({ app, group });
        });
        ...
        
		// 임의로 조작한 메서드를 테스트 메서드에 전달
    	await transferAppsByGroup(modifiedGroups, prevGroups, getAppList, getAppGroups, installApp);
    	...
        
    });
    
});

위와 같이 변경한 이후에는 테스트 코드가 원활하게 동작한다.

다만 부득이한 경우가 아니라면, 테스트 메서드 내부의 메서드를 Callback 함수를 전달하는 방식보다는, 별도의 외부 모듈로 분리하여 테스트할 것을 권장한다.

2. 통합 테스트

1) app 모듈화

Supertest를 사용하기 위해서는, 서버의 진입점이 되는 파일의 app 객체를 외부 모듈로 분리해야 한다. 여기서는 index.js 파일을 app.js와 index.js로 분리하기로 한다.

참고로 app 모듈을 분리해야 하는 이유는, 실제 서버가 아닌 가상 서버가 실행되도록 만들기 위함이다. 기존 index.js에 정의되어 있는 app을 그대로 사용하여 request(app).get('/user')와 같이 요청할 경우, app을 포함하고 있는 index.js의 listen 메서드에 의해 실제 서버가 가동되어버리고 말 것이다. 즉, Listening을 하지 않는 app 객체를 테스트에 사용하기 위해 app.js와 index.js를 분리하는 것이다. index.js 파일을 분리하는 방법은 아래와 같다.

① 기존 index.js

const express = require("express");
const router = require("./router");
const app = express();
...

const PORT = process.env.NODEJS_PORT;

app.use(express.json());
app.use("/", router);
...

app.listen(PORT, () => {
  console.log(`
        #############################################
           🛡️ Server listening on port: ${PORT} 🛡️     
        #############################################
    `);
});

② app.js 분리

const express = require("express");
const router = require("./router");
const app = express();
...

app.use(express.json());
app.use("/", router);
...

module.exports = app;

③ app이 분리된 이후의 index.js

const app = require('./app');
const PORT = process.env.NODEJS_PORT;

app.listen(PORT, () => {
  console.log(`
        #############################################
           🛡️ Server listening on port: ${PORT} 🛡️     
        #############################################
    `);
});

2) Mocking 없이 API 테스트하기

간단하게 DB 연결을 확인하는 API에 대한 테스트를 진행해보자.

① Controller 코드

const checkDBConnection = async (req, res, next) => {
  logger.info("######### [POST] /db/test 실행 #########");

  try {
    const data = await dbService.checkDBConnection(req);
    return res.status(data.status).send(success(data.status, data.message));
  } catch (e) {
    logger.error("######### [POST] /db/test 실패 #########");
    return next(e);
  }
};

② Service 코드

const { DataSource } = require("typeorm");
...

const checkDBConnection = async (req) => {
  await connectDB(req);
  return { status: statusCode.OK, message: message.DB_CONNECT_SUCCESS };
};

const connectDB = async (req) => {
	
  // 요청 객체가 비어있다면, env에서 환경변수 값 로드
  let username = process.env.DB_USERNAME || '';
  let password = process.env.DB_PASSWORD || '';
  let connectString = process.env.CONNECTSTRING || '';

  if(!isEmpty(req)) { // 요청 객체가 존재할 때에는 요청 객체의 값을 사용

    req = req.body;
    if(isAllValueExist(req)) { // 모든 원소가 Not Empty

      username = req.username;
      password = req.password;
      connectString = req.connectString;

    } else if(isAllValueValid(req) && !isAllEmpty(req)) { // value에 ""가 일부 포함된 경우
        throw new NullValueError();
    }

  } 

  try {
 
    const myDataSource = new DataSource({
      type: "oracle",
      username: username,
      password: password,
      connectString: connectString,
      synchronize: true,
    });

    await myDataSource.initialize();
    
    return myDataSource;
  }
  catch (e) {
    throw new DBAccessError();
  }
  
};

③ 테스트 코드

const request = require('supertest'); // supertest 모듈 import
const app = require('../app'); // app 객체 import
const { statusCode, message } = require("../constants");
const { checkDBConnection } = require('../service/database/oracledbService'); // 테스트 할 메서드 import

app.post('/db/test', checkDBConnection); // 테스트할 API의 엔드포인트 지정

describe('POST /db/test', () => {

  beforeAll(() => { // 환경변수 설정
    
    process.env.DB_USERNAME = 'HSBYUN';
    process.env.DB_PASSWORD = 'password';
    process.env.CONNECTSTRING = 'localhost:1521/xe';

  });

  test('환경 변수 값 그대로 입력 -> 데이터베이스 연결 성공', async () => {

    const response = await request(app)
      .post('/db/test')
      .send({
        username: process.env.DB_USERNAME,
        password: process.env.DB_PASSWORD,
        connectString: process.env.CONNECTSTRING
      });

    expect(response.body.status).toBe(statusCode.OK);
    expect(response.body.success).toBe(true);
    expect(response.body.message).toBe(message.DB_CONNECT_SUCCESS);
  });

  test('입력 값 없음 -> 데이터베이스 연결 성공', async () => {

    const response = await request(app)
      .post('/db/test')

    expect(response.body.status).toBe(statusCode.OK);
    expect(response.body.success).toBe(true);
    expect(response.body.message).toBe(message.DB_CONNECT_SUCCESS);
  });

  test('잘못된 값 입력 -> 데이터베이스 연결 실패', async () => {

    const response = await request(app)
      .post('/db/test')
      .send({
        username: 'wrong',
        password: 'wrong',
        connectString: 'localhost:1521/xe'
      });

    expect(response.body.status).toBe(600);
    expect(response.body.success).toBe(false);
    expect(response.body.message).toBe(message.DB_CONNECT_FAIL);

  });

});

이제 src 디렉토리 하위에서 npm test {테스트 파일의 경로} 명령을 입력하여 테스트 코드를 실행시켜보자.

테스트가 성공적으로 실행되는 것을 확인할 수 있다.

3) Mocking을 활용한 API 테스트

Mocking은 주로 단위 테스트에 활용되는 개념으로, 일반적으로 통합 테스트에서는 Mocking을 거의 사용하지 않는다. 그럼에도 아래와 같은 상황에서는 통합 테스트에서도 Mocking을 사용하기도 한다.

  • API 테스트와 무관한 로직을 무시하고 싶은 경우
  • 고의적으로 Network Error나, DB Connection Error를 발생시키는 등 특정 상황을 가정해야 하는 경우
  • 가짜 데이터를 이용하여 DB의 데이터가 변경된 상황을 가정하고 싶은 경우

실제로 Company DB와 Nubo DB를 동기화시키는 API를 테스트할 때, Mocking을 활용하여 통합 테스트를 진행하였다.

기존에는 DB Sync API를 테스트하기 위해 일일이 Company DB에 각종 Insert, Update, Delete Query를 직접 수행한 후에 변경 사항이 Nubo에 잘 적용되는지 확인하는 방식으로 테스트를 진행하였다.

그러나, 이러한 방식은 API가 수정될 때마다 일련의 DB Transaction을 다시 수행하여 안정성을 검증해야 하기 때문에 유지 보수성이 매우 떨어진다. 그리하여 DB Transaction을 수행하는 대신 적절한 Mocking을 활용하여 통합 테스트를 수행하였다. Mocking 방법은 단위 테스트에서 사용하던 방식과 동일하므로, 코드 첨부는 생략하기로 한다.

profile
LG전자 Connected Service 1 Unit 연구원 변현섭입니다.

0개의 댓글