우아한형제들 디자인 시스템에 시각적 회귀 테스트 적용하기

Apr.16.2024 이수정, 금교영

Web Frontend

들어가며

안녕하세요, 우아한형제들 디자인 시스템 ‘우아한공방’의 웹 프론트엔드 개발자 금교영, 이수정입니다.
우아한공방은 우아한형제들에서 운영하는 다양한 웹/앱 프로덕트에서 사용되고 있는데요.
(참고: 우아한형제들의 새로운 디자인 시스템 ‘우아한공방’을 소개합니다: 개발 편)
이번 글에서는 우아한공방에 시각적 회귀 테스트를 도입한 과정에 대해 이야기해 보려고 합니다.

시각적 회귀 테스트(Visual Regression Test)는 코드 변경 전/후의 스크린샷을 비교해 차이를 감지하고 예기치 못한 오류를 확인하여 UI의 시각적 일관성을 제공할 수 있도록 하는 테스트입니다.

그렇다면, 디자인 시스템에 시각적 회귀 테스트가 왜 필요할까요?
우아한공방은 총 115가지의 UI 컴포넌트를 제공하고 있어요. 우아한공방의 컴포넌트 단위 테스트의 커버리지를 91%로 유지하고 있지만, 단위 테스트로는 확인할 수 없는 UI 변경 사항이 있습니다.

A 컴포넌트를 변경했는데 해당 컴포넌트를 의존하는 다른 컴포넌트가 예상 못한 방식으로 변경되면서 A 컴포넌트에 의존하는 모든 컴포넌트를 확인해야 하는 경우가 있었습니다.

또한, 디자인 시스템 패키지의 스타일 코드 전체에 영향을 미치는 작업으로 인해 모든 컴포넌트에 UI 변경 사항이 발생했는지 확인해야 하기도 했습니다.

변경 사항을 일일이 확인하는 데에는 상당한 시간이 필요하고, 미세한 변화는 알아차리기 어렵습니다.
예상치 못한 변경 사항이 적용된 컴포넌트가 사용자에게 보이면 앱의 품질을 저하시키는 요인이 될 수 있고 사용자에게 불편함을 줄 수도 있습니다.

이러한 문제를 해결하여 컴포넌트의 시각적 안정성을 확보하고자 시각적 회귀 테스트를 도입했습니다.

그럼, 이제 시각적 회귀 테스트를 어떻게 구현했고, 어떻게 안정성을 확보할 수 있었는지 같이 살펴보시죠!

테스트 환경 구성하기

테스트도구 정하기

시각적 회귀 테스트를 지원하는 도구에는 Playwright, BackstopJS, Chromatic 등이 있습니다. 저희는 다음과 같은 이유로 Playwright를 선택했습니다.

  • 무료로 사용 가능합니다.
  • 한글을 지원합니다.
  • 추후 E2E 테스트(end-to-end test)로 확장하여 다양한 시나리오를 테스트할 수 있습니다.
  • HTML로 제공하는 테스트 리포트로 결과를 확인하고 디버깅할 수 있습니다.

Chromatic의 경우 유료여서, BackstopJS는 한글을 지원하지 않아 제외했습니다.

테스트베드 정하기

테스트베드(Testbed)
소프트웨어 개발이나 시스템 테스트를 위해 구성된 환경이나 플랫폼

우아한공방에는 컴포넌트 개발을 위한 세 가지 환경이 있습니다.

  1. 스토리북 : 디자이너도 쉽게 UI를 확인할 수 있어서 디자인 QA 용도로 사용합니다.
  2. 테스트 앱 : SSR(server-side rendering) 환경과 다양한 번들러에서 컴포넌트가 잘 동작하는지 확인하기 위해서 운영하고 있습니다.
  3. 공식 문서 사이트 : 디자이너와 개발자에게 사용 방법을 가이드하는 웹사이트입니다.

위 세 가지 중, 저희는 스토리북을 테스트베드로 선택했습니다. 그 이유는 가장 빠르게 시각적 회귀 테스트를 도입할 수 있는 방법이라고 판단했기 때문입니다. 구체적인 선정 기준은 다음과 같아요.

  1. 엘리먼트 선택이 쉬울 것
  2. 추가 코드 작성이 적을 것

