E2E 테스트를 쉽고 빠르게 작성할 수는 없을까?

정다빈·2023년 4월 26일
2
post-thumbnail

아래 화면은 키즈노트 로그인 이후 나타나는 첫 화면이에요. 이 대시보드 페이지는 유저들이 많이 사용하는 기능들을 간소화해서 보여주는 역할을 하고 있어요.

그중 하나가 내소식이라는 기능인데요, 유저에게 알림을 보여주고 알림 중 하나를 클릭하면 관련 페이지로 이동하는 기능입니다.

아이템을 클릭하면 페이지를 이동하는 심플한 기능인 것 같지만, 페이지를 이동하는 동안 내부에서는 복잡한 일들이 일어나고 있어요. 앨범과 관련된 알림이라면 유저가 앨범 데이터를 조회할 권한이 있는지, 페이지를 이동하는 사이에 앨범 데이터가 삭제되지는 않았는지 등을 확인하고 있어요.
GNB와 첫 화면에 들어갈 만큼 중요하지만 복잡하기도 한 기능인데요, 이러한 내소식을 좀 더 안정적으로 운영하고자 E2E 테스트를 작성하게 되었습니다.

🎭 왜 Playwright를 선택했나요?

FE개발파트는 과거에 다른 프로젝트에서 Cypress를 도입한 경험이 있어요. 그러나 키즈노트 프로젝트에는 Cypress 대신 Playwright를 도입하게 되었습니다.

  • 빠른 실행 속도: Playwright는 테스트를 병렬적으로 처리하여 빠른 실행이 가능합니다.
  • Chromium, WebKit 기반의 브라우저들과 Firefox를 지원: Cypress는 Chromium 기반의 브라우저들과 Firefox를 지원하지만 Webkit 기반의 브라우저는 실험적으로 지원합니다.
  • Hover, Drag 이벤트 제공: Cypress는 Hover, Drag 이벤트를 공식적으로 지원하지 않기 때문에 추가적인 패키지를 설치해야 합니다.
  • 테스트 assertion을 위한 단일 API 제공: Cypress는 should, expect, assert 등 다양한 API를 용도에 맞게 사용해야 하지만, Playwright는 expect만을 이용하여 테스트를 assertion 할 수 있습니다.
  • 새 창으로 열기 지원: Cypress는 새 창으로 열기를 지원하지 않습니다.

🖐🏻 E2E 테스트를 작성하기 전에

내소식 기능이 복잡한 만큼, 현재 어떻게 동작하고 있는지 정리해 볼 필요가 있습니다. 유저 타입과 메뉴를 기준으로 다양한 케이스를 테스트하고 이를 문서로 정리해 보았어요.

이렇게 정리해 보니 동작 수정이 필요한 부분도 보이고, 제가 어떤 테스트를 작성해야 하는지 목표가 구체적으로 잡힌 것 같아요! 이제 테스트를 작성해 볼까요?

🖋️ E2E 테스트를 작성해 봅시다!

1. Playwright 설정하기

Playwright는 공식 문서가 정말 잘 되어있어요. 문서에 있는 초기 설정을 거의 그대로 사용하고, 몇 가지 옵션만 바꿔주었습니다.

const config: PlaywrightTestConfig = {
  testDir: './e2e',
  use: {
    /* 기본 언어 설정 */
    locale: 'ko-KR',
    /* 네트워크 요청 시 HTTPS 에러 무시 */
    ignoreHTTPSErrors: true,
  },
};

export default config;
  • testDir: 기본값은 ./tests 입니다. 프로젝트 내에 유닛 테스트를 모아둔 /tests 디렉토리가 이미 있었기 때문에 E2E 테스트를 위한 /e2e 디렉토리를 추가해 주었어요.
  • locale: 기본값은 en 입니다. 키즈노트는 국문, 일문, 영문을 지원하지만 E2E 테스트는 국문으로 진행하기 위해 ko-KR로 지정해 주었어요.
  • ignoreHTTPSErrors: 기본값은 false 입니다. 로컬에서 원활한 테스트를 위해 HTTPS 관련 에러를 무시하도록 설정해 주었어요.

2. 테스트 시나리오 작성하기

Playwright는 describe 뿐만 아니라 fail, step 등 다양한 테스트 함수를 지원해 주어서 좀 더 직관적인 시나리오를 작성할 수 있어요.

test.describe('1. 앨범 알림을 클릭했을 때', () => {
  test('1-1. 해당 앨범이 존재할 때 앨범 상세 페이지로 이동한다.', async () => {});
  test('1-2. 해당 앨범이 삭제되었을 때', async () => {
    await test.step('1-2-1. 앨범 상세 페이지로 이동한다.', async () => {});
    await test.step('1-2-2. 해당 앨범의 삭제 안내 팝업을 노출한다.', async () => {});
    await test.step('1-2-3. 삭제 안내 팝업에서 확인 버튼 클릭 시 앨범 목록 페이지로 이동한다.', async () => {});
  });
});

