Playwrite

E2E(End to End) Test

실제 사용자가 사용하는 브라우저 환경에서 애플리케이션 전체를 동작해서 기능을 확인하는 테스트

즉, 사용자가 실제로 소프트웨어를 사용했을때 플로우를 테스트하는 것

E2E Test 특징

  • 실제 사용자 환경에서 테스트하므로 사용자의 입장에서의 경험을 직접적으로 테스트할 수 있다.

  • 전체 시스템을 대상으로 테스트를 수행하기 때문에 각각의 모듈이나 컴포넌트가 함께 작동할 때 발생하는 문제점들을 파악 가능

  • 외부 환경이나 네트워크를 이용한다면 조금 불안정 할 수 있다.

E2E Test Framework

Cypress

npm i -D cypress
npx cypress open

Cypress의 예제 코드를 살펴보면 UI 요소를 가져올 때 내부 구현 사항 (예: class)에 의존하고 있다.

testing-library는 Cypress도 지원하므로 함께 사용하면 UI 요소를 사용자 관점에서 가져올 수 있다

npm i -D @testing-library/cypress

E2E 테스트에서도 마찬가지로, 네트워크 관련 의존성들은 Mock 데이터를 활용하여 테스트를 진행한다.

이는 사용자의 실제 환경과 유사한 환경에서 테스트를 수행하더라도 외부 의존성에 의해 테스트가 실패하는 것을 방지하기 위함이다.

import '@testing-library/cypress/add-commands'

describe('App', () => {
  beforeEach(() => {
    cy.intercept('GET', '/restaurants', {
      fixture: 'restaurants.json', 
    })
    
    cy.visit('http://localhost:3001/');
  })

  it('display text search in the header', () => {
    cy.findByText(/search/).should('exist');
  })
})

Headless Chrome

Headless 상태에서도 크롬 브라우저를 실행할 수 있는 방법, 크롬없이 크롬을 실행하는 것이라고 설명하는데 크롤링을 하거나 E2E 테스트할 때 유용하다고 한다.

Chrome 브라우저의 기능을 그대로 가지고 있지만, 실제로 화면을 그리지 않고 내부적으로만 실행되는 모드 즉, GUI -> CLI

headless란?

Headless는 일반적으로 사용자 인터페이스 (UI)가 없는 응용 프로그램을 의미

"웹 개발에서는 브라우저에 UI를 렌더하지 않고 탐색하는브라우저 모드를 의미하기도 한다. 이 모드에서는 웹 사이트를 브라우저 창이나 탭 없이 백그라운드에서 실행할 수 있으며, 대개 자동화된 웹 테스트나 웹 스크래핑 작업 등에서 사용된다"

  • 일반적으로 headless로 동작하면 화면에 UI를 렌더링하지 않으므로 테스트 실행속도가 더 빠르다.

  • CI/CD 파이프라인에서 자동화된 테스트를 수행할 때 유용하다.

Puppeteer

Google에서 개발한 Node.js용 Headless Chrome 라이브러리

Puppeteer는 Headless Chrome을 자바스크립트로 제어하여 웹 크롤링, 스크린샷, E2E 테스트 등 다양한 작업을 자동화할 수 있다.

Chrome DevTools Protocol을 사용하여 Chrome 브라우저의 인스턴스를 제어하며, 이를 통해 Chrome 브라우저의 모든 기능을 사용할 수 있다.

E2E 테스트 도구로 보다 간단하고 테스트 코드 작성을 비개발자도 쉽게 이해할 수 있도록 직관적인 API를 제공한다.

장점

  • 가독성이 높은 DSL(Domain Specific Language)을 제공하여 테스트 코드 작성이 용이함

  • Selenium, Puppeteer, Playwright 등, 다양한 드라이버를 제공하여 다른 도구로 작성된 테스트 코드를 CodeceptJS 로 변환이 가능하다.

Getting Started

npx create-codeceptjs .

해당 명령어를 입력하면 package.json 스페이스가 4칸으로 바뀌게 된다. 아래 명령어를 통해 복구!

sed "s/    /  /g" package.json > package.json.tmp
rm package.json
mv package.json.tmp package.json

package.json 에 script가 자동으로 추가된다.

"scripts": {
  // 웹 브라우저를 화면에 띄워 테스트를 실행합니다. --steps: 각 단계의 실행 결과가 자세히 표시
  "codeceptjs": "codeceptjs run --steps",
  // 웹 브라우저를 화면에 띄우지 않고 테스트를 실행
  "codeceptjs:headless": "HEADLESS=true codeceptjs run --steps",
  // 웹 브라우저에 CodeceptUI를 띄워 훨씬 편하게 테스트를 실행합니다.
  "codeceptjs:ui": "codecept-ui --app",
  // demo는 기능과 사용법을 익히기 위해 codeceptjs가 제공하는 샘플 시나리오를 실행하는것
  "codeceptjs:demo": "codeceptjs run --steps -c node_modules/@codeceptjs/examples",
  "codeceptjs:demo:headless": "HEADLESS=true codeceptjs run --steps -c node_modules/@codeceptjs/examples",
  "codeceptjs:demo:ui": "codecept-ui --app  -c node_modules/@codeceptjs/examples"
}
npm uninstall @codeceptjs/examples 
npx codeceptjs init

