TypeScript로 API JSON Schema 테스트하기

박준용 · 원프레딕트 프론트엔드 엔지니어
September 29, 2023

기존에 발행된 글을 정리해서 다시 소개합니다. 주1

API 테스트 도입

모든 개발자는 테스트 코드를 작성해 품질을 향상하려고 노력한다. 수많은 테스트 도구가 제공되고 있고, 다양한 방식으로 테스트를 진행하고 있다. 많은 테스트 방법 중에서 API Type Schema 테스트를 도입한 부분에 대하여 정리하려고 한다. 앱을 개발할 때 타입스크립트로 타입에 관련된 에러는 컴파일 시점에, 타입 에러는 많이 잡을 수 있게 되었지만, 서버에 API를 호출하여 가져오는 데이터는 백앤드에서 설정한 모델을 보면서 개발을 진행해야 한다. 이때 만약 “별문제 없겠지”라는 마음으로 API Response Data 모델을 변경하거나 제거한 상태로 앱을 배포하면 어떻게 될까?

앱은 변경된 API를 사용하는 곳에서 에러를 낼 것이다. 😭

이 글에서 CI/CD는 GitHub Actions를 사용한다.주2

테스트 체계가 잘 잡혀있다면 QA나 다른 테스트에서 확인할 수 있지만, 테스트 관련 체계가 없다면 배포 이후에 오류 및 버그를 보고 다시 확인하여 재배포를 하게 되는 일이 자주 발생하게 된다. 배포되기 전에 테스트 Job을 추가하여 좀 더 안정성 있는 배포를 해보자는 생각이 들었다.

이번 글에서는 언제든지 변할 수 있는 API Response 값과 스키마를 테스트하는 방법에 대하여 이야기하려고 한다. Swagger로 표현된 API 모델을 이용해 TypeScript 파일을 자동 생성하고, 생성된 파일을 이용하여 API Response Schema를 생성하여 Ajv로 스키마와 값을 비교할 수 있는 Jest 테스트 코드를 작성하는 방법에 대하여 알아보자.

테스트 Flow

Test Flow

OpenAPIGenerator

💡 Swagger UI는 Live Demo를 사용했다. 링크

OpenAPI Typescript Codegen을 사용해, Swagger에서 정의한 모델을 TypeScript 코드로 만든다.

import * as path from "path";
import { generate, HttpClient } from "openapi-typescript-codegen";

const specURL = "https://petstore.swagger.io/v2/swagger.json";
const outputPath = path.resolve(
  path.join(__dirname, "../..", "__apiTypesTemp__")
);

async function swaggerModelGenerate() {
  try {
    await generate({
      input: specURL,
      output: outputPath,
      httpClient: HttpClient.AXIOS,
      exportCore: false,
      exportServices: false,
      exportModels: true,
      useOptions: true,
      useUnionTypes: true,
      exportSchemas: true,
    });
  } catch (err) {
    console.error(err);
  }
}

swaggerModelGenerate().then(() => console.log(`🚀 model 생성 완료`));
  • specURL: Swagger Basic Structure 파일(JSON or YAML) URL
  • outputPath: 모델 파일이 만들어질 위치
  • generate: SwaggerModel을 만드는 함수
    • 매개변수
      • input: Swagger Basic Structure 정보
      • output: 파일이 만들어질 위치
      • httpClient: HTTP 클라이언트 (fetch / xhr / node / axios)
      • exportCore: Core 정보 생성 여부
      • exportService: Service 정보 생성 여부
      • exportModel: Model 정보 생성 여부
        • 백앤드 정의한 Type Model를 가지고 TypeScript 파일을 생성하여 프론트앤드에서 사용한다.
      • useOptions: Argument 코드 스타일 규칙
      • useUnionTypes: TypeScript에서 enum 대신 Union Type 사용 여부
      • exportSchemas: 스키마 정보 생성 여부

ts-node를 사용하여 OpenAPIGenerator를 실행하면 output 경로에 파일이 생성된다.

Swagger Pet Model과 생성된 Model Type

import type { Category } from './Category';
import type { Tag } from './Tag';

