개요
e2e 테스트 라이브러리가 필요하여 cypress에 대해 분석하다가 이를 사용하는 것은 현재 구조에서 불가능하다고 판단하여 puppeteer를 사용하여 e2e 테스트 아키텍쳐를 직접 구현하기로 결정하였다.
cypress를 사용하는 것이 불가능한 이유는 아래와 같다.
- cypress의 너무 느린 속도
- 불가능한 유지보수
- 불가능한 병렬처리
- 쉽지않은 커스텀
특히 1번 속도 문제가 너무 심각하였다. 하지만 cypress에서 제공하는 테스트 코드 문법, 여러가지 e2e 기능은 충분한 가치가 있었기에 cypress의 특정 기능을 비개발자(qa,qc 등)가 사용할 수 있도록 cypress 인터페이스를 유지하면서 테스트를 실행할 수 있는 솔루션을 만드는게 이번 프로젝트의 핵심 목표이다.
cypress의 테스트 코드는 문법은 아래와 같다.
describe('describe description',() => {
it('it description',() => {
cy.get().should()
cy.test()
...
})
})
describe라는 큰 e2e 테스트 안에 it이라는 유닛이 존재하며 it 내부에서는 각각의 유닛 동작을 정의해준다.
여기서 주의해야할점은 이 모든 동작에서 비동기 문법을 사용하지 않으며 각각의 라인에 대한 상태가 존재하여 이를 기반으로 라인의 성공, 실패를 검증하고 다음 라인을 실행시켜주는 방식으로 동작한다는 점이다.
구현
시작점을 정의해준다.
// index.ts
import fs from "fs";
import "../core/describe";
import "../core/it";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
// get test js path
const { argv } = yargs(hideBin(process.argv))
.string("target")
.boolean("headless")
const { target, headless } =
argv as {
target: string;
headless: boolean;
};
if (!fs.existsSync(target)) {
console.log("Test file not found");
throw new Error("Test file not found");
process.exit(1);
}
globalThis.test_options = {
headless: headless ?? false,
};
eval(fs.readFileSync(target, "utf8"));
이번 프로그램에서는 전역변수를 많이 사용하게 될 것이다. 코드를 잘 알지 못하는 사람인 이상 유지보수가 힘들 수 있다는 단점이 있지만 각 라인의 체이닝, 상태관리, 결과 관리 등을 위해 불가피하게 선택하였다.
시작점에서는 테스트를 실행할 환경 설정, 타겟 등을 받아 이를 전달해주며 cypress 문법 테스트 코드 실행을 위해 eval을 사용하였다. eval은 사용하지 않는 것이 좋지만 테스트 환경이기에 전혀 문제가 되지 않을것이라고 판단하였다.
테스트는 한 개의 describe당 한 개의 프로세스를 점유하여 실행하도록 개발할 예정이다. 이렇게 하면 전역 상태 관리의 부담이 줄어들며 추후 병렬처리, 직렬처리 등에 있어 index.ts 파일의 실행만 관리하면 되기에 확장이 편하다는 장점이 있다.
실제로 이 프로그램 개발 이후 테스트 실행용 서버를 만들 때에 병렬 테스트, 직렬 테스트용 코드를 작성하는데 아무런 문제가 없었다.
이제 실행 파일을 만들었으니 index.ts 파일에 선언되어 있는 아래 두 개의 파일을 만들어줘야한다.
import "../core/describe";
import "../core/it";
describe, it은 전역에서 사용 가능해야하니 마찬가지로 전역 변수로 해당 함수를 선언해줘야한다.
전역 함수 사용을 위해 타입부터 정의해준다. 간단하게 정의한 타입은 아래와 같다.
// global.d.ts
import { Browser } from "puppeteer";
import { Cy, UnitTests } from "./ect";
declare global {
function describe(description: string, callback: () => void): void;
function it(description: string, callback: () => void): void;
// 전체 테스트, 유닛 테스트 완료 관리
var test_manager: {
unit_resolver: ((value: unknown) => void)[] | null;
resolve: (value: unknown) => void;
};
// 현재 테스트중인 브라우저
var puppeteer_browser: Browser;
// 추후 cy.get()과 같은 command를 추가할 공간
var cy: Cy;
// 모든 유닛 테스트
var unit_tests: UnitTests;
// 현재 실행중인 테스트
var current_unit: UnitTests[string];
// 사전 정의된 테스트 공간
var test_options: {
headless: boolean;
};
type OptionalPromise<T> = Promise<T> | T;
}
// describe.js
import puppeteer from "puppeteer";
import { getCommandEntry } from "../commands";
import { CommandForQueue } from "../types/command";
import fs from "fs";
import path from "path";
import { sanitizeFilename } from "../utils/fs";
globalThis.describe = async (description, unitCbs) => {
const browser = await puppeteer.launch({
...globalThis.test_options,
});
globalThis.puppeteer_browser = browser;
globalThis.cy = getCommandEntry({
isChain: false,
});
// 테스트 종료 관리
await new Promise((resolve) => {
globalThis.test_manager = {
resolve,
unit_resolver: null,
};
unitCbs();
});
await browser.close();
// 결과처리
const result = globalThis.unit_tests
// ...
};
describe의 2번째 인자로 받는 콜백 함수 실행 전, 후 브라우저를 시작, 종료해주며 비동기 기반이 아닌 상태 기반의 동작을 위해 테스트 종료 상태를 전달할 수 있는 promise resolver를 전역에 저장해준다.
또한 cypress 내부에서 사용하는 각 command들을 전역 cy 변수에 initialize 해준다. 이 때 각 command들은 하나의 베이스 클래스를 기반으로 모두 같은 인터페이스를 가지도록 만들어 추후 체이닝, 큐잉 등을 편하게 할 수 있도록 만들어주어야 한다.
import { CommandKey } from "../types/command";
import { AddQueryCommand } from "./addQuery";
import { ClearCommand } from "./clear";
import { ClickCommand } from "./click";
import { DbClickCommand } from "./dbClick";
import { FirstCommand } from "./first";
import { GetCommand } from "./get";
import { GetByUITypeCommand } from "./getByUIType";
import { GetSessionIdCommand } from "./getSessionId";
import { LastCommand } from "./last";
import { LogCommand } from "./log";
import { ShouldCommand } from "./should";
import { ThenCommand } from "./then";
import { TitleCommand } from "./title";
import { TypeCommand } from "./type";
import { UrlCommand } from "./url";
import { ViewportCommand } from "./viewport";
import { VisitCommand } from "./visit";
import { VisitMenuCommand } from "./visitMenu";
export const CommandEntry: Record<CommandKey, any> = {
addQuery: new AddQueryCommand(),
get: new GetCommand(),
visit: new VisitCommand(),
title: new TitleCommand(),
click: new ClickCommand(),
type: new TypeCommand(),
clear: new ClearCommand(),
viewport: new ViewportCommand(),
url: new UrlCommand(),
should: new ShouldCommand(),
then: new ThenCommand(),
log: new LogCommand(),
dbClick: new DbClickCommand(),
first: new FirstCommand(),
last: new LastCommand(),
//...
};
이제 describe 안에서는 globalThis.cy가 존재할 것이며 cy.[command]와 같이 원하는 command를 사용할 수 있게 되었다. 물론 단순하게 이런 방식으로는 프로그램이 동작하지 않아 추가 작업을 진행하였지만 이는 it 설명 이후 다시 설명하도록 하겠다.
//it.ts
import { runQueue } from "./runner";
globalThis.it = async (description, callback) => {
const test_manager = globalThis.test_manager;
// 현재 실행중인 유닛이 끝날때까지 대기
await new Promise((resolve) => {
if (test_manager.unit_resolver === null) {
test_manager.unit_resolver = [];
resolve(null);
return;
}
test_manager.unit_resolver?.push(resolve);
});
const browser = globalThis.puppeteer_browser;
const page = await browser.newPage();
globalThis.unit_tests[description] = {
store: {
current: null as never,
queue: [],
resultQueue: [],
},
page,
status: "pending",
};
current_unit = globalThis.unit_tests[description];
await callback(); // 큐잉
await runQueue(); // 실행
const test_result = current_unit.store.resultQueue
const next = test_manager.unit_resolver?.shift();
if (next) {
next(null);
return;
}
test_manager.resolve(null);
};
it에서도 마찬가지로 2번째 인자로 받는 callback을 내부에서 실행해줘야한다. 단 한 개의 describe에 여러개의 유닛이 존재할 수 있기에 현재 실행중인 유닛이 있다면 그동안 다음 유닛은 실행되면 안된다.
이를 위해 상단에서 현재 유닛을 제외한 다른 유닛이 실행되지 않도록 promise로 대기하게 만들고 코드 맨 아래에서는 현재 실행중인 유닛이 종료된 이후 이를 알려 다음 유닛이 실행될 수 있도록 하는 코드를 추가해준다.
테스트 결과 관리 등은 중요한 내용이 아니기에 생략한다.
await callback(); // 큐잉
await runQueue(); // 실행
코드의 중간에 위 두 줄의 코드가 있는데 이게 이번 프로그램의 핵심 코드이다.
cy.get().should()
cy.test()
위와 같은 테스트 코드에서 아래 라인의 코드인 cy.test()는 위 라인의 코드가 실행 완료된것을 알 수 없기에 처음 콜백 함수가 실행될 때 이를 단순하게 큐에 집어넣어주는 동작만 하도록 만들어준다.
cy.viewport()
cy.getSessionId().should()
예를들어 위 테스트 코드의 큐는 아래와 같다.
{
key: 'viewport',
commandCallback: [Function (anonymous)], //실행함수
status: 'pending',
chain: []
},
{
key: 'getSessionId',
commandCallback: [Function (anonymous)],
status: 'pending',
chain: [ [Object] ]
},
이를 통해 추후 테스트 실행 시 큐를 기반으로 현재의 진행 상태를 파악할 수 있으며, 이 구조를 그대로 가져간다면 결과값을 기록하기도 편하다는 장점이 있다. 단순하게 한 개의 라인에서 한 개의 command만 실행하는 것이 아니기에 각 큐 객체의 내부에는 체인이라는 배열 형태의 데이터가 존재하여 한 개의 라인에서 여러개의 command가 실행될 수 있도록 보조해준다.
또한 내부적으로 commandCallback을 argument 정보와 함께 들고있어 아래의 runQueue 함수에서 추가 정보 없이도 바로 큐에 저장되어 있는 테스트 코드를 실행할 수 있도록 만들어줄 예정이다.
2편에서는 큐잉을 어떤식으로 진행할지, command 클래스의 내부가 어떻게 이루어져 있어 이런 방식의 구현이 가능한지에 대해 설명하도록 하겠다.
2편 보러가기
'frontend' 카테고리의 다른 글
토스 테스트 자동화 플랫폼 구축 영상 요약 (0) | 2025.01.07 |
---|---|
Cypress의 단점을 극복하기 위한 puppeteer 기반의 e2e 테스트 프레임워크 개발(2) (0) | 2024.12.20 |
React Dev Tools는 정확한 성능 측정을 방해할 수 있다. (2) | 2024.10.24 |
immer, Redux Toolkit 성능 문제 (0) | 2024.10.11 |
프론트엔드 전체 테스트 환경 구축해보기(3.웹서버) (0) | 2024.09.22 |