Playwright로 E2E 테스트 작성하기


목차

  • Playwright는 무엇인가?
  • 주요 개념

    • 브라우저
    • 컨텍스트
    • 페이지와 프레임
    • 선택자
    • 자동 대기
  • TOAST UI 캘린더에 playwright 적용해보기
  • 결론

Playwright는 무엇인가?

하나의 API로 모든 최신 브라우저(크로미움, 파이어폭스, 웹킷)에서 빠르고, 안정적인 자동화를 지원하는 MS에서 만든 자동화 도구다. 안타깝게도 레거시 Edge나 IE11은 지원하지 않지만 다음과 같이 다양한 장점이 있다.

  • 여러 페이지, 도메인, iframe에 걸친 시나리오
  • 클릭 같은 액션을 실행하기 전에 엘리먼트가 준비될 때까지 자동으로 기다림
  • 네트워크 요청을 모킹하기 위한 네트워크 활동 가로채기
  • 마우스, 키보드의 기본 입력 이벤트

설치 방법과 기본적인 사용 방법은 공식 문서를 참고하면 된다. 이 글에서는 Playwright의 기본 개념을 알아보고 테스트 러너를 사용해 테스트를 작성해볼 것이다.

주요 개념

브라우저(Browser)

브라우저(크로미움, 파이어폭스, 웹킷)의 인스턴스를 나타낸다. Playwright는 브라우저 인스턴스를 시작하고 브라우저 인스턴스를 닫는 것으로 끝난다.

const { chromium } = require('playwright');

const browser = await chromium.launch();
// ...
await browser.close();

브라우저 인스턴스를 만드는 데 비용이 많이 들기 때문에 Playwright는 단일 인스턴스가 여러 브라우저 컨텍스트를 통해 수행할 수 있는 작업을 극대화하도록 설계되었다.

컨텍스트(Browser context)

브라우저 컨텍스트는 브라우저 인스턴스 내에서 분리된 유사 세션이다. 만드는데 빠르고 비용이 적다. 각 테스트를 새로운 브라우저 컨텍스트에서 실행하여 브라우저 상태를 테스트 간에 분리하는 것이 좋다.

const browser = await chromium.launch();
const context = await browser.newContext();

컨텍스트별로 다른 기기를 시뮬레이션 할 수 있다. 예제에서는 아이폰과 아이패드에 대한 컨텍스트를 생성하고 있다.

import { devices } from "playwright";

const iPhone = devices['iPhone 11 Pro'];
const iPad = devices['iPad Pro 11'];

const iPhoneContext = await browser.newContext({
  ...iPhone,
  permissions: ['geolocation'],
  geolocation: { latitude: 52.52, longitude: 13.39 },
  colorScheme: 'dark',
  locale: 'de-DE'
});
const iPadContext = await browser.newContext({
  ...iPad,
  permissions: ['geolocation'],
  geolocation: { latitude: 37.4, longitude: 127.1 },
  // ...
});

페이지와 프레임(Pages and frames)

컨텍스트는 컨텍스트 내의 단일 탭 또는 팝업 창을 나타내는 페이지를 가질 수 있다. URL로 이동해서 페이지의 콘텐츠와 상호작용할 때 사용하면 된다.

페이지를 이용해서 각기 다른 탭에서 작업하는 것처럼 할 수 있다. 예제에서는 각기 다른 페이지를 탐색한다.

// ...
// 페이지1 생성
const page1 = await context.newPage();

// 브라우저에 URL을 입력하는 것처럼 탐색
await page1.goto('http://example.com');
// 인풋 채우기
await page1.fill('#search', 'query');

// 페이지2 생성
const page2 = await context.newPage();

await page2.goto('https://www.nhn.com');
// 링크를 클릭하여 탐색
await page.click('.nav_locale');
// 새로운 url 출력
console.log(page.url());

페이지는 다시 1개 이상의 프레임 객체를 가질 수 있다. 각 페이지에는 메인 프레임이 있고 페이지 레벨 상호작용(클릭 등)이 메인 프레임에서 작동하는 것으로 가정한다. iframe 태그와 함께 추가 프레임을 가질 수 있다.

프레임을 이용해서 프레임 내부에 있는 엘리먼트를 가져오거나 조작할 수 있다. 예제에서는 프레임을 가져오는 방법들과 프레임 내부의 엘리먼트와 상호작용하는 방법을 보여준다.

// 프레임의 name 속성으로 프레임 가져오기
const frame = page.frame('frame-login');

// 프레임의 URL로 가져오기
const frame = page.frame({ url: /.*domain.*/ });