test.describe('2. 앨범 댓글 알림을 클릭했을 때', () => {
  test('2-1. 해당 앨범 댓글이 존재할 때 앨범 상세 페이지로 이동한다.', async () => {});
  test('2-2. 해당 앨범 댓글이 삭제되었을 때 앨범 상세 페이지로 이동한다.', async () => {});
});

3. 테스트 코드 작성하기

유저가 내소식을 사용하기 위해서는 먼저 로그인을 해야 하고, GNB에 있는 내소식 버튼을 클릭해서 내소식 팝업을 띄워주어야 해요.

이러한 부수적인 작업들은 beforeAllbeforeEach에서 처리해 주었어요.

// 모든 테스트 시작 전 한 번만 실행됩니다.
test.beforeAll(async ({ browser, baseURL }) => {
  const context = await browser.newContext();
  page = await context.newPage();

  // API Mocking
  await page.route(`${baseURL}/api`, async (route) => {});
  
  // 로그인 페이지로 이동
  await page.goto(`${baseURL}/login`);

  // 로그인
  page.getByTestId('login-username-input').fill('mock-username');
  page.getByTestId('login-password-input').fill('mock-password');
  page.getByTestId('login-button').click();

  // 대시보드 페이지로 이동
  await page.goto(baseURL);
});

// 각 테스트 시작 전 한 번씩 실행됩니다.
test.beforeEach(async () => {
  const isPopupHidden = await page.getByTestId('noti-popup').isHidden();

  // 내소식 팝업이 닫혀있다면 열어줍니다.
  if (isPopupHidden) {
    await page.getByTestId('noti-button').click();
  }
});

// 모든 테스트 종료 후 한 번만 실행됩니다.
test.afterAll(async () => {
  // 로그아웃
  await page.getByTestId('logout-button').click();
  await page.close();
});

내소식 테스트는 test 함수를 이용해서 테스트해 주었어요.

test.describe('1. 앨범 알림을 클릭했을때', () => {
  test('1-1. 해당 앨범이 존재할때 앨범 상세 페이지로 이동한다.', async () => {
    await page.getByTestId('noti-item').click();
    await expect(page.getByTestId('album-title')).toHaveText('Mock Album Title');
    await expect(page.getByTestId('album-contents')).toHaveText('Mock Album Contents');
    await expect(page.getByTestId('album-author')).toContainText('Mock Album Author');
  });
});

🐢 쉽게만 살아가면 재미없어~ 빙고!

Playwright를 도입하면서 다양한 이슈들을 만날 수 있었어요.

1. beforeAll, beforeEach, afterAll에서 모두 같은 page 사용하기

page는 브라우저에서 하나의 탭과 동일한 개념이에요. 저는 로그인부터 테스트까지 하나의 탭에서 실행하고 싶었지만, beforeAllafterAll은 콜백 함수의 인자로 page가 들어오지 않았어요.

test.beforeAll(async () => {}); // page를 찾을 수 없습니다.
test.beforeEach(async ({ page }) => {});
test.afterAll(async () => {}); // page를 찾을 수 없습니다.

beforeAll, afterAll의 콜백 함수 내부에서 page를 직접 만들어서 사용할 수 있지만, 그렇게 된다면 beforeEach, test와 다른 탭을 사용하게 되기 때문에 테스트 진행이 불가능하게 될 거예요.

test.beforeAll(async ({ browser }) => {
  const context = await browser.newContext();
  const page = await context.newPage(); // beforeEach와 또 다른 탭
});

로그인을 beforeEach에서 처리하면 하나의 탭에서 테스트를 진행할 수 있지만, 테스트마다 일일히 로그인을 하는 것은 너무 비효율적인 작업이 될 것 같았어요. 고민을 하던 중 하이퍼커넥트에서 사용한 방법을 동일하게 적용했습니다.

let page: Page;

test.beforeAll(async ({ browser }) => {
  // page를 생성합니다.
  const context = await browser.newContext();
  page = await context.newPage();

  // 위에서 생성한 page를 사용합니다.
  page.getByTestId('login-username-input').fill('mock-username');
  page.getByTestId('login-password-input').fill('mock-password');
  page.getByTestId('login-button').click();
});

test.beforeEach(async () => {
  // beforeAll과 동일한 page를 사용합니다.
  const isPopupHidden = await page.getByTestId('noti-popup').isHidden();

  if (isPopupHidden) {
    await page.getByTestId('noti-button').click();
  }
});

test.afterAll(async () => {
  // beforeAll과 동일한 page를 사용합니다.
  await page.getByTestId('logout-button').click();
  await page.close();
});

beforeAll에서 전역 변수에 page를 할당하고, beforeEach는 콜백 함수의 page가 아닌 전역에 선언된 page를 사용했어요.

2. 로컬 스토리지에 값 저장하기

