[TS] Type과 Interface로 테스트용 Mock Data 만들기
공부내용 공유하기

[TS] Type과 Interface로 테스트용 Mock Data 만들기

Mock Data의 필요성

프론트엔드 개발을 하는 입장에서는 백엔드 API가 이미 다 준비되어 있고, 인터페이스도 공유가 된 상태에서 작업을 시작하는 게 최고의 상황일 것이다.

 

그러나 세상일이 늘 그렇듯 상황이 원하는 대로 흘러가지는 않는 법.

 

꽤나 빈번한 상황에서 프론트 개발자는 백엔드의 인터페이스만 공유받거나, 혹은 그마저도 공유받지 못한 상황에서 개발을 시작해야 한다.

필자는 이런 상황에서 미리 Mock API를 만들어 내부적인 테스트를 먼저 진행하고, 추후 백엔드 API가 나오면 Mock API와 교체하는 방식으로 프로젝트에 소요되는 시간을 최대한 아끼려고 노력하였다.

 

다행히 Typescript를 사용하면서 API의 요청과 응답 값을 미리 Typing 해 놓을 수 있었기 때문에 요청 값과 응답 값이 공유된 상황이라면 미리 Mock API를 만들어 놓기 편했다.

근데 이것도 은근히 시간이 걸리는 일인 게, 매번 Mock API를 세팅하고 Mock API의 응답으로 받을 Mock Data를 구성하는 게 여간 귀찮은 일이 아니었다.

 

아... 이런 귀찮고 반복적인 일은 자동화해야 제맛이지.

Mock API 혹은 테스트 코드에서 쓸 Mock Data를 Type과 Interface에 따라 자동으로 생성해주면 너무 편할 것 같았다.

일단 찾아보자

그럴듯한 생각은 항상 누가 먼저 했기 때문에, 일단 서드파티를 찾아보기로 했다.

make mock data by typescript type 라고 검색하니 첫 번째로 바로 뜨는 라이브러리가 있었다.

intermock

Mocking library to create mock objects and JSON for TypeScript interfaces via Faker.

Faker라는 더미데이터 라이브러리를 사용해서 Mock object를 만들어준다고 하니 내가 딱 원하는 기능이다.

일단 써보자

불문곡직. 바로 써본다.

app의 Version Check를 위한 요청/응답 interface가 선언된 파일을 넣어보니

type YorN = "Y" | "N";

interface VersionCheckRequest {
  appVersion: string;
  //앱 버전
  osVersion: string;
  // OS 버전
  sourceHash: string;
  // 소스해쉬값
  osFG: string;
  // os 구분
}

interface VersionCheckResponse {
  recentAppVersion: string;
  //최신앱버전
  MandatoryUpdateYN: YorN;
  // 강제업데이트필요여부
  sourceValidYN: YorN;
  // 소스유효성 여부
}
{
  YorN: {},
  VersionCheckRequest: {
    appVersion: "Beatae voluptatem sit nam quis vero necessitatibus. Et ipsa corporis commodi vero ea.Et aut cupiditate occaecati sequi asperiores nihil sit eum et.Error pariatur possimus quod officiis.Voluptatem quas facere sed rem quia aut consequatur accusantium eos.",
    osVersion: "Repellendus ut repellat fugiat quaerat fugit.",
    sourceHash: "Iusto fugit harum reprehenderit voluptatibus.",
    osFG: "minima voluptas dolore",
  },
  VersionCheckResponse: {
    recentAppVersion: "Fugit et consequatur voluptas labore voluptate exercitationem quisquam.",
    MandatoryUpdateYN: "N",
    sourceValidYN: "Y",
  }
}

뭔가 잘 나오긴 하는데?

근데 string이 내가 생각한 길이가 아닌데?

단어 정도로 줄일 수 없나 찾아보니, faker를 사용해서 mocking을 해주기 때문에 jsdoc를 통해 faker형태를 알려주면 된다고 한다.

대충 찾아보니 faker에서 소수점 있는 number 값은 finance.amount정도인 것 같아서 다시 넣어봤다.

interface VersionCheckRequest {
  /** @mockType {finance.amount} */
  appVersion: string;
  //앱 버전
  /** @mockType {finance.amount} */
  osVersion: string;
  // OS 버전
  /** @mockType {finance.amount} */
  sourceHash: string;
  // 소스해쉬값
  /** @mockType {finance.amount} */
  osFG: string;
  // os 구분
}

