프로젝트를 진행하면서 E2E 테스트 코드를 작성하여 자동화를 시키기로 결정했다.
우리팀은 프로젝트에서 E2E 테스트를 진행하기 위해 Playwright를 도입하기로 결정했다.
따라서, 이번 포스팅에서는 Playwright를 적용하면서 어떤 고민이 있었고, 어떻게 해결했는지 정리해보려고 한다
E2E 테스트의 목적부터 정리하고 시작해야할 것 같다.
우리 프로젝트에서는 E2E 테스트의 목적을 다음과 같이 정의하고 시작했다.
E2E 테스트 목적
0. 실제 사용자가 사용하는 환경을 대전제로한다.
1. 예상한 시점에 API가 제대로 호출되는지 확인한다.
2. API의 응답이 우리가 예상한 것과 동일하게 넘어오는지 확인한다.
3. API의 응답이 발생한 이후, UI가 예상한 것과 동일하게 변화하는지 확인한다.
4. 예외 케이스도 제대로 동작하는지 확인한다.
이러한 목적을 가지고 테스트를 진행해야했기 때문에, 실제 Chromium 환경에서 동작하는 Playwright 를 도입하기로 결정했다.
위에서 말한 것처럼 E2E 테스트는 실제 사용자가 사용하는 환경을 대상으로 진행해야한다.
하지만, 우리는 아직 프로덕션 환경의 배포가 진행되지 않았으며, 당연히 운영 서버에 테스트 코드를 날리는건 위험하다
따라서, 우리는 목데이터가 세팅된 개발 서버 를 대상으로 E2E 테스트를 진행했다.
(개발서버 목데이터는 항상 동일한 데이터로 세팅된다)
테스트해야할 대상이 관리자 서비스 라는 것에서부터 약간의 문제가 있었다.
우선 프로젝트의 메인 시나리오를 소개하면 다음과 같다.
프로젝트 메인 시나리오
1. 팝업 등록 시나리오
2. 상품 등록 시나리오
3. 상품 조회 시나리오
4. 대시보드 조회 시나리오
5. 발주 요청 조회 시나리오 & 발주 진행 시나리오
여기서 중요한건 이 모든 시나리오는 로그인 & 팝업 선택 이라는 동일한 Flow를 사전에 가지고 있다는 것이다
다시 말하면, 팝업 등록 시나리오를 진행하기 위해서는 로그인 을 진행해야한다.
상품 등록 시나리오를 진행하기 위해서는 로그인 & 팝업 선택 을 진행해야한다.
또한, 로그인을 제외한 모든 API 요청에는 헤더에 AccessToken이 필요한데 이를 어떻게 처리해야할지도 고민해야하는 상황이 되었다.
로그인을 진행하면 AccessToken은 ResponseBody로, RefreshToken은 쿠키로 발급된다.
다른 모든 API 요청헤더에 AccessToken은 어떻게 추가해야하고, RefreshToken은 어떻게 관리해야할까?
다행히도 Playwright에서는 인증 정보를 사전에 세팅할 수 있도록 설정할 수 있는 방법을 제공하고 있었다.
찾아보니, 다른 분들도 global-setup.ts 라는 파일을 생성하여 맨 처음 실행되도록 설정하는 방법을 사용하는 것 같았다.
그래서 필자가 작성해본 global-Setup.ts 파일을 살펴보면 다음과 같다.
import { chromium, FullConfig } from "@playwright/test";
import fs from "fs";
import process from "process";
async function globalSetup(config: FullConfig) {
const authFile = "tests/auth.json";
if (fs.existsSync(authFile) && !process.env.CI) {
return;
}
const browser = await chromium.launch();
const page = await browser.newPage();
const baseURL = config.projects?.[0]?.use?.baseURL;
try {
await page.goto(`${baseURL}/onboarding`);
await page.waitForSelector('input[placeholder*="아이디를 입력해주세요"]', {
timeout: 10000,
});
await page.getByPlaceholder("아이디를 입력해주세요").fill("manager1");
await page.getByPlaceholder("비밀번호를 입력해주세요").fill("password1");
await page.click('button:has-text("login")');
await page.waitForTimeout(3000);
await page.context().storageState({ path: authFile });
} finally {
await browser.close();
}
}
export default globalSetup;
코드는 인증 정보를 tests/auth.json 에 파일 형식으로 저장한다.
이후, 현재 브라우저 컨텍스트의 모든 인증 정보를 위에서 생성한 auth.json 파일로 저장하는 코드를 추가하면 된다.
위에서 설정한 global-setup.ts 는 모든 테스트 코드가 실행되기 이전에 실행되어야한다.
이러한 실행 순서를 결정하는 작업은 설정 파일에서 진행할 수 있다.
필자가 세팅했던 설정 파일을 살펴보면 다음과 같다.
import { defineConfig, devices } from "@playwright/test";
import path from "path";
import process from "process";
import dotenv from "dotenv";
const isCI = !!process.env.CI;
dotenv.config();
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: false,
retries: isCI ? 2 : 0,
workers: isCI ? 1 : undefined,
reporter: "html",
globalSetup: path.resolve("./tests/global-setup.ts"),
use: {
baseURL: `${isCI ? process.env.VITE_DNS_URL : "http://localhost:3000"}`,
trace: "on-first-retry",
storageState: "tests/auth.json",
},
projects: [
{
name: "setup",
testMatch: /.*\.setup\.ts/,
},
{
name: "default-helper-func",
testMatch: /.*DefaultFlow\.spec\.ts/,
use: {
storageState: undefined,
},
dependencies: ["setup"],
},
{
name: "core",
testMatch: /^(?!.*DefaultFlow).*\.spec\.ts$/,
use: {
...devices["Desktop Chrome"],
},
dependencies: ["default-helper-func"],
},
],
...(isCI
? {}
: {
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: true,
},
}),
});
여기서 playwright.config.ts 에는 globalSetup 이라는 옵션을 제공한다.
여기에 이전에 생성했던 global-setup.ts 를 추가하면 모든 프로젝트 워커가 실행되기 이전에 딱 한번 실행되어 인증 정보를 생성하고 auth.json 을 저장하게 된다.
다음으로 projects의 setup 부분을 살펴보면 중간에 setup이라는 이름을 가진 파일들을 실행하게 된다.
보다시피, 앞단에서 정의된 프로젝트에 의존성을 갖기 때문에 어떠한 설정이 필요하다면 *.setup.ts 파일을 생성하여 추가하면 된다.
다음으로 공통 처리될 부분은 “팝업 리스트 선택”이다.
필자는 로그인 & 팝업 리스트 선택 을 진행하는 테스트 코드를 DefaultFlow.spec.ts 에 정의했고, 위에서 소개한 설정파일을 보면 이는 다른 테스트 코드가 실행되기 이전에 실행된다.
따라서, 이후 core 단계에 정의된 테스트 파일들은 모두 팝업 리스트 페이지까지 이동된 이후 실행됨을 보장한다.
필자는 이번에 E2E 테스트 코드를 작성하면서 테스트 환경이라는 상황이라 발생하는 딜레마를 겪을 수 밖에 없었다.
E2E 테스트를 작성하면서 가장 큰 고민 중 하나는 "실제 데이터 환경에서 어떻게 일관된 테스트를 작성할 것인가?" 였다.
우리 프로젝트는 개발 서버의 목데이터를 대상으로 테스트를 진행했는데, 이때 다음과 같은 문제가 발생했다
- 팝업이 이미 등록된 상태 vs 팝업이 없는 상태
- 상품이 등록된 상태 vs 상품이 없는 상태
예를들어, 개발 서버 DB에 이미 팝업이 하나라도 등록되어있는 상황이라면 팝업이 등록되지 않는 테스트 케이스를 작성하기가 매우 까다롭다.
개발한 UI를 살펴보면 다음과 같다.
등록된 팝업이 있는 상태
등록된 팝업이 없는 상태
팝업이 등록되지 않는 경우에 대한 테스트 케이스를 작성한다면, 등록된 팝업이 없습니다. 라는 문구가 발생되는지 여부를 통해 테스트 통과를 시킬 수 있을 것이다.
하지만, 등록된 팝업이 있는 상황에서 아무런 팝업이 등록되지 않는 상황을 테스트하기는 어렵다.
물론, 기존에 개발서버에 등록되어있던 모든 팝업을 삭제하고 그 결과를 확인하는 테스트 코드를 작성할수도 있겠으나 그렇게 진행하면 개발 서버 DB를 밀고 다시 세팅해야하는 번거로움이 있다.
// 팝업이 있는 계정용 테스트
await loginAs('manager1'); // 팝업 등록된 계정
// 팝업이 없는 계정용 테스트
await loginAs('manager2'); // 팝업 없는 계정
이 방법의 문제점은 각 상황별로 별도의 globalSetup이 필요하고, 테스트 케이스 관리가 복잡해진다는 것이었다.
또한, 해당 계정도 서버에 별도로 등록하는 번거로움이 있었다.
test.describe("헬퍼함수 기능 테스트 - 팝업 리스트 조회 및 대쉬보드 이동", () => {
test("팝업 리스트 조회가 가능하고 팝업이 있다면, 클릭시 대시보드 페이지로 이동한다. \n 만약 팝업이 없다면, 등록된 팝업이 없다는 문구가 보인다.", async ({
page,
}) => {
// given & when - 팝업 리스트 이동시 조회 API 호출
const [responsePromise] = await Promise.all([
page.waitForResponse(response => {
const url = response.url();
return url.endsWith("/popups") && response.request().method() === "GET";
}),
page.goto("/popup-list"),
]);
await page.waitForLoadState("networkidle"); // 네트워크가 안정화될 때까지 대기 -> waitFor과 동일한 효과 기대 가능
// then - 조회 결과 검증
expect(responsePromise.status()).toBe(200);
const responseBody = await responsePromise.json();
const numOfPopupFromAPI = responseBody.data.length;
// 조회된 데이터가 있을 경우와 없을 경우를 if문을 통해 분기 처리
if (numOfPopupFromAPI !== 0) {
const allPopups = page.locator('span[data-testid^="popup-card-"]');
await expect(allPopups.first()).toBeVisible();
const numOfPopupOnScreen = await allPopups.count();
expect(numOfPopupFromAPI).toBeGreaterThan(0);
expect(numOfPopupFromAPI).toBe(numOfPopupOnScreen);
const firstPopup = allPopups.first();
await firstPopup.click({ timeout: 3000 });
await expect(page).toHaveURL("/dashboard");
} else {
await expect(page.getByText("등록된 팝업이 없습니다.")).toBeVisible();
}
await page.waitForLoadState("networkidle");
});
});
결국 우리는 테스트 케이스를 분리하지 않고 여러가지 조건(ex. 계정 추가 생성 등)을 추가해야하는 경우에는 분기처리로 없는 경우에대한 테스트 코드만 작성하기로 결정했다.
E2E 테스트를 어떤 방식으로 작성해야 할지 고민하던 중, 팀원의 코드 리뷰 과정에서 좋은 접근법을 발견하게 되었다.
이 방법이 꽤 괜찮은 것 같아서 PPT에 사용된 장표 일부를 소개하고자 한다.

