date: 2026-09-18
tags:
원래 e2e라고 하면 정말 "end to end" 이므로 Frontend, Backend, Mobile 구분 없이 end와 end 양 끝단만, 그리고 필요하다면 영속성 레이어에 데이터가 의도대로 들어갔는지까지 보는 게 정의상 맞습니다. Agent 시대에 SWE간 구분이 흐려지고 모든 개발자가 모든 프로젝트를 보는 것이 흔해지고 있기에 더더욱 이 방향의 e2e test가 활성화되고 있다고 느껴집니다.
그럼에도 팀 구조나 프로젝트 규모 등등의 이유로 Frontend만 단독으로 e2e를 돌리고 싶은 니즈는 여전히 존재합니다. 이번 글에서는 Frontend 단독으로 e2e를 돌릴 때 어떻게 하는 것이 좋은지에 대한 제 생각들을 풀어보려 합니다.
이 글은 Frontend에서 e2e를 수 차례 구성해 본 적이 있는 분들을 대상으로 합니다.
세부적으로 파고 들어가면 케이스가 다양해지다 보니 혼란을 피하기 위해 먼저 이 글의 핵심 주제인 Frontend e2e에 대해 가볍게 정의하고 넘어가겠습니다. 일반적으로 Frontend e2e라고 하면 하나의 Frontend 프로젝트가 빌드되어 나온 JS 번들 등 각종 정적 Asset들과 서버 로직 등 모든 코드를 커버하는 테스트입니다. 가장 좁고 직관적인 표현으로는 백엔드에 api를 찔러서 받아온 데이터를 화면에 잘 그려주는지, 거기서 특정 버튼을 누르고 폼을 채우고 이동했을 때 특정 api가 잘 발송되는지 등등을 포함합니다. 백엔드 api가 의도대로 내려오는지는 보통 테스트 대상이 아닙니다. 더 넓게는 localStorage에 정보가 잘 저장되는지와 같은 정보들도 e2e 대상입니다.
일반적으로 e2e test를 구성할 때는 Cypress, Playwright 와 같은 도구들을 사용합니다. 이들은 화면에서 요소를 클릭하기 위한 각종 선택자들, 브라우저 렌더 및 화면 관련 설정들, service worker들, 브라우저 web api를 모두 잘 지원해줍니다. 여기에 Cucumber와 같은 도구를 합쳐 Gherkin 문법으로 BDD를 구성하기도 하구요. 또한 백엔드 api를 모킹할 때에는 MSW와 같은 도구들을 사용하기도 합니다.
mock/db/ 같은 것이 있지 않고 브라우저 쿠키가 결정합니다. 하나의 테스트 케이스가 서버가 내려줄 응답을 소유합니다.어쩌다 이렇게 되었는지 하나씩 살펴보겠습니다.
테스팅 프레임워크인 Playwright와 Cypress 중 하나를 선택하는 것이 좋습니다. 이들은 잘 관리되고 있고, ui 모드와 run 모드 등을 깔끔하게 지원하고, 커뮤니티도 매우 크기 때문에 안정적입니다.
데이터 관리 프레임워크인 MSW는 사용하지 않습니다. MSW는 endpoint별로 목데이터를 관리하고 그 안에서 분기를 할 수 있도록 구성되는데, 사실 e2e에서는 여러 시나리오가 독립적으로 구성되는 게 중요하므로 목데이터는 엔드포인트로 구분된 다음 시나리오로 분기되는 것이 아닌, 시나리오에 따라 먼저 구분된 다음 엔드포인트 등으로 분기되는 것이 자연스럽습니다. 이 부분은 아래 e2e 데이터 관리 섹션에서 더 자세히 다루겠습니다.
mock server는 직접 express 와 같은 가벼운 라이브러리를 사용하여 만듭니다. playwright의 page.route 기능도 훌륭하지만 이는 서버사이드 콜을 잘 대응해주지 못한다는 아쉬움이 있습니다. 또한 playwright는 어디까지나 테스트 시뮬레이터로 남겨두는 것이 좋은데, playwright는 page.route를 지원하지만 언젠가 등장할 "playwright보다 좋은 도구" 가 해당 기능을 지원하지 않을 수 있기 때문입니다. 우리는 이런 여러 상황에서 항상 유연하게 "더 좋은 도구"로 갈아탈 수 있도록 설계해야 합니다.
Meta Pixel과 같은 것들도 고민 대상입니다. e2e 상황에서 이런 외부 도구에 데이터가 실제로 발송되는 것은 원하지 않지만, 데이터가 발송되는 것을 확인까지는 하고 싶기 때문에 아예 꺼 버리기는 또 아쉽습니다.
가령 Meta Pixel은 fbq 함수를 글로벌에 지정해두고 사용하는 구조입니다. 따라서 e2e mode일 경우 실행 시 window.fbq 를 inject하여 덮어씌운 다음 테스트합니다.
e2e를 하다 보면 반드시 데이터 관리에 대한 고민을 하게 됩니다. 하나의 흔한 패턴은 엄청나게 큰 시드 데이터를 만들어두고 - 가령 user id 1은 학생이고 17살이고 무슨 무슨 반에 소속되어 있고.. 라는 정보를 시드 데이터가 들고 있으면, e2e에서 user id 1에 대해 조회하면 해당 데이터가 내려오는 등입니다. 이 경우 전체 시드 데이터의 정합성도 챙겨야 할 뿐더러 여러 시나리오가 user id 1이 17살 학생이다 라는 정보에 의존하게 됩니다. 다른 케이스의 학생이 필요해질 때마다 user id 2, 3, 4, 5, ...가 추가되고, 이들 간의 데이터 정합성이 깨지게 되고, 데이터 정합성을 맞춰주려다가 다른 e2e 테스트가 깨지고.. 하는 악순환이 발생합니다. 제라드 메자로스의 책 xUnit Test Patterns 에서 Interacting Tests 라는 용어로 소개된 개념이기도 합니다.
올바른 구조는 책에서 제안하였듯 Fresh Fixture 개념을 도입하여, 중앙화된 시드 데이터셋 없이 아래의 의사코드와 같이 하나의 시나리오가 자신을 위한 모든 데이터를 들고 있는 방향입니다.
test('학생 유저로 프로필 화면에 접속하면 학생 유저 라벨이 보인다', async (page) => {
const seed = {
users: [{ id: 1, type: 'student', age: 17 }],
// ... 다른 "이 시나리오만을 위한" 데이터들
}
await page.respondWith(seed);
await page.goto('/users/1')
await expect(page.getByTestId('user-type')).toHaveText('학생')
})
여기에 더해 몇 가지 현실적인 고민들을 하다 보면 아래와 같이 구성되기도 합니다.
test('학생 유저로 프로필 화면에 접속하면 학생 유저 라벨이 보인다', async (page) => {
await page.respondWithAndRejectElse({
'GET /api/users/1': { id: 1, type: 'student', age: 17 }
});
await page.goto('/users/1')
await expect(page.getByTestId('user-age')).toHaveText('17')
await page.유저나이를15로바꾸고저장버튼클릭()
await page.expectApiSent({
'PATCH /api/users/1': { age: 15 }
});
await page.respondWithAndRejectElse({
'GET /api/users/1': { id: 1, type: 'student', age: 15 }
});
await page.reload()
await expect(page.getByTestId('user-age')).toHaveText('15')
})
하지만 이런 방식은 데이터 양이 많아질수록 코드량이 많아져 어려워지는데요, 이를 위해 seed builder를 도입하면 도움이 됩니다.
test('학생 유저로 프로필 화면에 접속하면 학생 유저 라벨이 보인다', async (page) => {
const student1 = commonSeed.user({ id: 1, type: 'student' })
await page.respondWithAndRejectElse({
'GET /api/users/1': student1,
'GET /api/classes/8': commonSeed.class({ students: [student1] })
});
// ...
})
seed builder를 도입할 경우 필드가 추가될 때에도 builder만 수정하면 되니 default값을 줄 수 있어 더 편해집니다. 따라서 builder를 사용하는 것을 기본으로 하고 필요 시 override하는 게 더 좋을 수 있습니다. 하지만 과하게 적용할 경우 오히려 default seed의 구조가 Mystery Guest가 되어 버릴 수도 있으므로 적절한 선을 찾아야 합니다.
또는 쿠키 크기 제한 때문에 아래와 같은 방향을 선택해야 할 수도 있습니다.
// e2e/seeds/a2604099-84a7-4d0e-a7b9-10287e1b1a58.ts
const student1 = commonSeed.user({ type: 'student' })
export default {
'GET /api/users/1': student1,
'GET /api/classes/8': commonSeed.class({ students: [student1] })
}
// e2e/scenarios/profile-page.spec.ts
test('학생 유저로 프로필 화면에 접속하면 학생 유저 라벨이 보인다', async (page) => {
const seedId = 'a2604099-84a7-4d0e-a7b9-10287e1b1a58'
await page.respondWithAndRejectElse(seedId);
})
하지만 여전히 중앙화된 데이터에 의존하지 않고, 언제든지 시나리오가 자신만의 시드를 구성할 수 있도록 열려 있는 아키텍처라는 점이 핵심입니다.
피쳐 플래그를 사용하지 않는 프로젝트들도 많지만, Monorepo 로 프로젝트를 관리한다면 TBD를 사용하며 피쳐플래그를 사용하는 경우도 많아집니다. 관련 내용은 제 다른 글 왜 TBD 쓰세요?를 참고해 주세요.
피쳐플래그 활용 역시 시나리오가 제어할 수 있게 하는 게 좋습니다. 아래와 같은 형태입니다.
test('피쳐플래그가 꺼져있으면 숨기기 버튼이 안보인다', async (page) => {
await page.goto('/dashboard')
await expect(page.getByTestId('hide')).not.toBeVisible()
})
test('피쳐플래그가 켜져있으면 숨기기 버튼이 보인다', async (page) => {
await page.enableFlag(Flag.SOME_FEATURE_A)
await page.goto('/dashboard')
await expect(page.getByTestId('hide')).toBeVisible()
})
피쳐플래그를 환경변수로 관리하는 경우도 많다고 알고 있는데, 이렇게 구성하려면 피쳐플래그를 환경변수가 아닌 쿠키로 제어하도록 구현해두는 것이 좋습니다.
한 가지 걱정은 mock server를 운영하게 되며 mock server 와 백엔드 api 사이에 괴리가 생기는 것인데요, 다행히 백엔드 api는 대부분의 경우 FE 몰래 변경되지 않습니다.
downtime을 없애기 위해 백엔드 api 변동은 먼저 하위호환성을 유지하며 추가 필드를 내려준 다음, FE가 마이그레이션하여 기존 필드가 사용되지 않음을 확인하면 그 다음에 기존 필드를 제거하는 식으로 운영됩니다. 또한 이렇게 복잡한 과정을 거쳐야 하기에 이런 일이 자주 발생하지는 않습니다. 보통 v2 api를 만드는 대공사의 형태로 발생하는 경우가 많고, 이 경우 FE도 어차피 대공사를 합니다.
이 과정에서 api 변경이 FE측에 반드시 노티되었을 것입니다 (만약 노티되지 않았다면 사실 e2e의 문제가 아니라 서비스 장애이므로 해당 케이스는 고려하지 않겠습니다). 이 시점에 놓치지 않고 mock server와 시드들을 업데이트해주면 됩니다. openapi나 protobuf 등을 통해 자동생성되는 코드를 적용하는 것도 검토해 볼 만 한 아이디어이지만, 이 역시 현실적으로 적용하다 보면 배포 타이밍이 어긋나는 등의 문제를 낳을 수 있어 온전한 해결책은 아닙니다.
주제에서 조금 벗어나지만, 개인적으로는 tRPC처럼 BE/FE가 같은 도구를 사용하는 것이 아닌 이상 자동생성 도구는 한계가 명확하기에 그리 신뢰하지는 않습니다. 또한 FE e2e를 따로 돌리는 팀이라면 FE Team과 BE Team이 분리되어 있을 가능성이 높은데, 이런 상황이라면 더더욱 신뢰하기 어려울 것입니다.
물론 어떤 프로젝트는 웹소켓을 많이 사용하고, 어떤 프로젝트는 시드 데이터가 매우 고정되어 있거나 정적이고, 어떤 프로젝트는 시간 의존성이 매우 큰 등 각 프로젝트마다 서로 다른 특징을 가지고 있기에 이러한 방법론을 항상 적용하기는 어렵습니다.
따라서 몇 가지 원칙들:
을 항상 유념하고, 필요하다면 본 글에 소개된 쿠키 등을 활용하는 아이디어를 참고하여 각 프로젝트에 맞는 e2e 방법을 찾아야 합니다.