export type Pet = {
    id?: number;
    category?: Category;
    name: string;
    photoUrls: Array<string>;
    tags?: Array<Tag>;
    /**
     * pet status in the store
     */
    status?: 'available' | 'pending' | 'sold';
}

Swagger에서 정의한 Model 기반으로 TypeScript 코드가 자동으로 생성된 걸 확인할 수 있다.

💡 Swagger Model을 확실하게 정의하지 않은 상태라면 자동으로 생성된 파일을 수정해야 하는 경우가 발생하므로 백앤드와 함께 Model을 잘 맞추는 게 중요하다.

JsonSchemaGenerator

typescript-json-schema를 사용해 Schema를 생성한다.

💡 JSON Schema Draft-07 스펙으로 생성한다.

import * as path from "path";
import * as fs from "fs";
import * as TJS from "typescript-json-schema";
import { pipe } from "../../utils/pipe";
import { getAllFiles } from "../../utils/fileFn";

const BASE_PATH = path.resolve(__dirname, "../..");
const settings: TJS.PartialArgs = {
  required: true,
};
const compilerOptions: TJS.CompilerOptions = {
  strictNullChecks: true,
};


const getFiles = () => {
  return getAllFiles(path.resolve(BASE_URL, "apiSchemaTypes"), []);
};

const makeGenerator = (file: string[]): IGenerator => {
  const program = TJS.getProgramFromFiles(file, compilerOptions, BASE_URL);
  const generator = TJS.buildGenerator(program, settings);

  return {
    generator: generator as TJS.JsonSchemaGenerator,
    file,
  };
};

const makeSymbols = ({ generator, file }: IGenerator) => {
  const removePrefix = file.map((f) => {
    return f.replace("STD.ts", ".ts");
  });
  const filesStr = removePrefix.join(", ");
  const symbols = generator.getUserSymbols();

  const schemas = symbols.filter((symbol) => {
    return !!filesStr.match(symbol);
  });

  const schemaFolderPath = path.join(__dirname, "../../__schema__");
  if (!fs.existsSync(schemaFolderPath)) {
    fs.mkdirSync(schemaFolderPath);
  }

  console.log("Schema 파일 변환을 시작합니다.");
  schemas.forEach((schema) => {
    const schemaDefine = generator.getSchemaForSymbol(schema);
    const file = JSON.stringify(schemaDefine, null, 2);
    fs.writeFileSync(path.join(schemaFolderPath, `${schema}Schema.json`), file);
  });
  console.log("파일변환종료");
};

pipe(getFiles, makeGenerator, makeSymbols)();

typescript-json-schema를 줄여서 TJS라 하겠다.

  • settings에서 TJS 셋팅 값을 셋팅한다.
  • compilerOptions에서 TJS 컴파일 옵션을 셋팅한다.
  • getFiles 함수는 apiSchemaTypes 디렉터리를 재귀적으로 탐색해서 스키마를 만들어야 하는 파일을 모두 찾는다.
  • makeGenerator 함수는 스키마 유형 정보를 얻고, 스키마 정보를 생성한다.
  • makeSymbols 함수는 symbols를 찾아 TJS로 스키마 정의로 변환하고, __schema__ 디렉터리 안에 Schema 접미어가 붙은 이름의 파일을 만든다.

스키마 정보도 추출했으니, 이제 API에서 받은 데이터 구조와 스키마를 비교해서 일치하는지 확인하는 작업만 추가하면 된다.

Jest + AJV validator

Ajv JSON schema validator를 사용해 Schema 파일과 API Response Data에 대한 검증을 진행한다.

Default 값은 Draft-07이지만, 다른 버전을 사용할 수 있는 옵션이 있다.

import Ajv, { JSONSchemaType } from "ajv";

export const validate = (JSC: string, data: object) => {
  const ajv = new Ajv({ allErrors: true });
  // validate 메서드 호출시 오류는 덮어쓰므로 변수에 할당해서 사용해야 함.
  const valid = ajv.validate(JSC, data);
  // errorText는 ajv-errors 설치하여 메시지는 따로 정의할 수 있다.
  const errorText = ajv.errorsText();

  return {
    errorText,
    valid: !!valid,
  };
};
  • validate 함수는 파라미터 JSC로 위에서 생성한 JSON Schema를 받고, data로 API Response Data를 받아 JSON Schema와 일치하는지 검사하며, 만약 error가 있다면 error에 대한 message를 리턴한다.