시각적 회귀 테스트에서 비교할 엘리먼트를 정확하게 선택하는 것이 중요한데요. ‘공식문서 사이트’는 이 과정이 까다로워서 제외했어요. ‘테스트 앱’은 엘리먼트 선택은 비교적 쉬웠으나 모든 컴포넌트에 대한 코드가 작성된 상황이 아니어서 추가로 작성해야 할 코드가 많아서 제외했습니다.

출처: First-class Vite support in Storybook

반면, 스토리북을 사용하면 간단하게 테스트 코드를 구성할 수 있습니다. 컴포넌트가 렌더링되는 부분은 iframe으로 제공되기 때문에 iframe 경로에 접속하면 컴포넌트만 렌더링된 화면을 확인할 수 있습니다. 덕분에 스크린샷을 찍을 때 엘리먼트를 선택할 필요가 없습니다.

또한 우아한공방에서는 프로젝트 초기부터 개발 및 디자인 QA 단계에서 스토리북을 적극적으로 활용해 왔습니다. 총 115개의 컴포넌트에 대해 상태별 스토리가 이미 잘 작성되어 있었습니다. 덕분에 다른 테스트베드 후보와 달리 시각적 회귀 테스트를 위해 추가적인 코드 작성할 필요가 없이 그대로 사용할 수 있었습니다.

테스트 코드 실행해보기

스크린샷 찍기

이제 시각적 회귀 테스트를 실행하는 방법을 알아봅시다. Playwright의 toHaveScreenShot() 메서드를 사용하면 간단하게 스크린샷을 찍고 비교할 수 있습니다.

테스트를 실행하면 test-results 폴더에 시각적 비교에 필요한 3가지 이미지가 생성됩니다. 각 이미지의 파일명은 -expected, -actual, -diff 접미사를 가집니다.

3가지 스크린샷은 다음과 같아요.

  • 기대한 이미지(expected)
    ‘기대한 이미지’는 이전에 저장된 스크린샷입니다.
  • 실제 이미지(actual)
    ‘실제 이미지’는 가장 최근에 생성된 스크린샷입니다.
  • 차이 이미지(diff)
    ‘차이 이미지’는 ‘기대한 이미지’와 ‘실제 이미지’ 간의 픽셀 차이를 강조한 이미지입니다. 픽셀 매치에 따라 어떤 부분이 변경되었는지 시각적으로 확인할 수 있습니다.

만약 테스트 환경 구축 후 처음 테스트를 실행하거나, 컴포넌트가 새로 추가되어 비교할 스크린샷(expected)이 없다면 어떻게 될까요?

비교할 ‘기대한 이미지(expected)’가 없으니, 해당 테스트는 실패합니다.

앞서 toHaveScreenShot()은 3가지 이미지를 만들었었죠. 하지만 이전에 찍어둔 스크린샷이 없는 경우에는, 오직 현재의 스크린샷 이미지 하나만 생성합니다. 대신 이 때 생성한 이미지는 다음에 실행하는 테스트에서 ‘기대한 이미지(expected)’로서 동작하고, 그 때부터 기대한 대로 테스트를 수행할 수 있게 됩니다.

(이때 생성된 스크린샷은 playwright.config.ts에서 설정한 testDir에 저장됩니다. 이미지의 이름은 테스트의 제목과 테스트를 실행한 브라우저 엔진 이름의 조합으로 구성돼요.)

트러블슈팅: 일관된 스크린샷 환경 구성하기

테스트 실행 시 코드가 변경되지 않았는데도 불구하고 스크린샷이 다르게 찍히는 경우가 있었는데요, 이 문제를 해결했던 방법을 소개합니다.

a. 빈 화면이 스크린샷으로 찍히는 문제

테스트 페이지가 로드되기 전에 스크린샷이 찍혀 테스트가 실패하는 경우가 있었어요.