// 선택자로 프레임 가져오기
const frameElementHandle = await page.$('.frame-class');
const frame = await frameElementHandle.contentFrame();

// 프레임과 상호작용
await frame.fill('#username-input', 'John');

선택자(Selectors)

CSS 선택자, XPath 선택자, iddata-test-id같은 HTML 속성을 이용해 엘리먼트를 검색할 수 있다.

사용하기 쉬운 CSS 선택자는 물론이고 HTML 속성을 통해서도 엘리먼트를 찾을 수 있다는 장점이 있다. 예제에서는 엘리먼트를 검색하는 다양한 선택자들을 보여준다.

// data-test-id 선택자 사용
await page.click('data-test-id=foo');

// CSS, XPath 선택자가 자동으로 탐지된다.
await page.click('div');
await.page.click('//html/body/div');

// 부분 텍스트로 노드 검색
await page.click('text=Hello w');

// 명시적인 CSS, XPath 표기법
await page.click('css=div');
await page.click('xpath=//html/body/div');

// 쉐도우 DOM이 아닌 light DOM만 검색
await page.click('css:light=div');

>> 구분자를 이용해서 선택자들을 체이닝해서 사용할 수도 있다. 선택자로 찾아온 엘리먼트에서 다시 검색하지 않아도 >> 구분자를 통해서 선택자를 묶어서 사용 가능하다는 장점이 있다.

예를 들어, css=article >> css=.bar > .baz >> css=span[attr=value] 선택자는 document.querySelector를 체이닝해서 사용한 것과 같다.

// 동일한 엘리먼트를 가져오는 2가지 방법
await page.$('css=article >> css=.bar > .baz >> css=span[attr=value]');

document
  .querySelector('article')
  .querySelector('.bar > .baz')
  .querySelector('span[attr=value]')

다음은 실제로 사용하는 선택자 체이닝의 예시이다.

// #free-month-promo 안의 'Sign Up' 텍스트를 가진 엘리먼트 클릭
await page.click('#free-month-promo >> text=Sign Up');

자동 대기(Auto-wating)

작업들은 엘리먼트가 나타나고 실행 가능해질 때까지 자동으로 기다린다. 예를 들어, 클릭을 하면

  • 주어진 선택자가 DOM에 나타날 때까지 기다린다.
  • 나타날(visible) 때까지 기다린다. (예를 들어 visibility: hidden이 아닌)
  • 애니메이션을 멈출 때까지 기다린다. (예를 들어 css 트랜잭션이 끝날 때까지)

위와 같이 엘리먼트가 나타날 때까지 자동으로 기다린다.

이 기능 덕분에 엘리먼트를 가져오기 위해 엘리먼트가 화면에 나타날 때까지 대기하는 코드가 필요하지 않고 그냥 엘리먼트를 가져오거나 클릭을 하는 등의 동작을 수행하면 된다. 개인적으로 Playwright의 강력한 장점 중 하나라고 생각한다.

// DOM에 #search 엘리먼트가 나타날 때까지 기다린다.
await page.fill('#search', 'query');

// 에니메이션이 멈출 때까지 기다린 후 클릭한다.
await page.click('#search');

기본적으로는 자동으로 엘리먼트가 나타날 때까지 기다리지만 명시적으로 기다릴 수도 있다.

// #search 엘리먼트가 DOM에 나타날 때까지 기다린다.
await page.waitForSelector('#search', { state: 'attached' });

// #promo가 보일 때까지 기다린다. (예를 들어 visibility: visible)
await page.waitForSelector('#promo');

반대로 엘리먼트가 보이지 않거나 DOM에서 제거될 때를 기다릴 수도 있다.

// #details가 보이지 않을 때까지 기다린다. (예를 들어 display: none)
await page.waitForSelector('#details', { state: 'hidden' });

// #promo가 DOM에서 제거 될 때까지 기다린다.
await page.waitForSelector('#promo', { state: 'detached' });

TOAST UI 캘린더에 playwright 적용해보기

Playwright를 통해 E2E 테스트 자동화를 하는 방법을 간단하게 알아보자.

TOAST UI 캘린더는 NHN FE개발팀에서 개발 및 메인테이닝 하고 있는 오픈소스다. 캘린더의 메이저 업데이트를 준비하면서 E2E 테스트를 위한 도구를 결정해야 했다.

E2E 테스트 도구 중 후보로 Puppeteer, Cypress가 있었다. Puppeteer는 크로미움만 지원하고 Cypress는 다른 TOAST UI 애플리케이션에서도 사용하는 훌륭한 E2E 테스트 도구지만 속도가 느리고 hover와 drag 이벤트를 제공하지 않아 Playwright를 선택했다.