여러가지 고민끝에 생각한 순서는 다음과 같다.
마지막 예외 케이스 작성 단계가 중요한 이유는, 실제 사용자 데이터와 운영 환경에서 발생할 수 있는 다양한 시나리오를 미리 검증할 수 있기 때문이라고 생각한다!
이런 방식으로 테스트를 작성하다 보니 "과연 이것이 올바른 E2E 테스트일까?" 라는 의문이 들었다.
결국, 정리해보면 E2E 테스트의 Pros and Cons 는 다음과 같다고 볼 수 있다.
Pros
- 실제 데이터 환경에서 테스트하므로 현실적이다.
Cons
- 테스트 케이스가 데이터 상태에 의존적이다.
- 예외 상황 테스트가 어려우며, 테스트 코드가 복잡해진다.
필자가 이번에 E2E 테스트 코드를 작성하면서 느낀점을 정리해보면 다음과 같다.
E2E 테스트를 작성하면서 느낀 점은 E2E 테스트만으로는 모든 상황을 커버하기 어렵다는 것이었다.
특히, 예외 상황 테스트(서버 에러, 네트워크 오류 등)를 진행하기 위해서는 테스트 코드양이 방대해지며, 이를 유지보수하는게 과연 프로덕션 환경에서 에러를 잘잡아줄지도 약간 의문이 들었다.
따라서 앞으로 테스트 코드를 작성한다면 다음과 같이 작성하는게 더 예외 상황을 잘 잡아주고, 유지보수도 편해질 것이라고 생각한다.
- E2E 테스트: 핵심 사용자 플로우 검증
- 통합 테스트: API 레벨에서의 비즈니스 로직 검증
- 단위 테스트: 개별 컴포넌트/함수 검증
E2E 테스트는 "행복한 경로"를 중심으로, 나머지 예외 상황은 단위/통합 테스트에서 더 세밀하게 다루는 것이 효율적이라고 생각한다!