Puppeteer로 E2E 테스트하기 팁 - Puppeteer와 함께 속도 높이기!


원문 : https://goodguydaniel.com/blog/tips-end-to-end-testing-puppeteer/

자바스크립트 세계에서 E2E 테스트는 신나는 한 때를 보내고 있다. 지난 몇 년 동안, 자바스크립트 커뮤니티에서 cypressPuppeteer와 같은 도구들이 쏟아져나왔고 빠르게 채택되어 왔다.

오늘은 Puppeteer에 관해서 이야기할 것이다.

Puppeteer를 사용할 때 고려해야 할 사항들을 이해하는 데 전반적으로 도움이 될만한 실용적인 팁과 자원들을 공유하려고 한다.

다룰 주제들

  1. 실행하기
  2. 테스트 작성하기
  3. 디버깅하기
  4. 성능 측정 테스트 자동화
  5. 브라우저 지원 범위

실행하기

이번 목차에서는 Jest와 같은 기본 테스트 라이브러리/프레임워크 사용 시 고려해야 할 상호운용성(interoperability) 내용을 포함해, Puppeteer로 테스트를 실행하기 위한 주요 부분들을 다룬다.

병렬로 테스트 실행하기

작성한 테스트 케이스 묶음(test suite)을 다양한 브라우저 인스턴스에서 실행시키려면 선택한 테스트 러너(test runner)에 의존해야 한다. 예를 들어 Jest를 사용할 때는, 동시에 실행시킬 브라우저 세션 개수를 정의하기 위해서 maxWorkers 옵션을 설정한다.

전역 타임아웃값 알기

테스트 타임아웃에 대한 기본 전역 값을 증가시키는 것이 필요하다. E2E 테스트는 실행하는 데 몇 초가 소요될 수 있다. Jest를 사용한다면, testTimeout 속성을 사용해 타임아웃값을 설정할 수 있다. Jest 26.0의 기본값은 5초이다.

앞서 얘기한 설정을 위한 jest.config.js 예제 파일을 준비했다.

jest.config.js

module.exports = {
  verbose: true,
  rootDir: ".",
  testTimeout: 30000,
  maxWorkers: 3
};

mocha를 사용한다면 describe 블록 최상단에 this.timeout(VALUE_IN_SECONDS)를 추가할 수 있다.

puppeteer.launch 추상화하기

작성한 테스트를 검증하기 위해 puppeteer.launch를 실행해야 한다. 랩퍼 함수를 만들고 이 함수 안에서 호출하여 추상화하는 것이 좋다. 그렇게 하면 커스터마이징 한 모든 테스트 환경을 한 곳에서 쉽게 관리할 수 있다. 구성 내용은 다음과 같다.

  • 브라우저가 열어야할 페이지를 클라이언트가 지정할 수 있도록 한다.
  • 실행할 테스트의 네트워크 조건을 클라이언트가 선택할 수 있도록 한다. "네트워크 연결 속도 떨어뜨리기" 목차에서 이 내용을 다룰 것이다.
  • DevTools의 실행 여부 등을 클라이언트가 지정할 수 있도록 한다.

boot.js

import puppeteer from "puppeteer";

export default async function boot(options = {}) {
  let page = null;
  let browser = null;

  const {
    goToTargetApp = true,
    headless = true,
    devtools = false,
    slowMo = false
  } = options;

  browser = await puppeteer.launch({
    headless,
    devtools,
    ...(slowMo && { slowMo })
  });

  if (goToTargetApp) {
    page = await browser.newPage();
    // 테스트할 앱과 관련된 몇 가지 환경 변수가 여기 있다고 가정한다.
    await page.goto(process.env.APP_URL);
  }

  return { page };
}

실행 함수(launch function)를 가지고 테스트 환경의 설정을 추상화하여 사용하는 것을 선호한다. 이 함수를 가능한 한 작게 유지하려고 노력하지만, 가끔은 여기에 무언가를 더 추가하고 싶은 충동을 느끼곤 한다. 여기 이런 말이 있다.

"함수는 한 가지 일만 해야 한다. 그 일을 잘해야 하고. 그 일만 해야 한다."

원문: Robert C. Martin의 Clean Code 발췌

네트워크 연결 속도 떨어뜨리기

테스트는 다양한 네트워크 속도에서 실행될 수 있다. 운 좋게 찾은 gist 코드를 기반으로 한 사용 패턴을 공유하겠다.

puppeteer.launch 실행 구문을 추상화했다면, 다음과 같이 네트워크 프리셋(NETWORK_PRESETS)을 사용해 테스트 환경을 변경할 수 있다.