interface VersionCheckResponse {
  /** @mockType {finance.amount} */
  recentAppVersion: string;
  //최신앱버전
  MandatoryUpdateYN: YorN;
  // 강제업데이트필요여부
  sourceValidYN: YorN;
  // 소스유효성 여부
}
{
  YorN: {},
  VersionCheckRequest: {
    appVersion: "459.59",
    osVersion: "671.41",
    sourceHash: "550.39",
    osFG: "905.44",
  },
  VersionCheckResponse: {
    recentAppVersion: "982.20",
    MandatoryUpdateYN: "N",
    sourceValidYN: "Y",
  }
}

오.... 훨씬 그럴듯하다.

그런데 신기하게도 union type인 YorN은 빈 object만 나오고 있었다.

왜일까? 왜 VersionCheckResponse에 선언된 YorN 타입은 잘 읽혀서 mock에 Y or N이 들어가는데, 정작 YorN 타입은 Object가 리턴되는 걸까?

삼천포

오랜만에 켜보는 디버거

intermock 소스코드를 디버깅해서 어떤 흐름으로 처리되는지 까 보자.

 

일단 코드를 파싱 하면서 분석하는 메소드는 processFile 내에 processNode인데, TS AST(Abstract Syntax Tree)를 받아서 각 TS 파일을 처리하는 메소드이다.

 

processNode 메소드에서는 ts node의 타입에 따라 2가지 액션을 하고 있는데, Interface인 case와 type인 case 2가지이다.

YorN type은 traverseInterface를 통해 들어가게 되고, output["YorN"] = {} 으로 초기화된다.

여기에서 빈 Obj로 초기화된다.
프로퍼티를 가질 기회도 잃은 채...

Object의 프로퍼티에 Mock Data를 Mapping을 하는 부분에서 SyntaxKind가 PropertySignature(158)인 경우에만 값을 할당해주고 있는데, YorN과 같은 UnionType의 child는 SyntaxKind가 LiteralType(187)이어서 return이 되어버리고 만다.

 

이 부분을 수정하려면 상위 메소드인 traverseInterface에서 output 세팅 시에 node의 type이 UnionType이고, 자식 node가 모두 literal type이면 바로 리터럴 값을 랜덤으로 할당해주는 방식으로 해결이 가능할 것 같았다.

Before (node_modules/intermock/build/src/lang/ts/intermock.js)

  if (path) {
    output[path] = {};
    output = output[path];
  }

After (node_modules/intermock/build/src/lang/ts/intermock.js)

  if (path) {
    output[path] = {};
    if (node.kind === 178) { //ts.SyntaxKind.UnionType
      let isLiteralUnionType = true;
      node.forEachChild((children) => {
        if (isLiteralUnionType && 
            children.kind !== 187 && //ts.SyntaxKind.LiteralType
            children.literal && 
            children.literal.text) {
          isLiteralUnionType = false;
        }
      });
      if (isLiteralUnionType) {
        output[path] = node.types[0].literal.text;
        return;
      }
    }
    output = output[path];
  }

삼천포의 결과는?

{
  YorN: "Y",
  VersionCheckRequest: {
    appVersion: "459.59",
    osVersion: "671.41",
    sourceHash: "550.39",
    osFG: "905.44",
  },
  VersionCheckResponse: {
    recentAppVersion: "982.20",
    MandatoryUpdateYN: "N",
    sourceValidYN: "Y",
  }
}

잘 나오는 부분을 확인할 수 있었다.

삼천포로 빠지긴 했지만, 내가 원하는 부분을 알뜰살뜰하게 수정해서 만족스러웠다.

사실 제대로 하려면 literal union type의 값 중에서 random하게 뽑아내는 로직이 필요한데, 귀찮았다.

인터페이스쪽에 literal union type중에 랜덤으로 뽑아내는 로직이 있는 것 같긴 하다.

(중간에 ts 파일에서 디버깅을 하면서 수정하다가, js에서 해야 하는걸 뒤늦게 깨달아서 좀 삽질했다...ㅠㅠ)

그래서 어떻게 사용할까

현재 프로젝트에서는 src/apis 폴더 내에서 요청/응답에 대한 인터페이스를 정의하는 파일들이 산재해있었다.

node환경에서 src/apis 폴더에 있는 로컬 파일들을 긁어 하나의 더미 객체로 mocking 하기 위해 호다닥 스크립트를 짰다. intermock의 mock 메소드를 사용해서 생성한 객체를 파일에 쓰는 간단한 구조이며, 팀원들의 원활한 faker 사용을 위해 faker의 document 주소도 상단에 첨부했다.

package.json

  "scripts": {
    "mock": "node scripts/mock.js",
    ...
  }

script/mock.js

const { mock } = require("intermock");
const fs = require("fs");
const path = require("path");
const p = path.resolve;

