시각적 회귀 테스트로 서비스 안정성 끌어올리기

Feb.17.2022 신성환/웹프론트개발그룹 (blueStragglr)
clipboard facebook twitter

Web Frontend

서론

이 글은 2022년 1월 우아한테크세미나에서 발표 주제로 다루었던 시각적 회귀 테스트를 구현하는 방법을 좀 더 자세히 설명한 글입니다. 완성된 코드를 바로 보고 싶으시면 https://github.com/bluestragglr/visual-regression-test-demo 레포지토리를 확인해 주세요!

🤔 어디에 쓰나요?
시각적 회귀 테스트를 이용하면 컴파일 에러 같은 명시적인 에러 외에도 조용히 발생하는 스타일 에러를 잡을 수 있습니다. 바쁜 일정 속에서 조용히 쌓이는 스타일 관련 기술부채를 관리하는 데 효과적입니다.

에러 검거 사례

시각적 회귀 테스트란?

시각적 회귀 테스트란 렌더링 된 결과물이 기존과 동일한지를 확인하는 테스트입니다. 렌더링 화면을 저장해 두고 테스트를 실행할 때마다 저장된 이미지와 렌더링 결과물이 달라졌는지를 확인하여 화면이 기존과 동일하게 출력되는지를 보장받을 수 있는 테스트입니다.

이번 글에서는 Jest와 Puppeteer를 이용해서 Chromium 환경에서의 시각적 회귀 테스트를 작성하게 됩니다. 즉, 글에서 설명하는 데모를 완성하신 후에는 (아래 이미지처럼) 렌더링 결과를 비교하는 테스트를 수행할 수 있게 됩니다.

테스트 예시

테스트 구성하기

데모 앱 생성하기

일반적인 환경에 테스트를 작성하는 것을 재현하기 위해서 CRA(Create-React-App)를 이용해 리액트 앱을 만드는 것부터 시작합니다. 아래 명령어를 사용해서 리액트 앱을 생성해 주세요.

$ npx create-react-app visual-regression-test-demo --template typescript

혹시 프로젝트에서 타입스크립트를 사용하지 않을 예정이라면 --template typescript 구문을 제거하고 실행합니다. 만약 기존 프로젝트에 테스트를 세팅하고자 하는 상황이라면 이 단계를 무시하셔도 되며, 해당 프로젝트가 타입스크립트 프로젝트가 아니라면 타입 관련 구문만 제거하면 됩니다.

의존성 설치하기

CRA를 통해 앱이 생성 완료되었다면 프로젝트 디렉토리로 이동한 뒤 테스트를 위한 의존성을 추가로 설치합니다. 아래 명령어를 실행해서 devDependency로 설치해 줍니다.

$ yarn add -D @types/jest-image-snapshot cross-env jest jest-image-snapshot jest-puppeteer puppeteer ts-jest

빌드에 전혀 포함될 필요가 없는 패키지들이므로 devDependency로 설치합니다. 만약 패키지매니저로 npm을 이용하는 경우라면 yarn add -D 구문을 npm i --save-dev로 대체합니다.

테스트 환경 구성하기

테스트 환경을 구성하기 전에 디렉토리 구조를 먼저 살펴봅시다. 이 포스트에서는 CRA가 생성한 /src 디렉토리 아래에 /test 디렉토리를 만들고 아래와 같은 구조로 테스트를 구성하게 됩니다. 테스트 구성을 시작하기 전에 아래와 같이 디렉토리와 파일을 생성해 주세요.

테스트 레포 구조

  • 명령어 추가하기

제일 먼저 테스트를 실행하기 위해 사용할 명령어를 추가합니다. package.json의 script 영역에 다음과 같이 세 개의 커맨드를 추가합니다.

"testdev": "export PORT=4321 && react-scripts start",
"test": "cross-env JEST_PUPPETEER_CONFIG=./src/test/e2e/jest-puppeteer.config.js jest --config=./src/test/e2e/jest.config.js",
"test-update": "cross-env JEST_PUPPETEER_CONFIG=./src/test/e2e/jest-puppeteer.config.js jest --config=./src/test/e2e/jest.config.js --updateSnapshot",

testdev는 4321번 포트에 $ react-script start를 통해 리액트 앱을 서빙하는 스크립트입니다. 만약 다른 포트를 사용하고 싶은 경우, 해당 스크립트와 이후 등장하는 환경설정 파일을 수정하면 됩니다.

test는 테스트 수행 시, test-update는 테스트에 사용하는 스냅샷 업데이트 시 사용하는 스크립트입니다. 각각의 스크립트는 이후 구성하는 환경설정 파일을 참조하여 동작합니다.

  • Jest 설정(jest.config.ts) 작성하기

/src/test/e2e/jest.config.js에 Jest 관련 설정을 아래와 같이 작성합니다.

