TypeScript로 Open API JSON Schema Test 하기

API 테스트 도입

어떤 개발자든 테스트코드를 작성하여 품질을 향상시키려고 노력을 한다. 수많은 테스트 라이브러리도 제공되고 있고 다양한 방식으로 테스트를 진행하고 있다. 많은 테스트 방법 중에서 API Type Schema 테스트를 도입한 부분에 대하여 정리하려고 한다.
앱을 개발할 때 타입스크립트로 타입에 관련된 에러는 컴파일 시점에 타입에러는 많이 잡을 수 있게 되었지만, 서버에 API를 호출하여 가져오는 데이터는 백앤드에서 설정한 모델링을 보면서 개발을 진행해야한다. 이때 만약 별 문제 없겠지라는 마음으로 API response data 모델을 변경하거나 제거한 상태로 앱을 배포하면 어떻게 될까?
앱은 변경된 api을 사용하는 곳에서 에러를 발생을 하고 있을 것이다.
이 글에서 CI/CD는 gitAction를 사용하여 진행하였다.
테스트 체계가 잘 잡혀있다면 QA나 다른 테스트에서 확인은 가능 하지만, 테스트 관련 체계가 없다면 배포가 다 된 이후에 오류 및 버그를 보고 다시 확인하여 재배포를 하게 되는 일이 자주 발생하게 된다. 배포가 되기 전에 테스트 Job를 추가하여 좀 더 안정성 있는 배포를 해보자라는 생각이 들었다.
이번 글에서는 언제든지 변할 수 있는 API Response값과 스키마를 테스트를 하는 방법에 대하여 이야기를 하려고한다. Swagger 있는 API 모델을 TypeScript 파일로 자동으로 생성도 하고, 생성된 파일을 이용하여 API response Schema를 생성하여 ajv로 스키마와 값을 비교할 수 있는 jest Test코드를 작성하는 방법에 대하여 알아보자.

테스트 Flow

OpenAPIGenerator

Swagger UI는 Live Demo를 사용 했다. 링크
openapi-typescript-codegen(라이브러리)을 사용하여 swagger에서 정의한 모델을 통하여 typescript 코드를 generate을 생성한다.
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 생성 완료`));
TypeScript
복사
openAPIGenerator.ts
specURL: swagger Basic Structure 파일 (json or yaml) & url을 입력
outputPath: outputPath는 generate 된 model 파일이 만들어지는 위치
swaggerModelGenerate: SwaggerModel를 만들어내는 함수
속성
input에는 swagger Basic Structure 정보
output에는 generate된 파일위치
httpClient: http 클라이언트 생성 정보 fetch / xhr / node / axios
exportCore: core 정보 생성 여부
exportService: service 정보 생성 여부
exportModel: swagger에서 정의된 TYPE파일 생성
백앤드 정의한 type model를 가지고 typescript 파일을 생성하여 프론트앤드에서 사용한다.
useOptions: Argument 코드 스타일 규칙
useUnionTypes: typescript에서 enum vs union 선택
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'; }
TypeScript
복사
Pet.ts
swagger에서 정의한 model 기반으로 typescript가 자동으로 생성된걸 확인 할 수 있다.
이제 우리는 swagger model 기반으로 자동으로 typescript 파일을 추출 하였다.
swagger model 확실하게 정의를 안 한 상태에서 사용하게 되면 자동으로 생성된 파일을 또 수정해야 하는 경우가 발생 하므로 백앤드와 함께 model을 잘 맞추는게 중요하다.

JsonSchemaGenerator

typescript-json-schema 라이브러리를 사용하여 schema를 생성한다.
코드
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(buffer, null, 2); fs.writeFileSync(path.join(schemaFolderPath, `${schema}Schema.json`), file); }); console.log("파일변환종료"); }; pipe(getFiles, makeGenerator, makeSymbols)();
TypeScript
복사
jscGenerator.ts
typescript-json-schema를 TJS로 부른다.
settings에서 TJS 셋팅 값을 셋팅한다.
compilerOptions에서 TJS 컴파일 옵션을 셋팅한다.
getFiles에서 apiSchemaTypes 디렉토리 하위 스키마를 만들어야하는 파일를 전부 찾는다.
makeGenerator함수를 통해 typescript program을 통해 스키마 유형정보를 얻어서 스키마 정보를 생성한다.
makeSymbols 함수를 통해 symbols를 찾아서 TJS로 출력하여 __schema__
디렉토리 안에 Schema키워드를 붙여서 파일을 생성한다.
스키마 정보들도 추출을 하였고, 이제 api에서 받은 데이터 구조랑 스키마를 비교해서 일치하는지 확인 하는 작업만 추가 하면된다.

jest + ajv_validate

ajv 라이브러리를 사용하여 생성 된 schema 파일과 api response data에 validate 대한 검증을 진행한다.
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, }; };
TypeScript
복사
validate.ts
validate 파라미터로 JSC(위에서 생성한 json-schema)값을 받고 data에는 api response data값을 받아서 json-schema가 일치하는지 check를 하며, 만약 error가 있다면 error에 대한 message를 리턴해준다.
이제 jest 테스팅 라이브러리를 사용하여 테스트를 해보자.
Matchers는 jest expect에서 제공하는 유효성 검사 이외 다른 유효성 검사 추가해서 사용할 수 있도록 도와준다.
jest에 Matchers를 이용하여 validate(JSON 스키마 체크) 함수를 jest expect에 추가한다.
export {}; declare global { namespace jest { interface Matchers<R> { toMatchJSC(data: any): R; } } }
TypeScript
복사
index.d.ts
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}`, }; }, }); };
TypeScript
복사
jestExt.ts
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); }); }); });
TypeScript
복사
Pet.spec.ts
extendJSCMatcher함수를 호출하여 정의를 한다.
fetch를 이용하여 API를 호출 하고 리턴 받은 data와 생성한 petSchema 값을 비교하여 일치하는지 확인한다.
추가적으로 에러 / 필수 파라미터 / 잘못된 파라미터 값 등등 좀더 디테일 하게 작업을 하면 조금 더 타이트하게 체크할 수 있다.
webstorm(IDE)에서 테스트를 실행해보면,
테스트 통과
테스트 실패
테스트를 실패하게 되면 아래와 같은 메시지가 발생하게 된다.

