F-5: E2E 테스트 — 진짜 브라우저로 한 줄기를 밟습니다
목차 57
안녕하세요, 홍순구 튜터입니다.
지난 시간 마지막에 제가 이렇게 말씀드렸어요.
진짜 브라우저를 띄워 한 줄기를 통째로 밟아보는 방법과, 컴포넌트를 카탈로그로 만들어 눈으로 확인하는 방법이 남아 있습니다. 둘 다 오늘 만든 목록을 그대로 가져다 쓰고요.
오늘이 그 첫 번째 시간인데, 저 약속은 저도 확인해보기 전까지 확신이 없었어요. MSW를 테스트에서 쓸 때는 Node 요청 계층을 직접 손봤지만, 진짜 브라우저는 그럴 수 없습니다. 진짜 개발 서버에 진짜 크롬이 붙어서 진짜 네트워크를 쓰는데, 우리가 적어둔 목록이 거기까지 따라올까요?
따라옵니다. 오늘 직접 확인합니다. 연습용 서버를 완전히 끈 채로 브라우저를 띄웠는데 게시물 카드 열 장이 그대로 떴어요.
그리고 바로 그것이 오늘의 함정입니다.
목록이 따라오니까 앱이 살아 있어 보입니다. 카드도 뜨고 로그인도 되니까 "덮여 있구나" 하고 한 줄기를 짜는데, 그 목록은 읽기용으로 쓴 것이고 한 줄기는 쓰기를 합니다. 좋아요를 누르면 어떻게 될까요?
오늘 그걸 여덟 번 재봅니다.
오늘의 여정
손으로 밟던 확인 → 설치하고 녹화 → 판이 두 번 걸린다
(Step 1) (Step 2) (Step 3)
↓
판이 앱을 켠다 ← 기다림을 안 짠다 ← 하나만 가리키기
(Step 6) (Step 5) (Step 4)
↓
무엇에 붙일까 → 실패가 남기는 것 → 얼마나 깔 것인가
(Step 7 · 오늘의 핵심) (Step 8) (Step 9)
💡 오늘 수업의 핵심 — "판이 초록이라고 그 일이 일어난 것은 아닙니다"
E2E는 우리가 만든 것 중 가장 진짜에 가까운 확인입니다. 진짜 브라우저가 진짜 서버에 붙어서 사람이 하는 대로 눌러보니까, 초록불이 뜨면 가장 안심이 됩니다.
오늘 배울 것은 그 안심을 한 번 의심하는 방법입니다.
🎯 학습 목표
- Playwright로 로그인부터 댓글까지 한 줄기를 밟는 판을 쓰고, 기다림을 직접 짜지 않고도 안정적으로 돌게 만들 수 있다
- 같은 판이 무엇에 붙어 있느냐에 따라 다른 것을 확인한다는 것을 이해하고, 서버가 실제로 받았는지까지 확인하는 판을 쓸 수 있다
- E2E를 어디까지 깔지 값을 재서 정하고, 실패했을 때 남는 기록으로 원인을 되짚을 수 있다
Step 1: "손으로 밟던 한 줄기"
지난 네 시간 동안 우리는 꽤 많은 것을 지켜냈어요. 순수 함수의 가장자리를 지켰고, 버튼이 무슨 이름으로 읽히는지 지켰고, 서버가 이렇게 답한다는 우리 주장까지 판으로 만들었는데, 여러분 솔직히 말해서 코드를 고치고 나면 여전히 이거 하시죠?
브라우저를 엽니다. 새로고침합니다. 로그인 버튼을 누릅니다. 하트를 눌러봅니다. 댓글을 하나 달아봅니다. "어, 되네." 그러고 나서 커밋합니다.
이 확인은 아무도 대신 안 해주고 있고, 앞의 판들이 원리적으로 못 보는 것도 봅니다.
조각이 다 맞는데 전체가 안 맞을 수 있습니다
컴포넌트 판은 컴포넌트 하나를 그려놓고 확인합니다. 서버 흉내 판은 우리가 적어둔 답을 돌려주죠. 둘 다 통과하는데 실제로는 안 되는 경우가 있어요. 라우터 설정이 어긋났거나, 로그인해서 받은 토큰이 다음 요청에 안 실리거나, 서버가 실제로는 다른 모양으로 답하거나.
조각마다 다 맞는데 이어 붙이면 안 맞는 겁니다.
그런데 왜 얇게만 깔까요
테스팅 트로피 기억하시죠. 아래가 넓고 위로 갈수록 좁아집니다.
╱ E2E ╲ ← 오늘 배울 것. 가장 진짜에 가깝고 가장 비싸다
╱───────────╲
╱ 통합 (RTL) ╲ ← F-3 · F-4
╱───────────────────╲
╱ 단위 (Vitest) ╲ ← F-2
╱─────────────────────────╲
정적 (tsc · eslint) ← A-6 부터 이미 깔려 있었다
위로 갈수록 진짜에 가까워지는데, 동시에 느려지고 잘 흔들립니다. 오늘 마지막에 그 값을 숫자로 재볼 거예요. 미리 말씀드리면 판 하나에 백 배 넘게 차이가 나서, E2E는 "다 덮는다"가 목표가 아닙니다. 돈이 오가는 길, 사용자가 매일 지나가는 길 한두 줄기만 얇게 깝니다. 우리 앱이라면 로그인해서 좋아요 누르고 댓글 다는 그 흐름이겠죠.
오늘 만들 것이 정확히 그것입니다.
💡 한 줄 정리
E2E는 조각이 다 맞는데 이어 붙이면 안 맞는 경우를 잡습니다. 비싸니까 사용자가 매일 지나가는 한 줄기만 얇게 깝니다.
🙋 학생 질문 — "튜터님, 그럼 앞에서 만든 판들은 E2E 하나로 대체할 수 있는 거 아닌가요?"
정반대입니다. E2E로 다 덮으려고 하면 오히려 판이 못 쓰게 돼요.
이유가 둘입니다.
첫째, 느립니다. 오늘 Step 9에서 재보겠지만 우리 앱에서 E2E 판 하나가 단위 판 하나보다 백 배 넘게 걸립니다. 빈 입력 거절, 태그 고르기, 오류 문구 같은 것들까지 전부 E2E로 확인하면 한 번 돌리는 데 몇십 분이 걸리고, 그러면 아무도 안 돌립니다.
둘째, 실패했을 때 어디가 문제인지 안 알려줍니다. 단위 판이 빨개지면 그 함수를 보면 됩니다. E2E가 빨개지면 브라우저·앱·서버 중 어디가 문제인지부터 찾아야 해요.
아래층에서 잡을 수 있는 것은 아래층에서 잡고, E2E는 "조각들이 실제로 이어져 있는가"만 봅니다.
Step 2: "설치하고 녹화로 첫 판을"
들여봅시다.
npm install -D -w apps/web-spa @playwright/test
npx playwright install chromium
두 줄인 이유가 있어요. 첫 줄은 판을 돌리는 도구를 깔고, 둘째 줄은 브라우저 자체를 내려받습니다. 우리 맥에 크롬이 이미 있어도 따로 받아요. 여러분 노트북과 제 노트북과 서버가 전부 같은 버전으로 돌아야 결과를 비교할 수 있으니까요. 95 MB 쯤 됩니다.
첫 판은 손으로 안 씁니다
Playwright에는 우리가 브라우저에서 하는 짓을 그대로 코드로 받아적는 기능이 있어요.
npx playwright codegen http://localhost:5173/
창이 두 개 뜹니다. 하나는 진짜 브라우저고, 하나는 받아적는 창이에요. 브라우저에서 로그인 버튼을 누르면 받아적는 창에 그 줄이 생깁니다.
아무것도 안 누르고 끄면 이만큼이 나옵니다.
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
await page.goto('http://localhost:5173/');
});
⚠️ expect를 가져오는데 안 씁니다. 이대로 두면 린터가 뭅니다. 녹화가 준 것은 초안이지 완성이 아니에요.
더 중요한 것이 있는데, 녹화는 「누른 것」만 적습니다. 눌러서 무엇이 됐는지는 한 줄도 안 적어요. 이게 오늘 Step 7에서 크게 물립니다. 기억해두세요.
설정과 첫 판
// apps/web-spa/playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
// 판을 어디서 찾을지 정한다. 이 폴더 밖은 안 본다.
testDir: './e2e',
// 판마다 주소를 통째로 적지 않도록 앞부분을 여기에 둔다.
use: {
baseURL: 'http://localhost:5173',
},
});
// apps/web-spa/e2e/feed.spec.ts
import { test, expect } from '@playwright/test';
test('피드를 열면 게시물이 보인다', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('article').first()).toBeVisible();
});
baseURL을 정해뒀으니 page.goto('/')만 적으면 됩니다.
개발 서버를 띄워두고 돌려볼게요.
npx playwright test
Running 1 test using 1 worker
✓ 1 e2e/feed.spec.ts:4:1 › 피드를 열면 게시물이 보인다 (626ms)
1 passed (1.2s)
화면은 안 보이지만 진짜 크롬이 떴다가 닫혔습니다. 그 안에서 우리 앱이 그려지고, 서버에서 게시물을 받아오고, 카드가 렌더링됐어요. 626 밀리초 동안 그 전부가 일어났습니다.
getByRole('article')이 눈에 익으시죠. 지난 시간에 쓰던 그 이름 그대로입니다. Playwright도 "역할과 이름으로 찾는다"는 같은 원칙을 씁니다.
💡 한 줄 정리
codegen이 초안을 만들어주지만 녹화가 적는 것은 「누른 것」뿐입니다. 「그래서 무엇이 됐나」는 우리가 씁니다.
🙋 학생 질문 — "왜 브라우저를 따로 내려받나요? 제 크롬 쓰면 안 되나요?"
쓸 수는 있어요. 설정에 channel: 'chrome'을 주면 여러분 컴퓨터에 깔린 크롬을 쓰는데, 기본값이 따로 받는 쪽인 이유가 있습니다.
여러분 크롬은 지금 몇 버전인가요? 저는요? 자동 업데이트가 언제 돌았느냐에 따라 다른데, 브라우저 버전이 다르면 화면이 미묘하게 다르게 그려질 수 있어요. 판이 여러분 노트북에서는 통과하고 제 노트북에서는 빨개지면, 그때부터 우리는 앱을 의심하는 게 아니라 서로의 노트북을 의심하게 되니까, 도구가 "이 버전을 쓰자" 고 못박고 그것만 받습니다. 확인하는 조건을 모두가 똑같이 맞추는 거예요.
실제 사용자가 쓰는 크롬과 완전히 같지는 않은데, 받아오는 것은 개발용으로 다듬어진 판이라서 진짜 여러 브라우저에서 확인해야 하는 서비스는 별도의 기기 농장을 씁니다.
Step 3: "판이 두 번 걸립니다"
첫 판이 돌아갔으니 기존 판도 한 번 돌려볼까요.
npm test
❯ e2e/feed.spec.ts (0 test)
FAIL e2e/feed.spec.ts [ e2e/feed.spec.ts ]
Error: Playwright Test did not expect test() to be called here.
- You have two different versions of @playwright/test. This usually happens
when one of the dependencies in your package.json depends on @playwright/test.
Test Files 1 failed | 152 passed (153)
Tests 1061 passed (1061)
당황스럽죠. 판은 1,061개가 전부 통과했는데 파일 하나가 빨갛습니다.
오류가 대는 원인이 우리 것이 아닙니다
메시지를 읽어보세요. "@playwright/test가 두 벌 깔렸다" 고 하는데, 우리는 방금 한 벌 깔았어요. 확인해보면 1.62.1 하나뿐입니다.
도구가 자기가 아는 흔한 원인을 먼저 댔고, 그게 우리 상황과 무관한 겁니다.
진짜 원인은 이거예요. Vitest가 판을 찾는 기본 규칙이 이렇습니다.
**/*.{test,spec}.?(c|m)[jt]s?(x)
test도 잡고 spec도 잡습니다. 우리 파일 이름이 feed.spec.ts 죠. Vitest가 그걸 자기 판인 줄 알고 열었고, 그 안에서 Playwright의 test()가 불렸고, Playwright가 "여기서 나를 부를 리가 없는데" 하고 놀란 겁니다.
러너가 두 개인데 파일 이름 규칙이 겹쳤어요.
가릅니다
// apps/web-spa/vitest.config.ts
import { configDefaults, defineConfig, mergeConfig } from 'vitest/config';
import viteConfig from './vite.config';
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
// e2e/ 는 Playwright 가 돌린다. 그런데 파일 이름이 .spec.ts 라
// Vitest 기본 include(**/*.{test,spec}.…)에도 걸린다.
//
// 빼두지 않으면 Vitest 가 남의 파일을 열고, 그때 나오는 오류는
// "@playwright/test 가 두 벌 깔렸다" 고 엉뚱한 원인을 댄다.
// 깔린 것은 한 벌이고, 진짜 문제는 러너가 바뀐 것이다.
exclude: [...configDefaults.exclude, 'e2e/**'],
configDefaults.exclude를 펼쳐서 앞에 두는 것을 놓치지 마세요. 그냥 ['e2e/**']로 덮어쓰면 node_modules 안까지 뒤지기 시작합니다.
한 곳 더 있습니다
여기서 끝내면 안 되는 이유가 하나 더 있어요. 타입 검사 설정을 열어봅시다.
// apps/web-spa/tsconfig.json
"include": ["src", "e2e", "vite.config.ts", "vitest.config.ts", "playwright.config.ts"]
원래는 ["src", "vite.config.ts", "vitest.config.ts"] 였습니다. e2e가 없었어요.
무슨 뜻이냐면, 우리가 방금 쓴 E2E 코드는 타입 검사를 통째로 안 받고 있었습니다. tsc --noEmit이 종료 코드 0을 주는데 그건 통과한 게 아니라 안 본 것이었어요.
일부러 틀린 줄을 하나 넣어서 확인해봤습니다.
e2e/feed.spec.ts(10,7): error TS2322: Type 'string' is not assignable to type 'number'.
이제 봅니다.
고치고 나면 이렇게 돌아옵니다.
Test Files 152 passed (152)
Tests 1061 passed (1061)
그리고 부르는 방법을 하나 만들어둡니다.
// apps/web-spa/package.json
"test:e2e": "playwright test"
💡 한 줄 정리
파일 이름 규칙이 겹치면 러너가 남의 파일을 엽니다. 그때 나오는 오류는 도구가 아는 흔한 원인을 대지, 우리 원인을 대지 않습니다.
🙋 학생 질문 — "그냥 파일 이름을 .e2e.ts 같은 걸로 바꾸면 안 되나요?"
됩니다. 흔한 선택이기도 한데, 두 가지만 생각해보시면 좋겠습니다.
첫째, .spec.ts는 E2E 쪽에서 워낙 널리 쓰는 이름이라 검색해서 나오는 예제가 전부 그 이름입니다. 이름을 바꾸면 예제를 그대로 못 가져다 씁니다.
둘째, 더 중요한 건데 — 이름만 바꾸면 타입 검사 문제는 그대로 남습니다. 우리가 방금 발견한 것 중 진짜 무서운 쪽은 러너 충돌이 아니라 tsconfig에 e2e가 없었다는 거예요. 그쪽은 아무도 안 알려줬거든요. 러너 충돌은 최소한 빨간 줄로 알려줬으니까, 저는 폴더로 가르는 쪽을 권합니다. 폴더가 갈려 있으면 러너 설정에서도, 타입 설정에서도, 나중에 CI에서도 같은 경계를 쓸 수 있어요.
Step 4: "하나만 가리켜야 합니다"
이제 로그인을 밟아봅시다.
// apps/web-spa/e2e/journey.spec.ts
import { test, expect } from '@playwright/test';
test('로그인하면 머리말에 내 이름이 뜬다', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: '로그인' }).click();
await expect(page.getByText('jaehoon')).toBeVisible();
});
돌려보면 빨개집니다.
Error: strict mode violation: getByText('jaehoon') resolved to 3 elements:
1) <span data-slot="sign-in" ...>jaehoon</span>
2) <span class="text-sm font-semibold ...">jaehoon</span>
3) <strong>jaehoon</strong>
하나만 가리키라고 요구합니다
jaehoon이라는 글자가 화면에 셋입니다. 머리말의 로그인 표시 하나, 게시물 작성자 이름 하나, 댓글 작성자 이름 하나.
Playwright는 여럿이 잡히면 아무거나 고르지 않고 거부합니다. 이걸 엄격 모드라고 불러요.
처음엔 좀 답답합니다. "머리말 거 말하는 거잖아" 싶지만, 생각해보면 도구 입장에서는 알 방법이 없고 아무거나 골랐다가는 더 나쁜 일이 생깁니다. 지금 우리 화면에서는 우연히 머리말 것이 첫 번째지만, 나중에 누가 화면 순서를 바꾸면 판은 아무 말 없이 댓글 작성자 이름을 보게 됩니다. 그때부터 그 판은 로그인을 확인하는 게 아니에요.
거부하는 쪽이 낫습니다. 빨간 줄은 지금 우리를 멈춰 세우지만, 조용히 다른 걸 고르는 것은 몇 달 뒤에 우리를 속입니다.
어디서 찾을지를 먼저 말합니다
고치는 방법은 이름을 더 정확히 대는 게 아니라 범위를 좁히는 것입니다.
await expect(page.locator('header').getByText('jaehoon')).toBeVisible();
"머리말 안에서 jaehoon을 찾아라." 이러면 하나인데, 피드가 뜨기를 기다리는 줄도 하나 넣어둡니다.
// 피드가 다 뜬 뒤에 누른다.
await expect(page.getByRole('article').first()).toBeVisible();
이 줄은 지금 당장 없어도 판이 통과합니다. 저도 지워보고 열 번 돌려서 열 번 다 통과하는 것을 확인했지만, 남겨두는 이유가 있습니다.
⚠️ 이 판이 확인하려는 상황은 「피드가 있는 화면에서 로그인한 상태」입니다. 그 상황을 만들어놓고 확인해야지, 우연히 만들어진 상태에서 확인하면 안 돼요. 무엇을 확인하는지가 실행할 때마다 달라지니까요.
이 이야기는 Step 7에서 다시 나옵니다. 거기서는 훨씬 비싸게 물립니다.
⚠️ 교과서대로 하면 안 되는 곳이 있습니다
사실 Playwright 문법으로 가장 깔끔한 답은 page.getByRole('banner')입니다. <header>는 보통 banner라는 역할을 갖는데, 우리 앱에서 세어보면 0개입니다.
이유가 우리 마크업에 있어요. Layout.tsx를 보면 <header>가 <main> 안에 들어 있습니다. HTML 규칙상 header가 main·article·aside·nav·section 안에 있으면 banner 역할을 잃습니다.
더 고약한 건, getByRole은 0개일 때 오류를 안 냅니다. 그냥 안 맞을 뿐이라서 "왜 계속 못 찾지" 하면서 한참 헤매게 되니까, 우리는 Playwright가 오류 메시지에서 직접 제안한 locator('header')를 씁니다.
💡 한 줄 정리
같은 글자가 화면에 여럿이면 Playwright는 아무거나 고르지 않고 거부합니다. 이름으로 안 되면 어디서 찾을지를 먼저 말합니다.
🙋 학생 질문 — "지난 시간 RTL에서도 여럿이면 오류가 났잖아요. 뭐가 다른가요?"
거부한다는 점은 같습니다. 다른 것은 언제 세느냐예요.
RTL은 그 자리에서 화면을 한 번 보고 판단합니다. 화면은 이미 다 그려져 있으니까 결과가 늘 같아요.
Playwright는 될 때까지 다시 봐서, 몇 번째로 보는 순간에 몇 개였는지가 결과를 바꿀 수 있어요.
무슨 뜻이냐면, 지금 우리가 본 빨간 줄이 항상 뜬다는 보장이 없습니다. 만약 로그인 응답이 피드보다 먼저 도착하면, 그 순간 jaehoon은 머리말에 하나뿐이고 단언은 그 하나를 보고 만족하고 끝납니다. 초록불이 뜨는 거예요.
그게 더 무섭습니다. 빨간 줄은 지금 고치게 만들지만, 우연한 초록불은 몇 달 뒤에 서버가 조금 빨라진 어느 날 갑자기 빨개집니다. 코드는 한 줄도 안 바꿨는데, 우리는 빨간 줄이 떠서 고치는 게 아니라 여럿인 것을 알았으니 고칩니다. 개수를 세어보는 습관이 여기서 값을 합니다.
Step 5: "기다리라고 우리가 안 적습니다"
좋아요를 눌러봅시다.
// apps/web-spa/e2e/journey.spec.ts
const heart = page.getByRole('button', { name: '좋아요' }).first();
await heart.click();
await expect(heart).toHaveAttribute('aria-pressed', 'true');
sleep이 없죠. 이게 오늘 두 번째로 중요한 이야기입니다.
값을 한 번만 읽으면 실패합니다
이렇게 쓰면 어떻게 될까요.
await heart.click();
const v = await heart.getAttribute('aria-pressed');
expect(v).toBe('true');
실패합니다. 찍어보면 이렇습니다.
클릭 직후 aria-pressed = false
누른 직후에는 아직 안 바뀌어 있습니다. click()은 클릭을 보냈다는 뜻이지, 그 결과로 화면이 다시 그려졌다는 뜻이 아니에요. React가 상태를 바꾸고 다시 그리는 것은 그다음에 일어납니다.
같은 것이 피드에서도 보입니다.
goto 직후 카드 = 0
주소를 열자마자 카드를 세면 0입니다.
그래서 옛날에는 이렇게 썼습니다
await heart.click();
await page.waitForTimeout(1000); // 이렇게 쓰지 않습니다
const v = await heart.getAttribute('aria-pressed');
1초를 기다리는데, 이게 왜 나쁠까요.
1초가 너무 길거나 너무 짧기 때문입니다. 보통은 50 밀리초면 끝나는데 1초를 기다리니 판 백 개면 백 초를 그냥 버립니다. 그러다 서버가 느린 날에는 1초로도 부족해서 빨개지면 2초로 늘립니다. 이런 식으로 판이 점점 느려지고 점점 못 믿게 됩니다.
expect가 스스로 다시 봅니다
await expect(heart).toHaveAttribute('aria-pressed', 'true');
이 줄은 될 때까지 계속 다시 봅니다. 50 밀리초 만에 되면 50 밀리초에 넘어가고, 3초 걸리면 3초를 기다렸다가 넘어가요. 정해진 한도(기본 5초) 안에 안 되면 그때 빨개집니다.
빠를 때는 빠르고 느릴 때만 기다립니다. 우리가 숫자를 정할 필요가 없어요.
한 가지 짚고 갈 게 있습니다. expect(await heart.getAttribute(...))처럼 값을 먼저 꺼내면 이 성질이 사라집니다. 꺼내는 순간 그 값은 그때의 사진이 되고, 그 뒤로는 아무리 기다려도 안 바뀌어요. expect 안에 무엇을 넣느냐가 다시 보느냐 마느냐를 정합니다.
💡 한 줄 정리
기다리는 시간을 우리가 정하면 너무 길거나 너무 짧습니다. expect에 요소를 통째로 넘기면 될 때까지 스스로 다시 봅니다.
🙋 학생 질문 — "그럼 waitForTimeout은 아예 쓸 일이 없나요?"
거의 없지만, 아예 없지는 않아요.
쓸 만한 경우가 하나 있습니다. "아무 일도 안 일어나는 것"을 확인할 때입니다.
예를 들어 "저장 버튼을 안 눌렀으면 서버에 아무것도 안 보낸다"를 확인한다고 해봅시다. 이건 기다릴 대상이 없어요. 무언가가 나타나기를 기다리는 게 아니라 나타나지 않기를 확인하는 거니까요. 이럴 때는 잠깐 기다렸다가 "아직도 없네"를 보는 수밖에 없습니다.
이런 판은 원래부터 약한데, 1초 기다려서 안 왔다고 영원히 안 온다는 보장이 없잖아요. 가능하면 오늘 Step 7에서 배울 방식으로 바꿉니다 — 기다리는 대신 요청 자체를 지켜보는 거예요.
Step 6: "판이 앱을 켭니다"
지금까지는 개발 서버를 손으로 띄워두고 판을 돌렸습니다. 연습용 서버도 따로 띄워야 했고요. 깜빡하면 판이 빨개집니다.
이걸 판이 직접 하게 만들 수 있어요.
// apps/web-spa/playwright.config.ts
webServer: [
{
command: 'node apps/api-stub/server.mjs',
url: 'http://localhost:8090/api/posts',
cwd: '../..',
reuseExistingServer: !process.env.CI,
},
{
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
],
두 개를 적었습니다. 연습용 서버와 개발 서버요.
url이 재미있는데, 이건 "여기가 응답을 주면 준비된 것"이라는 신호입니다. 명령을 실행해놓고 이 주소를 계속 두드려보다가 답이 오면 그때 판을 시작해요. 서버가 몇 초 만에 뜰지 우리가 몰라도 됩니다.
아무것도 안 띄운 상태에서 돌려봤습니다.
Running 2 tests using 2 workers
✓ 2 e2e/feed.spec.ts:4:1 › 피드를 열면 게시물이 보인다 (1.3s)
✓ 1 e2e/journey.spec.ts:4:1 › 로그인하면 머리말에 내 이름이 뜬다 (2.2s)
3 passed (7.0s)
서버 두 개가 알아서 떴다가 판이 끝나면 알아서 내려갑니다.
reuseExistingServer는 왜 CI에서 꺼야 할까요
!process.env.CI라고 적었죠. 개발 중에는 켜고 CI에서는 끈다는 뜻입니다.
개발 중에는 이미 개발 서버를 띄워놓고 작업하고 있잖아요. 판을 돌릴 때마다 그걸 죽이고 새로 띄우면 느리고 불편합니다. 그래서 떠 있으면 그냥 씁니다.
그런데 CI에서 이걸 켜두면 위험해요. 누가 무엇을 띄워놨는지 모르는 채로 거기에 붙게 됩니다. 옛날 코드로 떠 있는 서버에 붙어서 초록불을 받을 수도 있어요. 무엇을 확인하고 있는지 모르게 되는 겁니다.
💡 한 줄 정리
webServer가 앱을 켜고 준비될 때까지 기다리는데, 이미 떠 있는 것에 붙는 편의는 개발 중에만 씁니다.
🙋 학생 질문 — "서버가 안 뜨면 어떻게 되나요? 영원히 기다리나요?"
아닙니다. 기본 한도가 있고 넘으면 포기하고 빨개집니다. timeout으로 조절할 수 있는데, 실제로 더 자주 겪는 문제는 다른 쪽입니다. 포트가 이미 물려 있는 경우요.
전에 띄워둔 서버가 안 죽고 남아 있으면, 새로 띄우려던 명령이 "그 포트 이미 쓰는 중" 이라며 죽습니다. reuseExistingServer가 켜져 있으면 판은 그 남아 있던 서버에 그냥 붙어버려서, 여러분이 방금 고친 코드가 아니라 아까 그 코드를 확인하게 됩니다.
증상이 아주 헷갈립니다. 분명히 고쳤는데 결과가 그대로거든요. 이럴 때는 앱을 의심하기 전에 무엇이 떠 있는지부터 확인하세요.
lsof -nP -iTCP:5173 -iTCP:8090 -sTCP:LISTEN
Step 7: "무엇에 붙일까요"
여기가 오늘의 핵심입니다.
지난 시간에 우리는 서버 흉내 목록을 만들었고, 그 목록을 브라우저에서도 쓸 수 있게 해뒀습니다.
VITE_MOCK_API=1 npm run dev
이렇게 띄우면 서비스 워커가 요청을 가로채서 우리 목록대로 답합니다. 연습용 서버가 없어도 화면이 돌았죠.
E2E도 거기에 붙이면 백엔드 없이 한 줄기를 밟을 수 있지 않을까요?
// apps/web-spa/playwright.mock.config.ts
import { defineConfig } from '@playwright/test';
import base from './playwright.config';
export default defineConfig({
...base,
webServer: {
command: 'VITE_MOCK_API=1 npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: false,
},
});
연습용 서버는 아예 안 띄웁니다. 개발 서버 하나뿐이에요.
먼저 좋은 소식입니다
돌려봤더니 카드가 열 장 그대로 떴습니다. 연습용 서버는 완전히 꺼져 있는데요.
콘솔에도 찍힙니다.
[MSW] Mocking enabled.
[MSW] GET http://localhost:8090/api/posts (200 OK)
제가 지난 시간에 한 약속, 참이었습니다. 브라우저가 진짜여도 우리 목록이 따라옵니다.
좋아요도 통과합니다. 여덟 번 다요.
같은 판을 양쪽에서 여덟 번씩 돌려봤어요.
| 진짜 연습용 서버 | 흉내 목록 | |
|---|---|---|
| 좋아요 판 8회 | 8번 통과 | 8번 통과 |
다 초록입니다. 백엔드 없이 한 줄기가 통째로 지나갔어요. 여기서 "됐다" 하고 넘어가면 오늘 수업은 끝입니다.
한 줄만 더 넣어봅시다. 단언이 통과한 바로 다음 줄에서 같은 값을 다시 읽는 줄이요.
| 진짜 연습용 서버 | 흉내 목록 | |
|---|---|---|
| 누르기 전 | false |
false |
| 단언이 통과한 시점 | true |
false |
| 2초 뒤 | true |
false |
흉내 쪽을 보세요. 단언은 true를 보고 통과했는데, 다음 줄에서 읽으면 이미 false입니다. 2초를 기다려도 false예요.
하트가 안 눌려 있는데 판은 초록입니다.
무슨 일이 일어난 걸까요
흉내 목록에는 좋아요 핸들러가 없습니다. 목록에 없는 요청은 그냥 밖으로 나가는데, 나가봤자 연습용 서버가 꺼져 있으니 실패하고, 우리가 C-7에서 만들어둔 되돌리기가 작동해서 하트를 원래대로 돌려놓습니다.
그럼 그 사이에 잠깐은 어떻게 보일까요? 눌린 것처럼 보입니다. 낙관적 업데이트가 서버 답을 기다리지 않고 먼저 바꿔놓으니까요.
그러니까 이런 일이 일어난 겁니다.
누름 ──▶ 낙관적 업데이트가 true 로 바꿈
│
│ ◀── 단언이 여기서 봤다 → 통과하고 끝남
▼
요청 실패 → 되돌리기 → false
단언이 서버를 아예 안 보고 있었습니다. 앱이 잠깐 만들어낸 값을, 그것이 취소되기 전에 낚아챈 거예요.
Step 4에서 우리를 도와줬던 "될 때까지 다시 본다"는 성질이 여기서는 반대로 작동합니다. 될 때까지 다시 보니까 되는 순간을 기어이 찾아냅니다. 그게 0.02초짜리 거짓말이어도요.
안 잡히는 것이 좋아요만이 아닙니다
콘솔 경고를 모아보니 이렇습니다.
흉내 목록이 안 잡는 것 (경고만 남고 그냥 나간다)
POST :8090/api/posts/1/like 좋아요
ws://localhost:8090/ws 실시간 통로 (C-9)
POST :9000/api/…/envelope 오류 보고 (H-1)
picsum.photos 사진 20건 진짜 인터넷으로
지난 시간에 만든 목록은 읽기용이었습니다. 피드를 가져오고, 쪽지를 가져오고, 로그인을 하는 것까지요. 그때는 그걸로 충분했어요. 확인하려던 게 읽기였으니까요.
한 줄기는 쓰기를 하는데, 쓰기는 목록에 없어요.
눌렀다와 서버가 받았다를 가릅니다
고치는 방법은 목록을 채우는 것일 수도 있지만, 더 먼저 할 일이 있습니다. 판이 무엇을 보는지를 고치는 것이에요.
const answered = page.waitForResponse(
(res) => res.url().includes('/like') && res.request().method() === 'POST',
{ timeout: 10_000 },
);
await heart.click();
expect((await answered).ok()).toBe(true);
클릭하기 전에 응답을 붙잡아두고, 누른 다음 서버가 실제로 답했는지 그리고 그 답이 성공이었는지를 봅니다.
이제 결과가 이렇게 갈립니다.
| 진짜 연습용 서버 | 흉내 목록 | |
|---|---|---|
| 6회 돌린 결과 | 6번 통과 | 6번 실패 |
| 실패 문구 | — | waitForResponse: Timeout … while waiting for event "response" |
판이 거짓말을 못 하고, 실패했을 때 이유도 분명해요 — 응답이 영영 안 옵니다.
⚠️ 여기서 눈여겨보실 것은 판을 고쳤지 앱을 고치지 않았다는 겁니다. 앱은 처음부터 똑같이 동작하고 있었어요. 흉내 목록에 붙이면 좋아요가 안 눌렸고, 진짜 서버에 붙이면 눌렸습니다. 바뀐 것은 판이 그걸 볼 수 있게 됐다는 것뿐이에요.
그래서 무엇에 붙일까요
정답은 하나가 아니지만, 고를 때 이걸 물어보세요.
이 판이 확인하려는 것에 서버가 포함되는가?
포함된다면 진짜 서버에 붙여야 합니다. 흉내 목록에 붙이면 서버를 안 보는 판이 되고, 그건 이미 컴포넌트 판이 하고 있는 일이에요. 굳이 백 배 비싼 방법으로 같은 것을 두 번 확인할 이유가 없습니다.
포함되지 않는다면 — 예를 들어 "화면 흐름이 이어지는가"만 본다면 — 흉내 목록이 낫습니다. 빠르고, 백엔드가 없어도 돌고, 서버 사정으로 흔들리지 않으니까요.
우리 한 줄기는 좋아요가 서버에 저장되는 것까지가 확인 대상이라서, 진짜 서버 쪽을 기본으로 둡니다.
💡 한 줄 정리
판이 초록이어도 무엇을 보고 초록인지 물어야 합니다. 낙관적 업데이트가 만든 값은 서버가 준 값이 아닙니다.
🙋 학생 질문 — "그럼 지난 시간에 만든 목록은 E2E에서는 쓸모가 없는 건가요?"
쓸모 있지만, 무엇에 쓰는지가 다릅니다.
우리 저장소에 두 벌을 다 남겨뒀어요.
npm run test:e2e # 진짜 연습용 서버
npm run test:e2e:mock # 흉내 목록
흉내 쪽이 값을 하는 경우가 분명히 있습니다.
서버가 만들기 어려운 상황을 만들 때가 대표적이에요. "서버가 500을 줄 때 화면이 어떻게 되나", "응답이 10초 걸릴 때 사용자가 무엇을 보나" 같은 것들이요. 진짜 서버로 이걸 재현하려면 서버를 고쳐야 하는데, 흉내 목록은 한 줄이면 됩니다.
또 백엔드가 아직 없을 때도 그렇습니다. 화면을 먼저 만드는 팀이라면 흉내 목록만으로 한 줄기를 미리 밟아볼 수 있어요.
오늘 우리가 배운 건 "흉내는 나쁘다"가 아니라 "흉내에 붙였을 때 무엇이 확인 대상에서 빠지는지 알고 있어야 한다"입니다. 모르고 쓰면 오늘 본 것처럼 서버를 안 보는 판이 초록불을 켭니다.
Step 8: "실패했을 때 무엇이 남나"
E2E가 빨개졌을 때 가장 답답한 게 뭘까요.
다시 못 봅니다. 브라우저는 이미 닫혔고, 화면이 그 순간 어땠는지 알 길이 없어요. 단위 판이라면 값을 찍어보면 되는데 여기는 그것도 어렵습니다.
그래서 기록을 남깁니다.
// apps/web-spa/playwright.config.ts
use: {
baseURL: 'http://localhost:5173',
// 실패한 판만 기록을 남긴다. 통과한 판까지 남기면 금방 무거워진다.
trace: 'retain-on-failure',
},
일부러 하나 깨뜨려봤습니다. 버튼 이름을 조아요로 바꿔서요.
1 failed
e2e/journey.spec.ts:18:1 › 좋아요를 누르면 눌린 상태가 된다
npx playwright show-trace test-results/journey-…/trace.zip
마지막 줄이 열어보는 방법입니다.
무엇이 남았을까요
trace.zip을 열어보니 1.3 MB에 파일이 69개 들어 있었습니다. 화면 사진, 오간 요청, 콘솔 기록, 단계마다의 화면 구조까지요.
show-trace로 열면 창이 하나 뜨는데, 왼쪽에 우리가 쓴 줄들이 순서대로 있고 그중 하나를 고르면 그 순간의 화면이 오른쪽에 나옵니다. 되감기가 되는 거예요.
그래서 이런 걸 볼 수 있습니다. "아, 로그인 버튼을 눌렀을 때 화면에 이게 떠 있었구나", "이 요청이 401로 돌아왔구나", "클릭은 됐는데 그 아래 다른 요소가 가리고 있었구나."
⚠️ 통과한 판까지 남기면(trace: 'on') 금방 기가바이트 단위가 되므로, 실패한 것만 남기거나 CI에서는 재시도할 때만 남기는 식으로 씁니다.
곁다리로 파일이 하나 더 생깁니다
같은 폴더에 error-context.md라는 파일도 남는데, 열어보면 머리에 이렇게 적혀 있어요.
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
사람이 읽으라고 쓴 파일이 아닙니다. AI 도구가 읽으라고 쓴 파일이에요. 실패한 판을 AI에게 물어볼 때 그대로 넘길 수 있게 도구가 미리 정리해준 겁니다.
2026년의 테스트 도구가 이런 것까지 만들어준다는 게 재미있지만, 이건 거들어주는 것이지 대신 판단해주는 게 아닙니다. 오늘 Step 7에서 본 것처럼 판이 초록인데 기능이 죽어 있는 경우는 이 파일이 아예 안 만들어져요. 실패해야 만들어지니까요.
💡 한 줄 정리
E2E는 실패했을 때 다시 볼 수 없는 게 가장 답답합니다. 기록을 남겨두면 그 순간으로 되감을 수 있습니다.
🙋 학생 질문 — "화면 사진만 남기면 안 되나요? 1.3 MB는 좀 큰데요."
사진만 남기는 것도 됩니다. screenshot: 'only-on-failure'를 쓰면 실패한 순간 화면만 한 장 남아요.
그런데 실제로 디버깅해보면 사진 한 장으로는 부족한 경우가 많습니다.
가장 흔한 게 이거예요. 사진을 보면 화면이 멀쩡합니다. 버튼도 있고 글자도 맞는데 판은 빨개졌습니다. 판이 그 요소를 찾던 시점에는 아직 안 그려져 있었고, 실패해서 사진을 찍는 시점에는 다 그려져 있었거든요.
기록은 시간축을 통째로 갖고 있어서 이걸 구분할 수 있습니다. "3.2초 시점에는 없었고 3.8초에 생겼다"를 볼 수 있어요.
그래서 저는 이렇게 권합니다. 판이 안정적으로 도는 프로젝트에서는 사진으로 충분하고, 판이 흔들리기 시작하면 그때 기록으로 올리세요. 흔들리는 판의 원인은 거의 항상 시간축에 있습니다.
Step 9: "얼마나 깔 것인가"
마지막입니다. 값을 재봅시다.
같은 노트북에서 세 번씩 재봤어요.
| 판 개수 | 걸린 시간 | 판 하나에 | |
|---|---|---|---|
| 단위 판 | 1,061 | 약 19초 | 약 18ms |
| E2E | 2 | 약 6.3초 | 약 3,100ms |
판 하나에 약 170배입니다.
지금은 E2E가 둘뿐이라 6초면 끝나지만, 이걸 쉰 개로 늘리면 2분 30초가 됩니다. 백 개면 5분이고요. 그때부터 사람들은 판을 안 돌리기 시작합니다.
그래서 우리는 셋이던 판을 둘로 줄이고 로그인·좋아요·댓글을 한 줄기로 합쳤습니다.
// apps/web-spa/e2e/journey.spec.ts
//
// 한 줄기만 얇게 깐다. 여기 없는 것들(빈 입력 거절·태그 고르기·오류 화면)은
// 이미 단위 판과 컴포넌트 판이 지키고 있다. 같은 것을 두 번 지키면
// 느린 쪽이 먼저 흔들린다.
⚠️ 한 가지 정직하게 밝혀둘 게 있습니다. 우리 한 줄기의 댓글 단계는 서버까지 안 갑니다. 지금 우리 앱의 댓글은 화면 안에만 쌓이거든요. 실제로 재보면 댓글을 달아도 서버로 나가는 쓰기 요청이 0건이고, 새로고침하면 사라집니다.
우리 판은 통과하는데, "댓글이 화면에 보인다"까지만 확인하니까요.
이게 방금 Step 7에서 배운 것과 똑같은 모양입니다. 판이 확인하는 것과 사용자가 기대하는 것이 다른 거죠. 다만 이번엔 흉내 목록 탓이 아니라 앱에 그 기능이 아직 없기 때문입니다. 지난 시간 과제가 그 서버 경로를 만드는 것이었어요.
그런데 개발 중에만 흔들리는 판이 있습니다
여기서 이상한 걸 하나 발견했어요. 같은 판인데 어떻게 돌리느냐에 따라 결과가 달랐습니다.
| 돌리는 방법 | 6회 결과 |
|---|---|
| 판이 서버를 직접 띄움 | 6번 통과 |
| 이미 떠 있는 서버에 붙음 | 3번 실패 |
Step 6에서 reuseExistingServer 이야기를 했었죠. 개발 중에는 서버를 띄워놓고 작업하니까 판이 그걸 재사용합니다. 그때만 흔들립니다.
원인은 우리 판이 아니라 연습용 서버에 있었어요.
// apps/api-stub/server.mjs
found.liked = !found.liked;
좋아요가 사람마다 따로가 아니라 게시물 하나에 하나입니다. 판이 서버를 직접 띄우면 매번 새로 시작하니까 늘 안 눌린 상태에서 출발하지만, 떠 있는 서버에 붙으면 앞선 판이 눌러둔 것 위에서 돕니다. 한 번 켜고 다음 번엔 끄는 거죠.
이게 E2E의 본질적인 성질입니다. 단위 판은 매번 깨끗한 상태에서 시작하지만 E2E는 남이 쓰던 상태 위에서 돕니다. 실제 서비스라면 며칠 전에 누가 눌러둔 것 위에서 지금 판이 도는 거예요.
이런 판이 가장 고약합니다. CI에서는 늘 통과하고 내 노트북에서만 가끔 빨개지거든요. 사람들이 "또 그거네" 하고 다시 돌리고, 그러다 진짜 문제도 그렇게 넘기게 돼요.
두 가지로 고쳤습니다.
// 서버의 좋아요는 우리만 쓰는 값이 아니다. 앞선 판도 눌렀고 옆 사람도 누른다.
// 그래서 "눌리면 true" 가 아니라 "누르기 전과 달라졌다" 를 본다.
const before = await heart.getAttribute('aria-pressed');
await heart.click();
expect((await answered).ok()).toBe(true);
await expect(heart).toHaveAttribute('aria-pressed', before === 'true' ? 'false' : 'true');
절대값 대신 변화를 봅니다.
그리고 하나 더. 연습용 서버는 좋아요를 다섯 번에 한 번 일부러 실패시킵니다. C-7에서 되돌리기를 배울 때 그게 교보재였는데, 한 줄기가 지나가는지 볼 때는 그것 때문에 다섯 번에 한 번 빨개집니다.
command: 'LIKE_FAIL_EVERY=0 node apps/api-stub/server.mjs',
E2E가 도는 세상은 우리가 정합니다. 서버 코드는 한 줄도 안 고쳤어요. 손잡이만 돌렸습니다.
이제 여섯 번 돌려도 여섯 번 다 통과하고, 시간도 5.6초로 일정합니다.
사용자가 받는 것은 안 변합니다
한 가지 안심하셔도 될 게 있어요. 오늘 도구를 하나 들였는데 빌드 결과가 어떻게 됐을까요.
첫 화면 자바스크립트 gzip 219,472 B → 219,473 B
1 바이트입니다. Playwright는 개발할 때만 쓰는 도구라 사용자가 받는 것에는 안 들어갑니다. 지난 시간에 세운 번들 상한도 그대로 지켜졌어요.
검색하면 나오는 다른 이름
E2E를 찾아보시면 Cypress라는 이름이 자주 나옵니다. 지금도 활발하게 관리되고 있고 잘 만든 도구예요.
다만 새로 시작하는 프로젝트에서는 Playwright 쪽을 더 많이 고릅니다. 여러 브라우저를 기본으로 다루고, 브라우저 밖에서 판이 돌아서 여러 탭이나 여러 사용자를 다루기 쉽고, 오늘 본 기록 도구가 강력하거든요.
여러분이 실무에서 만나실 경우는 대개 이미 Cypress로 짜여 있는 프로젝트에 들어가는 경우입니다. 그때 당황하지 마세요. 문법은 다르지만 오늘 배운 생각은 그대로 통합니다. 하나만 가리키기, 기다림을 직접 안 짜기, 무엇에 붙어 있는지 알기 — 도구가 바뀌어도 안 바뀌는 것들이에요.
💡 한 줄 정리
E2E는 판 하나가 단위 판의 백 배가 넘게 비싸서, 한 줄기만 깔고 그 판이 도는 세상은 우리가 정합니다.
🙋 학생 질문 — "실무에서는 E2E를 몇 개나 짜나요?"
정해진 숫자는 없지만 기준은 있습니다. "이게 안 되면 서비스가 멈추는가"를 물어보세요.
쇼핑몰이라면 상품을 담고 결제하는 흐름 하나. 우리 앱이라면 로그인해서 글을 보고 반응하는 흐름 하나. 이런 것들이요. 회사에 따라 다르지만 큰 서비스도 E2E는 수십 개 수준인 경우가 많습니다. 단위 판은 수천 개인데요.
그리고 시간이 지나면 늘어나는 게 아니라 줄어드는 경우도 많아요. 처음엔 이것저것 E2E로 만들었다가, 흔들리는 판들을 하나씩 아래층으로 내려보냅니다. "이건 사실 컴포넌트 판으로 충분하네" 하면서요.
한 가지 더 말씀드리면, E2E를 언제 돌리느냐도 정해야 합니다. 단위 판은 저장할 때마다 돌려도 되지만 E2E는 그럴 수 없어요. 보통은 코드를 합치기 전에 한 번, 배포하기 전에 한 번 돌립니다.
마무리
오늘은 지난 시간의 약속을 확인하는 것으로 시작했습니다.
"둘 다 오늘 만든 목록을 그대로 가져다 쓴다." 참이었어요. 연습용 서버를 끈 채로 브라우저를 띄웠는데 카드 열 장이 그대로 떴습니다.
그런데 그 확인이 우리를 더 중요한 질문으로 데려갔습니다. 목록이 따라온다고 해서 그 목록이 충분하다는 뜻은 아니었어요. 읽기용으로 쓴 목록 위에서 쓰기를 확인하려 하니, 판은 초록인데 아무 일도 안 일어나고 있었습니다.
오늘 배운 핵심 세 가지
💡 하나 — 통과한 판도 왜 통과했는지 물어야 합니다. jaehoon을 찾던 판은 맞아서 통과한 게 아니라 피드가 아직 안 그려져서 통과했습니다. 좋아요 판은 서버 답이 아니라 앱이 잠깐 만들어낸 값을 잡고 통과했고요. 둘 다 초록불이었어요.
💡 둘 — 판이 무엇에 붙어 있는지가 무엇을 확인하는지를 정합니다. 같은 판을 진짜 서버와 흉내 목록에 각각 붙였더니 한쪽은 여섯 번 다 통과하고 다른 쪽은 여섯 번 다 실패했습니다. 판은 한 글자도 안 바꿨는데요.
💡 셋 — E2E는 남이 쓰던 상태 위에서 돕니다. 좋아요가 게시물 하나에 하나뿐이라 앞선 판이 눌러둔 것 위에서 다음 판이 돌았어요. 절대값 대신 변화를 보고, 판이 도는 세상은 우리가 정해야 합니다.
다음 시간 예고
오늘 우리는 화면을 통째로 띄워서 밟았는데, 컴포넌트 하나하나를 눈으로 확인하고 싶을 때는 어떻게 할까요?
버튼 하나가 큰 것, 작은 것, 눌린 것, 잠긴 것으로 어떻게 보이는지를 한 화면에 늘어놓고 볼 수 있고, 그 늘어놓은 것 자체가 판이 되어서 색을 잘못 바꾸면 빨개진다면 어떨까요.
다음 시간에는 컴포넌트를 카탈로그로 만듭니다. 지난 시간에 만든 목록이 거기서도 한 번 더 쓰입니다.
과제
[구현] 좋아요 핸들러를 목록에 더해서 흉내 쪽 판도 통과시켜보세요
오늘 Step 7에서 흉내 목록에 붙인 판이 좋아요에서 막혔습니다. 목록에 그 주소가 없었기 때문이죠.
src/mocks/handlers.ts에POST /posts/:postId/like핸들러를 더하세요. 누를 때마다 눌림이 뒤집히고 개수도 따라 움직여야 합니다. 지난 시간 과제에서 만드셨다면 그걸 쓰셔도 됩니다.npm run test:e2e:mock이 통과하는지 확인하세요.- ⚠️ 통과하고 나면 한 가지를 더 따져보세요. 이 판은 진짜 서버에 붙였을 때와 같은 것을 확인하고 있나요? 우리 핸들러가 눌림을 뒤집어주는 것과, 진짜 서버가 데이터베이스에 저장하는 것은 같은 일인가요? 답을 한 문단으로 적어보세요.
[구현] 로그인이 실패하는 한 줄기를 하나 더 만들어보세요
지금 우리 한 줄기는 전부 잘 되는 경우만 밟습니다. 안 되는 경우도 하나 만들어봅시다.
⚠️ 먼저 짚고 갈 것이 있어요. 지난 시간에 배운 server.use()는 여기서 못 씁니다. 그건 판과 같은 곳에서 도는 흉내 서버를 바꾸는 방법인데, E2E에서 목록은 브라우저 안에서 돌거든요. 판은 브라우저 바깥에 있습니다.
대신 Playwright가 직접 가로챌 수 있어요.
await page.route('**/api/auth/login', (route) =>
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ success: false, data: null, message: '아이디 또는 비밀번호가 올바르지 않습니다' }),
}),
);
- 위 방법으로 로그인이 401을 주게 만들고, 그 상태에서 로그인을 눌러보세요.
- 화면이 무엇을 보여주는지 확인하고, 그것을 판으로 적어보세요.
- ⚠️ 2번에서 적을 것이 없다면 그게 답입니다. 왜 적을 게 없는지, 그리고 그건 누구의 문제인지 한 문단으로 쓰세요.
[탐구] 무엇이 판을 지키고 있는지 갈라서 재보세요
Step 4에서 우리는 두 가지를 했습니다. 범위를 좁혔고(locator('header')), 피드를 기다리는 줄을 넣었죠.
둘 중 무엇이 판을 지키고 있을까요? 짐작하지 말고 갈라서 재봅시다.
-
네 가지 조합을 만들어 각각 열 번씩 돌리고 통과 횟수를 적으세요.
범위 좁힘 범위 안 좁힘 기다림 있음 (지금 코드) ? 기다림 없음 ? ? -
결과를 보고 어느 쪽이 진짜 원인인지 정하고, 그게 왜 그런지 설명해보세요.
-
실패한 조합 하나를 골라
npx playwright show-trace로 기록을 열고, 실패한 순간 화면에 무엇이 있었는지 확인하세요. -
⚠️ 결과가 예상과 다를 겁니다. 다르다면 원래 무엇을 예상했는지부터 적고, 왜 그렇게 예상했는지 되짚어보세요. 그 되짚음이 이 과제의 본체입니다.
[탐구] 우리 판이 확인하지 않고 있는 것을 세 개 찾아보세요
오늘 만든 한 줄기는 로그인·좋아요·댓글을 밟는데, 밟지 않는 것이 훨씬 많아요.
- 앱을 직접 써보면서 "이게 망가지면 사용자가 화를 낼 텐데 우리 판은 모르겠다" 싶은 것을 세 개 찾으세요.
- 각각에 대해 어느 층에서 잡는 게 맞을지 정하세요. E2E 인가요, 컴포넌트 판인가요, 단위 판인가요, 아니면 타입 검사만으로 되나요?
- ⚠️ 셋 다 E2E라는 답이 나왔다면 다시 생각해보세요. 오늘 잰 값을 기억하시죠. 판 하나에 170배입니다.
생각해볼 주제
1. 초록불을 언제 의심해야 할까요
오늘 우리는 초록불인데 아무 일도 안 일어나는 판을 봤는데, 그게 우연히 통과한 것이라 다음에는 빨개질 수도 있었어요.
그런데 실무에서는 판이 수천 개입니다. 전부 하나씩 "이거 왜 통과했지"를 물어볼 수는 없어요.
의심해야 할 판을 골라내는 기준이 있을까요? 어떤 판이 "통과했지만 아무것도 안 지키고 있을" 가능성이 높을까요? 그런 판을 자동으로 찾아낼 방법도 있을까요?
2. E2E를 누구의 데이터로 돌려야 할까요
오늘 우리 판은 연습용 서버의 좋아요를 실제로 눌렀습니다. 앞선 판이 눌러둔 것 위에서 다음 판이 돌았고, 그래서 흔들렸죠.
실제 서비스라면 이 문제가 훨씬 큽니다. 판이 진짜 데이터베이스에 진짜 댓글을 다는데, 그 댓글은 누가 지우나요? 판을 돌릴 때마다 계정을 새로 만들어야 할까요? 아니면 매번 데이터베이스를 초기화해야 할까요?
각 방법의 대가를 따져보고, 여러분이라면 어떤 방법을 고르실지 그 이유는 무엇인지 정리해보세요.
3. 화면이 바뀌면 판도 바뀌어야 할까요
지난 시간에 우리는 "구현이 바뀌어도 안 깨지는 판"을 만드는 법을 배웠는데, 클래스 이름이나 태그가 아니라 사용자가 보는 것으로 찾으면 된다고 했습니다. 오늘 우리는 locator('header')를 썼습니다. 이건 태그 이름이죠. 사용자가 보는 것이 아닙니다.
이건 우리가 원칙을 어긴 걸까요, 아니면 어쩔 수 없는 예외일까요? 만약 나중에 누가 머리말을 <header>에서 <div>로 바꾸면 우리 판은 빨개집니다. 그게 좋은 걸까요 나쁜 걸까요?
그리고 이 문제를 더 잘 푸는 방법이 있을지도 생각해보세요.
✅ 예시 답안정답 보기
과제 예시답안
🎯 [과제 1 예시답안] 좋아요 핸들러를 목록에 더해서 흉내 쪽 판도 통과시키기
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 핸들러 동작 | 6 | http.post로 받고 눌림이 뒤집히며 개수도 따라 움직이는가 |
| 주소 정확성 | 5 | :postId 자리를 쓰고, 없는 번호에 404 를 돌려주는가 |
| 통과 확인 | 4 | npm run test:e2e:mock이 실제로 초록인가 |
| 3번 판단 | 8 | "같은 것을 확인하지 않는다" 를 근거와 함께 말하는가 |
| 덤 발견 | 2 | 단위 판 수가 하나 늘어난 것을 알아챘는가 |
| 합계 | 25 |
풀이 예시
// apps/web-spa/src/mocks/handlers.ts
http.post(`${MOCK_API_BASE}/posts/:postId/like`, ({ params }) => {
const id = Number(params.postId);
const post = allPosts.find((it) => it.id === id);
if (post === undefined) {
return HttpResponse.json(failure('게시물을 찾을 수 없습니다'), { status: 404 });
}
post.liked = !post.liked;
post.likeCount += post.liked ? 1 : -1;
return HttpResponse.json(ok({ id: post.id, liked: post.liked, likeCount: post.likeCount }));
}),
이렇게 더하면 흉내 쪽 판이 통과합니다.
✓ 1 e2e/feed.spec.ts:4:1 › 피드를 열면 게시물이 보인다
✓ 2 e2e/journey.spec.ts:8:1 › 로그인하고 좋아요를 누르고 댓글을 단다
2 passed
덤으로 하나 더 늘어납니다
단위 판을 돌려보면 숫자가 이렇게 바뀝니다.
Tests 1061 passed → 1062 passed
지난 시간에 만든 판 하나가 우리 핸들러 목록을 직접 읽어서 "이 주소가 진짜 서버에도 있나"를 확인하거든요. 새 핸들러를 더하면 확인 항목도 저절로 하나 늘어납니다. 사람이 목록을 따라 적을 필요가 없어요.
3번 — 같은 것을 확인하고 있을까요
아닙니다. 이걸 실행으로 확인할 수 있습니다.
로그인을 안 한 채로 좋아요를 눌러보세요.
| 흉내 목록 | 진짜 서버 | |
|---|---|---|
| 상태 코드 | 200 | 401 |
| 하트 | false → true |
false → false |
우리 핸들러는 누가 눌렀는지 안 봅니다. 로그인 안 한 사람이 눌러도 그냥 눌러줘요. 진짜 서버는 출입증이 없으면 거절하는데요.
한 줄기는 로그인을 먼저 하고 좋아요를 누르니까 이 차이를 영영 못 만납니다. 판은 통과하고, 우리는 "인증이 걸려 있구나"라고 착각한 채 지나갑니다.
차이는 이것만이 아닙니다. 진짜 서버는 좋아요가 들어오면 열려 있는 다른 화면에도 알리고, 게시물 주인에게 알림을 보내고, 다섯 번에 한 번 일부러 실패도 합니다. 우리 핸들러는 그중 아무것도 안 해요.
자주 나오는 실수
⚠️ allPosts를 직접 고치는 것을 그냥 넘어가는 경우가 많습니다. 이건 우리 앱이 화면에 쓰는 바로 그 데이터예요. 핸들러가 그걸 고치면 판이 끝나도 값이 그대로 남습니다. 판 순서에 따라 결과가 달라질 수 있어요. 오늘 Step 9에서 진짜 서버를 두고 겪은 것과 똑같은 문제입니다. 복사본을 두고 쓰는 편이 안전합니다.
⚠️ 주소를 /posts/1/like처럼 번호를 박아서 적으면 첫 번째 게시물에만 걸립니다. :postId를 쓰세요.
💡 튜터의 한마디
이 과제의 진짜 답은 3번입니다. 핸들러를 더하는 건 십 분이면 돼요.
중요한 건 초록불을 하나 더 얻었는데 그게 무엇을 지키는 초록불인지를 아는 겁니다. 우리는 방금 "흉내 쪽에서도 한 줄기가 지나간다"를 얻었고, 동시에 "인증은 확인 대상에서 빠져 있다"를 얻었어요. 둘 다 알고 있어야 이 판을 믿을지 말지 정할 수 있습니다.
🎯 [과제 2 예시답안] 로그인이 실패하는 한 줄기
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 가로채기 | 6 | page.route로 401 을 돌려주고, 실제로 로그인이 안 되는 것을 확인했는가 |
| 화면 확인 | 6 | 화면에 무엇이 뜨는지를 직접 찾아봤는가(짐작으로 적지 않았는가) |
| 3번 판단 | 9 | 적을 것이 없다는 사실과 그 원인을 갈라서 말하는가 |
| 판으로 남기기 | 4 | 확인한 것을 다시 돌릴 수 있는 형태로 적었는가 |
| 합계 | 25 |
풀이 예시
// apps/web-spa/e2e/journey.spec.ts
test('로그인이 거절당하면', async ({ page }) => {
await page.route('**/api/auth/login', (route) =>
route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({
success: false,
data: null,
message: '아이디 또는 비밀번호가 올바르지 않습니다',
}),
}),
);
await page.goto('/');
await expect(page.getByRole('article').first()).toBeVisible();
await page.getByRole('button', { name: '로그인' }).click();
// 로그인이 안 됐으니 머리말은 그대로 로그인 버튼이어야 한다
await expect(page.getByRole('button', { name: '로그인' })).toBeVisible();
});
3번 — 적을 것이 없습니다
화면을 뒤져보면 이렇습니다.
머리말 = "인스타그램 · 홈 · 탐색 · 회원가입 · 쪽지 · 실시간
jaehoon 으로 · minji 으로 · 로그인 · 밝게 · 어둡게 · 시스템"
아무 일도 안 일어납니다. 버튼이 "보내는 중…"이 됐다가 다시 "로그인"으로 돌아올 뿐이에요. 서버가 보낸 사유는 화면 어디에도 안 뜹니다.
그럼 누구의 문제일까요.
앱의 문제입니다. 판의 문제가 아니에요. 판은 오히려 제 일을 했습니다 — 없는 것을 없다고 알려줬으니까요.
우리 로그인 버튼은 성공했을 때 무엇을 할지만 정해두고 실패했을 때 무엇을 할지는 안 정해뒀기 때문에, 사용자는 비밀번호를 틀렸는지 서버가 죽었는지 자기가 버튼을 안 눌렀는지 알 방법이 없어요.
⚠️ 여기서 한 가지 짚고 갈 게 있습니다. 우리는 지금까지 이걸 한 번도 몰랐는데, 로그인이 실패하는 경우를 만들어본 적이 없거든요. 연습용 서버는 아는 이름을 보내면 늘 성공시켜줬으니까요.
E2E가 값을 하는 자리가 정확히 여기입니다. 잘 되는 길만 밟으면 영원히 안 보이는 것을, 안 되는 길을 한 번 밟아보면 바로 드러납니다.
자주 나오는 실수
⚠️ server.use()를 쓰려다 막히는 경우가 가장 많습니다. 지난 시간에 배운 그 방법은 판과 흉내 서버가 같은 곳에서 돌 때 쓰는 거예요. E2E에서는 목록이 브라우저 안에 있고 판은 바깥에 있어서 손이 안 닿습니다.
⚠️ 401을 만들어놓고 toBeVisible() 만으로 확인하려다 헤매기도 합니다. "오류 문구가 보이는가"를 확인하려면 그 문구가 있어야 하는데 없거든요. 없는 것을 확인할 때는 not.toBeVisible()이나 toHaveCount(0)을 씁니다.
💡 튜터의 한마디
이 과제에서 여러분이 얻으셨으면 하는 건 "빈손으로 돌아오는 것도 결과다"입니다.
판을 짜다 보면 확인할 게 없어서 당황할 때가 있어요. 그때 억지로 뭔가를 확인하려고 하지 마세요. 확인할 게 없다는 사실 자체가 보고할 내용입니다. 오늘 우리는 그렇게 앱의 구멍을 하나 찾았어요.
🎯 [과제 3 예시답안] 무엇이 판을 지키고 있는지 갈라서 재기
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 네 조합 측정 | 8 | 네 가지를 각각 열 번씩 돌리고 숫자를 적었는가 |
| 원인 판정 | 8 | 범위가 원인이고 기다림은 아니라는 것을 숫자로 말하는가 |
| 기록 확인 | 4 | 실패한 조합의 기록을 열어 그 순간 화면을 봤는가 |
| 예상 되짚기 | 5 | 원래 무엇을 예상했고 왜 틀렸는지 적었는가 |
| 합계 | 25 |
풀이 예시 — 측정 결과
범위 좁힘 (locator('header')) |
범위 안 좁힘 (getByText) |
|
|---|---|---|
| 기다림 있음 | 통과 | 전부 실패 |
| 기다림 없음 | 10번 다 통과 | 10번 다 실패 |
세로로 보면 결과가 안 바뀌고, 가로로 보면 확 바뀝니다.
⇒ 판을 지키고 있는 것은 범위를 좁힌 쪽입니다. 피드를 기다리는 줄은 지워도 열 번 다 통과해요.
왜 예상과 다를까요
많은 분이 "기다리는 줄이 판을 지킨다" 고 예상하십니다. 저도 그랬어요. 교안에서 그 줄을 넣으면서 "이게 있어야 안정적이다"라고 생각했거든요.
그런데 아닙니다. 이유를 따져보면 이래요.
기다리는 줄은 몇 개가 잡히느냐를 안 바꿉니다. 피드가 뜨든 안 뜨든 getByText('jaehoon')은 결국 셋이 됩니다. 조금 늦게 셋이 될 뿐인데, 단언은 될 때까지 다시 보니까 늦게 셋이 되면 늦게 거부당합니다.
반대로 범위를 좁히면 언제 봐도 하나입니다. 피드가 다 떴든 아직이든 머리말 안에는 하나뿐이에요.
그럼 기다리는 줄은 왜 남겨두나요
지우면 안 된다는 뜻은 아니고, 남겨두는 이유가 따로 있습니다.
이 판이 확인하려는 상황은 "피드가 있는 화면에서 로그인한 상태"입니다. 기다리는 줄이 없으면 어떤 상태에서 확인했는지가 실행할 때마다 달라져요. 지금은 결과가 같지만, 나중에 판이 길어지고 확인할 것이 늘어나면 그 차이가 드러납니다.
지금 안 물린다고 없어도 되는 것은 아니지만, 그것이 지금 무엇을 지키고 있는지는 정확히 알고 있어야 해요. 그게 이 과제의 요점입니다.
자주 나오는 실수
⚠️ 네 조합을 한 번씩만 돌리고 결론 내는 것이 가장 흔합니다. 흔들리는 판은 한 번 돌려서는 흔들리는지 알 수 없어요. 최소 열 번은 돌리셔야 합니다.
⚠️ 조합을 바꿀 때 두 가지를 동시에 바꾸는 것도 자주 나옵니다. 어느 쪽이 원인인지 못 가리니, 한 번에 하나씩만 바꾸세요.
💡 튜터의 한마디
오늘 여러분이 가져가실 게 하나 있다면 이겁니다. "이걸 넣으니까 됐다"는 원인을 찾은 게 아닙니다.
고치고 나서 되면 우리는 그걸로 만족하고 넘어가지만, 그중 상당수는 진짜 원인이 아니라 옆에 있던 것이에요. 그 코드는 아무도 이유를 모르는 채로 계속 남고, 몇 년 뒤에 누가 지우려다 무서워서 못 지우죠.
빼보고 재보는 데 십 분이면 됩니다.
🎯 [과제 4 예시답안] 우리 판이 확인하지 않고 있는 것 세 개
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 구멍 셋 | 9 | 실제로 앱을 써보고 찾았는가(짐작으로 나열하지 않았는가) |
| 층 배정 | 9 | 각각을 어느 층에서 잡을지 이유와 함께 정했는가 |
| 값 고려 | 5 | 셋 다 E2E로 몰지 않았는가, 몰았다면 근거가 있는가 |
| 우선순위 | 2 | 셋 중 무엇을 먼저 할지 정했는가 |
| 합계 | 25 |
풀이 예시 — 찾은 구멍 셋
하나. 댓글이 서버에 안 갑니다.
댓글을 달고 새로고침해보세요. 사라집니다. 재보면 이렇습니다.
댓글 달린 뒤 서버로 나간 쓰기 요청 = 0건
새로고침 뒤 그 댓글 = 0개
그런데 우리 한 줄기는 통과합니다. "화면에 보인다"까지만 확인하니까요.
- 어느 층: 이건 판의 문제가 아니라 기능이 없는 것입니다. 먼저 만들어야 해요. 만들고 나면 서버까지 갔는지는 E2E가 확인하는 게 맞습니다(오늘 좋아요에 쓴 방법 그대로).
- 우선순위: 첫 번째. 사용자가 쓴 글이 사라지는 건 가장 큰 배신입니다.
둘. 로그인이 실패해도 아무 말이 없습니다.
과제 2에서 확인한 그것입니다.
- 어느 층: 컴포넌트 판입니다. 실패 응답을 주고 화면에 문구가 뜨는지만 보면 되니까 브라우저를 통째로 띄울 이유가 없어요.
- 우선순위: 두 번째. 사용자가 막히는 자리인데 고치는 값이 쌉니다.
셋. 출입증이 만료된 뒤가 확인 안 됩니다.
연습용 서버는 출입증을 세 번 쓰면 만료시킵니다. 우리 앱에는 그걸 조용히 갈아 끼우는 장치가 있는데, 오늘 한 줄기는 요청을 몇 번 안 보내서 거기까지 못 갑니다.
- 어느 층: 컴포넌트나 단위 판입니다. 401을 주고 갱신 요청이 한 번만 나가는지를 보면 되는데, 이건 브라우저 없이도 확인할 수 있어요.
- 우선순위: 세 번째. 이미 만들어둔 장치라 확인만 하면 됩니다.
셋 다 E2E가 아닙니다
세어보면 E2E로 갈 것은 하나뿐이고, 그것도 기능을 먼저 만든 다음입니다.
이게 이 과제의 요점이에요. 구멍을 찾는 것과 E2E를 늘리는 것은 다른 일입니다. 구멍을 찾으면 반사적으로 "판을 하나 더 짜자"가 되는데, 그중 대부분은 더 싼 층에서 잡을 수 있습니다.
판 하나에 백칠십 배라는 숫자를 기억하세요.
자주 나오는 실수
⚠️ 앱을 안 써보고 목록만 보고 적는 것입니다. "오류 처리", "접근성", "성능" 같은 말은 구멍이 아니라 분류예요. 직접 눌러보시면 훨씬 구체적인 게 나옵니다.
⚠️ 반대로 너무 작은 것만 찾는 경우도 있습니다. "이 버튼 여백이 2px 어긋난다"는 사용자가 화를 낼 일이 아니에요. "이게 망가지면 화를 낼까"를 기준으로 삼으세요.
💡 튜터의 한마디
여러분이 실무에 나가시면 "테스트 커버리지를 올리자"는 이야기를 자주 들으실 겁니다. 그때 이 과제를 떠올려주세요.
중요한 건 판이 몇 개냐가 아니라 무엇이 안 지켜지고 있는지를 알고 있느냐입니다. 오늘 우리는 판 두 개짜리 앱에서 구멍 세 개를 찾았어요. 판을 하나도 안 늘리고요.
생각해볼 주제 예시답안
🤔 [생각해볼 주제 1] 초록불을 언제 의심해야 할까요
문제 상황 요약
오늘 우리는 초록불인데 아무것도 안 지키는 판을 봤습니다. 그런데 실무에서는 판이 수천 개예요. 전부 하나씩 의심해볼 수는 없습니다. 의심할 판을 골라내는 기준이 있을까요?
튜터의 가이드 및 해설
세 가지 기준을 말씀드리겠습니다.
첫째, 그 판이 실패하는 것을 본 적이 있는가.
가장 강력한 기준입니다. 한 번도 빨개진 적 없는 판은 두 가지 중 하나예요. 정말 잘 지키고 있거나, 아무것도 안 지키고 있거나.
판을 쓸 때 습관을 하나 들이시면 좋습니다. 일부러 깨뜨려보고 빨개지는 것을 확인한 다음 되돌리기. 오늘 우리도 그렇게 했어요. 기다리는 줄을 지워보고, 범위를 다시 넓혀보고, 버튼 이름을 틀리게 바꿔봤습니다. 십 초면 됩니다.
둘째, 그 판이 무엇을 보고 통과했는지 말할 수 있는가.
오늘 좋아요 판이 정확히 이 경우였습니다. "하트가 눌렸다"를 보고 통과했는데, 그 하트는 서버가 아니라 앱이 잠깐 만들어낸 것이었어요.
기준은 이겁니다. 판이 보는 값을 누가 만들었는가. 우리 앱이 만든 값이면 그 판은 앱의 주장을 확인하는 것이고, 서버가 만든 값이면 서버까지 확인하는 겁니다. 둘 다 필요하지만 어느 쪽인지는 알고 있어야 해요.
셋째, 커버리지가 아니라 변이로 재기.
이게 자동으로 찾는 방법에 가장 가깝습니다. 코드를 일부러 조금씩 망가뜨려놓고 판이 그걸 잡아내는지 세는 겁니다. 부등호를 뒤집는다든가, 조건을 반대로 한다든가요. 이런 도구를 변이 테스트라고 부릅니다.
커버리지는 "그 줄이 실행됐다"만 말하지 "그 줄이 틀렸을 때 빨개진다"는 말해주지 않아요. 지난 시간에 커버리지 숫자를 의심해야 하는 이유를 봤죠. 변이는 그 구멍을 정면으로 겨냥합니다.
느립니다. 코드를 망가뜨릴 때마다 판을 다 돌려야 하니까요. 보통은 돈이 오가는 몇 개 파일에만 걸어둡니다.
🎯 면접관을 홀리는 핵심 멘트
"저는 테스트를 볼 때 통과율보다 그 판이 실패하는 것을 본 적이 있는지를 먼저 봅니다. 한 번도 빨개진 적 없는 판은 잘 지키고 있거나 아무것도 안 지키고 있거나 둘 중 하나인데, 통과율로는 그 둘이 구분이 안 되기 때문입니다. 그래서 판을 쓸 때 반드시 일부러 깨뜨려서 빨개지는 것을 확인하고 되돌리는 단계를 넣고, 핵심 로직에는 변이 테스트를 걸어 판이 실제로 무엇을 잡아내는지를 숫자로 봅니다."
🤔 [생각해볼 주제 2] E2E를 누구의 데이터로 돌려야 할까요
문제 상황 요약
오늘 우리 판은 연습용 서버의 좋아요를 실제로 눌렀습니다. 그리고 서버가 떠 있는 채로 여러 번 돌리니 앞선 판이 눌러둔 것 위에서 다음 판이 돌아 흔들렸어요. 실제 서비스라면 이 문제가 훨씬 큽니다. 판이 진짜 데이터베이스에 진짜 글을 씁니다.
튜터의 가이드 및 해설
네 가지 방법이 있고, 각각 값이 다릅니다.
하나. 매번 데이터베이스를 초기화한다.
가장 확실합니다. 판을 돌리기 전에 통째로 비우고 정해둔 데이터를 넣어요.
대가는 속도와 나눠 쓰기입니다. 초기화가 몇 초에서 몇 분씩 걸리고, 그동안 다른 사람은 그 환경을 못 씁니다. 판을 여러 개 동시에 돌리는 것도 안 돼요.
둘. 판마다 새 계정을 만든다.
지금 실무에서 가장 많이 쓰는 방법입니다. 판이 시작할 때 임시 계정을 하나 만들고 그 계정으로만 움직여요. 다른 판과 안 겹치니 동시에 돌릴 수 있습니다.
대가는 쓰레기가 쌓이는 것입니다. 판을 천 번 돌리면 계정이 천 개 생겨서, 지우는 장치를 따로 만들어야 하는데 판이 중간에 죽으면 그 장치도 안 돌아서 결국 남습니다.
셋. 되돌릴 수 있는 것만 건드린다.
우리 좋아요처럼 눌렀다 다시 누르면 원래대로 돌아오는 것들이요. 판 끝에 되돌려놓으면 상태가 안 쌓입니다.
대가는 되돌릴 수 있는 것만 확인하게 되는 것입니다. 회원 탈퇴나 결제처럼 되돌릴 수 없는 흐름은 이 방법으로 못 보고, 판이 중간에 죽으면 되돌리는 줄이 안 돌아서 어중간한 상태가 남습니다.
넷. 상태에 안 기대게 판을 쓴다.
오늘 우리가 고른 방법입니다. 절대값을 확인하지 않고 변화를 확인해요. 누르기 전 값을 읽고 그 반대가 됐는지 봅니다.
가장 싸고, 되돌리는 장치도 필요 없습니다. 대가는 확인이 약해지는 것이에요. "눌린 상태가 됐다"보다 "달라졌다"가 덜 구체적이니까요.
제 결론입니다. 하나로 정하지 말고 섞으세요. 기본은 넷(상태에 안 기대기)으로 가고, 그걸로 안 되는 흐름만 둘(새 계정)로 갑니다. 하나(초기화)는 배포 직전에 도는 마지막 확인에서만 씁니다.
판이 도는 환경은 반드시 갈라두세요. 오늘 우리가 겪은 것처럼, 개발하면서 띄워둔 서버에 판이 붙으면 그때부터 결과를 못 믿습니다.
🎯 면접관을 홀리는 핵심 멘트
"E2E의 데이터 전략은 되돌릴 수 있는가로 먼저 가릅니다. 되돌릴 수 있는 흐름은 절대값 대신 변화를 확인하게 판을 짜서 아예 상태에 안 기대게 만들고, 되돌릴 수 없는 흐름만 판마다 새 계정을 만들어 격리합니다. 데이터베이스 초기화는 가장 확실하지만 환경을 혼자 써야 해서 배포 직전 마지막 확인에만 씁니다. 실제로 겪은 것 중 가장 골치 아팠던 건 개발용으로 띄워둔 서버에 판이 붙어서 CI는 늘 통과하고 로컬에서만 가끔 빨개지던 경우였는데, 그때부터는 판이 환경을 직접 띄우게 하고 재사용을 CI에서 껐습니다."
🤔 [생각해볼 주제 3] 화면이 바뀌면 판도 바뀌어야 할까요
문제 상황 요약
지난 시간에 "구현이 바뀌어도 안 깨지는 판"을 배웠는데, 오늘 우리는 locator('header')를 썼어요. 이건 태그 이름이고 사용자가 보는 것이 아닙니다. 원칙을 어긴 걸까요?
튜터의 가이드 및 해설
먼저 왜 그렇게 됐는지부터 짚겠습니다.
원래 쓰려던 것은 getByRole('banner')였어요. 그런데 우리 앱에서 0개였습니다. <header>가 <main> 안에 있어서 그 역할을 잃었거든요.
여기서 중요한 건 이겁니다. 이건 판의 문제가 아니라 앱의 문제입니다.
낭독기를 쓰는 사람은 화면을 눈으로 훑는 대신 "머리말로 가" 같은 명령으로 건너뜁니다. 그 명령이 찾는 것이 방금 우리가 못 찾은 그 역할이에요. 우리 앱에는 그게 없습니다.
그러니까 판이 못 찾은 것과 그 사람이 못 찾는 것이 같은 이유입니다. 판은 지금 접근성 문제를 하나 알려준 거예요.
그래서 어떻게 해야 할까요
가장 좋은 답은 앱을 고치는 것입니다. <header>를 <main> 밖으로 꺼내면 역할이 살아나고, 그러면 판도 getByRole('banner')로 쓸 수 있습니다. 판과 앱이 동시에 나아져요.
그건 화면 구조를 바꾸는 일이라 다른 판들이 함께 물릴 수 있어서, 지금은 안 고치고 남겨뒀어요.
locator('header')는 어떨까요? 저는 이걸 원칙을 어긴 게 아니라 정직하게 빚을 진 것이라고 봅니다. 지금 우리 앱에서 머리말을 가리키는 가장 나은 방법이고, 앱을 고치면 자연스럽게 더 나은 방법으로 바뀝니다.
<div>로 바뀌면 빨개지는 게 좋을까요 나쁠까요
좋습니다. 이게 이 주제의 핵심이에요.
누가 <header>를 <div>로 바꾸면 화면은 똑같아 보입니다. 아무도 못 알아채요. 그런데 낭독기 사용자에게는 머리말이 사라진 겁니다.
그때 판이 빨개지면 우리는 그 변경을 알아챕니다. 판이 "이거 의도한 거 맞아요?" 하고 물어보는 거죠.
지난 시간의 원칙을 다시 읽어보면 이렇게 됩니다. 우리가 피하려던 건 "안 중요한 게 바뀌었는데 빨개지는 것" 이었어요. 클래스 이름이나 여백 같은 것들이요. 뜻이 바뀌었는데 빨개지는 것은 원래 원하던 겁니다.
그리고 <header>는 클래스 이름과 다릅니다. 클래스는 꾸미기 위한 것이고 태그는 이게 무엇인지를 말하는 것이에요. 뜻을 담고 있는 쪽은 바뀌면 알아야 합니다.
🎯 면접관을 홀리는 핵심 멘트
"구현 세부에 기대지 말라는 원칙에서 핵심은 무엇이 구현 세부인가를 가르는 것이라고 봅니다. 클래스 이름이나 여백은 꾸미기라 바뀌어도 사용자에게 아무 일도 안 일어나지만, 시맨틱 태그는 그 요소가 무엇인지를 말하는 것이라 바뀌면 낭독기 사용자에게 실제로 영향이 갑니다. 그래서 저는 시맨틱 태그로 범위를 좁히는 것은 원칙 위반이 아니라 오히려 판이 접근성 회귀를 잡아주는 장치라고 보고, 다만 역할로 못 찾는 상황이 나오면 그건 판을 우회시킬 게 아니라 마크업을 고칠 신호로 읽습니다."