// /src/test/e2e/jest.config.js

module.exports = {
  // 디렉토리 설정
  rootDir: '../../',
  roots: ['./test/e2e'],
  // 타임스크립트 컴파일
  transform: { '^.+\\.ts?$': 'ts-jest' },
  // 테스트 코드 특정
  testMatch: ['**/?(*.)+(spec|test).ts'],
  // 테스트코드를 찾지 않을 경로
  testPathIgnorePatterns: ['/node_modules/', 'dist'],
  // 테스트 타임아웃
  testTimeout: 100000,
  // 개별 테스트 결과 표시
  verbose: true,
  // 프리셋(puppeteer 사용)
  preset: 'jest-puppeteer',
  // 테스트 셋업 후 실행할 스크립트
  setupFilesAfterEnv: ['./test/e2e/jest.image.ts']
}
  • Jest와 Puppeteer를 함께 사용하기 위한 환경 구성하기

방금 만든 환경설정 파일 바로 옆에 jest-puppeteer.config.js 파일을 생성하고 아래와 같이 작성합니다. 즉, /src/test/e2e/jest-puppeteer-config.js에 설정 파일을 생성합니다.

// /src/test/e2e/jest-puppeteer-config.js

module.exports = {
  server: {
    // Jest 실행 시 서버 서빙을 위해 실행할 커맨드
    command: `npm run testdev`,
    // 서버 포트 번호 (package.json의 "testdev" 스크립트에 설정됨)
    port: 4321,
    // 로컬호스트이므로 https가 아닌 http 사용
    protocol: 'http',
    // 서버 실행 타임아웃
    launchTimeout: 120000,
    debug: true
  },
  launch: {
    // headless 모드
    headless: true,
    // 브라우저 실행 타임아웃
    timeout: 120000,
  }
}
  • Jest-image-snapshot 설정 구성하기

그리고 /src/test/e2e/jest.image.ts'jest-image-snapshot' 패키지를 불러와 사용할 수 있도록 설정해 주세요. 해당 파일은 jest.config.js 의 setupFilesAfterEnv에 설정된 파일로, 환경 구성이 완료된 후 Jest가 가져와서 실행할 파일입니다. 이렇게 구성해 주면 기존의 Jest의 expect 구문에 존재하지 않는 인자인 toMatchSnapshot을 사용할 수 있게 됩니다.

// /src/test/e2e/jest.image.ts

import { toMatchImageSnapshot } from 'jest-image-snapshot'

expect.extend({ toMatchImageSnapshot })
  • 이미지 비교 방식 정의하기

마지막으로 jest-image-snapshot 패키지를 이용해 이미지를 비교할 방식과, 결과를 출력할 방식을 구성합니다. /src/test/e2e/imageComparison.ts 파일에 아래와 같이 작성해 주세요.

import { MatchImageSnapshotOptions } from 'jest-image-snapshot'

export const getSnapshotConfig: (
  imageName?: string
) => MatchImageSnapshotOptions = (imageName) => {
  return {
    // 비교 이미지를 배열할 방향
    diffDirection: 'horizontal',
    // 콘솔에 발생한 차이를 표시할지 여부: Base64 데이터라 의미 없음
    dumpDiffToConsole: false,
    // 비교 방법 'pixelmatch' | 'ssim'
    comparisonMethod: 'pixelmatch',
    // 스냅샷 이름 정의
    customSnapshotIdentifier: imageName,
    // 비교 이미지 출력 경로
    customDiffDir: 'src/test/e2e/tests/__image_snapshots__/__diff_output__/'
  }
}

여기까지 따라오셨으면 환경 구성은 모두 마무리된 것입니다! 이제 테스트를 작성하고 실제로 동작하는지 테스트해 봅시다.

테스트 작성 & 구동

  • 페이지 초기화 및 종료를 위한 유틸리티 구성

이 단계에서 별도 파일을 작성하는 것이 필수적이지는 않지만, 여러 시나리오를 구성하다 보면 항상 반복되어 등장하기 때문에 처음부터 별도 모듈로 작성하는 것이 효율적입니다. 여기에서 정의하는 initalizeTest 함수는 Puppeteer를 이용해 브라우저 인스턴스를 실행하고 페이지에 접근한 뒤 페이지 객체와 페이지 종료를 위한 콜백을 반환합니다. 아래 코드는 /src/test/e2e/initialize.ts에 작성합니다.

// /src/test/e2e/initialize.ts

import puppeteer from 'puppeteer'
const HOST_BASE_URL = 'http://localhost:4321/'

