- Published on
실무에 바로 적용하는 프런트엔드 테스트 2부
- Authors
- Name
- Hyo814
1.1 테스트 주도 개발은 무엇일까?
- 개발 코드를 작성하기 전에 요구 사항을 테스트 케이스로 먼저 작성한 뒤, 실제 기능을 추가하고 리팩토링하는 과정을 반복하며 지속 검증하며 개발하는 방법론
- 테스트 실패 ➡ 테스트 성공 ➡ 리팩토링 사이클로 개발 진행
테스트 주도 개발의 장점
- 개발 단계에서 버그의 원인을 찾고 수정할 수 있다.
- 지속적으로 테스트를 통해 검증하기 때문에 안정성있는 작업을 할 수 있다.
- 효율적인 테스트 단위나 코드의 가독성 등 여러 방면을 고민하게 되어 자연스럽게 좋은 설계에 대한 사고로 이어진다.
- 초기 테스트 작성 비용이 많이 들지만, 앱의 장기적인 관점에서 봤을 때 효과적이다.
하지만, 테스트를 작성한다고 반드시 TDD를 도입할 필요는 없다.
- 현실적인 리소스 문제가 있다면 일부 중요한 기능의 단위 테스트만 작성하거나 개발 후에 중요한 특정 워크플로우에 만 E2E 테스트를 적용하는 등 앱에 맞는 현실적인 테스트 작성법을 찾아야 한다.
1.2 테스트 주도 개발과 프런트엔드 테스트
TDD와 단위 테스트
- 공통 컴포넌트, 훅과 같은 모듈은 TDD를 적용하기에 적합
- 검증하고자 하는 기능이 명확하고 범위가 넓지 않다.
TDD와 통합 테스트
- 비즈니스 로직에 대한 테스트는 TDD를 적용하기에 적합
- 상태 관리, API 호출 로직, 컴포넌트 조합 등을 TDD를 통해 안정적으로 리팩토링 할 수 있음
모든 테스트를 작성할 때 TDD를 적용할 필요는 없다.
- 결국 중요한 것은 개발 단계에서 테스트 피드백을 통해 기능의 안정성을 높이는 것
- 방법론 자체에 몰두하기 보다는, 테스트의 목적에 집중하자.
- 꼭, TDD가 아니더라도 테스트를 도입하는 현실적인 방법을 찾아 팀 문화로 만들고 정착시키자.
2.1 UI 테스트와 스냅샷 테스트
UI 테스트와 스냅샷 테스트는 프론트엔드 개발에서 UI 컴포넌트가 의도한 대로 렌더링되는지 확인하고, 예상치 못한 변경 사항을 탐지하는 데 유용합니다.
스냅샷 테스트란?
스냅샷 테스트는 UI 컴포넌트의 렌더링 결과나 함수의 실행 결과를 직렬화하여 기록하고, 이를 이전에 저장된 스냅샷과 비교하는 방식의 테스트입니다.
- 스냅샷은 UI 또는 함수의 결과를 문자열 형태로 기록한 파일로, 이후 테스트에서 비교 기준으로 사용됩니다.
- 스냅샷 테스트를 통해 코드 변경 시 UI에 의도하지 않은 영향을 미쳤는지 빠르게 탐지할 수 있습니다.
장점
- 변경 감지: 코드 변경으로 인해 UI나 함수 결과가 어떻게 달라졌는지 쉽게 확인 가능.
- 자동화된 회귀 테스트: 예상치 못한 UI 회귀를 방지.
- 단순성: 작성 및 유지보수가 상대적으로 쉽고, 코드 커버리지 향상에 기여.
단점 및 주의점
- 스냅샷 업데이트 남용 위험: 의도치 않은 변경 사항을 간과할 가능성이 있음.
- 의미 없는 결과 비교: 지나치게 복잡한 UI나 데이터를 스냅샷으로 저장하면 테스트가 불필요하게 복잡해질 수 있음.
- 유지보수 필요: UI가 자주 변경되면 스냅샷도 자주 업데이트되어야 함.
Vitest를 사용한 스냅샷 테스트
Vitest는 JavaScript/TypeScript 테스트 프레임워크로, 스냅샷 테스트를 간단하게 지원합니다.
Vitest에서 제공하는 주요 스냅샷 관련 메서드:
toMatchSnapshot()
- 테스트 결과를 스냅샷 파일로 저장 및 비교.
- 스냅샷 파일은 테스트 디렉토리 하위의
__snapshots__
폴더에 저장됨. - 주로 변경 이력을 관리할 필요가 있는 컴포넌트 테스트에 사용.
toMatchInlineSnapshot()
- 스냅샷 데이터를 테스트 파일 내부에 직접 저장 및 비교.
- 별도의 스냅샷 파일을 생성하지 않으며, 간단한 테스트에 유용.
예제 코드
toMatchSnapshot
)
기본 스냅샷 테스트 (import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import MyComponent from './MyComponent'
describe('MyComponent', () => {
it('renders correctly', () => {
const { container } = render(<MyComponent />)
expect(container).toMatchSnapshot() // 스냅샷 파일에 저장
})
})
toMatchInlineSnapshot
)
인라인 스냅샷 테스트 (import { describe, it, expect } from 'vitest'
describe('Inline snapshot test', () => {
it('generates correct output', () => {
const result = { name: 'Shopping Mall', items: 5 }
expect(result).toMatchInlineSnapshot(`
{
"name": "Shopping Mall",
"items": 5
}
`) // 테스트 파일에 스냅샷 기록
})
})
테스트 실행 명령어
스냅샷 저장 및 비교:
vitest run
스냅샷 업데이트:
코드 변경 후 스냅샷이 변경될 경우 아래 명령어로 업데이트.
vitest run --update-snapshot
2.2 스냅샷 테스트의 한계
스냅샷 테스트 관리는 어렵다
- 컴포넌트의 크기가 커지고 복잡할수록 스냅샷 결과는 가독성이 떨어진다.
- 개개인에 따라 무분별한 스냅샷 업데이트가 발생할 수 있다.
이런 문제를 해결하기 위해
- eslint의 no-large-snapshots와 같은 규칙을 사용해 스냅샷을 간결하게 유지하자
그럼에도..
- 여전히 스냅샷 업데이트는 쉽고, 잘못 업데이트될 가능성은 크다.
- 실제로 렌더링을 하는 것이 아니기 때문에 CSS의 변경 또는 UI상에서 어떤 변화가 있는지 정확하 게 감지할 수 없다.
- TDD 사이클과는 맞지 않다.
3.1 시각적 회귀 테스트를 위한 스토리 북
스토리북
- 비즈니스 로직 및 컨텍스트의 간섭 없이 원하는 컴포넌트를 시나리오별로 렌더링 할 수 있도록 도와주는 개발 도구
- 단위·통합 테스트로 컴포넌트의 기능과 비즈니스 로직을 검증하며, 스토리북으로는 컴포넌트의 실제 UI(스타일, 레이아웃 등)를 검증한다
시각적 회귀 테스트
- UI 변경 사항이 발생했을 때 기존과 다른 점이 있는지 비교하여 검증하고, 예상치 못한 문제가 있는지 반복적으로 검증하는 테스트
- 실제 렌더링 된 UI 결과의 이미지를 스냅샷으로 저장하여 비교·검증한다.
- 컴포넌트 시나리오별로 렌더링되는 스토리북의 스토리는 시각적 회귀 테스트의 대상으로 활용하기 적합하다.
스토리북 렌더링에 사용되는 공통 설정은 .stroybook 하위에서 정의할 수 있다.
3.2 스토리 작성하기
스토리
- CSF(Component Story Format)으로 작성
- .stories.(tsx|ts|jsx|js)로 끝나는 파일에 작성
- 메타 데이터와 각각의 시나리오를 보여주는 스토리로 구성
- 메타 데이터: default export로 정의, 제목, 필드 정보, 매개변수, 데코레이터 등에 대한 정보를 정의
- 스토리: named export로 정의
- args: 각 스토리 별로 인자 값을 동적으로 변경해 UI에 반영할 수 있음
Play
- 스토리를 렌더링한 후 사용자 상호 작용을 시뮬레이션 할 수 있도록 도와줌
스토리 작성 대상
- 단순하게 UI만 렌더링 하는 컴포넌트의 시나리오를 상세화
- 비즈니스 로직이 응집되어 있는 컴포넌트의 경우 별도 준비 과정이 필요해 작성이 어려울 수 있다.
- 스토리 작성 대상과 통합 테스트 대상을 구분해 비즈니스 로직과 UI를 구분하기 좋은 설계를 하자
3.3 크로마틱을 통한 UI 테스트 자동화
전문적인 도구를 사용해 시각적 회귀 테스트를 실행하는 것이 좋다
- 일관된 환경에서 스냅샷을 촬영할 수 있음
- 고도화된 비교 알고리즘을 사용하기에 사용자 관점에서 테스트가 가능하다
- 다양한 운영 체제, 브라우저에서 테스트 할 수 있는 환경을 제공한다
- 변경 이력을 모두 저장하기 때문에 수정 사항 히스토리를 파악하기도 좋음
크로마틱(Chromatic)
- 스토리북 메인테이너들이 만든 시각적 회귀 테스트 도구
- 스토리북 연동이 매우 편리하기 때문에 스토리가 이미 존재한다면 편하게 시각적 회귀테스트에 활용할 수 있다.
3.4 크로마틱을 활용한 시각적 회귀 테스트 워크 플로우 만들기
시각적 회귀 테스트 워크플로우
- 스토리북 작성
- Chromatic CI 연동
- UI 회귀 테스트 실행
- PR 승인 및 머지
- 스토리북 배포
자동화된 크로마틱 워크플로우의 장점
- 편리한 스토리북 연동
- 자동화된 회귀 테스트로 예상치 못한 UI 변화를 빠르게 검출
- 팀원들이 크로마틱에서 어떤 스토리가 변경되었는지 스냅샷을 보고 빠르게 파악할 수 있음
- 별도 환경 구성 없이 크로마틱 배포 사이트를 통해 최신 버전의 스토리를 확인할 수 있음
3.5 시각적 회귀 테스트의 한계
- 실제 렌더링 된 UI 결과 이미지를 스냅샷으로 저장해 비교해 스타일, 레이아웃에 대한 변경사항까지 모두 감지해 검증할 수 있다.
- 스토리북 같은 컴포넌트 렌더링 도구를 연동하면 좀 더 편하게 시각적 회귀 테스트를 할 수 있다.
- 깃헙 액션을 활용해 워크플로우를 자동화하면 UI리뷰까지 빠르게 피드백 받을 수 있다.
시각적 회귀 테스트의 한계
- 유료 도구가 많아 비용에 대한 부담이 존재하며, 직접 구축할 경우 관리에 대한 부담이 존재한다.
- 어떤 이유로 변경 사항이 발생했는지 추론하는데 시간이 오래걸릴 수 있다.
- 실행 시간이 오래걸리기 때문에 빠른 피드백을 받을 수 없다.
CI 연동은 필수!
- 시간이 오래 소요되는 테스트이기 때문에, 특정 시점에만 시각적 회귀 테스트를 실행해 피드백을 받고 확인하는 것이 효율적이다.
기타 자료 안내
1. Percy
특징:
- 웹사이트와 UI 컴포넌트를 캡처하고, 이전에 저장된 스냅샷과 비교하여 시각적 변경 사항을 탐지.
- GitHub, GitLab, Bitbucket과 같은 CI/CD 툴과 통합.
- 모바일, 태블릿, 데스크톱 등 다양한 뷰포트를 지원.
장점:
- 자동화된 브라우저 렌더링으로 실제 환경에서 테스트.
- 변경 사항에 대한 풍부한 UI 제공.
단점:
- 무료 플랜에서 사용량 제한이 있을 수 있음.
공식 웹사이트:https://percy.io
2. BackstopJS
특징:
- 오픈소스 시각적 회귀 테스트 도구.
- Puppeteer와 Playwright를 사용하여 페이지를 렌더링하고 비교.
- 여러 뷰포트 크기를 설정해 테스트 가능.
장점:
- 유연한 설정과 구성 파일 제공.
- 무료로 사용 가능.
단점:
- 설정이 다소 복잡할 수 있음.
- UI가 없고, 터미널 중심으로 작동.
**공식 웹사이트:**https://github.com/garris/BackstopJS
3. Applitools
특징:
- AI 기반 시각적 회귀 테스트 도구.
- 동적 콘텐츠와 애니메이션을 다루는 데 강력한 성능 제공.
- 브라우저, 장치, OS 간의 호환성 테스트를 지원.
장점:
- AI로 작은 변화와 의도된 변화를 구분 가능.
- 다양한 프로그래밍 언어와 통합 가능 (Selenium, Cypress 등).
단점:
- 무료 플랜의 제한이 존재하며, 고급 기능은 유료 플랜에서 제공.
공식 웹사이트:https://applitools.com
4. Visual Regression Tracker
특징:
- 오픈소스 시각적 회귀 테스트 플랫폼.
- 스크린샷을 비교하고 결과를 대시보드에서 확인 가능.
- CI/CD와 통합하여 워크플로에 쉽게 추가.
장점:
- 무료이며 자체 호스팅 가능.
- 간단한 API 및 다국어 SDK 지원.
단점:
- 설정을 직접 관리해야 함.
공식 웹사이트:https://visual-regression-tracker.netlify.app
5. Happo
특징:
- UI 컴포넌트의 스크린샷을 생성하고, 시각적 차이를 탐지.
- Storybook과 통합 가능.
- GitHub, GitLab, Bitbucket 등의 CI/CD와 연동.
장점:
- 다양한 뷰포트와 브라우저에서 테스트 가능.
- 병렬 테스트 실행으로 빠른 속도 제공.
단점:
- 무료 플랜에서 사용량 제한.
공식 웹사이트:https://happo.io
6. Playwright + Pixelmatch
특징:
- Playwright는 브라우저 자동화 도구로 스크린샷을 캡처하고, Pixelmatch를 통해 이미지 비교 가능.
- 사용자 정의 시각적 테스트 워크플로를 구성할 수 있음.
장점:
- 오픈소스이며 높은 유연성 제공.
- 다양한 브라우저와 장치에서 테스트 가능.
단점:
- 설정 및 관리가 추가적으로 필요.
공식 웹사이트:https://playwright.dev
7. Loki
특징:
- Storybook과 같은 UI 컴포넌트 라이브러리에서 시각적 회귀 테스트를 자동화.
- 크롬, 파이어폭스, 퍼시스턴트 렌더링 등 다양한 모드 지원.
장점:
- Storybook과의 완벽한 통합.
- 오픈소스 프로젝트로 무료 사용 가능.
단점:
- 대규모 테스트 환경에서는 성능이 제한될 수 있음.
공식 웹사이트:https://loki.js.org
비교 요약
도구 | 오픈소스 | 가격 | 브라우저 지원 | 주요 특징 |
---|---|---|---|---|
Percy | 아니오 | 유료 | 모든 주요 브라우저 | 직관적인 UI와 다양한 통합 지원 |
BackstopJS | 예 | 무료 | 모든 주요 브라우저 | 설정 유연성, 터미널 중심 |
Applitools | 아니오 | 유료 | 모든 주요 브라우저 | AI 기반의 스마트 비교 |
Visual Regression Tracker | 예 | 무료 | 모든 주요 브라우저 | 자체 호스팅 가능, 대시보드 제공 |
Happo | 아니오 | 유료 | 모든 주요 브라우저 | 병렬 실행, 빠른 속도 제공 |
Playwright + Pixelmatch | 예 | 무료 | 모든 주요 브라우저 | 커스텀 워크플로 구성 가능 |
Loki | 예 | 무료 | 크롬, 파이어폭스 등 | Storybook과 완벽 통합 |
4.1 E2E 테스트란 무엇일까?
E2E(End-to-End) 테스트
- 실제 앱을 구동해 전체 소프트웨어 시스템의 전체 흐름을 검증
- 사용자 입장에서 앱을 사용하면서 발생할 수 있는 시나리오가 실제 환경에서 정상적으로 작동하는지 확인
- 직접 앱을 구동하기에 다른 테스트들에 비해 시간이 오래 걸림
E2E 테스트의 장점
- 사용자 관점에서 시나리오를 완벽하게 테스트 할 수 있다.
- 프런트엔드부터 백엔드까지 앱의 전반적인 상태를 확인할 수 있다.
- 변경 사항이 전체 시스템에 미치는 영향을 확인할 수 있다.
E2E 테스트 작성 시 중요한 점
- 유관 부서와의 협력이 필요하다.
- 가능한 한 API는 모킹하지 않는다.
- 도입을 위한 일정 확보가 필요하다.
4.2 Cypress로 E2E 테스트 시작하기
Cypress
- 실제 웹 앱을 기준으로 다양한 테스트를 작성할 수 있는 오픈 소스 자동화 도구
- 브라우저 안과 밖에서 일어나는 상황을 제어할 수 있어 일관된 환경에서 테스트를 실행할 수 있다
Head모드와 Headless모드
- head 모드
- 브라우저 UI까지 모두 구동하여 시각적으로 확인할 수 있는 환경에서 테스트 실행
- 주로 실행 과정을 확인하거나 디버깅 시 사용
- headless 모드
- UI없이 브라우저 엔진을 명령어 인터페이스로 제어하여 테스트를 실행
- 구동속도가 상대적으로 빨라 CI 또는 클라우드 환경에서 사용
Cypress의 장점
- 편리하고 빠른 디버깅
- Time Travel과 스크린샷
4.3 Cypress로 첫 번째 E2E 테스트 작성하기
단위·통합 테스트와 E2E 테스트의 중복 기능 검증
- 전체 앱을 띄웠을 때도 정상 동작하는지 검증 → 안정성 향상
- 통합 테스트에서 검증된 기능을 찾아 필터링 하는 과정 → 불필요한 시간 소요
활용한 Cypress 주요 문법
- beforeEach, beforeAll을 통한 setup
- ‘cy.’ prefix로 내장 API를 사용할 수 있음 •visit() 함수를 통한 특정 페이지 방문
- Cypress Testing Library를 통해 Testing Library에서 제공하는 DOM 노드 쿼리 사용
- should() 함수를 통해 단언. Cypress는 내부적으로 chai, chai-jQuery, sinon-chai를 확장해 사용
E2E 테스트의 효과
- 단위·통합 테스트에서 처럼 별도 모킹 과정이 없기 때문에 페이지 이동, API 연동, 로그인 후 처리 등 실제 앱에 서 발생하는 모든 과정을 검증할 수 있음
기타 자료.
Cypress에서 사용되는 Assertion
Cypress는 Chai, Chai-jQuery, Sinon-Chai 등의 Assertion 라이브러리를 기본적으로 제공하여 강력한 검증 기능을 지원합니다. 이를 통해 다양한 UI와 함수 동작을 검증할 수 있습니다.
Assertions의 주요 개념
- Assertion: 테스트 실행 중 특정 조건이 참인지 확인.
- Cypress는 **should()**와 expect() 메서드를 통해 Assertion을 작성.
- Chaining: 하나의 명령에 여러 Assertion을 연결하여 가독성을 높임.
Chai를 사용한 Assertions
Chai는 Cypress에서 사용하는 기본 Assertion 라이브러리입니다. BDD 스타일의 Assertion을 작성할 수 있습니다.
주요 Assertion
Assertion | 설명 | 예시 |
---|---|---|
not | 부정 조건 | .should('not.equal', 'Jane') |
deep | 객체를 깊이 비교 | .should('deep.equal', { name: 'Jane' }) |
nested | 중첩된 속성을 검증 | .should('have.nested.property', 'a.b') |
ordered | 순서가 보장된 배열 비교 | .should('have.ordered.members', [1, 2]) |
include | 값이 포함되어 있는지 확인 | .should('include', 2) |
exist | 요소나 값이 존재하는지 확인 | .should('exist') |
empty | 배열, 객체 등이 비어있는지 확인 | .should('be.empty') |
greaterThan | 값이 특정 값보다 큰지 확인 | .should('be.greaterThan', 5) |
within | 값이 특정 범위 내에 있는지 확인 | .should('be.within', 5, 10) |
lengthOf | 길이가 특정 값인지 확인 | .should('have.lengthOf', 3) |
Chai-jQuery를 사용한 DOM 관련 Assertions
DOM 객체를 다룰 때 유용한 Assertion입니다. 주로 Cypress 명령어인 cy.get()
또는 cy.contains()
이후에 사용됩니다.
주요 Assertion
Assertion | 설명 | 예시 |
---|---|---|
attr | 속성이 존재하거나 특정 값을 가지는지 확인 | .should('have.attr', 'href', '/home') |
prop | 속성(Property) 값을 검증 | .should('have.prop', 'disabled', true) |
css | CSS 속성 값을 검증 | .should('have.css', 'color', 'red') |
class | 클래스가 포함되어 있는지 확인 | .should('have.class', 'active') |
id | 특정 ID를 가지는지 확인 | .should('have.id', 'main') |
text | 텍스트 내용을 검증 | .should('have.text', 'Hello') |
value | 입력 필드의 값을 검증 | .should('have.value', 'example') |
visible | 요소가 화면에 표시되는지 확인 | .should('be.visible') |
hidden | 요소가 숨겨져 있는지 확인 | .should('be.hidden') |
exist | 요소가 DOM에 존재하는지 확인 | .should('exist') |
Sinon-Chai를 사용한 Spy 및 Stub 관련 Assertions
Sinon-Chai는 cy.spy()
와 cy.stub()
로 생성된 함수 호출을 검증하는 데 사용됩니다.
주요 Assertion
Assertion | 설명 | 예시 |
---|---|---|
called | 함수가 호출되었는지 확인 | .should('have.been.called') |
callCount | 호출 횟수 확인 | .should('have.callCount', 3) |
calledOnce | 한 번 호출되었는지 확인 | .should('have.been.calledOnce') |
calledWith | 특정 인수와 함께 호출되었는지 확인 | .should('have.been.calledWith', 'arg1') |
returned | 함수가 특정 값을 반환했는지 확인 | .should('have.returned', 'value') |
주요 Use Case
길이 검증
cy.get('li.selected').should('have.length', 3)
클래스 확인
cy.get('form').find('input').should('not.have.class', 'disabled')
값 확인
cy.get('textarea').should('have.value', 'Hello World')
텍스트 내용 확인
cy.get('[data-testid="greeting"]').should('have.text', 'Welcome')
가시성 확인
cy.get('[data-testid="button"]').should('be.visible')
존재 여부 확인
cy.get('[data-testid="loading"]').should('not.exist')
Custom Assertions
Cypress의 기본 Assertion으로 부족한 경우, 커스텀 Assertion을 작성할 수 있습니다.
cy.get('div').should(($div) => {
expect($div).to.have.length(1)
expect($div[0].className).to.match(/active/)
})
Assertion 체이닝
하나의 요소에 대해 여러 Assertion을 연결하여 검증 가능합니다.
cy.get('[data-testid="link"]')
.should('have.class', 'active')
.and('have.attr', 'href')
.and('include', 'example.com')
주의점
재시도 메커니즘: Cypress는 Assertion이 실패하면 자동으로 재시도합니다.
하나의 요소에 대해 상반된 Assertion 사용 금지: 같은 요소에 대해
visible
과not.visible
을 동시에 사용할 수 없습니다.잘못된 방법
cy.get('[data-testid="loading"]').should('be.visible').and('not.be.visible')
올바른 방법
cy.get('[data-testid="loading"]').should('be.visible') cy.get('[data-testid="loading"]').should('not.be.visible')
4.4 Cypress와 쿼리
cy.get()
- jQuery와 유사한 CSS Selector를 사용해 DOM에 접근할 수 있다.
- cy.as()로 선언한 별칭(alias)과 함께 사용하면 테스트 코드를 간결하게 작성할 수 있다.
체이닝과 subject
- subject 객체는 테스트의 시작 지점 또는 대상이 되는 요소를 의미
- 이를 통해 체이닝 명령 수행이나 종료 또는 오류를 제어
- Cypress의 커맨드 실행이 완료되는 타이밍을 맞추기 위해서는 subject 체이닝 형 태로 연속해서 커맨드를 실행하거나 then() API를 사용. 이러한 실행 결과 전달을 yield라고 한다.
Retry-ability
- cy.get(), cy.should()등 잠재적으로 업데이트 가능성이 있다고 판단해 재시도를 실행하는 API
- subject 덕분에 타이밍 문제 없이 재시도하여 얻은 결과를 순차적으로 사용할 수 있다.
- 테스트 타이밍에 대한 다양한 고민 요소를 해결해주는 핵심 기능
Retry-ability 기능 여부에 따른 API 구분
- Query: 전체 체이닝 로직을 재시도하며 수행 • Assertion: 단언을 수행하는 특별한 쿼리의 유형 • Non-Query: 재시도를 하지 않으며, 단 한 번만 실행되는 커맨드
5.1 커스텀 커맨드와 쿼리
커스텀 쿼리
- Retry-ability 지원 • 동기로 동작하며, subject 결과를 받아 내부적으로 체이닝 코드를 재시도 • Cypress.Commands.addQuery()와 Cypress.Commands.overwriteQuery()로 정 의 • https://docs.cypress.io/api/cypress-api/custom-queries
기타 자료.
Cypress Custom Queries
Cypress 12부터 Custom Query API가 도입되어, 사용자가 자신만의 쿼리를 쉽게 정의할 수 있게 되었습니다. 이는 Cypress의 기존 쿼리(cy.get
, cy.contains
등)가 사용하는 API와 동일한 방식으로 작동합니다.
Custom Query란?
Custom Query는 어플리케이션 상태를 쿼리하기 위한 명령으로 다음과 같은 특징을 갖습니다:
- 동기적(Synchronous): Promise를 반환하거나 대기하지 않습니다.
- 재시도 가능(Retriable): 쿼리 함수가 실패하면 Cypress가 자동으로 재시도합니다.
- 멱등성(Idempotent): 동일한 입력으로 쿼리가 여러 번 호출되더라도 어플리케이션 상태가 변하지 않아야 합니다.
Custom Query와 Command의 차이
- Query: 반복 호출과 재시도가 필요한 경우.
- Command: 비동기 작업이나 단일 실행이 필요한 경우.예: 여러 Cypress 명령을 연결하여 단축키처럼 사용하는 경우에는 Custom Command를 사용해야 합니다.
Custom Query 작성법
기본 문법
Cypress.Commands.addQuery(name, callbackFn)
Cypress.Commands.overwriteQuery(name, callbackFn)
name
: 추가하거나 덮어쓸 쿼리의 이름(문자열).callbackFn
: 쿼리의 로직을 정의하는 함수. 이 함수는 두 단계로 나뉩니다:- 외부 함수: 초기화 작업을 수행하며, 호출 시 한 번 실행됩니다.
- 내부 함수: 쿼리를 실행하며, 여러 번 반복 호출됩니다.
Custom Query 작성 예제
새로운 쿼리 추가
다음은 cy.getById()
라는 새로운 쿼리를 추가하는 예제입니다:
Cypress.Commands.addQuery('getById', function (id) {
return (subject) => {
return subject.find(`#${id}`) // `id`에 해당하는 요소를 찾음
}
})
사용 예:
cy.get('div').getById('example')
기존 쿼리 덮어쓰기
기존 cy.get()
쿼리를 확장하여 동작을 추가할 수 있습니다:
Cypress.Commands.overwriteQuery('get', function (originalFn, ...args) {
console.log('get 호출됨:', args)
const innerFn = originalFn.apply(this, args)
return (subject) => {
console.log('get의 내부 함수 호출:', subject)
return innerFn(subject) // 기존 로직을 유지
}
})
구체적인 예제
커스텀 쿼리 작성
아래는 Cypress 내부 cy.focused()
쿼리를 기반으로 작성된 cy.focused2()
의 예입니다:
Cypress.Commands.addQuery('focused2', function focused2(options = {}) {
const log = options.log !== false && Cypress.log({ timeout: options.timeout })
this.set('timeout', options.timeout)
return () => {
let $el = cy.getFocused()
log &&
cy.state('current') === this &&
log.set({
$el,
consoleProps: () => ({
Yielded: $el?.length ? $el[0] : '--nothing--',
Elements: $el != null ? $el.length : 0,
}),
})
if (!$el) {
$el = cy.$$(null)
$el.selector = 'focused'
}
return $el
}
})
cy.contains()
확장
Alias를 지원하도록 cy.contains()
를 수정하여, 별칭(@
)을 지원하도록 변경:
Cypress.Commands.overwriteQuery('contains', function (originalFn, filter, text, userOptions) {
if (_.isString(filter) && filter[0] === '@') {
const alias = cy.state('aliases')[filter.slice(1)]
const subject = cy.getSubjectFromChain(alias?.subjectChain)
filter = subject
}
if (_.isString(text) && text[0] === '@') {
const alias = cy.state('aliases')[text.slice(1)]
const subject = cy.getSubjectFromChain(alias?.subjectChain)
text = subject
}
return originalFn.call(this, filter, text, userOptions)
})
사용 예:
cy.wrap('li').as('element')
cy.wrap('example').as('content')
cy.contains('@element', '@content')
ensure
함수
Cypress의 기본 쿼리 내부에서 Cypress가 제공하는 ensure
함수로 입력 검증을 수행할 수 있습니다:
cy.ensureSubjectByType(subject, types, this)
주어진 subject가element
,document
,window
등인지 검증.cy.ensureAttached(subject)
subject가 DOM에 부착되었는지 확인.cy.ensureVisibility(subject)
subject가 화면에 표시되는지 확인.
예:
Cypress.Commands.addQuery('customQuery', function () {
return (subject) => {
cy.ensureSubjectByType(subject, ['element'], this)
return subject.find('.custom-class')
}
})
Best Practices
- 모든 것을 커스텀 쿼리로 만들지 마세요 반복적으로 사용되는 특정 동작만 커스텀 쿼리로 정의하세요. 예:
cy.getLoginForm()
. - 단순함 유지 Cypress의 기본 제공 기능이 충분히 강력하므로, 필요 이상으로 복잡한 커스텀 쿼리를 작성하지 마세요.
- 가독성 우선 Cypress 테스트는 이해하기 쉬워야 합니다. 과도한 추상화는 피하세요.
커스텀 커맨드
- Retry-ability 미지원 • 비동기로 동작할 수 있으며, 특별한 설정이 없다면 subject를 이어받지 않음 • 재시도를 실행하지 않고, 단 한번만 실행 • Cypress.Commands.add() 와 Cypress.Commands.overwrite()로 정의 • https://docs.cypress.io/api/cypress-api/custom-commands • Parent Commands, Child Commands
기타 자료.
Cypress Custom Commands
Cypress는 Custom Commands API를 통해 사용자가 명령을 정의하거나 기존 명령을 덮어쓸 수 있는 기능을 제공합니다. 이를 통해 테스트를 간결하고 재사용 가능하게 만들 수 있습니다.
Custom Commands의 종류
Cypress.Commands.add()
: 새 Custom Command를 추가.Cypress.Commands.overwrite()
: 기존 Cypress 명령을 덮어써서 동작을 수정.
Custom Commands 작성법
기본 문법
// 명령 추가
Cypress.Commands.add(name, callbackFn)
Cypress.Commands.add(name, options, callbackFn)
Cypress.Commands.addAll(callbackObj)
Cypress.Commands.addAll(options, callbackObj)
// 명령 덮어쓰기
Cypress.Commands.overwrite(name, callbackFn)
파라미터 설명
name
: 명령의 이름(문자열).callbackFn
: 명령의 로직을 정의하는 함수.options
: 명령의 동작을 설정하는 객체.prevSubject
: 이전 명령의 결과를 현재 명령에 전달하는 방식.false
: 이전 명령 무시(Parent Command).true
: 이전 명령의 결과 사용(Child Command).optional
: 결과를 선택적으로 사용할 수 있음(Dual Command).
Custom Commands 예제
Parent Command (새 체인 시작)
// "Buy Now" 텍스트를 가진 링크 클릭
Cypress.Commands.add('clickLink', (label) => {
cy.get('a').contains(label).click()
})
cy.clickLink('Buy Now')
SessionStorage 조작
Cypress.Commands.add('setSessionStorage', (key, value) => {
cy.window().then((window) => {
window.sessionStorage.setItem(key, value)
})
})
Cypress.Commands.add('getSessionStorage', (key) => {
cy.window().then((window) => window.sessionStorage.getItem(key))
})
cy.setSessionStorage('token', 'abc123')
cy.getSessionStorage('token').should('eq', 'abc123')
UI를 통한 로그인
Cypress.Commands.add('loginViaUi', (user) => {
cy.session(
user,
() => {
cy.visit('/login')
cy.get('input[name=email]').type(user.email)
cy.get('input[name=password]').type(user.password)
cy.get('button#login').click()
cy.get('h1').contains(`Welcome back ${user.name}!`)
},
{
validate: () => {
cy.getCookie('auth_key').should('exist')
},
}
)
})
cy.loginViaUi({ email: 'test@email.com', password: 'password123', name: 'John Doe' })
API를 통한 로그인
Cypress.Commands.add('loginViaApi', (userType) => {
const users = {
admin: { name: 'Admin User', admin: true },
user: { name: 'Regular User', admin: false },
}
cy.request('POST', '/api/login', users[userType]).then((response) => {
cy.setCookie('auth_token', response.body.token)
})
})
cy.loginViaApi('admin')
Child Command (체인에 연결)
Cypress.Commands.add('console', { prevSubject: true }, (subject, method = 'log') => {
console[method]('Subject:', subject)
return subject
})
cy.get('button').console('info')
Dual Command (옵션으로 체인 사용)
Cypress.Commands.add('dismiss', { prevSubject: 'optional' }, (subject) => {
if (subject) {
cy.wrap(subject).click()
} else {
cy.get('#modal').click()
}
})
cy.dismiss() // 직접 호출
cy.get('#dialog').dismiss() // 체인에서 호출
명령 덮어쓰기
visit
명령 덮어쓰기
Cypress.Commands.overwrite('visit', (originalFn, url, options) => {
const baseUrl = Cypress.env('BASE_URL')
url = `${baseUrl}${url}`
return originalFn(url, options)
})
type
명령 덮어쓰기 (민감한 데이터 마스킹)
Cypress.Commands.overwrite('type', (originalFn, element, text, options = {}) => {
if (options.sensitive) {
Cypress.log({
$el: element,
name: 'type',
message: '*'.repeat(text.length),
})
options.log = false
}
return originalFn(element, text, options)
})
cy.get('#password').type('SuperSecret123', { sensitive: true })
Best Practices
모든 동작을 Custom Command로 만들지 마세요
테스트 파일 내부에서만 사용되는 로직은 일반 함수로 작성하는 것이 간단하고 효율적입니다.
명령을 단순하게 유지하세요
Custom Command는 Cypress 명령을 추상화한 것이므로, 지나치게 복잡하지 않게 유지해야 이해와 유지보수가 쉽습니다.
명령을 재사용 가능하게 만드세요
명령을 작성할 때 너무 많은 기능을 넣기보다는, 테스트 목적에 맞는 작고 독립적인 동작을 구현하는 것이 좋습니다.
UI를 가능한 건너뛰세요
로그인, 세션 초기화 등은 Cypress의 API 요청(
cy.request()
)을 활용하여 빠르게 처리하세요.TypeScript 정의를 작성하세요
Custom Command에 TypeScript 정의를 추가하면 IntelliSense와 문서화 기능을 활용할 수 있습니다.
5.2 서버 요청 가로 채기
Cypress의 네트워크 요청 테스트
Cypress는 어플리케이션의 HTTP 요청 라이프사이클을 테스트할 수 있는 강력한 도구를 제공합니다. Cypress를 사용하면 요청의 본문, URL, 헤더 등을 검증하고, 응답을 스텁(stub)하거나 대기(wait)할 수 있습니다.
학습 목표
- 네트워크 요청을 테스트하는 다양한 전략.
- 응답을 스텁하고 대기하는 방법.
- GraphQL 쿼리 및 변이에 대한 테스트 베스트 프랙티스.
테스트 전략
1. 서버 응답 사용
서버 요청을 스텁하지 않고 실제 서버와 통신하는 End-to-End 테스트를 작성할 수 있습니다. 이는 실제 사용자의 동작을 가장 잘 시뮬레이션합니다.
- 장점:
- 클라이언트와 서버 간 계약(데이터 구조 및 형식)이 올바른지 보장.
- 서버의 엔드포인트까지 테스트 가능.
- 단점:
- 테스트 속도가 느림.
- 데이터베이스 초기화와 같은 사전 작업이 필요.
- 경계 케이스 테스트가 어려움.
- 사용 추천:
- 애플리케이션의 핵심 경로(로그인, 회원가입 등)에 사용.
2. 응답 스텁
cy.intercept()
를 사용하여 응답 데이터를 제어할 수 있습니다. 이는 네트워크 요청 없이 빠른 테스트를 가능하게 합니다.
- 장점:
- 응답 본문, 상태 코드, 헤더 등을 제어 가능.
- 네트워크 지연을 시뮬레이션 가능.
- 서버나 클라이언트 코드를 수정할 필요 없음.
- 단점:
- 실제 서버 응답과 일치하지 않을 수 있음.
- 서버 엔드포인트의 테스트 커버리지가 부족할 수 있음.
- 사용 추천:
- 대부분의 테스트에 사용.
- JSON API 테스트에 적합.
cy.intercept()
로 네트워크 요청 스텁
cy.intercept(
{
method: 'GET',
url: '/users/*',
},
[] // 빈 배열 응답 스텁
).as('getUsers') // 별칭 생성
Fixtures
Fixture는 테스트에서 사용할 수 있는 고정된 데이터 파일입니다. 이를 사용하면 테스트 환경을 일정하게 유지할 수 있습니다.
예제: Fixture 데이터 사용
cy.intercept('GET', '/activities/*', { fixture: 'activities.json' })
폴더 구조:
/cypress/fixtures/example.json /cypress/fixtures/images/cats.png
Fixture 호출:
cy.fixture('example.json').then((data) => { console.log(data) })
요청 대기
cy.wait()
를 사용하여 네트워크 요청과 응답이 완료될 때까지 기다릴 수 있습니다.
예제: 요청 대기
cy.intercept('/activities/*', { fixture: 'activities.json' }).as('getActivities')
cy.visit('/dashboard')
cy.wait('@getActivities')
cy.get('h1').should('contain', 'Dashboard')
요청 객체 검증
cy.intercept('POST', '/users').as('newUser')
cy.wait('@newUser').its('request.body').should('deep.equal', {
id: 123,
name: 'John Doe',
})
GraphQL 요청 테스트
GraphQL 쿼리 및 변이 감지
GraphQL 요청의 operationName
을 기반으로 요청을 감지하고 별칭을 설정할 수 있습니다.
export const aliasQuery = (req, operationName) => {
if (req.body.operationName === operationName) {
req.alias = `gql${operationName}Query`
}
}
export const aliasMutation = (req, operationName) => {
if (req.body.operationName === operationName) {
req.alias = `gql${operationName}Mutation`
}
}
GraphQL 테스트 예제
cy.intercept('POST', '/graphql', (req) => {
aliasQuery(req, 'GetUser')
aliasMutation(req, 'UpdateUser')
})
cy.wait('@gqlGetUserQuery').its('response.body.data.user').should('have.property', 'name')
Command Log
Cypress는 네트워크 요청을 Command Log에 표시합니다.
- 실제 서버 요청: 채워진 원(circle).
- 스텁된 요청: 비어 있는 원(circle).
베스트 프랙티스
End-to-End와 스텁 테스트를 혼합
중요한 경로에서는 실제 서버 요청을 사용하고, 나머지는 스텁 처리.
명확한 별칭 사용
cy.intercept()
에 별칭을 설정하여 가독성을 높이고, 테스트의 의도를 명확히 표현.GraphQL 요청 처리
쿼리 및 변이를 명확히 분리하고, 응답 데이터를 수정하여 다양한 상황을 테스트.
Flaky 테스트 방지
cy.wait()
를 사용하여 요청 및 응답 타이밍 문제를 방지.
5.4 E2E 테스트의 한계
- 단위· 통합 테스트에서 검증하지 못했던 API 호출이나 모든 모듈이 조합 되었을때의 기능을 검증할 수 있음 E2E 테스트의 한계
- 단위·통합 테스트에 비해 느린 속도 • 실제 웹앱을 구동해 테스트를 진행하기 때문에 렌더링이나 API 응답에 많은 시간 소요 • 테스트 실행 시간 증가 → 개발 생산성 저하 • 외부 환경 요소로 인해 테스트가 깨질 수 있음 • 온전한 테스트 실행을 위한 관리 비용이 많이 들어감 • 디버깅 시간이 오래 걸림 • 전체 앱을 구동해 검증 → 테스트에 영향을 미칠 수 있는 모듈의 범위가 매우 큼 • 테스트가 실패 했을 때 원인을 찾아 수정하는데 많은 시간이 소요될 수 있음
5.7 테스트 더블
- 더미(Dummy) • 테스트 환경에서 특정 모듈 또는 함수가 필요하지만, 실제로 해당 모듈의 구현이나 기능 실행까지는 필요 없는 경우 사용
- 스텁(Stub) • 모듈이 호출될 때 테스트 환경에 정해진 값을 반환 • 따라서, 정해진 경우 외에는 대응할 수 없음
- 스파이(Spy) • 스텁을 고도화한 형태로 구현된 객체의 호출 정보까지 기록
- 목(Mock) • 실제 모듈과 유사한 행동을 하도록 만들어진 모의 객체. • 모의 객체가 모듈 사양에 맞게 동작 했는지 행동 기반으로 검증
- 페이크(Fake) • 특정 모듈을 아주 단순한 테스트 전용 모듈로 대체 • 테스트를 위해 만든 단순 구현체이기 때문에 실제 프로덕션에서 사용하면 안됨
- 테스트 더블의 장점
- 실제 구현체를 사용하지 못할 때 적절한 테스트 더블로 대체해 검증할 수 있다.
- 테스트 코드와 외부 의존성 모듈을 분리할 수 있다.
- 예외 처리에 대한 상황을 쉽게 재현하여 검증할 수 있다.
