logo
menu

프론트엔드 테스트 코드 경험 2

2024. 07. 20.

  • #리액트

  • #프로젝트

이전 편에서 테스트 코드에 대한 기본적인 개념과 react-testing-library 에 대해서 테스트 코드 작성 경험에 대해서 공유했다면 이번편에서는 제가 적용하고 있는 테스트 방식에 대해서 공유하고자 합니다.
 
현재 저는 E2E 테스트 툴인 playwright 기반으로 테스트를 진행하고 있기 때문에 playwright 를 활용하여 테스트를 작성하는 방식에 대해 공유하고자 합니다.
 

✅ Cypress vs Playwright

E2E 테스트의 대표적인 툴이 cypress, playwright 가 있습니다. 이 2가지 툴 중에 playwright 를 사용하게 된 이유는 아래와 같습니다.
  1. Playwright 가 러닝 커브가 낮다
    1. cypress 는 자체 문법 들을 학습해야하는데 playwright 는 react-testing-library, jest-dom 문법과 거의 비슷해서 러닝커브가 낮다고 판단합니다
  1. Playwright 에 code-gen 기능을 제공한다. (유저 행동을 녹화해서 해당 액션을 코드로 작성해줍니다)
    1. 현재는 cypress 도 제공하고 있습니다.
  1. Playwright 가 좀 더 다양한 환경 제공
    1. cypress 는 크롬, Electron, Firefox 를 제공하고 Playwright 는 Chromium, WebKet, Firefox 뿐만 아니라 기본 모바일 애뮬레이션을 제공한다.
  1. Playwright 가 속도가 더 빠르다.
    1. Cypress 실제 브라우저 환경 + iframe 방식으로 동작한다면(실제 브라우저를 띄우고 동작) Playwright 는 Socket 과 CDP(chrome devtool protocol)을 사용하여 브라우저 테스트를 하여 Playwright 가 더 빠름
      그리고 Playwright 는 기본적으로 병렬 기능을 제공하지만 Cypress 는 유료 결제 또는 우회하는 추가 설정 필요
 

✅ 나의 테스트 방식

  • 구현한 기능들 한정 테스트 코드를 작성합니다.
  1. 코드를 구현하기 전에 디자인과 기획서를 참고해서 작업에 따른 테스트 시나리오를 작성합니다.
  1. 기능 구현
    1. 구현하면서 놓친 케이스가 존재하면 해당 시나리오를 추가합니다.
  1. 놓친 테스트가 있다면 추가로 테스트 작성
해당 방식을 선택한 이유는 기능을 다 만들고 테스트 코드를 작성하면 제가 구현한 것만 테스트 코드를 한다고 생각합니다(폭이 좁아짐) 그래서 기획서와 디자인을 참고해서 발생할 수 있는 케이스를 모두 시나리오를 작성해서 미리 어떤 작업을 구현할 것인지 고민할 수 있고 개발하면서 예상하지 못했던 케이스도 확인할 수 있어서 좀 더 넓게 테스트 코드를 작성할 수 있어서 해당 방식을 사용하고 있습니다.
 

✅  Playwright 테스트시 알아두면 좋은 내용

Playwright 에서 다양한 개념과 기능들이 있는데 실제적으로 도움이 됐던 내용을 공유하고자 합니다.

1. Getting started - VS Code

notion image
테스트 코드를 관리하는 확장 프로그램으로 작성한 테스트 코드를 리스트 형태로 노출한다. 테스트를 실행시키는 기능과 디버깅 기능을 제공해준다.
또한 code-gen 기능을 제공하여 실제 행동이 코드로 변환되는 기능을 제공한다. 그리고 Pick Locator 기능을 통해 특정 DOM 에 접근할 수 있는 기능을 제공하여 더 쉽게 테스트 코드를 작성할 수 있습니다.
 

2. Trace viewer

notion image
테스트의 실행 과정을 기록한 트레이스 파일을 시각적으로 탐색할 수 있도록 도와주는 GUI 도구
주요 기능
  • 트레이스 타임라인: 테스트 실행 과정을 시각적으로 표시하며, 각 단계에서 발생한 이벤트를 확인할 수 있습니다.
  • DOM 스냅샷: 각 단계에서 웹 페이지의 DOM 트리를 확인할 수 있습니다.
  • 네트워크 요청: 테스트 실행 과정에서 발생한 네트워크 요청을 확인할 수 있습니다.
  • 콘솔 로그: 테스트 실행 과정에서 출력된 콘솔 로그를 확인할 수 있습니다.
  • 스크립트 코드: 테스트 스크립트 코드를 확인할 수 있습니다.
 