Playwright에서 제공하는 {waitUntil : “networkidle”} 옵션을 사용해 네트워크 요청이 없을 때까지 기다렸지만 웹페이지 렌더링이 끝나기 전에 스크린샷이 찍히는 문제는 여전히 발생해요. 웹페이지의 렌더링이 완료될 때까지 기다린 후 스크린샷을 찍을 방법이 필요합니다.

렌더링이 완료된 시점을 어떻게 알 수 있을까요?

먼저 브라우저 이벤트 load, domContentLoaded를 떠올렸는데, 이 방법은 사용할 수 없었습니다. 리액트와 같은 CSR 환경에서는 이 이벤트로 렌더링이 완료되었음을 보장할 수 없으니까요. 그래서 MutationObserver로 DOM의 변화를 감지하는 방법을 선택했습니다. MutationObserver에서 이벤트가 발생하지 않을 때까지 기다리면 렌더링이 완료된 시점을 파악할 수 있습니다. 렌더링이 완전히 완료된 것을 확인하고, 이후에 테스트 코드가 실행되도록 했어요.

테스트 코드는 Node 환경에서, MutationObserver는 브라우저 환경에서 실행됩니다.
Node 환경과 브라우저 환경이 통신할 수 있도록 Playwright가 제공하는 exposeFunction 메서드를 사용합니다.

exposeFunction에 함수의 이름과 실행할 콜백 함수를 넣어줍니다. 이제 브라우저의 window 객체에서 해당 메서드를 호출하면 Node 환경에서 콜백 함수가 실행돼요.

Playwright가 제공하는 addInitScript 메서드를 사용해 테스트가 실행되기 전에 웹페이지에 스크립트가 실행될 수 있도록 했어요. MutationObserver의 콜백에는 앞서 exposeFunction에서 생성한 함수를 실행합니다.

MutationObserver의 콜백이 실행되면 DOM 변화가 생긴 것으로 판단하고 기다립니다. 콜백이 더 이상 실행되지 않으면 렌더링이 끝난 것으로 판단하고 테스트를 마저 실행합니다.

b. 찍을 때마다 다른 스크린샷

초기 렌더링 문제는 해결했지만 컴포넌트 내부에서 사용하는 타이머 동작 때문에 실패하는 문제가 있었어요.

기대했던 스크린샷은 배너 컴포넌트의 첫 번째 슬라이드였지만, 실제 스크린샷은 두 번째 슬라이드가 찍혔어요. 스크린샷을 찍기 전에 배너가 다음 슬라이드로 이동한 것이 문제였습니다.

배너 컴포넌트는 렌더링 이후, 자동으로 다음 슬라이드로 넘어가도록 동작해요. 이 동작은 내부적으로 setTimeout을 사용해서 구현되어 있어요. 의도하지 않은 시점에 스크린샷이 찍히는 문제를 해결하기 위해서, 테스트를 수행하는 동안에는 setTimeout이 실행되지 않도록 했습니다.

시각적 회귀 테스트에서는 위와 같은 시간에 영향을 받는 요소를 직접적으로 테스트하지 않습니다. 시간의 영향을 받게 되면 테스트를 실행할 때마다 일관된 결과를 얻기가 매우 어려워지기 때문이에요. 하지만, 시간에 영향을 받는 요소들은 컴포넌트에서 중요한 역할을 할 수 있습니다. 이 경우, 단위 테스트에서 시간적 요소를 테스트에 포함시켜 컴포넌트의 동작을 테스트할 수 있습니다.

CI를 활용한 시각적 회귀 테스트

위에서 설명한 시각적 회귀 테스트가 어떻게 진행되는지 같이 확인해 볼까요?

전체적인 프로세스

Playwright를 활용한 시각적 회귀 테스트는 CI(Continuous Integration) 환경에서 실행하고 있습니다.

CI 환경에서 시각적 회귀 테스트를 실행한다면, 코드가 변경될 때마다 자동으로 실행되고 테스트를 실행하는 환경이 항상 동일하기 때문에 일관된 테스트 환경을 제공할 수 있습니다. 또한 MR(Merge Request)에서 테스트 결과를 확인할 수 있도록 리포트를 제공할 수 있습니다.