E2E 테스트만을 위해 별도의 디렉터리를 생성해야 했고 Playwright에서도 공식 테스트 러너를 사용하기를 권장하기 때문에 기본 테스트 러너를 사용하기로 했다.

공식 Playwright test-runner 사용하기

Playwright test-runner를 사용하기로 결정했다. 먼저 테스트 러너를 설치하자.

npm i -D @playwright/test

다음은 테스트 설정을 작성하자.

import { PlaywrightTestConfig } from '@playwright/test';

const config: PlaywrightTestConfig = {
  // 캘린더의 테스트 폴더 위치
  testDir: 'apps/calendar/playwright',
  timeout: 30000,
  // CI 환경에서 test.only를 금지
  forbidOnly: !!process.env.CI,
  // CI 환경에서 워커의 수를 2까지로 제한
  workers: process.env.CI ? 2 : undefined,
  use: {
    // CI 환경에서는 스크린샷을 찍지 않고 로컬 환경에서는 실패 시에만 스크린샷을 찍는다.
    screenshot: process.env.CI ? undefined : 'only-on-failure',
  },
};

Playwright는 기본적으로 headless로 동작하지만 GUI로 동작하도록 설정할 수도 있다. 이 외에도 다양한 설정 항목이 있으며 브라우저별로 다른 설정을 적용할 수도 있다. 설정 문서를 참고해서 원하는 옵션을 설정해보자.

이제 테스트를 위한 페이지를 만들어야 하는데, 캘린더2에는 아직 예제 페이지를 만들지 못해서 컴포넌트 테스트 용으로 사용하는 storybook을 이용해 테스트를 수행하려고 한다. 기존에 작성했던 스토리는 랜덤으로 이벤트를 생성해서 캘린더에 등록하기 때문에 테스트에서 일정들이 제대로 렌더링 됐는지 확인하기 어렵다. 따라서 고정적인 이벤트를 등록하는 스토리를 작성했다.

스크린샷 2021-08-13 오후 2.21.16.png

간단하게 이벤트 3개를 등록했다. 빨간 네모 부분은 이벤트를 늘이거나 줄일 수 있는 리사이즈 핸들러다.

이제 실제 테스트를 작성해보자. 구현하려는 테스트는 2가지다. 먼저 이벤트가 제대로 렌더링 되는지 테스트할 것이다. 2번째는 이벤트 중 하나의 오른쪽 끝에 있는 리사이즈 핸들러(빨간 사각형 부분)를 오른쪽으로 드래그&드롭해서 해당 이벤트를 늘리는 리사이징이 제대로 동작하는지 테스트할 것이다.

먼저 이벤트의 렌더링을 확인하는 테스트를 작성한다. 테스트를 위해선 먼저 해당 페이지로 이동해야 한다.

import { expect, test } from '@playwright/test';

test('basic test', async ({ page }) => {
  await page.goto('http://localhost:6006/?path=/story/weekview--fixed-events'); // 테스트하려는 페이지로 이동
  const events = await page.$$('.toastui-calendar-weekday-event'); // 가져오려는 엘리먼트
  
  expect(events).toHaveLength(3);
});

페이지로 이동하고 나면 테스트하려는 이벤트 엘리먼트를 가져와야 한다. 이벤트 엘리먼트가 제대로 렌더링 되었는지 확인하면 테스트가 끝난다.

다음은 이벤트 리사이징을 테스트할 것이다. 간단하게 page.dragAndDrop API를 이용하면 될 줄 알았으나 해당 API는 테스트 러너에 포함되어 있지 않아 사용할 수 없다. 아쉬운 대로 page.mouse.down 등의 API를 이용해 드래그&드롭을 구현했다.

test('basic test', async ({ page }) => {
  const boundingBoxBeforeResizing = await events[0].boundingBox(); // 리사이징하려는 이벤트의 사각형 영역을 가져온다.
  const eventWidthBeforeResizing = boundingBoxBeforeResizing.width; // 리사이징 전의 너비

  const resizer = await events[0].$('.toastui-calendar-handle-y');
  const resizerBoundingBox = await resizer?.boundingBox(); // 리사이징 핸들러의 위치를 가져오기 위해 사각형 영역을 가져온다.

  const targetX = resizerBoundingBox.x + resizerBoundingBox.width / 2;
  const targetY = resizerBoundingBox.y + resizerBoundingBox.height / 2;

  await page.mouse.move(targetX, targetY, { steps: 15 }); // 리사이징 핸들러의 중앙 위치로 이동 후
  await page.mouse.down(); // mousedown을 일으킨다.
  await page.mouse.move(targetX + 500, targetY, { steps: 15 }); // 다시 오른쪽으로 이벤트를 늘린 후
  await page.mouse.up(); // mouseup을 일으킨다.

  const boundingBoxAfterResizing = await events[0].boundingBox();
  const eventWidthAfterResizing = boundingBoxAfterResizing.width; // 리사이징 후의 너비

  expect(eventWidthBeforeResizing).toBeLessThan(eventWidthAfterResizing); // 리사이징 후의 너비가 전보다 커야한다.
});

