TDD(Test-Driven-Development, 테스트 중심 개발 방법론)까지는 아니더라도, 새로운 기능을 만들거나, 기능의 변경, 코드 리팩토링 등 다양한 코드의 변화에 대해서 검증을 하기 위해 테스트 코드를 작성하게 되었습니다. 워낙 다른 글들에 자세한 설명이 잘 나와 있어, 이 포스트에서는 제가 삽질한 부분들에 대해서 설명하려고 합니다! 틀린 부분이나 궁금한 점들은 댓글로 지적해주시면 감사하겠습니다. ㅎㅎ
플램 앱 서버를 koa + sequelize로 구현하며, 좀 더 견고하고 최소한의 기능 성공을 보장하는 서비스를 만들어야겠다는 생각에 기반해 테스트 코드를 도입하게 되었습니다.
기존 프로젝트에 Mocha를 이용한 테스트 코드 샘플이 있었습니다. 하지만 jest를 사용하게 된 계기는 몇 가지가 있습니다.
Mocha는 정말 테스트 코드만 실행시켜 주는 '라이브러리'입니다. 추가로 Mocking이나 Coverage를 이용하고자 할 때는 다른 라이브러리를 설치해야 합니다. 하지만 Jest는 테스트 '프레임워크' 라고 불리우는 만큼 다양한 기능을 기본적으로 제공하고 있습니다. 그래서 다른 라이브러리에 더 많은 의존을 하지 않아도 되는 장점이 있습니다.
Jest는 타 테스트 라이브러리에 비해 빠른 속도를 가지고 있습니다. 모든 테스트를 병렬적으로 처리하기 때문에, 순차적인 실행보다 훨씬 가볍고 빠르고 우수한 성능을 가지고 있다고 소개합니다.
문서가 정말 깔끔하고 알아보기 쉽게 잘 나와있습니다. 이용자가 많아서 그런지 커뮤니티나 다른 레퍼런스들도 많은 편입니다.
굉장히 이해가 쉽고 간단한 편입니다! (저는 자동화때문에 삽질 많이 했어요...)
이 외에도 다양한 장점들이 있습니다!
테스트 방법에는 크게 '단위 테스트'와 '통합 테스트'로 나눌 수 있습니다. 저는 Node.js에 시나리오를 부여해서 모든 API의 실행을 테스트할 수 있게 도와주는 라이브러리인 supertest를 이용해서 통합테스트를 진행했습니다.
계획한 시나리오의 대략적인 순서는 다음과 같습니다.
유저와 관리자가 회원가입, 로그인을 한다.
로그인 후 사용할 수 있는 모든 API를 사용한다.
모든 API가 이상적인 상태 코드(201, 200)를 반환하면 생성했던(회원가입) 유저를 DB에서 삭제한다.
결과를 콘솔에 출력하며 종료한다.
이렇게 수행하게 되면, 한 유저가 가입해서 모든 기능을 이용했을 때, 에러가 있는지 없는지 판단할 수 있게 됩니다. 그리고 모든 테스트를 마칠 때, DB에 남는 찌꺼기 데이터가 최대한 없도록 만들었습니다. 물론 테스트 스키마를 새로 만들어서 수행하는 방법도 있지만, 우리가 설정한 DB에 값이 잘 들어가는지 확인하기 위해 이 방법은 사용하지 않았습니다.
흐름, 플램 서버에는 생성된 DB Table마다 기본적인 CRUD를 제공하는 API(이하 baseAPI)와 직접 개발자가 구현해 다양한 예외 처리와 리턴 값을 제공하는 API(이하 customAPI)가 있습니다. 이 두 가지의 API를 분기처리해서 자동으로 돌아가게 하는 코드를 짜는 것이 첫 번째 설계였습니다. 그리고 ServerConfig.js에 있는 API별 권한과 DB접근 가능 여부를 파악해 토큰을 관리자 토큰과 유저 토큰으로 나누어 권한 별 테스트를 실행하도록 했습니다.
/asset: 테스트용 음원파일, 이미지 파일이 있는 곳
baseTest.js: baseAPI가 돌아가도록 로직처리 해놓은 파일
createDummyData.js: 테스트에 수행되는 데이터를 생성하는 파일
customTest.js: 커스텀 테스트의 설정파일(testConfig.js)를 읽어 테스트를 수행하는 파일
main.test.js: jest가 인식하는 테스트 파일, 이 파일안에 baseTest.js와 customTest.js를 import해 모든 테스트를 수행한다.
settingConfig.js: 테스트의 기본설정을 객체로 저장한 파일
testTool.js: json값을 읽어 테스트를 수행하는 파일
jest는 테스트 파일을 순회하며 순서를 파악한 후에 실질적인 테스트를 실행하기 때문에 테스트와 테스트 사이에 변수 공유가 안되는 문제가 있었습니다.. 그래서 global로 선언해 전역으로 접근할 수 있도록 만들었습니다.
const API_VERSION = "v1";
const BASE_URL = `/api/${API_VERSION}`;
// 서버 생성, 유저와 어드민 로그인
beforeAll(async () => {
const server = await startServer();
const request = supertest(server.app.callback());
global.__request = request;
global.__models = server.models;
const getAdminInfo = await __request
.post(BASE_URL + settingConfig.adminToken.url)
.send(settingConfig.adminToken.data);
const getUserInfo = await __request
.post(BASE_URL + settingConfig.userToken.url)
.send(settingConfig.userToken.data);
global.__userId = getUserInfo.body.data.id;
global.__userToken = getUserInfo.body.data.token;
global.__adminToken = getAdminInfo.body.data.token;
});
// 생성된 유저 삭제
afterAll(async () => {
await __models["Users"].destroy({ where: { id: __userId }, force: true });
});
customTest(); // custom api 테스트
baseTest(); // base CRUD api 테스트
const customTest = async () => {
for (let apis in testConfig) {
describe(`[${apis}] CUSTOM API TEST`, () => {
afterAll(async () => {
await __models[apis].destroy({ where: { id: __id }, force: true });
});
testConfig[apis].forEach((api) => {
describe(`${api.url}:`, () => {
testTool(api);
});
});
});
}
};
const baseTest = () => {
const models = modelConfig.models;
const apis = apiConfigs[API_VERSION];
// api별 순회
for (let api in apis) {
// 테이블이 없으면 패스
if (!apis[api].baseModelConfig) {
continue;
}
const { columns, foreignKeys } = models[apis[api].baseModelConfig.name];
const { baseMethodConfig, name } = apis[api].baseModelConfig;
const { postData, putData } = createDummyData(columns);
const testData = { url: api, isPK: true };
let isPost = false; // 포스트 했는지 확인하는 플래그
// 모든 통신은 기본적으로 true
const defaultMethodConfig = {
get: true,
gets: true,
delete: true,
put: true,
posts: true,
};
// 테스트 할 항목들을 객체으로 생성
for (let defaultMethod in defaultMethodConfig) {
if (baseMethodConfig[defaultMethod] === false) {
continue;
}
// ...객체 생성 로직
// 시스템만 허용하거나, 유저나 관리자 사용 금지 분기처리
if (!testData[defaultMethod].authState || testData[defaultMethod].authState === 0) {
delete testData[defaultMethod];
}
}
// 각 BaseAPI에 대한 테스트
describe(`[${apis[api].baseModelConfig.name}] BASE CRUD TEST`, () => {
// foreignKey 필요한 거 생성 (재귀로 처리)
if (testData.posts) {
useDummyForeignKey(foreignKeys, models, testData.posts.data);
}
// post될 것이 없는 경우, sequelizer를 이용해 생성
if (api === "/users") {
beforeAll(async () => {
const insertDummyUserRes = await __request
.post(BASE_URL + settingConfig.userTempToken.url)
.send(settingConfig.userTempToken.data);
global.__id = global.__userTempId = insertDummyUserRes.body.data.id;
global.__userTempToken = insertDummyUserRes.body.data.token;
});
if (api !== "/users") {
afterAll(async () => {
await __models["Users"].destroy({ where: { id: __userTempId }, force: true });
});
}
} else if (!testData.posts && isPost === false) {
useDummyForeignKey(foreignKeys, models, postData);
beforeAll(async () => {
const insertDummyRes = await __models[name].create(postData);
if (!insertDummyRes) {
console.log(err);
return;
}
global.__id = insertDummyRes.dataValues.id;
});
afterAll(async () => {
await __models[name].destroy({ where: { id: __id }, force: true });
});
}
testTool(testData);
});
}
};
// 권한 판별기
const authIdentifier = (authState = 1, config) => {
if (
(config.url === "/users" ||
config.url === "/student-certifications" ||
config.url === "/revenue-histories") &&
(authState === 3 || authState === 4)
) {
return __userTempToken;
} else if (authState === 3 || authState === 4) {
return __userToken;
} else if (authState === 5) {
return null;
} else {
return __adminToken;
}
};
const testTool = async ({ isPK = false, tokenFlag = false, ...config }) => {
// testConfig.js에서 넘어온 객체의 변수가 적용되지 않아 일시적으로 치환하는 방법 선택
beforeEach(() => {
if (JSON.stringify(config).includes("__firstId")) {
config = JSON.parse(JSON.stringify(config).replace(/__firstId/g, __id[0]));
}
if (global.__id && JSON.stringify(config).includes("__id")) {
config = JSON.parse(JSON.stringify(config).replace(/__id/g, __id));
}
if (global.__userToken && JSON.stringify(config).includes("__userToken")) {
config = JSON.parse(JSON.stringify(config).replace(/__userToken/g, __userToken));
}
if (global.__userId && JSON.stringify(config).includes("__userId")) {
config = JSON.parse(JSON.stringify(config).replace(/__userId/g, __userId));
}
if (global.__albumId && JSON.stringify(config).includes("__albumId")) {
config = JSON.parse(JSON.stringify(config).replace(/__albumId/g, __albumId));
}
if (global.__dummyData1 && JSON.stringify(config).includes("__dummyData1")) {
config = JSON.parse(JSON.stringify(config).replace(/__dummyData1/g, __dummyData1));
}
if (global.__dummyData2 && JSON.stringify(config).includes("__dummyData2")) {
config = JSON.parse(JSON.stringify(config).replace(/__dummyData2/g, __dummyData2));
}
});
// customAPI에서 foreignKey가 필요한 경우 생성
if (config.foreignKey) {
const { postData } = createDummyData(modelConfig.models[config.foreignKey.model].columns);
beforeAll(async () => {
const res = await __models[config.foreignKey.model].create(postData);
config[config.foreignKey.method].data[config.foreignKey.inputKey] =
res.dataValues[config.foreignKey.inputValue];
});
afterAll(async () => {
await __models[config.foreignKey.model].destroy({
where: { [config.foreignKey.inputValue]: config.post.data[config.foreignKey.inputKey] },
force: true,
});
});
}
// customAPI에서 전/후처리가 필요한 경우 처리
config.beforeAll &&
beforeAll(async () => {
await config.beforeAll();
});
config.afterAll &&
afterAll(async () => {
await config.afterAll();
});
// 각 테스트로직을 수행
config.post &&
it(`[POST] ${config.url}`, async () => {
const res = await __request
.post(BASE_URL + config.url)
.send(config.post.data)
.set("Authorization", authIdentifier(config.post.authState, config))
.set("Accept", "application/json");
res.status !== config.post.status && console.log('[POST ERROR]', config.url, res.body)
expect(res).toBeDefined();
expect(res.status).toEqual(config.post.status);
expect(res.type).toEqual(config.post.type !== undefined ? config.post.type : "application/json");
expect(res.body).toEqual(config.post.expect || expect.anything());
isPK && (global.__id = res.body.id);
tokenFlag && (global.__userTempToken = res.body.token);
});
// ...post - get - put - delete 로직 생략
config.deletes &&
it(`[DELETES] ${config.url}`, async () => {
const res = await __request
.delete(BASE_URL + config.url)
.send(config.deletes.data)
.set("Authorization", authIdentifier(config.deletes.authState, config));
res.status !== config.deletes.status && console.log('[DELETES ERROR]', config.url, res.body)
expect(res).toBeDefined();
expect(res.status).toEqual(config.deletes.status);
expect(res.type).toEqual(config.deletes.type !== undefined ? config.deletes.type : "application/json");
expect(res.body).toEqual(config.deletes.expect || expect.anything());
});
config.upload &&
it(`[UPLOAD] ${config.url}`, async () => {
const res = await __request
.post(BASE_URL + config.url)
.set("Authorization", authIdentifier(config.upload.authState, config))
.attach(
config.upload.data.fileName,
fs.readFileSync(`${__dirname}/${config.upload.data.filePath}`),
config.upload.data.filePath
);
res.status !== config.upload.status && console.log('[UPLOAD ERROR]', config.url, res.body)
expect(res).toBeDefined();
expect(res.status).toEqual(config.upload.status);
expect(res.type).toEqual(config.upload.type !== undefined ? config.upload.type : "application/json");
expect(res.body).toEqual(config.upload.expect || expect.anything());
});
return;
};
// 모듈화를 위한 testConfig.js
// 테스트 할 수 있는 옵션이 정해져 있고, 추가하고 싶다면 예외처리를 추가해줘야한다.
// ... 다른 테스트들 생략, 아래와 같은 형식으로 되어있음
{
beforeAll: async () => {
const createDummySong = await __models['Songs'].create({ name: v4(), yLinkId: v4() })
const createDummyMixtapesHaveSongs = await __models['MixtapesHaveSongs'].create({
songId: createDummySong.dataValues.id, mixtapeId: __id
})
global.__dummyData2 = createDummySong.dataValues.id
global.__dummyData1 = createDummyMixtapesHaveSongs.id
},
afterAll: async () => {
await __models['Songs'].destroy({ where: { id: __dummyData2 }, force: true })
await __models['MixtapesHaveSongs'].destroy({ where: { id: __dummyData1 }, force: true })
},
url: '/mixtapes/other-mixtapes-latest',
gets: {
status: 200,
query: {
songId: '__dummyData2', // 하나 만들어줘야 할듯, yLinkId 도 있어야함
}
}
}, {
/** 동작하는 쿼리인가. database에러가 뜬다. */
url: '/mixtapes/other-mixtapes-popular',
gets: {
status: 200,
query: {
songId: '__dummyData2', // 하나 만들어줘야 할듯, yLinkId 도 있어야함
}
}
},
// 일반적으로 customAPI를 작성한 모습
// API에 따라 다양한 옵션을 붙일 수 있다.
// 물론 코드가 더 길고, 양이 많긴 하다
describe('[Songs] CUSTOM API TEST', () => {
it('[POSTS] /songs', async (done) => {
const res = await __request
.post(`${BASE_URL}/songs`)
.send([{
"userId": 1,
"mp3Uri": "/new/songs/2020/11/06/audios/ee1e2557-0ca8-4960-8799-0bf934f60bd0.mpeg",
"name": "test",
"search": "test1"
}])
.set("Authorization", __userToken)
expect(res).toBeDefined();
expect(res.status).toEqual(201)
expect(res.type).toEqual('application/json')
expect(res.body.constructor).toEqual(Array)
expect(res.body[0]).toMatchObject({
id: any(Number),
name: any(String),
license: any(Number),
youtubeUrlLevel: any(Number),
mp3Uri: any(String),
duration: any(Number),
category: any(Number),
genre: any(Number),
playCount: any(Number),
likeCount: any(Number),
isDeleted: any(Boolean),
search: any(String),
isRecommended: any(Boolean),
isAdult: any(Boolean),
isCheckedPlamUrl: any(Boolean),
})
global.__id = res.body[0].id
done()
})
it('[POSTS] /songs/tags', async (done) => {
const res = await __request
.post(`${BASE_URL}/songs/tags`)
.send([{
songId: __id,
name: v4()
}])
.set("Authorization", __userToken)
expect(res).toBeDefined();
expect(res.status).toEqual(201)
expect(res.type).toEqual('application/json')
expect(res.body.constructor).toEqual(Array)
expect(res.body[0]).toMatchObject({
Tag: expect.any(Object),
songsHaveTags: expect.any(Object),
created: expect.any(Boolean)
})
done()
})
})
물론 선호도의 차이겠지만, 모든 customAPI를 테스트 코드의 규격에 맞게 짜는 것이 아니라면, 네이티브하게 테스트 스크립트를 작성하는 것이 더 괜찮을 것 같다고 생각했습니다.
참고한 링크
좋은 글 잘 봤습니다 :) main.test.ts에서 다른 test 함수들을 호출을 하는 경우 jest-junit이나 report.json과 같은 테스트 결과 파일에서 main.test에 종속되서 나타게 되는데 해결 방법이 있을까요?