CI를 활용한 전체적인 프로세스는 다음과 같습니다.

  1. 시각적 회귀 테스트 실행하기
    변경 사항이 생기면, CI 파이프라인에서 시각적 회귀 테스트를 실행하고. 리포트를 S3에 업로드해서 MR에서 확인할 수 있도록 합니다.
  2. 테스트 결과 확인하기
    리포트로 테스트 결과를 확인할 수 있습니다.
  3. 테스트 실패 대응하기
    테스트가 성공해야 머지 가능한 상태가 되기 때문에, 테스트가 실패한다면 기대한 이미지와 실제 이미지를 일치시키도록 수정해서 테스트가 성공하도록 합니다.

이제 이 과정을 하나씩 살펴보시죠.

1. 시각적 회귀 테스트 실행하기

CI 환경에서 시각적 회귀 테스트를 실행하기 위해 다음과 같은 스크립트를 작성했습니다.

image
Microsoft Artifact Registry에서 제공하는 Playwright 도커 이미지를 사용합니다..

rules
merge request event로 인해 파이프라인이 시작되는 경우 해당 Job을 트리거해 테스트를 실행합니다. 일반적으로 MR을 생성하거나, MR을 생성한 이후 원격저장소에 commit을 push할 때 실행해요.

script
지정한 config 파일을 참조하여 시각적 회귀 테스트를 실행합니다.

artifacts
리포트를 artifacts로 전달해 다음에 실행될 Job에서 리포트를 업로드할 수 있도록 합니다. Job을 분리한 이유는 리포트를 우아한형제들 내부망 S3 버킷에 올려야 하는데 Playwright 이미지에서는 내부망에 접근할 수 없기 때문이에요.

시각적 회귀 테스트를 완료하면 리포트를 배포하고 미리보기 URL을 생성하는 단계에 진입합니다.

image
우아한형제들 사내 공용 빌드킷 이미지를 사용해 사내망 S3 버킷에 접근할 수 있습니다.

script
artifacts로 공유받은 리포트 파일을 s3에 업로드하고 업로드한 버킷 경로를 별도의 변수에 할당합니다.

environment
다양한 배포 및 실행 환경을 관리할 수 있도록 합니다. 이를 통해 각 MR에서 테스트 결과를 확인할 수 있습니다.

  • name은 각 environment를 식별하는데 사용하는 값으로 브랜치명( $CI_COMMIT_REF_SLUG) 변수를 활용해 MR마다 다른 환경을 갖도록 합니다.
  • url은 해당 환경에 접근할 수 있는 주소입니다. 리포트의 S3 버킷 주소로 설정합니다.
    자세한 내용 및 추가로 설정해야 하는 부분은 Gitlab의 environments를 참고해 주세요.

2. 테스트 결과 확인하기

MR 페이지에서 제공하는 리포트를 통해 CI의 성공/실패를 확인할 수 있습니다.

위 이미지에서 시각적 회귀 테스트 Job(visual-test)로 인해 CI 실패했다는 것을 알 수 있습니다.
테스트가 실패한 이유를 파악하기 위해 리포트를 확인합니다. "View app" 버튼을 클릭하면 CI의 environment 과정을 거치며 만들어진 환경(리포트)를 확인할 수 있습니다.

리포트를 살펴볼게요.

a. 메인페이지

리포트의 메인 페이지에서는 각 테스트 성공 여부와 브라우저 환경을 확인할 수 있는 목록을 제공합니다.
테스트를 클릭하면 각 테스트의 상세페이지로 이동합니다.

b. 상세 페이지

상세 페이지는 Errors, Test Steps, Image mismatch, Traces로 구성되어 있어요.

Errors

에러 로그를 출력합니다.
기대한 이미지와 실제이미지 사이에 1970개의 픽셀, 즉 1%의 픽셀 차이가 있어서 테스트가 실패했음을 알 수 있습니다.

Test Steps

