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
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) URLoutputPath
: 모델 파일이 만들어질 위치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 경로에 파일이 생성된다.
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를 실행하여 배포를 하게 되면 빌드하기 전에 API schema 테스트가 먼저 진행되고, 만약 변경된 schema 정보가 있다면 error가 발생하며 어떤 API에 schema정보가 잘못 되었는지 확인할 수 있다.
결론
프론트엔드에서 받는 API Response Data에 관련된 타입은 Swagger Model에 맞춰 자동으로 TypeScript 파일로 생성되기 때문에, API가 추가되더라도 타입 작성에 대한 고민이 사라졌고, 백앤드에서 API를 긴급 패치 하더라도 API Schema Validate를 통해 확인되기 때문에 Key/Type 변경 및 API 제거 등을 확인할 수 있게 됐다. 또 변경된 API Schema 정보를 알고 있어서, 관련 화면에 대한 테스트도 가능하여 더 빠르게 상황에 대처할 수 있게 되었다.