? Where are your tests located? (./*_test.js)
# => ./tests/**/*_test.js

? What helpers do you want to use?
(Use arrow keys)
# => ❯ Playwright

? Where should logs, screenshots, and reports to be stored? (./output)
# => ./output

? Do you want localization for tests? (See https://codecept.io/translation/)
(Use arrow keys)
# => ❯ English (no localization)

? [Playwright] Base url of site to be tested (http://localhost)
# => http://localhost:8080

? [Playwright] Show browser window (Y/n)
# => Y

? [Playwright] Browser in which testing will be performed.
Possible options: chromium, firefox or webkit (chromium)
# => chromium

Creating a new test...
----------------------

? Feature which is being tested (ex: account, login, etc)
# => google

? Filename of a test (google_test.js)
# => google_test.js

샘플 코드

Feature('Google');

Scenario('Search “CodeceptJS”', ({ I }) => {
  I.amOnPage('https://www.google.com/ncr');
  I.fillField('[name="q"]', 'CodeceptJS');
  I.click('Google Search');
  I.see('codecept.io');
});

실행

npm run codeceptjs

Playwright

Headless Chrome을 기반으로 한 Puppteer을 계승하면서 더 많은 웹 브라우저를 지원한다.

npm i -D @playwright/test eslint-plugin-playwright
npx playwright test

환경설정

playwright.config.ts

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

const config: PlaywrightTestConfig = {
  testDir: './tests',
  retries: 0,
  use: {
    channel: 'chrome',
    baseURL: 'http://localhost:8080', // 도메인 URL 설정
    headless: !!process.env.CI, // CI 환경에선 headless로 동작 
    screenshot: 'only-on-failure', // 테스트 실패할 때 스크린샷을 찍어 남김
  },
};

export default config;

.gitignore 파일에 에러 상황의 스크린샷 등이 담기는 test-results/ 디렉터리 추가.

tests/.eslintrc.js

module.exports = {
  env: {
    jest: false, // jest를 사용하지 않고 playwright에서 제공하는 api를 사용하므로 false
  },
  extends: ['plugin:playwright/playwright-test'],
  rules: {
    'import/no-extraneous-dependencies': 'off',
  },
}

jest.config.js

E2E 테스트는 .spec, 단위 테스트는 .test로 구분하여 단위테스트 실행시 E2E 테스트는 실행하지 않도록 설정

module.exports = {
  //...
  testMatch: ['**/*.test.{ts,tsx}', '!**/*.spec.{ts,tsx}'],
  // 또는
  testPathIgnorePatterns: ['<rootDir>/e2e/'],
};

테스트 코드 예시

UI가 복잡해지면 사용자 관점에서 특정 요소를 지정하는 것이 아직은 어려운데 추후에 알게되면 해당 내용을 반영해서 다시 작성할 예정

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

test('Filter Food', async ({ page }) => {
  await page.goto('/');

  await expect(page.getByRole('heading', { name: '식당 검색창' })).toBeVisible();

  const searchInput = page.getByLabel('검색');

  await searchInput.fill('메리김밥');

  await expect(page.getByText('제육김밥')).toBeVisible();

  await searchInput.fill('메가반점');

  await expect(page.getByText('짜장면')).toBeVisible();

  await searchInput.fill('아무거나');

  await expect(page.getByText('짜장면')).toBeHidden();

  const radioBtn = page.getByRole('radio', { name: '한식' });

  await radioBtn.click();

  await expect(page.getByText('메리김밥')).toBeVisible();

  await expect(page.getByText('메가반점')).toBeHidden();
});

test('Cart', async ({ page }) => {
  await page.goto('/');

  const addBtn = await page.waitForSelector('button[name="#짜장면"]');

  await addBtn.click();
  await addBtn.click();

  await expect(page.getByText('합계: 8000원 주문')).toBeVisible();

  const removeBtn = page.getByRole('button', { name: '짜장면제거' });

  await removeBtn.click();

  await expect(page.getByText('합계: 0원 주문')).toBeVisible();
});

test('When order complete display receipt ', async ({ page }) => {
  await page.goto('/');

  const addBtn = await (page.waitForSelector('button[name="#짜장면"]'));

  await addBtn.click();

  const orderBtn = page.getByRole('button', { name: '주문하기' });

  await orderBtn.click();

  await expect(page.getByRole('table', { name: '영수증' })).toBeVisible();
});

test('Receipts disappear after 5 seconds', async ({ page }) => {
  await page.goto('/');

  const addBtn = await page.waitForSelector('button[name="#짜장면"]');

  await addBtn.click();

  const orderBtn = page.getByRole('button', { name: '주문하기' });

  await orderBtn.click();

  // eslint-disable-next-line playwright/no-wait-for-timeout
  await page.waitForTimeout(5000);

  await expect(page.getByRole('table', { name: '영수증' })).toBeHidden();
  await expect(page.getByRole('heading', { name: '[영수증 나오는 곳]' })).toBeVisible();
});

waitFor*와 같은 특정 시간을 기다리는 API를 사용할 때, 이전에 expect()를 작성했다면 동작하지 않는다.

Last updated