boot.js

import puppeteer from "puppeteer";
import NETWORK_PRESETS from "./network-presets";

export default async function boot(options = {}) {
  let page = null;
  let browser = null;

  const {
    goToTargetApp = true,
    headless = true,
    devtools = false,
    slowMo = false
  } = options;

  browser = await puppeteer.launch({
    headless,
    devtools,
    ...(slowMo && { slowMo })
  });

  if (goToTargetApp) {
    page = await browser.newPage();
    // 테스트할 앱과 관련된 몇 가지 환경 변수가 여기 있다고 가정한다.
    await page.goto(`${process.env.TARGET_APP_URL}${targetAppQueryParams}`);
    if (network && NETWORK_PRESETS[network]) {
      // 커스터마이징 한 네트워크 속도를 설정한다.
      const client = await page.target().createCDPSession();
      await client.send(
        "Network.emulateNetworkConditions",
        NETWORK_PRESETS[network]
      );
    }
  }

  return { page };
}

network-presets.js

// source: https://gist.github.com/trungpv1601/2ccd3cc998149a84ba80ed7a4c9ef562
export default {
  GPRS: {
    offline: false,
    downloadThroughput: (50 * 1024) / 8,
    uploadThroughput: (20 * 1024) / 8,
    latency: 500
  },
  Regular2G: {
    offline: false,
    downloadThroughput: (250 * 1024) / 8,
    uploadThroughput: (50 * 1024) / 8,
    latency: 300
  },
  Good2G: {
    offline: false,
    downloadThroughput: (450 * 1024) / 8,
    uploadThroughput: (150 * 1024) / 8,
    latency: 150
  },
  Regular3G: {
    offline: false,
    downloadThroughput: (750 * 1024) / 8,
    uploadThroughput: (250 * 1024) / 8,
    latency: 100
  },
  Good3G: {
    offline: false,
    downloadThroughput: (1.5 * 1024 * 1024) / 8,
    uploadThroughput: (750 * 1024) / 8,
    latency: 40
  },
  Regular4G: {
    offline: false,
    downloadThroughput: (4 * 1024 * 1024) / 8,
    uploadThroughput: (3 * 1024 * 1024) / 8,
    latency: 20
  },
  DSL: {
    offline: false,
    downloadThroughput: (2 * 1024 * 1024) / 8,
    uploadThroughput: (1 * 1024 * 1024) / 8,
    latency: 5
  },
  WiFi: {
    offline: false,
    downloadThroughput: (30 * 1024 * 1024) / 8,
    uploadThroughput: (15 * 1024 * 1024) / 8,
    latency: 2
  }
};

브라우저 익스텐션 불러오기

여기에 브라우저 익스텐션을 불러오는 방법이 있다.

// 1. 프로젝트에서 EXTENSION_PATH 값에 따라서 puppeteer를 실행한다.
// EXTENSION_PATH는 익스텐션 에셋의 디렉토리를 가리키는 상대 경로다.
browser = await puppeteer.launch({
  // 익스텐션은 head-full 모드로만 동작한다.
  headless: false,
  devtools,
  args: [
    `--disable-extensions-except=${process.env.EXTENSION_PATH}`,
    `--load-extension=${process.env.EXTENSION_PATH}`
  ],
  ...(slowMo && { slowMo })
});

// 2. title 값으로 익스텐션을 찾는다.
// 이것도 사용 사례에 따라 다르게 다루고 싶어할 수 있다.
const targets = await browser.targets();
const extensionTarget = targets.find(({ _targetInfo }) => {
  return _targetInfo.title === "my extension page title";
});

// 3. URL에서 extensionId를 얻는다.
// 고정된 extensionId를 가지고 있다면 환경 변수의 값을 가져오고 그렇지 않으면 빈 값으로 동작할 것이다.
const partialExtensionUrl = extensionTarget._targetInfo.url || "";
const [, , extensionID] = partialExtensionUrl.split("/");
// "popup.html" 파일은 익스텐션의 진입점이다.
const extensionPopupHtml = "popup.html";

// 4. 새 탭에서 크롬 익스텐션 열기
// 익스텐션 URL을 정상적으로 빌드하려면 extensionId와 진입점 파일이 필요하다.
extensionPage = await browser.newPage();
extensionUrl = `chrome-extension://${extensionID}/${extensionPopupHtml}`;
await extensionPage.goto(extensionUrl);

// ... 이제 extensionPage을 사용하여 익스텐션과 상호 작용한다.

