들어가며
내가 요즘 개발 중인 웹 메모 서비스는 확장 프로그램의 사이드 패널에서 메모를 작성하고 이를 웹사이트에서 확인할 수 있는, 확장 프로그램과 웹사이트가 결합된 서비스이다. 개발한 기능을 배포할 때마다 일일이 테스트하는 과정이 번거로워 서비스의 안정성을 높이고자 E2E 테스트를 도입했다.
그런데 확장 프로그램이라는 환경이 발목을 잡았다. 사이드 패널은 열리지도 않고, 자료는 없고, 에러는 계속 났다. 이 글은 그 삽질의 기록이다.
확장 프로그램을 설치한 환경 만들기
fixture로 테스트 환경 구성하기
fixture는 각 테스트의 환경을 미리 설정할 수 있는 기능이다. 일반적인 웹 애플리케이션 테스트와 달리, 내가 테스트하고자 하는 환경은 '확장 프로그램이 설치된 브라우저'이기 때문에 이에 맞는 커스텀 설정이 필요했다.
const pathToExtension = path.join(path.resolve(), "..", "dist");
type ExtensionFixture = {
context: BrowserContext;
};
export const test = base.extend<ExtensionFixture>({
context: async ({}, use) => {
const context = await chromium.launchPersistentContext("", {
headless: false,
args: [
`--headless=new`,
`--disable-extensions-except=${pathToExtension}`,
`--load-extension=${pathToExtension}`,
],
});
await use(context);
await context.close();
},
});
export const expect = test.expect;
여기서 핵심은 launchPersistentContext를 통해 브라우저 컨텍스트 인스턴스를 만드는 것이다. args에는 확장 프로그램 관련 설정을 전달하는데, --disable-extensions-except와 --load-extension 플래그를 통해 특정 확장 프로그램만 로드하도록 지정할 수 있다.
자세한 설정 방법은 공식 문서에서 확인할 수 있다. 한 가지 주의할 점은, 예전 문서에는 headless: false로 설정하고 --headless=new 인자를 전달하라는 내용이 있었는데, 현재는 Playwright가 업데이트되면서 이 내용이 약간 변경된 것 같다. 최신 문서를 꼭 참고하길 바란다.
playwright.config.ts 설정 파헤치기
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests", // 테스트 파일들이 위치한 디렉토리 지정
fullyParallel: true, // 모든 테스트를 병렬로 실행할지 여부
forbidOnly: !!process.env.CI, // CI 환경에서 .only() 사용을 금지 (실수로 특정 테스트만 실행되는 것을 방지)
retries: process.env.CI ? 2 : 0, // 테스트 실패 시 재시도 횟수 (CI: 2회, 로컬: 0회)
maxFailures: 0, // 최대 실패 허용 개수 (0: 무제한)
reporter: [["html", { open: "on-failure" }]], // 테스트 결과 리포터 설정 (HTML 형식, 실패 시에만 브라우저에서 열기)
webServer: { // 테스트 실행 전 웹 서버 설정
command: "pnpm run -w dev:web:preview", // 실행할 서버 명령어
url: "http://localhost:3000", // 서버 URL
reuseExistingServer: !process.env.CI, // CI가 아닌 환경에서는 기존 서버 재사용
stdout: "ignore", // 서버 출력 무시
},
use: { // 모든 테스트에 공통으로 적용되는 설정
trace: "on-first-retry", // 첫 번째 재시도 시에만 추적 정보 기록
screenshot: "only-on-failure", // 실패 시에만 스크린샷 촬영
baseURL: "http://localhost:3000", // 기본 URL 설정
},
projects: [ // 프로젝트별 설정 (서로 다른 테스트 환경이나 조건)
{
name: "parallel", // 병렬 실행 프로젝트
use: { ...devices["Desktop Chrome"] }, // 데스크톱 크롬 브라우저 설정 사용
testDir: "./tests/parallel", // 병렬 테스트 디렉토리
fullyParallel: true, // 병렬 실행 활성화
workers: undefined // 워커 수 (undefined: 자동 설정)
},
{
name: "serial", // 순차 실행 프로젝트
use: { ...devices["Desktop Chrome"] }, // 데스크톱 크롬 브라우저 설정 사용
testDir: "./tests/serial", // 순차 테스트 디렉토리
fullyParallel: false, // 병렬 실행 비활성화
workers: 1, // 워커 수를 1개로 제한 (순차 실행)
},
],
timeout: 60 * 1000, // 각 테스트의 타임아웃 시간 (60초)
});
설정 파일에서 특히 눈여겨봐야 할 부분들이 있다. webServer 설정은 테스트 실행 전에 개발 서버를 자동으로 띄워주는데, reuseExistingServer 옵션을 통해 로컬 환경에서는 이미 실행 중인 서버를 재사용할 수 있어 테스트 시작 시간을 크게 단축시킬 수 있다. 또한 screenshot: "only-on-failure" 설정은 나중에 CI 환경에서 디버깅할 때 정말 유용했다.
설정 옵션은 공식문서에 훨씬 더 자세히 작성되어있으니, 더 파고들고 싶으면 공식문서를 보면 된다.
문제점: 웹 환경에서 side panel이 열리지 않는다
여기서부터 진짜 문제가 시작됐다. 웹 메모 서비스의 핵심 기능은 확장 프로그램의 사이드 패널에서 메모하는 건데, Playwright에서는 확장 프로그램의 사이드 패널을 여는 방법을 공식적으로 제공하지 않는다. 서비스에는 사이드 패널을 열 수 있는 단축키(Option + S)가 있지만, Playwright 환경에서는 이 단축키마저 동작하지 않았다.
한참을 헤매다가 Playwright의 한 Issue에서 해결 방법을 찾을 수 있었다. 해결 과정을 단계별로 설명해보겠다.
process.env.PW_CHROMIUM_ATTACH_TO_OTHER = "1";를 설정한다.
이 환경 변수는 일반적인 웹 페이지 외에도 Chrome의 다른 컨텍스트들(사이드 패널, DevTools 등)에 연결할 수 있게 해주는 옵션이다. 이걸 설정하지 않으면 사이드 패널이 열려도 Playwright가 이를 인식하지 못한다.
- content-ui에 사이드 패널을 열 수 있는 버튼을 추가한다.
// pages/content-ui/src/index.tsx
const renderOpenSidePanelButton = async () => {
if (isProduction()) return;
attachShadowTree({
shadowHostId: "OPEN_SIDE_PANEL",
shadowTree: <OpenSidePanelButton />,
});
};
ExtensionBridge.responsePageContent();
renderOpenSidePanelButton();
확장 프로그램은 사용자가 사용 중인 브라우저에 접근할 수 있는 권한을 가지고 있다. 이를 활용해서 사용자의 브라우저에 사이드 패널을 열 수 있는 버튼을 프로그래밍적으로 추가했다.
여기서 중요한 건, 배포된 환경에서 이런 테스트용 버튼이 보이면 안 되니까 isProduction() 체크를 통해 개발 환경에서만 렌더링하도록 했다는 점이다.
사실 이 방법은 마음이 완전히 편하지는 않았다. 순수한 테스트라면 제품 코드는 건드리지 않고 바깥에서 조작만 해야 하는데, 나는 테스트를 돌리려고 제품 코드(content-ui)에 '사이드 패널 여는 버튼'이라는 테스트용 훅을 심은 셈이니까. 제품에 테스트 전용 코드가 섞이는 건 분명 냄나는 선택이다.
그럼에도 이 길을 택한 이유는, Playwright가 확장 프로그램 사이드 패널을 여는 공식 방법을 제공하지 않아 다른 우회로가 마땅치 않았기 때문이다. 대신 위험을 최소화하려고 isProduction() 가드로 감쌀다. 이 버튼은 개발 환경에서만 렌더링되고, 실제 배포 빌드에서는 아예 존재하지 않는다. '테스트를 돌릴 수 있다'는 실익이 '제품에 개발용 코드가 한 조각 들어간다'는 찜불함보다 크다고 판단했고, 가드로 프로덕션 유출만 확실히 막았다.
주의사항이 하나 있는데, Playwright는 visibility: hidden이거나 display: none처럼 화면에 보이지 않는 요소는 클릭할 수 없다. 그리고 다른 요소에 가려져 있어도 클릭이 안 된다. 그래서 이 버튼은 실제로 화면에 표시되어야 한다. 나는 우측 하단에 작은 버튼으로 배치했다.
- 테스트 환경에서는 위에서 만든 버튼을 찾아 사이드 패널을 연다.
export async function openSidePanel(page: Page) {
await page.locator("#OPEN_SIDE_PANEL_BUTTON").click();
}
이제 테스트 환경에서는 위에서 만든 버튼을 찾아서 클릭하면 사이드 패널이 열린다.
실제 동작 확인하기
아래는 UI 모드로 Playwright를 실행했을 때 캡처한 화면이다.
우측 하단에 사이드 패널을 여는 버튼이 보인다. 이 버튼을 클릭하면
사이드 패널이 열리고
새로운 컨텍스트가 생성되는 것을 확인할 수 있다.
위에 있는 컨텍스트가 기존 웹사이트이고, 아래 있는 컨텍스트가 새롭게 열린 사이드 패널의 컨텍스트이다. 실제로 서비스를 이용할 때는 아래 이미지처럼 웹사이트와 사이드 패널이 옆에 있지만, 테스트 환경에서는 위와 같이 각 컨텍스트로 분리되어 있는 것을 확인할 수 있다.
사이드 패널 컨텍스트 찾기
이제 Playwright 환경에서 사이드 패널 컨텍스트를 찾아야 한다.
export async function findSidePanelPage(page: Page) {
await page.waitForTimeout(5000);
return page
.context()
.pages()
.find(
(page) =>
page.url() ===
`chrome-extension://${확장 프로그램 ID}/side-panel/index.html`,
)!;
}
page의 컨텍스트에서 모든 페이지를 가져온 다음, URL로 필터링하면 된다. 사이드 패널의 URL은 chrome-extension://[확장 프로그램 ID]/side-panel/index.html 형식이다.
한 가지 팁을 주자면, waitForTimeout(5000)으로 충분한 시간을 주는 게 중요하다. 사이드 패널이 열리고 완전히 로드되기까지 시간이 필요하기 때문이다. 물론 더 나은 방법은 특정 조건을 기다리는 것이다. 그런데 여기서는 '로드가 끝났다'고 판단할 마땅한 신호가 없었다. 사이드 패널은 별도 컨텍스트라 웹사이트 쪽 DOM에는 아무 변화가 없고, "로드 완료" 같은 이벤트를 확장 프로그램이 밖으로 던져주는 것도 아니었다. 기다릴 조건 자체를 잡기가 어려우니, 결국 이 경우偏 넘넘한 타임아웃이 가장 간단하고 안정적이었다.
E2E테스트를 작성하기 위한 실전 팁
1. 한 번에 하나씩, 단계적으로 접근하자
처음부터 CI 환경에서 테스트를 돌리면 문제가 생겼을 때 원인 파악이 너무 어렵다. CI 설정 문제인지, 테스트 코드 문제인지, 환경 문제인지 구분이 안 된다. 게다가 코드를 수정하고 결과를 확인하기까지 너무 오래 걸려서 생산성이 떨어진다.
그래서 나는 이렇게 점진적으로 접근했다.
- 먼저 로컬 환경 + UI 모드 + 개발 서버를 직접 실행해서 테스트를 작성하고 검증했다. 이 단계에서는 테스트가 제대로 동작하는지, 셀렉터가 정확한지 등을 빠르게 확인할 수 있다.
- 다음으로 로컬 환경 + UI 모드 + 개발 서버 자동 실행으로 넘어갔다.
webServer설정이 제대로 동작하는지 확인하는 단계다. - 그다음은 로컬 환경 + UI 모드 끄고 + 개발 서버 자동 실행. 헤드리스 모드에서도 모든 테스트가 통과하는지 확인한다. 여기서 타이밍 이슈나 예상치 못한 문제들이 종종 발견된다.
마지막으로 CI 환경에서 테스트한다. 이 시점에는 이미 대부분의 문제가 해결된 상태라 CI 관련 설정 문제에만 집중할 수 있다.
이렇게 외부 요인들을 하나씩 제거하면서 점진적으로 테스트하는 게 정말 중요하다. 급하게 가려다가 더 많은 시간을 낭비하는 경우가 많았다.
2. 테스트 기다리는 동안 뭐 할지 미리 정해두자
E2E 테스트는 정말 시간이 오래 걸린다. 브라우저를 띄우고, 환경을 세팅하고, 하나씩 액션을 수행하고... 단위 테스트나 통합 테스트보다 몇 배는 느리다. 특히 서버 상태에 의존하는 테스트들은 동시 실행이 불가능해서 직렬로 돌려야 하는데, 그러면 시간이 배로 늘어난다.
그래서 테스트가 돌아가는 동안 뭘 할지 미리 정해두는 게 시간 낭비를 줄이는 핵심이다. 나는 이런 식으로 시간을 활용했다.
할 만한 것들)
- 다른 CI 코드 작성하기 - 테스트와 독립적인 작업이라 안전하다
- 학습하기 - 테스트 관련 문서를 더 읽거나, 다음 작업에 필요한 기술 공부
- 다음 작업 미리 착수하기 - 현재 테스트와 무관한 새로운 기능 작업
절대 하면 안 되는 것)
- 현재 돌아가는 테스트 코드 리팩토링 - 테스트가 실패했을 때 원인 파악이 불가능해진다
실제로 기다리는 시간에 다른 테스트 케이스를 짜거나 문서를 읽었더니, 죽은 시간이 확 줄었다.
3. 병렬과 직렬 테스트를 명확히 구분하자
위에서 말했듯이 E2E 테스트는 서버 상태 같은 외부 환경에 의존하는 경우가 많다. 예를 들어 메모를 생성하고 수정하고 삭제하는 테스트들은 서로 영향을 주기 때문에 순차적으로 실행해야 한다. 하지만 읽기 전용 테스트처럼 서로 독립적인 테스트들은 병렬로 실행해도 문제없다.
나는 playwright.config.ts에서 병렬과 직렬 테스트를 아예 프로젝트 단위로 분리했다.
// playwright.config.ts
export default defineConfig({
// ..
projects: [
{
name: "parallel",
use: { ...devices["Desktop Chrome"] },
testDir: "./tests/parallel",
fullyParallel: true,
workers: undefined
},
{
name: "serial",
use: { ...devices["Desktop Chrome"] },
testDir: "./tests/serial",
fullyParallel: false,
workers: 1,
},
],
})
누군가는 test.describe.configure를 사용하면 되는 거 아니냐고 물어볼 수 있다. 나도 처음엔 그렇게 생각했는데, 직접 써보니까 내가 원하는 방식과는 좀 달랐다.
이 옵션은 한 파일 내에서의 병렬/직렬은 제어할 수 있지만, 여러 파일에 걸친 병렬/직렬은 제어하기 어렵다. 예를 들어 parallel 모드에서 workers를 여러 개로 설정하면 여러 파일의 테스트가 동시에 실행되는데, 특정 파일들만 순차 실행하고 싶어도 세밀한 제어가 안 된다.
그래서 프로젝트를 나누고 각 프로젝트의 workers를 설정하는 방식을 택했다. 이렇게 하니까 parallel 프로젝트는 최대한 병렬로 돌리고, serial 프로젝트는 완전히 순차적으로 돌릴 수 있었다.
이렇게 나눈 것만으로 CI 시간이 40% 줄었다.
- 모든 테스트 코드를 직렬로 실행했을 때 : 14분
- 직렬과 병렬을 나누어 실행했을 때 : 8분
6분 차이가 별것 아닌 것 같지만, 하루에 여러 번 CI를 돌리다 보면 정말 체감이 크다.
4. screenshot 설정으로 실패 원인을 빠르게 파악하자
// playwright.config.ts
export default defineConfig({
// ..
use: {
screenshot: "only-on-failure",
},
})
로컬에서는 UI 모드로 실행해서 테스트가 실패하면 바로 화면을 볼 수 있다. 하지만 CI 환경에서는 그게 불가능하다. 그래서 스크린샷 설정이 정말 중요하다.
only-on-failure 옵션을 설정하면 테스트가 실패한 시점의 화면을 자동으로 캡처해준다. 이게 없으면 CI에서 테스트가 실패했을 때 로그만 보고 원인을 추측해야 하는데, 스크린샷이 있으면 한눈에 뭐가 문제인지 파악할 수 있다.
실제로 CI에서 테스트가 실패했을 때 스크린샷을 보니 "아, 로딩이 덜 됐구나" 또는 "요소가 다른 요소에 가려져 있구나" 같은 걸 바로 알 수 있었다. 디버깅 시간이 확 줄어드는 느낌이었다.
5. 시크릿 모드에서 확장 프로그램을 테스트하려면
개발하다 보면 시크릿 모드에서도 확장 프로그램을 테스트해야 할 때가 있다. 설정 방법은 간단하다.
- 확장 프로그램 > 세부 정보 클릭
- 시크릿 모드에서 허용 활성화
이렇게 하면 시크릿 모드에서도 확장 프로그램이 동작한다. 쿠키나 로컬 스토리지가 초기화된 상태를 테스트할 때 유용하다.
6. wait 메서드는 조건 기반을 우선으로 쓰자
E2E 테스트에서 가장 흔한 실패 원인 중 하나가 타이밍 이슈다. 요소가 아직 렌더링되지 않았는데 클릭하려고 한다거나, 페이지 전환이 완료되지 않았는데 다음 액션을 수행하려고 하는 경우가 많다. Playwright는 이를 위해 여러 wait 메서드를 제공한다.
- waitForURL은 특정 URL로 이동될 때까지 기다린다. 페이지 전환 후 액션을 수행해야 할 때 유용하다.
await page.click('button');
await page.waitForURL('**/dashboard');
// 이제 dashboard 페이지에서 안전하게 작업 가능
- waitForSelector는 특정 요소가 DOM에 나타날 때까지 기다린다.
await page.waitForSelector('#memo-list');
await page.click('#memo-list .memo-item');
조건 기반 wait가 가능하면 나는 항상 이걸 먼저 쓴다. 하지만 때로는 명확한 조건을 정의하기 어려운 경우도 있다. 예를 들어 애니메이션이 완료되어야 한다거나, 여러 비동기 작업이 순차적으로 완료되어야 하는 경우다.
- 이럴 때는 waitForTimeout을 사용한다.
await openSidePanel(page);
await page.waitForTimeout(2000); // 사이드 패널이 완전히 열릴 때까지 대기
다만 waitForTimeout은 명시적인 대기 시간을 설정하기 때문에 테스트가 불안정해질 수 있다. 환경에 따라 필요한 시간이 달라질 수 있기 때문이다. 그래서 가능한 한 조건 기반 wait를 사용하고, 정말 필요한 경우에만 timeout을 사용하는 게 좋다.
내 경험상, timeout을 사용해야 한다면 넉넉하게 잡는 게 낫다. 테스트가 몇 초 더 오래 걸리는 것보다 간헐적으로 실패하는 게 훨씬 더 큰 문제다.
실제 적용 결과
최종적으로 구축한 테스트 코드는 Web-Memo/e2e/tests에서 확인할 수 있다. 관심 있으면 한번 열어봐도 좋다.
맺으며
6번의 PR 끝에 성공했다. 처음 PR을 올릴 때만 해도 이렇게 오래 걸릴 줄 몰랐다.
사실 우테코 이전인 2024년 11월에도 여러 차례 시도했었다. 그때는 번번이 실패하고 좌절했었다. 그런데 우테코 레벨 2가 끝난 방학 때, 며칠에 걸쳐 다시 도전했다. 이번에는 접근 방식을 바꿨다. 에러가 발생하면 요인을 하나씩 체크하며 제거하고, 순차적으로 하나씩 테스트 케이스를 통과시키는 데 집중했다. 덕분에 모든 테스트 케이스를 CI 환경에서 통과시킬 수 있었다.
돌이켜보면 우테코에서 배운 건 기술적인 것만이 아니었다. 끝까지 포기하지 않는 끈기와 문제에 체계적으로 접근하는 방법론을 배웠던 것 같다. 이 두 가지가 없었다면 이번 프로젝트도 완성하지 못했을 것이다.
이번 작업에서 가장 어려웠던 부분은 확장 프로그램과 사이드 패널이었다. 일반적인 웹 애플리케이션과 달리 특수한 환경이다 보니 자료도 부족하고 예상치 못한 문제들이 많았다. 하지만 결국엔 초반 세팅만 확실하게 해두면 이후에는 일반적인 E2E 테스트와 크게 다를 게 없다는 걸 깨달았다. 그 초반 세팅을 제대로 하는 게 핵심이다.
커버리지는 아직 갈 길이 멀다. 그래도 세팅이 잡혀 있으니 이제 테스트 하나 추가하는 건 10분 일이 됐다.
확장 프로그램 E2E를 구축하려는 사람에게 이 글이 지름길이 되면 좋겠다. 기존 기능 깨질 걱정을 안 해도 되니 진짜 든든하다.
안 되면 요인 하나씩 지워가며 다시 해보시길. 나도 그렇게 겨우 통과시켰다.