프론트엔드 테스트 적용기 with Playwright👍

junamee·2023년 3월 18일
3

프론트엔드

목록 보기
14/17

글을 시작하자마자 우선,
프론트엔드 앱에 테스트를 적용해보고 느낀 점은, 프론트엔드는 테스트하기가 까다롭다 - 였다. 그렇지만 개발용이성은 분명 있었고 이 용이성은 개발과정에서 무엇을 개발해야할지 유스케이스가 되고, 또한 개발 후에도 변경사항이 많은 환경(기획, 운영상)에서 예상치못한 에러를 사전 예방할 수 있는 것이다.

어떤 테스트를 해야할까?

단위 테스트 개념을 프론트엔드에 적용하게 되면 ui에 대한 테스트일 것이고, 이 UI 테스트는 사실 상 변경사항이 많기 때문에 구조가 바뀌거나 명세가 바뀌면 그때마다 테스트코드를 다시 작성해야하는 번거로움이 있으며 추가로 보이는 UI만 테스트를 하지 내부 로직에는 관여할 수 없기 때문에 정말 반쪽 짜리 테스트여서 의미가 없다고 느껴졌다. 안그래도 회사에서는 일정압박을 주는데(ㅜ) UI테스트는 storybook으로 만족하기로 했다.

통합테스트는 2가지 이상의 모듈, 코드로 생각하면 페이지 단위 2개 이상의 범위를 테스트하는 것으로 생각해볼 수 있다. 또한 실제 서버API응답에 대한 UI변경 등을 체크한다.

e2e테스트는 사용자관점의 비즈니스 로직 테스트인데 사실 방법의 이 둘의 개념이 비슷하다고 생각한다. 비즈니스 로직에 맞춰 개발하는 것이니 통합테스트나 e2e나 테스트하려는 내용은 같은 것 같다.

다만 알아본 바로는 통합테스트가 넓은 의미로는 실제 서버에 대해 테스트를 진행하고 좁은 개념으로는 Mocking된 api로의 테스트가 진행됨을, e2e의 특징으로 브라우저를 직접 실행한 환경에서 테스트가 진행되 실제 유저 입장에서의 시각적 플로우를 체크할 수 있다는 점이다. 때문에 테스트에 소요되는 시간이 길다는 단점이 있다.

회사에서는 비즈니스 로직 관점에 맞춰 Mocking API를 적용해 e2e테스트를 진행했다. (이러니 더욱 개념의 분리가 어려운 듯하다.)


테스트 도구 Playwright

검색해보니 cypress와 playwright 비교를 많이 하던데,
둘다 테스트 코드도 직관적이어서 이해하기 쉽고 비슷했다.
내가 playwright가 더 좋다고 생각한 건 webkit브라우저를 지원한다는 점이었다.(cypress는 아직 experimental 단계이다.)


기본 설정

  • Playwright 사용 설정
    - testDir: 테스트 directory
    - outputDir: 테스트 결과 (비디오, 스크린샷 등의 결과 저장)
    - use - trace: trace GUI 사용
    - storageState: 요청컨텍스트에 대해 storage state를 반환한다. (쿠키를 담고 있다)
    - projects: test를 진행할 브라우저 환경
import 'dotenv/config'
import {PlaywrightTestConfig, devices} from '@playwright/test'
import path from 'path'

const config: PlaywrightTestConfig = {
  globalSetup: require.resolve('./test/global-setup'),
  timeout: 30 * 1000,
  testDir: path.join(__dirname, 'test'),
  outputDir: path.join(__dirname, './test/results/'),
  retries: 0,
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on',
    storageState: 'cookie.json',
  },

  projects: [
    {
      name: 'Chrome',
      use: {
        ...devices['Desktop Chrome'],
      },
    },
    {
      name: 'Safari',
      use: {
        ...devices['Desktop Safari'],
      },
    },
    {
      name: 'Mobile Chrome',
      use: {
        ...devices['Pixel 5'],
      },
    },
    {
      name: 'Mobile Safari',
      use: devices['iPhone 12'],
    },
  ],
}
export default config
  • 글로벌 셋업
    모든 테스트를 수행하기 전, 공통된 속성과 작업을 정의
    api요청을 위해 로그인 과정(getAuth)을 거치고, cookie설정을 해준다.
import path from 'path'
import fs from 'fs'
import {chromium, FullConfig} from '@playwright/test'
import {nextBuild} from 'next/dist/cli/next-build'
import {getAuth} from './utils/auth'

