Jest + Supertest 적용기

YeongWoooo·2021년 9월 8일
1

들어가며


TDD(Test-Driven-Development, 테스트 중심 개발 방법론)까지는 아니더라도, 새로운 기능을 만들거나, 기능의 변경, 코드 리팩토링 등 다양한 코드의 변화에 대해서 검증을 하기 위해 테스트 코드를 작성하게 되었습니다. 워낙 다른 글들에 자세한 설명이 잘 나와 있어, 이 포스트에서는 제가 삽질한 부분들에 대해서 설명하려고 합니다! 틀린 부분이나 궁금한 점들은 댓글로 지적해주시면 감사하겠습니다. ㅎㅎ
플램 앱 서버를 koa + sequelize로 구현하며, 좀 더 견고하고 최소한의 기능 성공을 보장하는 서비스를 만들어야겠다는 생각에 기반해 테스트 코드를 도입하게 되었습니다.

Mocha대신 jest를 선택한 이유


기존 프로젝트에 Mocha를 이용한 테스트 코드 샘플이 있었습니다. 하지만 jest를 사용하게 된 계기는 몇 가지가 있습니다.

  • Mocha는 정말 테스트 코드만 실행시켜 주는 '라이브러리'입니다. 추가로 Mocking이나 Coverage를 이용하고자 할 때는 다른 라이브러리를 설치해야 합니다. 하지만 Jest는 테스트 '프레임워크' 라고 불리우는 만큼 다양한 기능을 기본적으로 제공하고 있습니다. 그래서 다른 라이브러리에 더 많은 의존을 하지 않아도 되는 장점이 있습니다.

  • Jest는 타 테스트 라이브러리에 비해 빠른 속도를 가지고 있습니다. 모든 테스트를 병렬적으로 처리하기 때문에, 순차적인 실행보다 훨씬 가볍고 빠르고 우수한 성능을 가지고 있다고 소개합니다.

  • 문서가 정말 깔끔하고 알아보기 쉽게 잘 나와있습니다. 이용자가 많아서 그런지 커뮤니티나 다른 레퍼런스들도 많은 편입니다.

  • 굉장히 이해가 쉽고 간단한 편입니다! (저는 자동화때문에 삽질 많이 했어요...)

    이 외에도 다양한 장점들이 있습니다!

테스트 내용


테스트 시나리오

테스트 방법에는 크게 '단위 테스트'와 '통합 테스트'로 나눌 수 있습니다. 저는 Node.js에 시나리오를 부여해서 모든 API의 실행을 테스트할 수 있게 도와주는 라이브러리인 supertest를 이용해서 통합테스트를 진행했습니다.

계획한 시나리오의 대략적인 순서는 다음과 같습니다.

  1. 유저와 관리자가 회원가입, 로그인을 한다.

  2. 로그인 후 사용할 수 있는 모든 API를 사용한다.

    • 모든 API는 POST - GET - PUT - DELETE순으로 생성부터 삭제까지 수행한다.
    • 필요한 값(외래키)가 있다면 테스트 전 생성 후, 테스트가 끝나면 해당 데이터를 삭제한다.
    • 모든 API의 기대값은 코드 또는 response값으로 비교를 한다.
  3. 모든 API가 이상적인 상태 코드(201, 200)를 반환하면 생성했던(회원가입) 유저를 DB에서 삭제한다.

  4. 결과를 콘솔에 출력하며 종료한다.

    이렇게 수행하게 되면, 한 유저가 가입해서 모든 기능을 이용했을 때, 에러가 있는지 없는지 판단할 수 있게 됩니다. 그리고 모든 테스트를 마칠 때, 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로 선언해 전역으로 접근할 수 있도록 만들었습니다.

main.test.js

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 테스트
  1. settingConfig에 저장되어 있는 유저 정보와 관리자 정보를 읽어와 회원가입한다.
  2. 회원가입 후 리턴되는 유저의 id값과 token을 변수에 저장합니다.
  3. 마지막에 모든 테스트가 종료되면 생성했던 유저를 삭제합니다.

customTest.js

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);
        });
      });
    });
  }
};
  1. testConfig.js의 객체를 읽어 모든 api를 순회하며 testTool()을 통해 테스트를 진행합니다.
  2. 테스트 내용은 모두 testConfig.js에 작성합니다.
  3. customAPI를 통해 생성됐던 데이터는 해당 route가 종료될 때, afterAll을 통해 삭제합니다.

baseTest.js

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);
    });
  }
};
  1. 모든 baseAPI를 순회하며 인증 권한과 접근 권한에 따라 테스트 할 값을 객체로 생성
  2. 외래키가 필요한 데이터는 외래키를 생성 후, 외래키의 ID를 넣어주고, 해당 테스트가 종료되면 삭제되게 설정 (beforeAll, AfterAll)

testTool.js

// 권한 판별기
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;
};

사용 후


성공 실패 라우터 확인 가능

결과 한번에 보기 (옵션만 달면 Coverage를 확인할 수 있다)

문제점


  • BaseAPI 테스트의 경우, 비교적 정해진 골격이 있고, 인풋 값과 아웃 풋 값이 일정해서 자동화(모듈화)를 통해 테스트를 하는 것이 유의미하다고 판단했습니다.
  • 이에 반면 customAPI의 경우, 인풋 값과 아웃 풋 값이 모두 다르고, 수행되는 로직이나 시간이 달라 전부 개별적으로 처리를 했어야 했습니다. 이에 대한 해결방안으로 baseAPI를 serverConfig.js로 테스트 한 것 처럼 사용해보려 testConfig를 이용해서 자동화를 시켰지만, 그냥 모듈화를 시키지 않고 작성하는 것보다 작성하는 시간과 예외처리 비용이 많이 들었습니다.
  • 아래를 보면 두개의 차이가 거의 없고, 오히려 직접 작성해주는 것이 다양한 옵션을 걸어 테스트할 수 있다는 것을 알 수 있습니다.
// 모듈화를 위한 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를 테스트 코드의 규격에 맞게 짜는 것이 아니라면, 네이티브하게 테스트 스크립트를 작성하는 것이 더 괜찮을 것 같다고 생각했습니다.

생각해본 점


  • 확실히 테스트 코드를 구현하고 리팩토링이나 추가기능을 개발할 때, 테스트를 통과한다면 왠지모를 자신감이 샘솟는다. (물론 성공했을 경우가 아닌 모든 예외처리를 테스트시키면 훨씬 큰 자신감을 얻게될 것 같다!)
  • ServerConfig같이 설정파일? 이 있다면 자동화를 하는 방법도 굉장히 좋은 것 같다.
  • 테스트코드를 짜고 통과하기 위해 기능 코드를 짜면, 알게 모르게 모든 코드가 비슷한 모양?을 하는 것 같다. 그냥 손으로 쿠키를 만드는 것이 아니라 다양한 모양틀로 쿠키를 만드는 것 같은 느낌...?
  • 확실히 시간을 쏟은 만큼 안정성을 얻게 되는 것 같다. 처음이라 시간이 오래걸렸지만 익숙해진다면, 틀림없이 좋은 도구가 될 것 같다.

참고한 링크

profile
개발 재밌다!

1개의 댓글

comment-user-thumbnail
2022년 5월 26일

좋은 글 잘 봤습니다 :) main.test.ts에서 다른 test 함수들을 호출을 하는 경우 jest-junit이나 report.json과 같은 테스트 결과 파일에서 main.test에 종속되서 나타게 되는데 해결 방법이 있을까요?

답글 달기