이 테스트는 1번째 이벤트의 리사이징 핸들러의 위치에서 mousedown을 실행하고 오른쪽으로 500px 움직인 후 mouseup을 실행한다. 그 후 리사이징 전의 이벤트의 길이와 후의 길이를 비교한다.

이제 테스트를 실행해보자. --browser=all 옵션으로 모든 브라우저에 대해 테스트를 실행했다.

playwright test --browser=all

스크린샷 2021-07-30 오후 3.41.04.png

트러블 슈팅

Inspector

Inspector라는 Playwright의 GUI 도구를 이용해 디버깅을 할 수도 있다. PWDEBUG라는 환경 변수를 1로 세팅해서 Inspector를 킬 수 있으며 이때의 브라우저는 headed 모드로 동작하며 기본 타임아웃이 0으로 설정된다.

화면-기록-2021-08-17-오후-3.21.31.gif

Inspector의 아래에 브라우저에서 어떤 작업들이 실행됐는지 확인할 수 있다. 예시에서는 이벤트의 리사이징이 일어나는 것을 확인할 수 있다. 예시에서는 멈추지 않았지만 실행 중간에 잠시 멈추고 싶다면 await page.pause 메서드를 사용하면 된다.

frame

스크린샷 2021-07-30 오후 3.26.28.png

모든 테스트가 실패하기도 했다. 원인을 파악해보니 스토리북에서는 스토리를 iframe으로 렌더링 하기 때문에 엘리먼트를 제대로 가져오지 못해서 타임아웃으로 테스트가 실패하는 것이었다. frame 내에서 엘리먼트를 검색하는 API를 사용해보기도 하고 다양한 방법을 동원했지만 엘리먼트를 가져올 수 없었다. 선택자로 프레임 내의 선택자를 가져올 때는 자동 대기가 제대로 동작하지 않는 것이 원인이였고 이 점은 Playwright의 미흡한 점이라고 할 수 있다. 다행히 스토리북에서 스토리를 iframe이 아닌 최상단에 렌더링 하는 방법이 있었다. 해당 스토리 URL로 페이지 이동을 해서 테스트를 성공할 수 있었다.

결론

Playwright를 TOAST UI 캘린더에 적용해본 결과 다음과 같은 장단점을 느낄 수 있었다.

  • 직관적이고 간단한 API

    • 페이지 이동, 클릭 같은 직관적인 API를 사용해서 코드만 읽어도 어떤 동작을 의도했는지 쉽게 알 수 있었고 반대로 의도하려는 사용자의 동작을 동작별로 그대로 API로 구현해나갈 수 있어 편리했다.
    • 자동 대기 기능을 통해 엘리먼트가 나타날 때까지 기다리는 코드를 넣지 않고 그냥 엘리먼트를 가져오거나 클릭하기만 하면 된다는 점도 좋았다.
  • 빠른 구동 속도

    • 테스트 결과 스크린샷에서 볼 수 있듯이 간단한 테스트긴 하지만 각 브라우저들이 휙휙 켜졌다 꺼지며 테스트가 빠르게 수행된다.
  • frame에서의 엘리먼트 탐색

    • frame 내부의 엘리먼트를 탐색하는 부분도 조금 아쉬웠다. 해당 엘리먼트의 선택자가 잘못된 줄 알고 원인을 찾는데 시간이 좀 걸렸다.

결론적으로 Playwright를 사용해본 느낌은 만족스러웠다. 자동 대기를 통해 코드의 양을 줄일 수 있었고 아직 테스트 러너에는 없어 mousedown, mouseup 이벤트로 구현했지만 dragAndDrop API를 통해 드래그 또한 하나의 API 호출로 간단하게 구현할 수 있게 될 것이다. 또한 TOAST UI 캘린더의 특성상 이벤트를 드래그하는 리사이징이나 클릭을 통한 디폴트 팝업 노출 등의 테스트도 Playwright로 구현할 수 있을 것이다.

이 글을 통해 E2E 테스트를 위한 도구를 고민 중이거나 Playwright를 적용하려고 생각 중인 사람들이 결정하는 데 도움이 되었으면 한다.

임재언2021.08.18
Back to list