async function globalSetup(config: FullConfig) {
  const browser = await chromium.launch()
  await getAuth({browser})
  await browser.close()
  const cookiePath = path.join(__dirname, '../cookie.json')
  const jsonFile = fs.readFileSync(cookiePath, 'utf8')
  const jsonData = JSON.parse(jsonFile)
  if (jsonData.cookies && jsonData.cookies.length > 0) {
    jsonData.cookies[0].domain = 'localhost'
  }
  fs.writeFileSync(cookiePath, JSON.stringify(jsonData))
}

export default globalSetup

getAuth는 정말 브라우저에서 로그인을 하는 것 처럼, 테스트 환경에서 로그인 과정을 거친다.

export const getAuth = async (
  {browser}: any,
  userInfo: IUserInfo = {
    member_value: process.env.NEXT_PUBLIC_MEMBER_VALUE_TEST || '',
    password: process.env.NEXT_PUBLIC_PASSWORD_TEST || '',
  }
) => {
  const {member_value, password} = userInfo
  const page = await browser.newPage()
  await page.goto(`https://localhost:3000/login`)
  await page.fill('input[id="member_value"]', member_value)
  await page.click('text=시작하기')
  await page.waitForNavigation()
  await page.fill('input[id="password"]', password)
  await page.click('text=로그인')
  await page.waitForNavigation()
  await page.context().storageState({path: 'cookie.json'})
}
  • Mock API 설정
    테스트 과정 중 api요청을 보낼 때에 그 요청을 interceptor에서 받아 목데이터로 응답을 내려주도록 설정한다. msw 라이브러리로 설정했다.
export const requestInterceptor = (() => {
        const {setupServer} = require('msw/node')
        const requestInterceptor = setupServer()

        requestInterceptor.listen({
          onUnhandledRequest: 'bypass',
        })

        return requestInterceptor
      })()
   

테스트 시작

이제 진짜 테스트 코드 작성이다.
예시로 등록된 카드 리스트 페이지에 대해 테스트를 작성할 건데, 사용자가 접할 케이스에 대해 항목을 먼저 작성해본다.

