1편에 이어 큐잉 어떤식으로 진행하며 command 클래스의 내부가 어떻게 이루어져 있어 이런 방식의 구현이 가능한지 설명하도록 하겠다.
import { getCommandEntry } from ".";
import { Command, CommandKey, Executor } from "../types/command";
import { enqueue, enqueueChain } from "../utils/queue";
export abstract class BaseCommand<K extends CommandKey> {
constructor(protected key: K) {
this.key = key;
this.enqueueManager = this.enqueueManager.bind(this);
this.enqueueManagerWithChain = this.enqueueManagerWithChain.bind(this);
}
abstract executor: Executor<K>;
enqueueManager(...args: Parameters<Command[K]>) {
enqueue({
key: this.key,
commandCallback: this.executor(...args),
});
return getCommandEntry({
isChain: true,
});
}
enqueueManagerWithChain(...args: Parameters<Command[K]>) {
enqueueChain({
key: this.key,
commandCallback: this.executor(...args),
});
return getCommandEntry({
isChain: true,
});
}
}
위는 각 command의 부모가 되는 baseCommand의 코드이다.
각 command들은 큰 틀에서 executor와 enqueueManager, 체이닝을 위한 enqueueManagerWithChain이라는 메서드들로 이루어져 있다.
executor 메서드는 큐에 있는 코드가 실행될 때 실행되는 코드이다. 예를들어 cy.viewport의 executor 콜백은 아래와 같다.
export class ViewportCommand extends BaseCommand<"viewport"> {
constructor() {
super("viewport");
}
executor: Executor<"viewport"> = (width, height) => {
return async () => {
const page = current_unit.page;
await page.setViewport({ width, height });
};
};
}
enqueueManager는 it 안의 콜백이 실행될 때 해당 라인의 정보를 큐잉해주는 역할을 한다. 또한 enqueueManagerWithChain을 사용하여 여러개의 command를 체이닝 할 수 있는 기능을 제공해준다.
실제로 cy.[command]가 실행될 때 executor가 실행되는 것이 아니라 enqueueManager가 실행되어 큐잉되도록 사전 작업을 해놓았다. 아래는 해당 코드이다.
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(),
}
export function getCommandEntry({ isChain }: { isChain: boolean }) {
const entry: Record<CommandKey, any> = {} as Record<CommandKey, any>;
Object.entries(CommandEntry).forEach(([commandKey, command]) => {
const key = commandKey as CommandKey;
const enqueueManager = isChain
? (command.enqueueManagerWithChain as never)
: (command.enqueueManager as never);
entry[key] = enqueueManager;
});
return entry;
}
처음 initialize 과정에서 getCommandEntry 함수를 사용하여 각 커맨드의 enqueueManager 함수를 cy객체에 추가해준다.
각 command의 타입은 아래와 같은 방식으로 사전에 정의해주었다. 모든 command를 내가 개발하는 것이 아니고 아키텍쳐만 설계해주는 것이기에 다른 개발자가 개발할 때 문제가 발생하지 않도록 세세한 부분까지 타입을 정의해줘야 한다. 아래 타입은 많이 간소화한 코드이며 실제로는 대부분의 함수, 변수를 타입으로 제어하였다.
import { ElementHandle } from "puppeteer";
import { MenuMap } from "./constant";
// Command Types
export type GetCommand = (
selector: string
) => OptionalPromise<ElementHandle | null | string>;
export type VisitCommand = (url: string) => OptionalPromise<void>;
// ex)hello{enter}world{tab} '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | 'Power' | 'Eject' | 'Abort' | 'Help' | 'Backspace' | 'Tab' | 'Numpad5' | 'NumpadEnter' | 'Enter' | '\\r' | '\\n' | 'ShiftLeft' | 'ShiftRight' | 'ControlLeft' | 'ControlRight' | 'AltLeft' | 'AltRight' | 'Pause' | 'CapsLock' | 'Escape' | 'Convert' | 'NonConvert' | 'Space' | 'Numpad9' | 'PageUp' | 'Numpad3' | 'PageDown' | 'End' | 'Numpad1' | 'Home' | 'Numpad7' | 'ArrowLeft' | 'Numpad4' | 'Numpad8' | 'ArrowUp' | 'ArrowRight' | 'Numpad6' | 'Numpad2' | 'ArrowDown' | 'Select' | 'Open' | 'PrintScreen' | 'Insert' | 'Numpad0' | 'Delete' | 'NumpadDecimal' | 'Digit0' | 'Digit1' | 'Digit2' | 'Digit3' | 'Digit4' | 'Digit5' | 'Digit6' | 'Digit7' | 'Digit8' | 'Digit9' | 'KeyA' | 'KeyB' | 'KeyC' | 'KeyD' | 'KeyE' | 'KeyF' | 'KeyG' | 'KeyH' | 'KeyI' | 'KeyJ' | 'KeyK' | 'KeyL' | 'KeyM' | 'KeyN' | 'KeyO' | 'KeyP' | 'KeyQ' | 'KeyR' | 'KeyS' | 'KeyT' | 'KeyU' | 'KeyV' | 'KeyW' | 'KeyX' | 'KeyY' | 'KeyZ' | 'MetaLeft' | 'MetaRight' | 'ContextMenu' | 'NumpadMultiply' | 'NumpadAdd' | 'NumpadSubtract' | 'NumpadDivide' | 'F1' | 'F2' | 'F3' | 'F4' | 'F5' | 'F6' | 'F7' | 'F8' | 'F9' | 'F10' | 'F11' | 'F12' | 'F13' | 'F14' | 'F15' | 'F16' | 'F17' | 'F18' | 'F19' | 'F20' | 'F21' | 'F22' | 'F23' | 'F24' | 'NumLock' | 'ScrollLock' | 'AudioVolumeMute' | 'AudioVolumeDown' | 'AudioVolumeUp' | 'MediaTrackNext' | 'MediaTrackPrevious' | 'MediaStop' | 'MediaPlayPause' | 'Semicolon' | 'Equal' | 'NumpadEqual' | 'Comma' | 'Minus' | 'Period' | 'Slash' | 'Backquote' | 'BracketLeft' | 'Backslash' | 'BracketRight' | 'Quote' | 'AltGraph' | 'Props' | 'Cancel' | 'Clear' | 'Shift' | 'Control' | 'Alt' | 'Accept' | 'ModeChange' | ' ' | 'Print' | 'Execute' | '\\u0000' | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z' | 'Meta' | '*' | '+' | '-' | '/' | ';' | '=' | ',' | '.' | '`' | '[' | '\\\\' | ']' | "'" | 'Attn' | 'CrSel' | 'ExSel' | 'EraseEof' | 'Play' | 'ZoomOut' | ')' | '!' | '@' | '#' | '$' | '%' | '^' | '&' | '(' | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'J' | 'K' | 'L' | 'M' | 'N' | 'O' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'U' | 'V' | 'W' | 'X' | 'Y' | 'Z' | ':' | '<' | '_' | '>' | '?' | '~' | '{' | '|' | '}' | '"' | 'SoftLeft' | 'SoftRight' | 'Camera' | 'Call' | 'EndCall' | 'VolumeDown' | 'VolumeUp';
export type TypeCommand = (text: string) => OptionalPromise<void>;
export type ClearCommand = (text?: string) => OptionalPromise<void>;
export type Command = {
["get"]: GetCommand;
["visit"]: VisitCommand;
["type"]: TypeCommand;
["clear"]: ClearCommand;
};
export type CommandKey = keyof Command;
export type CommandForQueue = Record<
CommandKey,
(...args: Parameters<Command[CommandKey]>) => void
>;
export type Executor<K extends CommandKey> = (
...args: Parameters<Command[K]>
) => OptionalPromise<() => void>;
export type EnqueueCommand<K extends CommandKey> = (
...args: Parameters<Command[K]>
) => void;
// common
export type CommandForExecutor = OptionalPromise<() => void>;
지금까지의 과정을 통해 테스트 코드 실행 시 각 유닛을 라인별로 파싱하여 큐에 저장할 수 있도록 구현이 완료되었다. 아래는 테스트 코드가 실행되었을 때 실제로 저장되는 큐의 데이터이다.
//temp.cy.js
describe("test describe", () => {
it("test it", () => {
cy.viewport(800, 800);
cy.visit("<http://localhost:8080>");
cy
.get("#header_data_model_toolbar_item_menu_name", { timeout: 30000 })
.should("exist");
cy
.get('div[id*="cust_item"] input')
.click({ force: true })
.type("hello", { force: true });
cy
.get('div[id*="cust_item"] span.tag')
.first()
.should("have.text", "hello");
cy
.get('div[id*="cust_item"] input')
.then(($input) => {
cy.log($input);
})
.then(() => {
cy.log("hello");
});
});
});
//queue.json
[
{
"key": "viewport",
"status": "pending",
"chain": []
},
{
"key": "visit",
"status": "pending",
"chain": []
},
{
"key": "get",
"status": "pending",
"chain": [
{
"key": "should",
"status": "pending"
}
]
},
{
"key": "get",
"status": "pending",
"chain": [
{
"key": "click",
"status": "pending",
"commandCallback": {}
},
{
"key": "type",
"status": "pending",
"commandCallback": {}
}
]
},
{
"key": "get",
"status": "pending",
"chain": [
{
"key": "first",
"status": "pending"
},
{
"key": "should",
"status": "pending"
}
]
},
{
"key": "get",
"status": "pending",
"chain": [
{
"key": "then",
"status": "pending"
},
{
"key": "then",
"status": "pending"
}
]
}
]
//queue console.log
runQueue [
{
key: 'viewport',
commandCallback: [Function (anonymous)],
status: 'pending',
chain: []
},
{
key: 'visit',
commandCallback: [Function (anonymous)],
status: 'pending',
chain: []
},
{
key: 'get',
commandCallback: [Function (anonymous)],
status: 'pending',
chain: [ [Object] ]
},
{
key: 'get',
commandCallback: [Function (anonymous)],
status: 'pending',
chain: [ [Object], [Object] ]
},
{
key: 'get',
commandCallback: [Function (anonymous)],
status: 'pending',
chain: [ [Object], [Object] ]
},
{
key: 'get',
commandCallback: [Function (anonymous)],
status: 'pending',
chain: [ [Object], [Object] ]
}
]
큐의 형태가 굉장히 간단한것을 확인할 수 있다. 모든 argument 정보를 담은 실행 함수가 commandCallback에 저장되어 있기에 큐의 형태는 간단해도 상관이 없으며 나중에 dequeue를 진행하면서 commandCallback을 실행하기만 하면 되는 구조로 만들어져 있다.
이제 테스트 코드 실행에 따른 큐잉 작업은 완료되었다.
다음은 마지막인 commandCallback을 실행해주는 runQueue의 구현부이다. 이 부분은 단순하게 큐의 콜백을 실행해주면 되기에 크게 복잡하지 않다.
// runQueue.ts
import { Chain, QueueType } from "../types/ect";
import { dequeue } from "../utils/queue";
import { cloneDeep } from "lodash";
export async function runQueue() {
const item = dequeue();
if (!item) {
return;
}
const { commandCallback, chain } = item;
try {
const cb = await commandCallback;
await cb();
if (chain && chain.length > 0) {
for (let i = 0; i < chain.length; i++) {
try {
const command = chain[i];
const { commandCallback } = command;
const cb = await commandCallback;
await cb();
} catch (e: any) {
throw e;
}
}
}
} catch (e: any) {
// 에러처리
} finally {
await runQueue();
}
}
위 함수에서는 큐가 빌 때 까지 재귀적으로 dequeue를 실행해준다. dequeue 실행 시 반환된 command의 commandCallback을 실행해주며 실행 이후 체이닝 된 commandCallback들도 실행해준다.
각 try, catch문에서는 결과 값도 저장해야하지만 이 코드들은 생략하였다.
만약 추후 beforeEach, afterEach, beforeAll, afterAll 등을 추가하려면 글로벌로 해당 함수를 선언한 뒤 해당 함수의 실행 콜백을 executor에 저장해주고 그 executor를 unit 혹은 describe부분의 원하는 위치에 넣어주기만해도 정상적으로 실행될 것이다. 실제로도 그렇게 구현하였다.
이로써 많은 부분에서 전역 변수를 사용하는 리스크를 감수한 대신 높은 자유도를 제공해 원하는 방향으로의 수정이 용이한 프로그램이 완성되었다. cypress를 분석하면서 cypress또한 이러한 방식으로 전역으로 변수들을 관리하는 모습을 확인하였으며 cypress측이 그런식의 구현을 채택한 이유 역시 비슷한 이유일 것이라고 추측하였다.
import { getCommandEntry } from ".";
import { Command, CommandKey, Executor } from "../types/command";
import { enqueue, enqueueChain } from "../utils/queue";
export abstract class BaseCommand<K extends CommandKey> {
constructor(protected key: K) {
this.key = key;
this.enqueueManager = this.enqueueManager.bind(this);
this.enqueueManagerWithChain = this.enqueueManagerWithChain.bind(this);
}
abstract executor: Executor<K>;
enqueueManager(...args: Parameters<Command[K]>) {
enqueue({
key: this.key,
commandCallback: this.executor(...args),
});
return getCommandEntry({
isChain: true,
});
}
enqueueManagerWithChain(...args: Parameters<Command[K]>) {
enqueueChain({
key: this.key,
commandCallback: this.executor(...args),
});
return getCommandEntry({
isChain: true,
});
}
}
동작
이후 프로젝트가 진행되면서 특정 디렉토리의 cy 파일을 찾아 보여주는 extension까지 개발하였으며 hover시 보여지는 재생버튼을 통해 원하는 테스트를 진행할 수 있도록 개발하였다.
describe('테스트', () => {
it('1', () => {
cy.visit('<https://example.cypress.io/commands/actions>');
cy.get('.action-focus').focus();
cy.get('.action-focus').should('have.class', 'focus');
});
it('2', () => {
cy.visit('<https://example.cypress.io/commands/actions>');
cy.get('.action-focus').focus();
cy.get('.action-focus').should('have.class', 'focus');
});
it('3', () => {
cy.visit('<https://example.cypress.io/commands/actions>');
cy.get('.action-focus').focus();
cy.get('.action-focus').should('have.class', 'focus');
});
it('4', () => {
cy.visit('<https://example.cypress.io/commands/actions>');
cy.get('.action-focus').focus();
cy.get('.action-focus').should('have.class', 'focus');
});
});
현재 프로젝트는 대부분 마무리 되었으며 병렬 처리 또한 정상적으로 동작하는 것을 테스트 완료하였다. 위와 같은 구조의 테스트와 코드가 있을 떄의 결과와 동작은 아래 이미지와 같다. (*영상으로 첨부하고 싶지만 회사 환경상 쉽지 않기에 이미지로 첨부하였다.)
실제로 병렬로 테스트가 잘 실행되며 기존 cypress에 비해 확실하게 빠른 속도를 확인할 수 있었다. (기존 한개의 큰 기능 테스트 직렬 실행 시 24분 → 병렬 실행시 4분)
결과 reporting 기능은 현재 개발중이며 어차피 데이터는 전부 가지고 있기에 이를 가공하여 UI와 연결하는 작업만 진행하면 되어 그리 어렵지 않을것으로 예상된다. 내 역할은 데이터까지 뽑아내는거기에 UI 작업은 추후 다른 개발자가 진행하게 될 것 같다.
결론
이로써 이번 프로젝트를 마무리하게 되었다. Cypress는 분명 훌륭한 라이브러리지만, 분석 결과 외부에서 확장하거나 사용하는 것이 거의 불가능하도록 설계되어 있는 라이브러리이다. 또한, Cypress의 공식 입장에서도 이러한 사용을 선호하지 않는 것으로 보여졌다(참고). 더불어 Cypress는 무겁고 느린 특성 때문에, 이를 사용했더라면 여러 가지 문제가 발생했을 가능성이 크다고 판단하였다.
이번 프로젝트에서는 Cypress 의존성을 제거하면서도 필요한 기능만 구현해 속도와 유연성을 확보할 수 있었다. 특히, Cypress에서 유료로 제공하는 병렬 실행 기능도 큰 노력 없이 구현할 수 있었으며, 전체적으로 가볍고 확장성 높은 테스트 솔루션을 완성할 수 있었다.
Puppeteer를 사용해 Cypress와 유사한 인터페이스를 구현했으며, 실제 테스트 결과 몇 가지 구조적으로 불가능한 이슈를 제외하면 대부분 원하는 대로 동작하는 것을 확인했다. 또한, 이러한 구조적 이슈는 공통적인 부분에 해당해 수정도 비교적 간단했다.
위 성과를 포함해서 이번 프로젝트의 가장 큰 성과는 테스트 실행 방식을 완전히 커스터마이징할 수 있다는 점이다. 예를 들어, Cypress는 .cy.js 파일을 통해서만 테스트 코드를 실행해야 하지만, 이번 솔루션은 Node 실행 파일을 수정해 개발자가 원하는 방식, 단위로의 호출 및 병렬 실행이 가능해졌다. 더불어 약간의 코드 수정만으로 브라우저를 매번 새로 띄우지 않도록 최적화할 수도 있었으며, 실제로 이러한 방식으로 구현을 완료했다. 새로운 Command를 추가하는 작업도 클래스 하나만 추가하면 될 정도로 확장성을 고려한 구조를 설계했기 때문에, 추후 기획이나 개발 방향이 변경되더라도 수정이 어렵지 않을 것이다.
물론 프로젝트 전반적으로 부족한 부분이 남아 있지만, 애초에 완벽함을 목표로 했다면 개발을 끝낼 수 없었을 것이다. 앞으로 부족한 부분들에 대해 다른 개발자들의 피드백을 바탕으로 지속적으로 개선해 나갈 예정이다.
'frontend' 카테고리의 다른 글
토스 테스트 자동화 플랫폼 구축 영상 요약 (0) | 2025.01.07 |
---|---|
Cypress의 단점을 극복하기 위한 puppeteer 기반의 e2e 테스트 프레임워크 개발(1) (0) | 2024.12.16 |
React Dev Tools는 정확한 성능 측정을 방해할 수 있다. (2) | 2024.10.24 |
immer, Redux Toolkit 성능 문제 (0) | 2024.10.11 |
프론트엔드 전체 테스트 환경 구축해보기(3.웹서버) (0) | 2024.09.22 |