테스트 실행 중 각 단계에 대한 정보를 순서대로 보여줍니다.
비교하는 두 이미지가 같을 것으로 예측한 expect.toHaveScreenshot 단계만 실패했어요. 모든 단계는 문제 없이 동작했고 이미지가 어긋나서 테스트가 실패했다는 것을 좀 더 명확히 알 수 있습니다.

Image mismatch

테스트에 필요한 기대한 이미지(Expected), 실제이미지(Actual), 비교 이미지(Diff)를 확인할 수 있습니다.
이를 통해 UI 변경 사항을 한눈에 알 수 있습니다.

Trace

상세페이지에서 제공하는 정보로 테스트가 실패한 이유를 파악하기 어려울 때는 trace 기능을 사용할 수 있습니다. 빨간 네모 영역을 클릭하면 디버깅 페이지로 이동합니다.

c. trace 페이지

trace 페이지에서는 다양한 디버깅 환경을 제공하고 있어요.
상단에서는 시간의 흐름에 따른 UI 변경 사항을, 좌측 사이드바에서는 함수 실행에 따른 UI 변경 사항을 확인할 수 있습니다. 하단에서는 개발자 도구처럼 Console, Nextwork, Error 등을 확인할 수 있어서 어느 시점에, 어떤 이유로 테스트가 실패했는지 유추할 수 있습니다.

3. 테스트 실패 대응하기

테스트가 성공했다면 머지 가능한 상태가 됩니다. 반면에 테스트가 실패했다면 문제를 해결해 머지 가능한 상태로 만들어야 해요.

테스트가 실패하는 상황은 크게 두 가지로 나눌 수 있습니다.

  1. 예상치 못한 변경 사항으로 인해 테스트가 실패한 경우, 코드를 직접 수정하여 문제를 해결합니다.
  2. 변경된 디자인이나 기능을 반영하는 등 올바른 변경 사항으로 인해 테스트가 실패하는 경우, 실제 이미지를 기대한 이미지로 업데이트하여 문제를 해결합니다.

올바른 변경 사항으로 인한 테스트 실패 대응하기

작업자는 실제 이미지를 기대한 이미지로 업데이트하는 수동 Job을 실행해 이미지를 업데이트할 수 있습니다.

Job(update-snapshot)은 아래와 같이 구성합니다.

when
manual 옵션을 사용해 사용자가 수동으로 Job을 실행하도록 합니다.

script
Playwright가 제공하는 플래그(–update-snapshots)를 사용해 실제 이미지를 기대한 이미지로 업데이트하고, 변경 사항을 push합니다.

해당 Job을 실행하면 시각적 회귀 테스트가 성공해 머지 가능한 상태가 됩니다.

이제 작업자가 모르는 UI 변경 사항은 없습니다. 시각적 회귀 테스트를 통해 안정적이고 신뢰할 수 있는 제품을 제공할 수 있게 되었어요!

마치며

시각적 회귀 테스트를 구축한다면 규모가 큰 프로젝트에서는 일종의 방파제 역할 뿐만 아니라 고객 경험과 직결된 서비스에서는 더욱 중요한 역할을 할 수 있습니다.

시각적 회귀 테스트를 도입하기 전에는 컴포넌트에 대한 영향도를 파악하기 어려워서 코드 수정을 주저했었습니다. 도입 후에는 영향범위를 쉽게 파악할 수 있었기 때문에 스타일 영향 범위가 큰 작업을 진행할 때도 코드 수정에 자신감을 얻을 수 있었습니다.

지금까지 시각적 회귀 테스트를 구축했던 이야기를 들려드렸는데요. 시각적 회귀 테스트를 구축할 예정이거나 관심이 많은 분들에게 도움이 되었길 바랍니다.
우아한형제들 디자인 시스템에 적용한 시각적 회귀 테스트는 아직 초기 단계인데요. 시각적 회귀 테스트를 좀 더 운영하고 이야깃거리가 생기면 또 찾아오겠습니다. 앞으로 우아한공방의 다른 이야기들도 기술 블로그에 올라올 예정이니 많은 관심 부탁드립니다.