현재 개발하고 있는 Coloso 서비스에서 기존 E2E Test 도구인 Cypress에서 Playwright로 전환한 과정을 공유합니다.

테스트 코드 작성법보단 Playwright의 주요 기능, 리팩토링 과정 위주로 공유하려 합니다.

Playwright JS (v.1.29.1) 를 사용하고 있습니다.

Playwright 전환 배경

제공되는 Github Action CI/CD 시간이 초과됐다는 CTO 님의 공지와 함께 개선점으로 가장 많은 시간을 소비하는 E2E 테스트 workflow가 언급됐습니다. 이 시간을 줄이기 위해 저희 팀은 현재 사용하는 Cypress의 버전이 최신 버전에 비해 현저히 낮은 점, 버전을 맞추기 위해 들이는 공수, 만족할 수 없는 테스트 속도를 이유로 E2E 테스트 도구를 전환하기로 결정했습니다.

빠른 속도에 뚜렷한 장점이 있는 Playwright가 가장 먼저 제시되었습니다. 기존의 Cypress 테스트 케이스들을 모두 마이그레이션할 수 있는지, 실제로 눈에 띄일 정도로 속도 변화가 있는지를 체크 후 전환을 시작했습니다. (예제 샘플)

최종적인 목표는 workflow 시간 개선입니다. Playwright 소개를 마치며 기존의 Cypress와 비교해 어떻게 달라졌는지 비교해 보려 합니다.

Playwright란?