GitAction Job 등록

package.json에 script 명령어를(test: jest) 추가해서 workflow step에 등록만 하면 된다.
workflow yaml
gitAction를 실행하여 배포를 하게 되면 빌드하기 전에 API schema 테스트가 먼저 진행되고, 만약 변경된 schema 정보가 있다면 error가 발생하며 어떤 API에 schema정보가 잘못 되었는지 확인 할 수 있다.
gitAction schema test error

결론

front-end에서 받는 API response data에 관련된 타입은 Swagger model에 맞춰 자동으로 TypeScript 파일이 생성이 되기 때문에 API가 추가되더라도 타입 작성에 대한 고민이 사라졌고, 백앤드에서 API를 긴급 패치 하더라도API schema validate을 통해 체크가 되기 때문에 KEY/TYPE 변경 및 API 제거에 관련 해서 확인이 가능해졌다. 또 변경된 API schema 정보를 알고 있어서 관련 화면에 대하여 테스트도 가능하여 더 빠르게 상황에 대처 할 수 있게 되었다.
원프레딕트 SW1팀에서는 테스트를 추가하는 작업 등 GuardiOne을 완성하는 에너제틱한 업무들을 수행하고 있습니다. 원프레딕트와 함께 산업의 혁신을 만들어내고 싶은 소프트웨어 엔지니어 분들이 계시다면, 아래 채용 공고를 확인해보세요! 원프레딕트와 함께 더 높은 곳으로 도약해봐요!

이 글을 쓴 사람

박 준 용 | SW1팀
성장에 배고픔이 많은 Front-End 개발자 박준용 입니다.
요즘도 많이 배고파서 많은 새로운 기술 및 정보를 먹고 있으며, 데이터 시각화 및 UI/UX에 관심이 많습니다.
원프레딕트 홈페이지 https://onepredict.ai/
원프레딕트 블로그 https://blog.onepredict.ai/
원프레딕트 기술 블로그 https://tech.onepredict.ai