이제 Jest를 사용해 테스트해 보자.

Matchers는 Jest Expect에서 제공하는 유효성 검사 외의 다른 유효성 검사 추가해서 사용할 수 있도록 도와준다.

JSON 스키마를 검사하는 `validate`` 함수를 Jest Expect에 추가한다.

export {};
declare global {
  namespace jest {
    interface Matchers<R> {
       toMatchJSC(data: any): R;
    }
  }
}

`toMatchJSC`` 함수를 Jest Extend로 추가한다.

import { validate } from "../utils/validate";

export const extendJSCMatcher = (): void => {
  expect.extend({
    toMatchJSC(JSC: string, data: any) {
      const schemaValid = validate(JSC, data);
      const pass = schemaValid.valid;
      const errorText = schemaValid.errorText;

      if (pass) {
        return {
          pass,
          message: () => `데이터 스키마 매칭 통과`,
        };
      }

      return {
        pass,
        message: () => `데이터 스키마 매칭 오류 ${errorText}`,
      };
    },
  });
};

Spec 파일을 만들어 스키마 테스트 코드를 작성한다.

import { extendJSCMatcher } from "../../../jestExt";
import { fetchPetId } from "../../../api/Pet/Pet";
import petSchema from "../../../__schema__/StocksSchema";

extendJSCMatcher();

// params
const PET_ID = 1;

describe("Pet api Pet Group", () => {
  describe("pet/{petId} api", () => {
    it("정상 처리(200)", async () => {
      expect(PET_ID).toEqual(1);
      const data = await fetchPetId(PET_ID);
      expect(petSchema).toMatchJSC(data);
    });
  });
});
  • 먼저 extendJSCMatcher 함수를 호출한다.
  • fetch로 API를 호출하고, 응답으로 받은 data와 생성한 petSchema를 비교해 일치하는지 확인한다.

추가적으로 에러 / 필수 파라미터 / 잘못된 파라미터 값 등을 좀더 디테일하게 작업하면 조금 더 타이트하게 체크할 수 있다.

WebStorm(IDE)에서 테스트를 실행해 보면, 다음과 같이 보인다.

테스트 통과, 테스트 실패

테스트가 실패하면 아래와 같은 메시지가 발생하게 된다.

에러 메시지

GitHub Actions Job 등록

package.json 파일에 test script 명령어를 추가하고, GitHub Actions Workflow 파일(.github/workflows/ci.yaml)에 Step으로 등록하면 된다.

GitHub Actions Workflow 파일

GitHub Actions를 실행하여 배포를 하게 되면 빌드하기 전에 API schema 테스트가 먼저 진행되고, 만약 변경된 schema 정보가 있다면 error가 발생하며 어떤 API에 schema정보가 잘못 되었는지 확인할 수 있다.

GitHub Actions Schema Test Error

결론

프론트엔드에서 받는 API Response Data에 관련된 타입은 Swagger Model에 맞춰 자동으로 TypeScript 파일로 생성되기 때문에, API가 추가되더라도 타입 작성에 대한 고민이 사라졌고, 백앤드에서 API를 긴급 패치 하더라도 API Schema Validate를 통해 확인되기 때문에 Key/Type 변경 및 API 제거 등을 확인할 수 있게 됐다. 또 변경된 API Schema 정보를 알고 있어서, 관련 화면에 대한 테스트도 가능하여 더 빠르게 상황에 대처할 수 있게 되었다.


  1. 2022년 1월 12일, 원프레딕트 기술 블로그에 발행된 글.
  2. 2023년 9월 기준으로 원프레딕트는 GitLab CI/CD를 사용하고 있습니다.
원프레딕트는 더 나은 제품을 고민하며 기술적인 문제를 함께 풀어낼 동료를 찾고 있습니다.
자세한 내용은 채용 사이트를 참고해 주세요.