Puppeteer로 크롬 익스텐션을 테스트하는 방법에 대해서 더 알고 싶다면, Gokul Kathirvel크롬 익스텐션 UI 테스트 자동화하기 글을 읽어보길 바란다.

테스트 작성하기

이 장에서 "input 필드에서 텍스트 값 지우기" 목차를 제외한 다른 내용들은 공식 문서에서 쉽게 찾을 수 있다. Puppeteer API의 필수 주제들을 살펴보겠다.

page.evaluate로 작업하기

page.evaluate를 사용할 때, 페이지 컨텍스트 상에서 실행된다는 것을 자세히 알아야 한다. 즉, 화살표 함수의 인자로 page.evaluate를 사용하면 함수 밖에서 page.evaluate를 참조할 수 없다. page.evaluate의 세 번째 인자로 필요한 모든 데이터를 제공해야 한다는걸 잊지 말아라.

// input 엘리먼트에서 "value" 값 추출하기
const inputValue = await inputEl.evaluate(e => e.value);

page.waitForSelector와 page.waitForFunction

page.waitForSelectorpage.waitForFunction API에 익숙해지면 테스트를 작성하는 데 생산성이 매우 높아진다. 변경 사항을 작성하기 위한 몇 가지 테스트가 있는 경우, 테스트를 진행하기 전 UI에는 일부 조건이 충족될 때까지 기다려야 된다. 테스트 진행을 일시 정지하고 UI를 기다리는 일은 Puppeteer에서만 일어나는 것이 아니라 일반적인 관행이다. 아래에서 몇 가지 기본적인 사용 예를 살펴보자.

// 이 함수는 테스트를 진행하기 전에 메뉴가 표시되기를 기다린다.
// 이렇게하면 메뉴의 리스트 항목과 상호 작용할 수 있다.
const getSmuiSelectOptions = async () => {
  const selector = ".mdc-menu-surface li";
  await page.waitForSelector(selector, { timeout: 1000 });
  return await page.$$(selector);
};

// 애플리케이션에서 어떤 항목이 제거되었을 때 스네이크바가 나타날 때까지 기다린다.
await extensionPage.waitForFunction(
  () =>
    !!document
      .querySelector('*[data-testid="global-snackbar"]')
      .innerText.includes("deleted"),
  {
    timeout: 2000
  }
);

결정을 내려야한다. 타임아웃 시간을 늘리거나 줄여야 한다. 테스트 속도를 빠르게 하기 위해서는 가능한 타임아웃 시간을 짧게 설정하기를 권장한다. (목킹되지 않은) 네트워크 요청을 수행해야 하는 시스템에 대해 E2E 테스트를 실행하는 것은 네트워크 불안정성을 고려해야 한다는 것을 의미한다. 일반적으로 완벽한 네트워크 조건에서 실행되더라도, 당신은 타임아웃값으로 약간의 여유마저 줄이고 싶어할 것이다.

element.select

네이티브 HTML select 엘리먼트에서 옵션을 선택할 수 있는 방법이 마음에 든다. 이것은 단일 및 다중 선택에서 모두 동작하며 자연스럽다.

// "custom-http-method" id 값을 가진 select 엘리먼트에서 HTTP method 방식을 선택하기
const selectEl = await page.$("#custom-http-method");

await selectEl.select("POST");

element.select를 사용하면 편리하지만, Material UI 컴포넌트의 select 객체처럼 숨겨진 input 필드를 가진 div > ul > li 구조로 작성된 사용자 정의 select 필드에 대해서는 다르게 접근해야 한다.

스크린샷

특정 테스트 케이스의 경우에는 테스트 전체에서 애플리케이션이 어떻게 보이는지 타임라인으로 생성해주는 스크린샷 묶음으로 출력하고 싶다. 테스트 사이에 스크린샷을 찍으면, 실패한 테스트에서 무엇을 디버깅할지 초기 지점을 알 수 있다. page.screenshot API를 감싼 작은 유틸리티 모듈이 여기 있다.

// 스크린샷이 실패했을 때 테스트를 중단시키지 않기 위해서 `page.screenshot` 호출을 try-catch로 감쌌다.
export async function prtScn(
  page,
  path = `Screenshot ${new Date().toString()}`
) {
  try {
    await page.screenshot({ path, type: "png", fullPage: true });
  } catch (error) {
    // eslint-disable-next-line no-console
    console.error(error);
    // eslint-disable-next-line no-console
    console.info("Failed to take screenshot but test will proceed...");
    return Promise.resolve();
  }
}

import * as utils from "test-utils";
// (...)