const initializeTest = async () => {
  // 크로미움 브라우저 실행
  const browser = await puppeteer.launch()
  // 크로미움 페이지 열기
  const page = await browser.newPage()
  // 페이지 뷰포트 사이즈 고정
  await page.setViewport({
    width: 1200,
    height: 800,
    deviceScaleFactor: 1
  })

  // 테스트용 페이지 로드
  const response: any = await page.goto(HOST_BASE_URL)
  // 페이지 정상로드 확인
  expect(response.status()).toBe(200)
  // 페이지 로드 완료 확인
  await page.waitForSelector('#root')

  return {
    page,
    // 종료 시 크로미움 프로세스 종료를 위한 콜백을 함께 반환
    async cleanUp() {
      await page.close()
      await browser.close()
    }
  }
}

export default initializeTest

이후 테스트 작성 시 initializeTest 함수가 반환한 page 객체를 계속해서 사용하게 됩니다. 이 객체는 puppeteer를 이용해 브라우저와 상호작용하기 위한 객체입니다. 일정 시간을 기다리도록 하거나 특정 요소를 찾아 클릭하는 등의 동작을 바인딩 할 수 있습니다. 자세한 API 레퍼런스는 공식 문서(https://pptr.dev/#?product=Puppeteer&version=v13.1.1&show=api-class-page)를 참고하세요.

  • 테스트 작성

드디어 환경 구성이 모두 마무리되었습니다! 이제 정상 동작을 확인하기 위해서 테스트를 작성해 봅시다. 테스트는 모두 /src/test/e2e/tests/ 아래에 위치하도록 구성합니다.

가장 간단한 예제로, 화면을 불러와 바로 스크린샷을 찍은 뒤 화면을 저장하거나 비교하는 테스트를 구성해 보겠습니다.

// /src/test/e2e/tests.VisualRegression.test.ts

import { getSnapshotConfig } from '../imageComparison'
import initializeTest from '../initialize'

it(`Visual Regression Test`, async () => {
  // Puppeteer 페이지 초기화 & 콜백함수 가져오기
  const { page, cleanUp } = await initializeTest()

  // 스크린샷을 찍어서
  const image = await page.screenshot({ fullPage: true })

  // Snapshot config 에 정의된 대로 비교
  const snapshotConfig = getSnapshotConfig()
  expect(image).toMatchImageSnapshot(snapshotConfig)

  await cleanUp()
})

테스트 구성은 복잡했지만, 테스트는 이게 끝입니다!

테스트 실행하기

이제 모든 구성이 마무리되었으니, 아래 명령어를 사용해 테스트를 실행해 봅시다.

$ yarn test

최초 구성 완료 후에는 스냅샷이 없으니 이미지가 저장되면서 아래와 같이 성공 메시지가 출력될 것입니다. 서버를 띄우고 크로미움 인스턴스도 띄운 뒤 테스트를 실행해야 하므로 시간이 조금 소요됩니다.

최초 테스트 실행결과

그런데 테스트를 한 번 더 실행해 보면?

$ yarn test

테스트에 실패합니다.

두 번째 테스트 실행결과

사실 이것은 CRA로 만들어진 앱의 기본 CSS에 애니메이션 프레임이 들어가 있기 때문에 발생하는 동작입니다. 아래와 같이 diff_output 폴더를 확인해 보면 리액트 로고 주변에 약간의 픽셀 차이가 있어 테스트가 실패했음을 알 수 있는 이미지가 있습니다.

픽셀 비교 결과

테스트가 성공하는 것을 확인해 보고 싶다면, /src/App.css 파일에서 애니메이션 관련 코드를 삭제한 후 스냅샷을 업데이트하고 다시 테스트를 실행해 보세요.

그래서 시각적 회귀 테스트.. 진짜 쓸모 있을까요?

츄라이 츄라이

마크업과 CSS 디버깅은 골치 아픈 영역입니다. HTML과 CSS의 특성상 에러를 잘 반환하지 않고, 적당히 마음대로 해석해서 레이아웃을 정렬하고 요소를 그립니다. 옆에 있는 요소에 의해 다양한 사이드이펙트도 발생할 수 있고요. 그러다 보니 프론트엔드 개발자들은 항상 “화면 여기 깨졌어요”를 들으며 개발하게 됩니다.

하지만 시각적 회귀 테스트를 작성해 두고 배포 파이프라인에 적절히 끼워 넣기만 한다면 이런 문제는 상당 부분 해결할 수 있습니다. 문제 자체가 없어지진 않겠지만, 최소한 스타일 부분에서 프로젝트가 망가졌는지는 조금 더 쉽게 감지할 수 있게 됩니다. 저도 실제 프로젝트 진행 당시에도 심심찮게 시각적 회귀 테스트 덕을 보았고요! (실제 사례가 궁금하시다면? 👉  2022년 1월 우아한테크세미나 보러 가기)

스타일 관련 기술부채가 쌓이고 1px씩 틀어지던 게 줄넘김으로 변해버리기 전에 시각적 회귀 테스트를 구성해서 서비스 안정성을 끌어올려 보면 어떨까요?