const apiPath = "./src/apis";

function filesToArray(absPath) {
  const fileStatus = fs.statSync(absPath);
  if (fileStatus.isFile()) {
    return [
      absPath,
      fs.readFileSync(absPath, {
        encoding: "utf8",
      }),
    ];
  }
  const files = fs.readdirSync(absPath);
  return files.map((f) => {
    const path = p(absPath, f);
    const fileOrFolder = fs.statSync(path);
    if (f && f.charAt(0) === ".") {
      return null;
    }
    if (fileOrFolder.isDirectory()) {
      return filesToArray(path);
    }
    if (fileOrFolder.isFile()) {
      return [
        path,
        fs.readFileSync(path, {
          encoding: "utf8",
        }),
      ];
    }
  });
}

async function makeMock() {
  const typeFiles = filesToArray(apiPath).flat(1);
  const mocks = mock({
    files: typeFiles,
  });
  const fakerUrl =
    "/** @faker document = https://github.com/Marak/faker.js */\n";
  const dummyObjTxt =
    fakerUrl + "export const DUMMY =" + JSON.stringify(mocks) + ";";
  const outPath = path.resolve(apiPath, "mock/dummy.ts");
  fs.writeFileSync(outPath, dummyObjTxt);
}

makeMock().catch(console.error);

chkVersion.ts

api별로 별도의 파일을 생성했고, 테스트를 위한 dummy를 반환하는 baseMockApi에 dummy에 있는 mock response 객체를 넘겨준다.

import { YorN } from "@src/apis/types";
import apiRequest from "@src/axios/ApiRequest";
import { baseMockApi } from "@src/apis/mock/base";
import { DUMMY } from "@src/apis/mock/dummy";

interface VersionCheckRequest {
  /** @mockType {finance.amount} */
  appVersion: string;
  //앱 버전
  /** @mockType {finance.amount} */
  osVersion: string;
  // OS 버전
  /** @mockType {finance.amount} */
  sourceHash: string;
  // 소스해쉬값
  /** @mockType {finance.amount} */
  osFG: string;
  // os 구분
}

interface VersionCheckResponse {
  /** @mockType {finance.amount} */
  recentAppVersion: string;
  //최신앱버전
  MandatoryUpdateYN: YorN;
  // 강제업데이트필요여부
  sourceValidYN: YorN;
  // 소스유효성 여부
}

export const versionCheck = async (params: VersionCheckRequest) => {
  return apiRequest<VersionCheckResponse>(params, "comm/chkVersion");
};

export const mockVersionCheck = async () => {
  return baseMockApi<VersionCheckResponse>(
    DUMMY.VersionCheckResponse as VersionCheckResponse
  );
};

apis/base/baseMockApi.js

import { BaseApiResponse, ResCode } from "@src/axios/ApiRequest/type";
import { makeTrcNo } from "@src/utils/api";

export async function baseMockApi<Response>(
  mockData: Response,
  isFail?: boolean
): Promise<BaseApiResponse<Response>>;
export async function baseMockApi(mockData, isFail) {
  return new Promise((resolve, reject) => {
    if (isFail) {
      const failResponse: BaseApiResponse = {
        resCode: "9999",
        trcNo: makeTrcNo(),
        detailMsg: "실패",
      };
      reject(failResponse);
    }
    const response: BaseApiResponse = {
      resCode: ResCode.SUCCESS,
      trcNo: makeTrcNo(),
      detailMsg: "정상",
      responseData: mockData,
    };
    setTimeout(() => {
      resolve(response);
    }, 300);
  });
}

apis/mock/dummy.ts

/** @faker document = https://github.com/Marak/faker.js */
export const DUMMY = {
  VersionCheckRequest: {
    appVersion: "292.39",
    osVersion: "350.74",
    sourceHash: "612.91",
    osFG: "412.15",
  },
  VersionCheckResponse: {
    recentAppVersion: "9.92",
    MandatoryUpdateYN: "Y",
    sourceValidYN: "Y",
  },
  //...etc
}

사용해보니...

기존에 Mock API를 일일이 세팅하면서, Mock Data를 작성하는 귀찮음이 덜어진 측면에서 매우 만족스럽다.

다만 원하는 형식으로 Mock Data 세팅을 위해서는 Faker를 사용해야 한다는 점, 기본 string type의 경우 mock 데이터의 길이가 랜덤이라는 점이 불편사항인 것 같다.

프로젝트에서 typescript를 사용 중이고, Mock Data 작성에 귀찮음을 느끼는 사람이라면 한 번쯤 이렇게 Intermock과 Faker를 사용해보는 건 어떨까?