이 글은 2022년 1월 우아한테크세미나에서 발표 주제로 다루었던 시각적 회귀 테스트를 구현하는 방법을 좀 더 자세히 설명한 글입니다. 완성된 코드를 바로 보고 싶으시면 https://github.com/bluestragglr/visual-regression-test-demo 레포지토리를 확인해 주세요!
🤔 어디에 쓰나요?
시각적 회귀 테스트를 이용하면 컴파일 에러 같은 명시적인 에러 외에도 조용히 발생하는 스타일 에러를 잡을 수 있습니다. 바쁜 일정 속에서 조용히 쌓이는 스타일 관련 기술부채를 관리하는 데 효과적입니다.
🧐 어디서 본 것 같은데...
이 글은 우아한형제들 기술블로그 https://techblog.woowahan.com/7332/ 에 게시된 것과 같은 내용의 글입니다.
시각적 회귀 테스트란 렌더링 된 결과물이 기존과 동일한지를 확인하는 테스트입니다. 렌더링 화면을 저장해 두고 테스트를 실행할 때마다 저장된 이미지와 렌더링 결과물이 달라졌는지를 확인하여 화면이 기존과 동일하게 출력되는지를 보장받을 수 있는 테스트입니다.
이번 글에서는 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
는 테스트에 사용하는 스냅샷 업데이트 시 사용하는 스크립트입니다. 각각의 스크립트는 이후 구성하는 환경설정 파일을 참조하여 동작합니다.
/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.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,
}
}
💡 Q. 왜 타입스크립트 환경을 구성해 두고 jest.config.js와 jest-puppeteer.config.js는 자바스크립트로 구성하나요?
A. jest.config.js와 jest-puppeteer.config.js는 트랜스파일 및 컴파일되어 브라우저에서 사용되는 파일이 아니라, Node.js 프로세스가 직접 읽어 들여 사용하는 값이기 때문입니다. 타입스크립트의 효용이 없는 파일이긴 하지만 그래도 타입스크립트를 쓰고 싶다면 별도의 트랜스파일링 구성이 필요합니다. 하지만 타입 설정을 할 부분이 거의 없기 때문에 권장하지 않습니다.
그리고 /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씩 틀어지던 게 줄넘김으로 변해버리기 전에 시각적 회귀 테스트를 구성해서 서비스 안정성을 끌어올려 보면 어떨까요?