프론트엔드 테스트 코드 경험 2
2024. 07. 20.
#리액트
#프로젝트
이전 편에서 테스트 코드에 대한 기본적인 개념과 react-testing-library 에 대해서 테스트 코드 작성 경험에 대해서 공유했다면 이번편에서는 제가 적용하고 있는 테스트 방식에 대해서 공유하고자 합니다.
현재 저는 E2E 테스트 툴인
playwright
기반으로 테스트를 진행하고 있기 때문에 playwright 를 활용하여 테스트를 작성하는 방식에 대해 공유하고자 합니다.✅ Cypress vs Playwright
E2E 테스트의 대표적인 툴이 cypress, playwright 가 있습니다. 이 2가지 툴 중에 playwright 를 사용하게 된 이유는 아래와 같습니다.
- Playwright 가 러닝 커브가 낮다
cypress 는 자체 문법 들을 학습해야하는데 playwright 는 react-testing-library, jest-dom 문법과 거의 비슷해서 러닝커브가 낮다고 판단합니다
- Playwright 에 code-gen 기능을 제공한다. (유저 행동을 녹화해서 해당 액션을 코드로 작성해줍니다)
현재는 cypress 도 제공하고 있습니다.
- Playwright 가 좀 더 다양한 환경 제공
cypress 는 크롬, Electron, Firefox 를 제공하고 Playwright 는 Chromium, WebKet, Firefox 뿐만 아니라 기본 모바일 애뮬레이션을 제공한다.
- Playwright 가 속도가 더 빠르다.
Cypress 실제 브라우저 환경 + iframe 방식으로 동작한다면(실제 브라우저를 띄우고 동작) Playwright 는 Socket 과 CDP(chrome devtool protocol)을 사용하여 브라우저 테스트를 하여 Playwright 가 더 빠름
그리고 Playwright 는 기본적으로 병렬 기능을 제공하지만 Cypress 는 유료 결제 또는 우회하는 추가 설정 필요
✅ 나의 테스트 방식
- 구현한 기능들 한정 테스트 코드를 작성합니다.
- 코드를 구현하기 전에 디자인과 기획서를 참고해서 작업에 따른 테스트 시나리오를 작성합니다.
- 기능 구현
구현하면서 놓친 케이스가 존재하면 해당 시나리오를 추가합니다.
- 놓친 테스트가 있다면 추가로 테스트 작성
해당 방식을 선택한 이유는 기능을 다 만들고 테스트 코드를 작성하면 제가 구현한 것만 테스트 코드를 한다고 생각합니다(폭이 좁아짐) 그래서 기획서와 디자인을 참고해서 발생할 수 있는 케이스를 모두 시나리오를 작성해서 미리 어떤 작업을 구현할 것인지 고민할 수 있고 개발하면서 예상하지 못했던 케이스도 확인할 수 있어서 좀 더 넓게 테스트 코드를 작성할 수 있어서 해당 방식을 사용하고 있습니다.
✅ Playwright 테스트시 알아두면 좋은 내용
Playwright 에서 다양한 개념과 기능들이 있는데 실제적으로 도움이 됐던 내용을 공유하고자 합니다.
1. Getting started - VS Code
테스트 코드를 관리하는 확장 프로그램으로 작성한 테스트 코드를 리스트 형태로 노출한다. 테스트를 실행시키는 기능과 디버깅 기능을 제공해준다.
또한 code-gen 기능을 제공하여 실제 행동이 코드로 변환되는 기능을 제공한다. 그리고 Pick Locator 기능을 통해 특정 DOM 에 접근할 수 있는 기능을 제공하여 더 쉽게 테스트 코드를 작성할 수 있습니다.
2. Trace viewer
테스트의 실행 과정을 기록한 트레이스 파일을 시각적으로 탐색할 수 있도록 도와주는 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 방식
- 내장 API 인
page.route
를 활용하여 API 모킹하기
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(); });
- HAR file 방식으로 관리하기
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 에서 기존의 인증된 상태를 로드하는 방식을 제공
- 인증된 브라우저 상태를 준비하는
auth.setup.ts
파일 준비
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 }); });
- playwright.config.ts 환경에서 auth.setup.ts 의 환경을 공유하도록 설정
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가지 방식을 제공한다.
- Parallelism (병렬 처리)
각 테스트마다 동기적으로 하나씩 처리하는 것이 아닌 여러개의 worker 를 지정하여 여러 테스트를 동시에 실행시키는 방식
npx playwright test --workers 4
- 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
- ‣
- ‣