3. Fixture

fixture 는 테스트에 필요한 설정을 관리하는 개념으로 테스트에 필요한 공통 자원 및 기능을 재사용 가능한 코드 블록으로 캡술화하여 테스트 코드 작성 및 유지 관리를 간소화할 수 있습니다.
예제
import { test as base } from '@playwright/test'; import { TodoPage } from './todo-page'; // Extend basic test by providing a "todoPage" fixture. const test = base.extend<{ todoPage: TodoPage }>({ todoPage: async ({ page }, use) => { const todoPage = new TodoPage(page); await todoPage.goto(); await todoPage.addToDo('item1'); await todoPage.addToDo('item2'); await use(todoPage); await todoPage.removeAll(); }, }); test('should add an item', async ({ todoPage }) => { await todoPage.addToDo('my item'); // ... }); test('should remove an item', async ({ todoPage }) => { await todoPage.remove('item1'); // ... });
 

4. Mock APIs

API 를 모킹하는 방식이 msw 와 같은 라이브러리를 사용하거나 Playwright 에서 제공하는 방식이 있는데 저는 Mock APIs 에서 제공하는 방식을 사용하고 있습니다. (하지만, 추후에는 msw 로 관리할지 고민하고 있습니다)
Mock APIs 방식
  1. 내장 API 인 page.route 를 활용하여 API 모킹하기
    1. test("mocks a fruit and doesn't call api", async ({ page }) => { // Mock the api call before navigating await page.route('*/**/api/v1/fruits', async route => { const json = [{ name: 'Strawberry', id: 21 }]; await route.fulfill({ json }); }); // Go to the page await page.goto('https://demo.playwright.dev/api-mocking'); // Assert that the Strawberry fruit is visible await expect(page.getByText('Strawberry')).toBeVisible(); });
  1. HAR file 방식으로 관리하기
    1. HAR file 은 페이지가 로드될 때 이루어진 모든 네트워크 요청 기록이 포함된 HTTP 아카이브 파일 (request, response, header, cookie, contents, timing 등)
      page.routeFromHAR API 를 이용해 해당 시점으로 테스트 수행시 해당 API 들을 기록하고 이후에 테스트 수행할 때는 이전에 기록한 HAR file 의 API 환경 기반으로 테스트를 수행합니다. 따라서 HAR file 로 API 를 모킹하는 방식입니다.
      Recording HAR file 예제
      test('records or updates the HAR file', async ({ page }) => { // Get the response from the HAR file await page.routeFromHAR('./hars/fruit.har', { url: '*/**/api/v1/fruits', update: true, }); // Go to the page await page.goto('https://demo.playwright.dev/api-mocking'); // Assert that the fruit is visible await expect(page.getByText('Strawberry')).toBeVisible();
      Replaying HAR File (re use)
      test('gets the json from HAR and checks the new fruit has been added', async ({ page }) => { // Replay API requests from HAR. // Either use a matching response from the HAR, // or abort the request if nothing matches. await page.routeFromHAR('./hars/fruit.har', { url: '*/**/api/v1/fruits', update: false, }); // Go to the page await page.goto('https://demo.playwright.dev/api-mocking'); // Assert that the Playwright fruit is visible await expect(page.getByText('Playwright', { exact: true })).toBeVisible(); });
저는 page.route 방식으로 API를 모킹하고 있습니다. 왜냐하면 해당 방식이 제가 원하는 케이스로 조작(동작)? 하기 편하고 유지보수가 쉽다고 생각하기 때문입니다
 

5. Authentication

Playwright 는 테스트마다 격리된 환경에서 테스트를 수행하는데 이때 매번 인증과정(로그인 페이지 → 로그인 수행 → 원하는 테스트 케이스) 을 하는 것은 매우 비효율적입니다.
그래서 Playwright 에서 기존의 인증된 상태를 로드하는 방식을 제공
  1. 인증된 브라우저 상태를 준비하는 auth.setup.ts 파일 준비
    1. import { test as setup, expect } from '@playwright/test'; const authFile = 'playwright/.auth/user.json'; setup('authenticate', async ({ page }) => { // 로그인 과정 수행 await page.goto('https://github.com/login'); await page.getByLabel('Username or email address').fill('username'); await page.getByLabel('Password').fill('password'); await page.getByRole('button', { name: 'Sign in' }).click(); // 로그인 성공 await page.waitForURL('https://github.com/'); await expect(page.getByRole('button', { name: 'View profile and more' })).toBeVisible(); // 로그인 성공 후 관련 스토리지 정보를 authFile 경로에 저장 await page.context().storageState({ path: authFile }); });
  1. playwright.config.ts 환경에서 auth.setup.ts 의 환경을 공유하도록 설정
    1. import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ projects: [ // Setup project { name: 'setup', testMatch: /.*\.setup\.ts/ }, { name: 'chromium', use: { ...devices['Desktop Chrome'], // Use prepared auth state. storageState: 'playwright/.auth/user.json', }, dependencies: ['setup'], }, { name: 'firefox', use: { ...devices['Desktop Firefox'], storageState: 'playwright/.auth/user.json', }, dependencies: ['setup'], }, ], });

6. 테스트 속도 개선

E2E 테스트는 기본적으로 실제 브라우저 환경에서 동작하기 때문에 RTL 테스트에 비해 속도가 느리다. 그리고 각 테스트는 독립적인 환경을 위해 각 테스트마다 브라우저를 띄우기 위한 작업을 수행하므로 속도가 느리다.
Playwright 에서 테스트 속도를 개선하기 위해 2가지 방식을 제공한다.
  1. Parallelism (병렬 처리)
    1. 각 테스트마다 동기적으로 하나씩 처리하는 것이 아닌 여러개의 worker 를 지정하여 여러 테스트를 동시에 실행시키는 방식
      npx playwright test --workers 4
  1. Sharding
      • 테스트 코드 모음을 여러 컴퓨터에서 실행할 수 있음
      npx playwright test --shard=1/4 // 각 PC 에서 npx playwright test --shard=2/4 // 각 PC 에서 npx playwright test --shard=3/4 // 각 PC 에서 npx playwright test --shard=4/4 // 각 PC 에서
💡
마지막으로 Best Practices 를 추천한다!
 

✅  실전 테스트 예제

링크 클릭시 새 탭이 노출되는지

RTL 의 경우 테스트 실행 환경이 NodeJS 환경이기 때문에 새 탭을 테스트할 때에는 href 속성과 target 속성이 잘 있는지 테스트 했다면, Playwright 에서는 실제로 새 탭이 잘 나왔는지 테스트할 수 있다.
예제
// RTL it('"공지사항"을 클릭하면 "공지사항" 페이지로 이동한다.', async () => { customRender(); const 공지사항 = screen.getByRole('link', { name: '공지사항 바로가기', }); expect(공지사항).toHaveAttribute('target', '_blank'); expect(공지사항).toHaveAttribute( 'href', MY_PAGE_SERVICE_LINK.ANNOUNCEMENT, ); });
// Playwright test('"공지사항"을 클릭하면 "공지사항" 페이지로 이동한다.', async ({ page }) => { await page.getByRole('link', { name: '공지사항' }).click(); // new tab open test const newPage = await page.waitForEvent('popup'); await newPage.waitForURL((url) => url.href.includes(MY_PAGE_SERVICE_LINK.ANNOUNCEMENT)) // URL 이 일치하는지 확인 await expect(newPage.getByText("공지사항")).toBeVisible(); // 새 탭에서 노출되는 UI 요소 (이렇게 테스트 하면 실제 UI 도 잘 나오는지 확인 가능) }); });
 
 

퍼널 패턴 테스트

퍼널 작업을 URL 로 현재 퍼널 단계를 알 수도 있지만 상태로 관리하였다. 그래서 해당 퍼널 패턴을 테스트 하기 위해 각 스텝마다 session-storage 를 정의하여 처리했다.
Playwright 에서 session storage 는 context().storageState 로 저장이 안되기 때문에 해당 방식을 참고해서 sessionStorage 를 정의할 수 있다.
예제
// Step 2 const test = base.extend({ // override `context` fixture to add init script context: async ({ context }, use) => { await context.addInitScript((initApplyValues) => { window.sessionStorage.setItem( 'teacher-applied', JSON.stringify({ ...initApplyValues, step: 2, // ✅ }), ); }, initApplyValues); await use(context); }, }); // Step 3 const test = base.extend({ // override `context` fixture to add init script context: async ({ context }, use) => { await context.addInitScript((initApplyValues) => { window.sessionStorage.setItem( 'teacher-applied', JSON.stringify({ ...initApplyValues, step: 3, // ✅ }), ); }, initApplyValues); await use(context); }, }); test.describe('활동 시작 섹션', () => { test.beforeEach(async ({ page }) => { await page.goto('http://localhost:3000/teacher-apply'); }); test('활동 시작 섹션이 노출된다.', async ({ page, context }) => { await expect(page.getByText('활동을 시작할 수 있는 일자를 선택해 주세요')).toBeVisible(); await expect(page.getByText('지원서 작성 일자로부터 최대 1개월까지 선택이 가능합니다.')).toBeVisible(); await expect(page.getByPlaceholder('YYYY.MM.DD')).toBeVisible(); }); });
 
 

외부 API/라이브러리 팁

iamport 를 이용해서 본인인증 기능을 테스트 하는데 해당 기능은 외부에서 제공한다. iamport 에서 제공하는 본인인증 UI도 테스트 코드로 (이름, 나이, 전화번호 입력 및 버튼 클릭) 자동화할 수는 있지만 본인인증의 경우 사람인지 판단하는 랜덤 값 입력 항목과 본인인증 성공시 문자로 오는 랜덤한 값으로 인해 테스트하기에 어려움 성이 있다.
그래서 해당 부분을 고민하다가 내부적으로 API 응답값으로 처리되기 때문에 외부 라이브러리의 API 를 모킹해서 의도대로 처리하여 (본인인증 성공, 실패 제어 가능) 해당 부분을 테스트했다.
 

Date 테스트 팁 (캘린더 테스트 팁)

주로 캘린더를 만들면 현재 날짜를 기준으로 캘린더의 다양한 기능을 제공할 수 있다. 이런 경우 날짜는 매번 달라지게 되는데 이런 경우 필자는 특정 날짜를 지정해서 테스트를 작성하는 편이다. (매일 달라지는 날에 맞춰서 테스트 코드를 수정할 수 없고 하면 안된다!)
예제
  • react-day-picker 라이브러리 활용해서 캘린더 구현한 경우
const test = base.extend({ // override `page` page: async ({ page }, use) => { const fakeNow = new Date('2024.05.15').valueOf(); // fixture 에서 처리 가능 await page.addInitScript(`{ Date = class extends Date { constructor(...args) { if (args.length === 0) { super(${fakeNow}); } else { super(...args); } } } const __DateNowOffset = ${fakeNow} - Date.now(); const __DateNow = Date.now; Date.now = () => __DateNow() + __DateNowOffset; }`); await use(page); }, }); test('[캘린더] 지원서 작성 기준으로 한달 까지만 선택할 수 있다.', async ({ page }) => { await page.getByPlaceholder('YYYY.MM.DD').click(); const calendar = await page.getByTestId('calendar'); await expect(calendar).toBeVisible(); // 2024년 5월 15일 이후부터 선택 가능 await expect(calendar.getByText('14')).toBeDisabled(); await expect(calendar.getByText('15')).toBeEnabled(); // 다음 년도는 전월 일자 - 1 일 까지 가능 await calendar.getByTestId("calendar-next-month").click(); await expect(calendar.getByText('14')).toBeEnabled(); await expect(calendar.getByText('15')).toBeDisabled(); });
  • 2024.05.15 일로 날짜 모킹해서 해당 일자를 기준으로 한달 까지 선택할 수 있는 것을 테스트
 
 
이외에도 다양한 케이스가 있지만, 시간 관계상 여기까지만 제공
 

✅  이런 점이 좋았어요

  • 테스트 코드의 장점
    • 구현전 테스트 시나리오를 통한 다양한 케이스에 집중하고 어떤 것을 구현할지 더 명확해짐
    • 테스트 코드가 있어서 리팩토링 및 유지보수에 용이
    • QA 양이 많이 줄음
    • 문서화 기능
  • 디버깅이 매우 좋음
    • 개발하다보면 어디에서 버그가 났는지 힘들수도 있는데 playwright 는 디버깅 모드로 실행시키는 것과 trace 모드 로 수행시키면 실행시 동작하는 화면, 로그 들을 순차적으로 확인할 수 있어 디버깅이 매우 좋음
    • 그리고 API 를 모킹하는 환경에서는 특정한 상황(상태에 따라 화면이 다르게 나오는 경우)에서 제대로 나오는지 API 가 모킹된 환경 기반으로 확인할 수 있어서 디버깅에 좋음
      • ⇒ 의도된 환경으로 확인할 수 있어 특정 케이스가 어떻게 동작하는지 확인할 수 있다.
  • 개발 및 실제 프로덕션 환경을 테스트 코드 하나로 가능
    • 개발 환경에서 테스트를 하든 프로덕션 환경이든 URL 만 다르기 때문에 페이지 접속 URL 만 변경하면 여러 환경의 테스트 코드를 수행하여 테스트를 확인할 수 있음
 

Reference