빠른 테스트 속도로 잘 알려진 E2E `테스트 프레임워크입니다. 하지만 공식 문서 메인 페이지를 보면 빠른 속도가 다가 아니란 걸 알 수 있는데요, 대표적으로 다양한 테스트 브라우저 플랫폼 제공, 브라우저 환경 커스터마이징, 테스트 코드 작성과 디버깅을 위한 다양한 툴 등 다양한 기능을 제공하고 있습니다.

Puppeteer 팀에서 분리되어 만들어져 문법적으로 매우 유사합니다. (참고) JS에 익숙하다면 문법에 대해 약간의 러닝 커브가 있는 Cypress에 비해 익숙한 문법을 이용해 테스트 코드를 작성할 수 있습니다.

Playwright 전환

대략적인 기능 설명과 전환하면서 느낀 장점들을 공유합니다.

1. 테스트 브라우저 환경 설정

테스트를 수행할 브라우저 환경을 커스터마이징할 수 있습니다. test 함수의 제공되는 기능을 통해 스토리지, 세션, 쿠키, 플러그인 등 다양한 세팅을 한 브라우저를 생성할 수 있습니다.

tests/example.spec.ts
1 test('[테스트 브라우저 생성]', async ({ browser }) =>{
2 const context = await browser.newContext({options: ...});
3 const page = new context.newPage();
4 await page.goTo('/');
5 })

만약 아무것도 설정되지 않은, 마치 시크릿 모드로 켠 페이지를 생성하고 싶다면 context 생성과정을 생략 후 page를 이용해 간단하게 설정할 수 있습니다.

tests/example.spec.ts
1 test('[빈 페이지 생성]', async ({ page }) => {
2 await page.goto('/');
3 });

2. 인증 재사용

대표적으로 브라우저를 커스터마이징해 인증을 재사용할 수 있습니다. 예를 들어 localStorage에 access token을 저장하는 방식을 사용한다면, 미리 token을 삽입한 브라우저를 만들어 인증이 된 채로 테스트를 시작할 수 있습니다.

먼저 api post requset를 통해 login response를 가로채 accessToken을 얻어냅니다.

tests/auth.spec.ts
1 let accessToken;
2
3 test.beforeAll(async ({ request }) => {
4 const loginResponse = await request.post('/signIn', {
5 data: {
6 userEmail: '테스트 계정 이메일',
7 password: '테스트 계정 비밀번호'
8 },
9 });
10
11 // api 요청 테스트
12 expect(loginResponse.ok()).toBeTruthy();
13
14 const loginResponseJson = await loginResponse.json();
15 accessToken = loginResponseJson.data.accessToken;
16 });

얻어낸 accessToken을 addInitScript를 이용해 localStorage에 삽입 후 메인 페이지로 이동하면 로그인이 되어있는 걸 확인할 수 있습니다.

tests/auth.spec.ts
1 test('[token 삽입 후 페이지 이동]', async ({ page }) => {
2 await this.page.addInitScript((value) => {
3 window.localStorage.setItem('accessToken', value);
4 }, accessToken);
5
6 await page.goTo('/');
7 });

3. 다양한 테스트 모드 제공

headless 모드를 off 하면 실제 테스트 과정들을 지켜볼 수 있는데요, 지켜볼 수 없습니다(?). 눈 깜짝할 새 브라우저 창이 켜졌다 테스트를 마치고 종료되거든요..! 이러한 문제를 해결할 수 있었던 다양한 모드와 테스트 코드 작성을 도와주는 codegen 모드를 소개합니다.

trace 모드

trace 모드는 테스트를 실행하면서 실패한 경우 리포트 페이지를 생성 후 로그, 스냅샷과 같이 실패한 에러를 보여줌으로써 디버깅을 쉽게 할 수 있는 모드입니다. 주로 아래의 inspector 모드로 디버깅을 하기 전 간단한 에러를 찾기 위해 실행합니다.
(실행 이미지는 DOCS에서 ..)

package.json
1npx playwright test --trace on

inspector 모드

trace 모드보다 직관적이고 디테일한 테스트 디버깅 모드입니다. 에디터에서 한 줄 한 줄 코드를 실행시키는 것처럼 테스트 코드를 실행하고 브라우저에서 반영되는 걸 확인할 수 있습니다.

package.json
1npx playwright test --debug
  • 생성되는 inspector 창 inspector mode

codegen 모드

codegen 모드를 이용하면 locator에 해당하는 부분을 자동으로 작성해 줍니다. locator를 assertion 하는 데엔 사람이 직접 해야 해 큰 효과는 없지만 locator를 지정하는데 복잡한 경우 유용하게 사용했습니다.

package.json
1npx playwright codegen https://coloso.co.kr/
  • 로그인 링크 hover codegen hover helper text
  • 로그인 링크 클릭 시 자동 생성되는 locator codegen locator

Playwright 리팩토링

테스트 케이스를 모두 마이그레이션 후, 리팩토링 한 과정을 공유합니다.

1. POM

Playwright는 코드 재사용을 위해 POM (Page Object Model)을 제안합니다. (Cypress의 command 기능과 유사)

저는 위에서 설정한 access token 삽입하는 과정을 재사용하기 위해 POM으로 구현했습니다.

tests/modles/pageObject.spec.ts
1import { Page } from '@playwright/test';
2
3export class LoginPage {
4 constructor(page) {
5 this.page = page;
6 }
7
8 async setToken(accessToken) {
9 await this.page.addInitScript((value) => {
10 window.localStorage.setItem('accessToken', value);
11 }, accessToken);
12 }
13}

브라우저 생성은 test 단위로 나눠지기 때문에 beforeEach를 안에 구현했습니다.

tests/auth.ts
1 import { LoginPage } from './LoginPage'
2
3 let accessToken;
4
5 // getAccessToken 과정...
6
7 test.beforeEach(async ({ page }) => {
8 // loginPageModel 인스턴스 객체 생성
9 const loginPageModel = new LoginPage(page);
10
11 // accessToken 삽입
12 await loginPageModel(accessToken)
13
14 await page.goTo('/');
15
16 });

2. ROM

마찬가지로 get Access Token 과정, Request도 obejct model 형식으로 구현했습니다. (이건 그냥 제가 지어봤습니다..)

tests/models/requestObject.spec.ts
1import { expect } from '@playwright/test';
2
3export class LoginRequest {
4 constructor(request) {
5 this.request = request;
6 }
7
8 async getAccessToken() {
9 const loginResponse = await this.request.post('/signIn', {
10 data: {
11 userEmail: '테스트 계정 이메일',
12 password: '테스트 계정 패스워드',
13 },
14 });
15 expect(loginResponse.ok()).toBeTruthy();
16 const loginResponseJson = await loginResponse.json();
17 return loginResponseJson.data.accessToken;
18 }
19}

getAccessToken 메서드는 테스트 파일별 한 번만 호출되면 되기 때문에 beforeAll 함수를 이용했습니다.

tests/auth.ts
1import { LoginRequest } from './LoginRequest'
2
3let accessToken;
4
5test.beforeAll(async ({ request }) => {
6 const loginRequest = new LoginRequest(request)
7 accessToken = await loginRequest.getAccessToken();
8});

3. test 함수 커스터마이징

기존의 test 함수를 확장할 수 있습니다. test 함수 확장을 통해 위에서 구현한 POM, ROM을 단 한 번만 인스턴스하여 재사용할 수 있습니다.

tests/customApi
1import { test as base } from '@playwright/test';
2
3import { LoginPage } from './LoginPage';
4import { LoginRequest } from './LoginRequest';
5
6export const test = base.extend({
7 loginRequest: async ({ request }, use) => {
8 await use(await new LoginRequest(request));
9 },
10 loginPage: async ({ page }, use) => {
11 await use(await new LoginPage(page));
12 },
13});
14
15export { expect } from '@playwright/test';

최종 리팩토링 테스트 코드

tests/auth.ts
1import { test, expect } from './customTest';
2
3let accessToken;
4
5test.beforeAll(async ({ loginRequest }) => {
6 accessToken = await loginRequest.getAccessToken();
7});
8
9test.beforeEach(async ({ loginPage }) => {
10 await loginPage.setToken(accessToken);
11});
12
13test('[인증 완료 페이지]', async ({ page, commonPage }) => {
14 await page.goTo('/')
15});

workflow 개선

Cypress workflow 시간 때와 얼마나 줄었는지 비교해 보겠습니다.

PlaywrightCypress
Test 시간1m 17s3m 24s

줄긴 했다. 그런데..?

사실 workflow 붙이는 작업을 하면서 뭔가 이상함을 느꼈습니다. 로컬에선 10초 내에 모두 끝나는 테스트가 workflow 내에선 몇 분씩이나 걸리는 거지? 답은 테스트를 수행하는 worker의 개수에 있습니다.

사실상 직렬 테스트

Playwright는 테스트를 병렬적으로 테스트하는 건 잘 알려진 특징인데요, 바로 worker의 개수만큼 병렬로 테스트를 진행합니다. 참고 기본적으로 이 worker의 개수는 테스트 환경 CPU의 50%를 기본으로 설정하고 있습니다. Github Action에서 제공하는 가상머신의 CPU는 2이니.. 사실상 직렬로 테스트를 한다고 판단이 되어 아쉬움이 좀 남았습니다.

  • Github Action workflow log. worker 수를 강제로 늘려도 1과 같은 시간으로 수행된다. github action worker 개수
  • 10 Core CPU M1 Mac Pro M1 Mac Pro worker 개수

마치며

workflow 개선이 다이나믹하게 변하진 않아 아쉬움이 남지만, 그럼에도 개인적으로 좋은 장점이 많다고 생각이 듭니다.

테스트 코드가 많으면 많을 수록 효과적이다.

속도에 대해 Cypress와 간단한 비교를 해봤습니다. input에 setTimeOut 200ms 설정 후 submit button을 누를 때 Playwright는 갱신되지 않은 data를 submit 하면서 에러가 발생했지만 Cypress는 간헐적으로 Pass가 됐습니다.

기본적으로 테스트 속도가 Cypress보다 빠르기 때문에 test case가 많으면 많을수록 더 효과적 일것 같습니다.

ci-skip을 이용해 local에서 Pass 하는 방법

ci-skip을 이용해 local에서 테스트 Pass 후 PR을 올리는 방식도 논의되었습니다. 좋은 방법이었지만 한번 E2E 테스트를 수행하면 3분이 넘는 시간이 걸렸기에 (migrator, build와 같은 과정도 껴있습니다.) 아쉽게도 채택되지 않았습니다. 하지만 Playwright를 이용한다면 local에서 10초 내로 끝나기 때문에 가능한 시나리오가 될 것이라고 판단됩니다.

참고

  1. Playwright 공식문서: https://playwright.dev/
  2. 브라우저 커스터마이징: https://playwright.dev/docs/api/class-browsercontext
  3. API Requset: https://playwright.dev/docs/api/class-apirequestcontext#api-request-context-post
  4. 브라우저 AddInitScript: https://playwright.dev/docs/api/class-page#page-add-init-script
  5. Page Object Model (POM): https://playwright.dev/docs/pom
  6. test 함수 확장: https://playwright.dev/docs/test-fixtures#creating-a-fixture
  7. worker 개수 설정: https://playwright.dev/docs/api/class-testconfig#test-config-workers