키즈노트는 유저의 편의성을 위해 다양한 데이터를 로컬 스토리지에 저장하고 있어요. 페이지 진입 시 특정 데이터가 없다면 데이터를 채워 넣기 위해 팝업을 띄우기도 하는데요, 이 팝업 역시 내소식 테스트와는 관련이 없기 때문에 beforeAll에서 처리해 주어야 해요. 처음부터 팝업이 노출되지 않도록 로컬 스토리지에 값을 미리 넣어주었는데요, 처음에는 아래와 같이 단순한 방법을 사용했어요.

window.localStorage.setItem('key', JSON.stringify(value));

그러나 이 방법으로는 로컬 스토리지에 값이 저장되지 않고 계속 팝업이 노출되었어요. 구글링을 하던 중 Playwright Github에 등록된 이슈를 보고 evaluate 함수를 사용해야 한다는 것을 알 수 있었습니다.

await page.evaluate((value) => {
  window.localStorage.setItem('key', JSON.stringify(value));
}, newValue);

Evaluating 문서를 읽어보니, Playwright 코드는 Playwright 환경에서 실행되고 페이지 코드는 브라우저 페이지 환경에서 실행된다고 하는군요. Playwright 코드와 페이지 코드는 전혀 다른 환경에서 실행되기 때문에, Playwright에서 로컬 스토리지에 값을 넣어주어도 페이지 환경에 영향을 줄 수 없던 것이었습니다. 하지만 evaluate 함수를 사용하면 페이지 환경에서 자바스크립트 코드를 실행하고 결과를 Playwright 환경으로 가져올 수 있어요. 덕분에 내소식 테스트에 더 집중하면서 코드를 작성할 수 있겠네요!

3. 유닛 테스트와 분리하기

터미널에 npx playwright test 명령어를 입력하면 E2E 테스트가 실행되고, 그 결과가 아래와 같이 나타납니다.

음, 잘 실행되는군요! 크로미움, 파이어폭스, 웹킷 브라우저에서 테스트를 실행했고 모두 통과했어요. 그러면 기존에 있었던 유닛 테스트도 (E2E 테스트의 영향 없이) 잘 실행되는지 확인해 볼게요.

유닛 테스트를 실행했더니 E2E 테스트 파일도 함께 실행되어서 결국 테스트를 실패했군요. Jest 문서를 참고해서 유닛 테스트 실행 시 E2E 테스트 파일은 실행하지 않도록 jest.config.js 파일을 수정해 줄게요.

"jest": {
  "testMatch": [
    "**/__tests__/**/*.[jt]s?(x)",
    "**/?(*.)+(spec|test).[jt]s?(x)",
    "!**/e2e/**/?(*.)+(spec|test).[jt]s?(x)"
  ]
}

정상적으로 유닛 테스트를 실행하는 것을 확인할 수 있습니다.

4. API 폴링(Polling) 하기

폴링은 클라이언트에서 실시간 데이터를 얻기 위해 API를 지속적으로 호출하는 것을 의미해요.
Playwright는 테스트를 병렬적으로 실행하는 특징 덕분에 비교적 빠른 테스트가 가능하다는 장점이 있어요. 그런데 여러 테스트 파일을 거의 동시에 실행하다 보니 로그인 API를 파일 수만큼 동시에 호출하게 되었고, 서버는 Status Code 409를 응답하는 현상이 발생했습니다.
내소식 테스트를 위해 로그인 과정은 필수였기 때문에 이 부분에서 고민이 많았는데요, 로그인을 할 때 <input>에 아이디와 패스워드를 직접 입력하는 대신 로그인 API를 폴링 하는 방식으로 변경했습니다.

await expect.poll(
  async () => {
    const response = await page.request.post(`${baseURL}/api/login`, {
      form: {
        username: 'mock-username',
        password: 'mock-password',
      },
    });
    return response.status();
  },
).toBe(200);

Playwright에서 제공하는 poll 함수를 이용하면 콜백 함수를 쉽게 폴링 할 수 있어요. 위 코드는 Status Code 200을 응답으로 받지 않으면 다시 로그인 API를 호출해요.

일부 테스트 파일이 Status Code 409를 응답으로 받았지만, 잠시 뒤 Status Code 200을 받는 것을 확인할 수 있어요.

🤓 마무리

테스트를 작성했다면 CI를 적용하는 것이 인지상정인데요, Playwright는 Github Actions에서 테스트를 실행할 수 있도록 가이드를 제공하고 있습니다. 하지만 키즈노트는 Jenkins를 통해 CI/CD가 이루어지고 있기 때문에, Jenkins에서 Playwright를 실행할 수 있도록 다양한 테스트를 진행하고 있어요.

E2E 테스트를 쉽게 작성하고 빠르게 실행할 수 있다는 점에서 Playwright는 매력적인 도구인 것 같습니다.

📚 Reference

profile
Frontend Developer

0개의 댓글