//paycard.test
test.describe('card list page', () => {
  test('should show card list when user registered card before')
  //사용자가 카드를 등록한 경우: 카드리스트를 보여준다.
  test('should be masked on 2nd, 4th number')
  //카드는 2번째, 4번째 넘버가 마스킹 처리되어 있어야 한다.
  test('should print text - 카드를 등록해주세요 - when there is not registered card')
  //사용자가 카드를 등록하지 않은 경우: '카드를 등록해주세요' 라는 텍스트를 노출한다.
}

예시로 간단한 케이스에 대해 작성했지만, 미리 테스트 항목을 정리하면서 기획내용도 정리할 수 있어 좋다.

저 테스트 케이스를 작성하기 위해 환경을 만들어준다.
먼저 api요청을 보내 카드 리스트를 받아 데이터를 갖고있는 상태에서 저 페이지로 이동한 후 테스트를 진행한다.

test.beforeEach를 통해 각 테스트별 수행해야할 일을 작성했다. 앞에서 만들어놓은 msw - requesetInterceptor를 사용해 get요청을 보내고 그 응답으로 카드리스트 목데이터를 내려줄 것으로 설정했다.

순차적으로 플로우가 진행될 수 있도록 async-await 비동기식으로 코드를 짜야한다.


test.describe('pay page test code', () => {
  initializeAuth()

  test.beforeEach(async ({page,requestInterceptor, rest, port}) => {
    requestInterceptor.use(
      rest.get(
        `${process.env.NEXT_PUBLIC_VERCEL_SERVER}/v1/card`,
        (req: any, res: any, ctx: any) =>
          res(ctx.json(NORMAL_CARD_LIST_RESPONSE))
      )
    )
    await page.goto(`http://localhost:${port}/mypage/card_info`, {
      waitUntil: 'load',
    })
  })

  test('should show card list when user registered card before')
  ....
  

목데이터의 형태는 아래와 같다.


export const NORMAL_CARD_LIST_RESPONSE = {
  result_msg: 'Success',
  result_code: 1000,
  cards: [
    {
      idx: '82',
      card_nickname: '테스트카드1',
      card_company: 'BC카드',
      card_number: '5137-****-0084-****',
      reg_date: '2022-02-15 14:21:29',
    },
    {
      idx: '84',
      card_nickname: '테스트카드2',
      card_company: '국민카드',
      card_number: '5137-****-0084-****',
      reg_date: '2022-02-15 14:21:29',
    },
  ]
}

그리고 첫번째 테스트,

test('should show payCard list when the payCard list exists', async ({
    page,
  }: any) => {
    await page.screenshot({path: 'test/e2e/pay/[GET]card_list.png'})
    await expect(getElement(page, '#CardInfo')).toBeTruthy()
  })

데이터를 받아 리스트가 존재하면 #CardInfo 엘리먼트가 있을 것으로 코드를 작성했다. screenshot은 테스트 환경의 크롬브라우저를 열어 직접 저 리스트 페이지로 이동해 리스트가 보이는가에 대한 결과 화면을 찍어준다.

만약 테스트가 통과하지 못한경우에는 저 스크린샷을 보고 어디가 잘못되었는지 눈으로 확인할 수 있어 유용하게 사용했다.

두번째 테스트

  test('should be masked on 2nd, 4th number', async ({page}: any) => {
    await page.screenshot({path: 'test/e2e/pay/[GET]masking_card.png'})
    await expect(getElement(page, '.card_number')).toContainText('****')
  })

.card_number class명의 엘리먼트에 접근해 마스킹 ****을 포함하고 있는 텍스트인지에 대한 테스트이다. beforeEach에 서술한내용대로 응답을 받은 상태에서 테스트가 진행되었다.

세번째 테스트
이 경우는 목데이터가 달라져야 한다. 빈 배열을 내려줘야하기 때문에 requestInterceptor로 다른 데이터를 설정해준 후 테스트를 진행한다.

 test('should print the text - 결제카드를 등록해 주세요 - when there is no payCard list data', async ({page, port, requestInterceptor,rest,}: any) => {
    requestInterceptor.use(
      rest.get(
        `${process.env.NEXT_PUBLIC_VERCEL_SERVER}/v1/card`,
        (req: any, res: any, ctx: any) => res(ctx.json(NO_CARD_LIST_RESPONSE))
      )
    )
    await page.goto(`http://localhost:${port}/mypage/card_info`, {
      waitUntil: 'load',
    })
    await page.screenshot({path: 'test/e2e/pay/[GET]no_card.png'})
    await expect(getElement(page, '#PayCardList')).toContainText('결제카드를 등록해 주세요')
  })

이 외에도 여러 다양한 케이스들이 존재한다.
해당 페이지에서 세션이 만료되어 403 에러가 났을 경우,
카드 등록시 유효한 카드를 등록했는지 등..
여러 케이스에 대한 api응답이 필요하고 그에 따라 노출해야할 ui나 페이지가 달라진다.

이를 정리하고 테스트를 돌리면 한번의 테스트로 여러케이스에 대한 환경을 다 검토할 수 있는 것이다.


마무리

만약, 테스트코드를 작성하지 않는다면 어떻게 개발하고 있을까.
내가 마치 테스트 서버가 된것 처럼, 1의 케이스로 목데이터를 정해주고 실제 개발 브라우저에서 결과를 확인하고, 다시 2의 케이스로 데이터를 수정한 후 직접 브라우저에서 확인하고....

이 과정을 케이스별로 확인해야하니 정말 많은 시간이 걸릴 것이다.

물론 테스트를 하면서 어려운 점이 없었던 것은 아니다.
기본적으로 해주어야 하는 설정이 번거롭게 느껴질 수도 있다.
나의 경우에는 element를 찾는게 쉽지않았다. 선택자가 여러가지 있기는 한데 렌더링 시점의 차이인것인지 엘리먼트 찾는 것에서 시간이 오래걸렸고 답답한 나머지 불필요하게 요소에 아이디나 클래스명을 지정해주기도 했다. 이 부분은 익숙해지면서 노하우가 생기면 쉽게 극복할 수 있을 것 같다.

빨리 개발해야 한다고 테스트를 배제하는 상황이 스타트업에는 많은 것 같다. 그 말도 맞다. 하지만 앞으로의 유지보수나 qa단계를 생각하면 테스트는 강력한 이점을 갖고있다. 또 테스트가 익숙하면 위에서 이점으로 말했던것 처럼 여러 유스케이스에대한 빠른 검증이 가능한 것이 개발속도를 높여줄수있다.


https://playwright.dev/docs/api/class-playwright

https://ui.toast.com/posts/ko_20210818

https://blog.mathpresso.com/%EB%AA%A8%EB%8D%98-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A0%84%EB%9E%B5-1%ED%8E%B8-841e87a613b2

profile
아티클리스트 - bit.ly/3wjIlZJ

0개의 댓글