// 참고: 이 유틸리티는 클래스로 만들 수 있고 스크린샷을 찍을 때마다 매번 전달하지 않도록 페이지를 컨텍스트로 전달한다.
await utils.prtScn(page);

페이지 다시 불러오기

page.reload API를 사용해보자. 테스트를 계속해서 진행하기 전에 특정 브라우저 작업이 유휴 상태(idle)가 될 때까지 기다리는 옵션 세트를 지정할 수 있다.

await page.reload({ waitUntil: ["networkidle0"] });

위 예제에서는 networkidle0를 사용하여 페이지를 다시 불러오고, 0.5초 내에 HTTP 요청이 없으면 테스트를 진행할 수 없다.

input 필드에서 텍스트 지우기

필자는 input 필드를 지우는 아주 창의적인 방법을 찾느라 기절할뻔 했다. 몇몇 개발자들은 이 기능에 관심을 보였었지만, 반대로 관심이 없어 보이기도 했다. 찾아낸 방법은 다음과 같다.

/**
 * 엘리먼트 지우기
 * @param {ElementHandle} el
 */
export async function clear(el) {
  await el.click({ clickCount: 3 });
  await el.press("Backspace");
}

세 번 연속된 클릭으로 전체 텍스트를 선택하는 기능을 사용하며 이 방법은 크롬에서만 동작한다. 그런 다음 전체 필드를 지우기 위해 키보드 이벤트를 트리거해야 한다.

디버깅하기

몇 가지 디버깅 기술을 강조하고 싶고, 특히 slowMo 옵션이 그렇다.

slowMo로 디버깅하기

slowMo 옵션을 사용하여 개별 테스트를 디버그하고 싶을 것이다. 이 옵션은 사람이 실제로 애플리케이션과 상호 작용하는 것과 같은 상황에서 확인할 수 있도록 E2E 테스트의 상호 작용(단계)을 느리게 만든다. 이 방법은 굉장히 가치있다고 생각한다.

page.launch({ slowMo: 50 });

다음 GIF 이미지에서 slowMo 옵션을 사용해서 실행했을 때와 그렇지 않았을 때의 차이를 볼 수 있다.

source: © 2020 by goodguydaniel.com slowMo 옵션을 사용하지 않은 tweak 크롬 익스텐션에 대한 E2E 테스트. 무슨 일이 일어나고 있는지 알 수 없다.

source: © 2020 by goodguydaniel.com slowMo 옵션을 사용한 tweak 크롬 익스텐션에 대한 E2E 테스트. 마치 사람이 입력하는 것처럼 'URL' 영역에 문자가 나타나는 것을 볼 수 있다.

이 예제에서는 서로 다른 사용 사례를 보여주기 위해서 tweak 브라우저 익스텐션을 사용했다.

디버깅을 위한 더 놀라운 팁을 주자면, 구글의 디버깅 팁에 관한 짧은 글을 읽어보길 바란다.

디버거 사용하기

구글의 디버깅 팁에서 이 내용을 얻었다. X초 동안 테스트를 정지시키고 테스트 실패 이유를 확인하고 애플리케이션을 검사하기 위해 sleep문을 던지는 습관이 있었다. 그러나 지금은 아래 코드를 사용하는 방식으로 완전히 바꿨다.

await page.evaluate(() => {
  debugger;
});

더 훌륭한 디버깅 팁을 보려면, 구글의 디버깅 팁에 관한 짧은 글을 읽어보길 바란다.

성능 측정 테스트 자동화

웹 성능 테스트를 자동화하기 위해 Puppeteer를 사용하는 것이 굉장히 화제 다. Addy Osmani가 개발한 addyosmani/puppeteer-webperf 모듈이 없었다면 이 글을 작성할 수도 없었고, 이 모듈 외에 더 추천할만한 것도 없다. 프로젝트의 README.md에서 성능 자동화를 사용해볼 수 있는 여러 예제를 확인할 수 있다.

브라우저 지원 범위

공식 문서에 따르면, 이 글을 작성하던 시점에 호환성 실험 단계로 발생할 수 있는 몇 가지 문제만 주의하면 파이어폭스에서도 Puppeteer를 사용할 수 있다고 했다. "puppeteer.launch 추상화하기" 목차에서 다룬 puppeteer.launch API 옵션을 통해서 실행시킬 브라우저를 지정할 수 있다.

Puppeteer에서 가장 좋아하는 부분은 무엇인가? 다음에 무엇을 배우고 싶은지 추천해주지 않겠는가?

류선임2020.06.30
Back to list