E2E 테스트를 쓰다 보면 막막한 순간이 있다. 사용자가 화면에서 버튼을 누르는 것부터 서버와 DB를 거쳐 결과가 화면에 다시 뜨는 것까지 한 번에 흉내 내는 테스트인데, 막상 짜려고 하면 「이 테스트가 성공했다고 판단할 기준이 뭐지?」라는 질문에서 멈춘다.
나는 SSR 프로젝트에서 Playwright로 이 테스트를 짰다. SSR은 서버가 데이터를 채운 화면(HTML)을 완성해서 보내는 방식이고, Playwright는 실제 브라우저를 띄워 사람 대신 「이 주소를 열고, 이 버튼을 누르고, 이 글자가 보이는지 확인해라」를 그대로 따라 하는 도구다. 짜는 동안 이런 고민이 생겼다.
- 서버의 API가 「성공」 번호(200)를 돌려주면 무조건 성공일까?
- 페이지가 다 떴다는 건 어떻게 확인하지?
- 일이 어긋나는 경우는 어떻게 테스트하지?
- 테스트끼리 같은 데이터를 건드려 부딪히는 건 어떻게 막을까?
이 글은 그 고민을 하나씩 풀면서 내가 정한 테스트 성공 기준을 적는다.
글의 테스트 코드는 전부 TypeScript다. Playwright 테스트를 이 언어로 쓰기 때문이다. 자바와 다른 문법은 코드가 처음 나오는 곳에서 한 줄씩 따라간다.
HTTP 200을 받아도 요청이 실패했을 수 있다
브라우저나 테스트가 서버에 요청을 보내면 서버는 응답을 돌려준다. 응답 맨 앞에는 HTTP 상태 코드라는 세 자리 번호가 붙는다. 200이면 「성공」, 4로 시작하면 「보낸 쪽 잘못」, 5로 시작하면 「서버 쪽 잘못」이다. HTTP는 브라우저와 서버가 이렇게 요청과 응답을 주고받는 약속이다.
그런데 많은 API 서버가 이 번호 말고도 응답 본문 안에 성공 여부를 따로 적는다. API마다 응답 모양이 제각각이면 받는 쪽이 매번 다르게 읽어야 하니, 모든 API가 같은 모양의 응답 객체를 쓰도록 맞춰 둔다. 내 프로젝트의 서버(자바)가 쓰는 공통 응답 클래스의 필드는 이렇다.
@Schema(description = "성공 여부", example = "true")
private final Boolean success;
@Schema(description = "HTTP 상태 코드", example = "200")
private final Integer status;
@Schema(description = "응답 메시지", example = "요청이 성공적으로 처리되었습니다.")
private final String message;
@Schema(description = "응답 데이터")
private final T data;
@Schema(description = "에러 코드 (실패 시)", example = "ERR_001")
private final String errorCode;
@Schema(description = "응답 시간", example = "2025-11-25T23:16:52")
private final LocalDateTime timestamp;
클래스 선언은 빼고 필드만 옮겼다.
@Schema(...)는 API 문서에 그 필드의 설명과 예시 값을 적어 두는 애너테이션이다. 동작에는 영향이 없다success는 요청이 업무상 성공했는지,status는 상태 번호,message는 사람이 읽을 설명,errorCode는 실패했을 때 어떤 실패인지 가리키는 이름이다data의 타입T는 제네릭이다. 클래스 선언부(class ApiResponse<T>같은 줄, 여기서는 생략)에서 빈칸으로 남겨 두고, 상품 목록 API면 상품 목록, 주문 API면 주문이 들어간다
서버는 이 자바 객체를 JSON이라는 글자 형식으로 바꿔 보낸다. 테스트가 받는 것은 객체가 아니라 이 글자다. 그러면 이런 응답이 올 수 있다.
// HTTP 상태 코드는 200이지만 업무 규칙상 실패
{
"success": false,
"status": 200,
"message": "사용자를 찾을 수 없습니다",
"data": null,
"errorCode": "USER_NOT_FOUND",
"timestamp": "2026-03-10T14:30:00"
}
응답 맨 앞의 HTTP 상태 코드도 200이고, 본문의 status 필드도 200이다. status 필드는 HTTP 상태 코드를 본문에 한 번 더 적어 둔 것이다. 그런데 success는 false다. 요청은 서버까지 잘 도착했고 서버도 정상으로 돌았지만, 「그런 사용자가 없다」는 업무 규칙 때문에 원하는 일은 못 했다는 뜻이다.
상태 코드만 확인하면 이런 실패를 놓친다. 그래서 나는 응답을 세 단계로 본다.
마지막 data 단계에서 「필요한 값이 있다」를 어디까지 따질지는 API마다 다르다. 그 기준이 다음 섹션이다.
어떤 실패를 HTTP 4xx로 돌려주고 어떤 실패를 「200 + success: false」로 돌려줄지는 서버가 정한다. 뒤의 예시에서도 「상품이 없다」는 404, 「재고가 부족하다」는 200으로 온다. 테스트는 서버가 정한 대로 두 곳(상태 코드와 success)을 다 본다. 한쪽만 보면 다른 쪽으로 오는 실패를 놓친다.
API 응답은 중요도에 따라 검증 깊이를 다르게 한다
모든 API를 똑같이 꼼꼼히 검증하면 테스트가 길어지고, API가 조금만 바뀌어도 테스트를 고쳐야 한다. 그래서 나는 API의 중요도에 따라 검증 깊이를 다르게 정했다. 모든 API가 공통으로 거치는 기본 검증, 단순 조회용 가벼운 검증, 주문 · 결제 같은 핵심 API용 상세 검증 셋이다.
1. 기본 검증은 모든 API에 똑같이 건다
test('API 기본 검증', async ({ page }) => {
const response = await page.request.get('/api/products');
const json = await response.json();
// 1단계: HTTP 레벨 검증
expect(response.status()).toBe(200);
// 2단계: 비즈니스 레벨 검증
expect(json.success).toBe(true);
expect(json.errorCode).toBeNull();
});
Playwright 테스트 코드를 처음 보는 거라 한 줄씩 따라간다.
test('이름', async ({ page }) => { ... })— 테스트 하나를 등록한다. 첫 번째 인자는 테스트 이름, 두 번째는 테스트 내용을 담은 함수다.( ) => { }는 함수를 짧게 적는 화살표 함수이고, 자바의 람다와 모양이 비슷하다({ page })— Playwright가 테스트 함수에 넣어 주는 값 중page(브라우저 탭 하나)를 꺼내 쓴다는 뜻이다. 이렇게 테스트에 필요한 것을 미리 준비해 넣어 주는 장치를 fixture라고 부른다async·await— 서버에 요청을 보내면 응답이 올 때까지 시간이 걸린다. 그래서page.request.get(...)은 결과 대신 「나중에 결과를 담아 줄 Promise」를 돌려준다.await를 앞에 붙이면 결과가 올 때까지 기다렸다가 그 결과를 꺼낸다.await는async가 붙은 함수 안에서만 쓸 수 있다const— 변수를 만드는 키워드다. 자바의final변수처럼 다시 값을 넣을 수 없다. 타입은 적지 않아도 TypeScript가 알아서 정한다page.request.get('/api/products')— 브라우저 화면을 거치지 않고/api/products주소로 곧장 GET 요청을 보낸다. 주소 앞부분(http://localhost:3000같은 것)은 뒤에 나오는 설정 파일의baseURL이 채운다await response.json()— 응답 본문(JSON 글자)을 읽어 자바스크립트 객체로 바꾼다. 이것도 Promise를 돌려주므로await가 필요하다. 그 뒤로는json.success처럼 필드를 바로 읽는다expect(값).toBe(기대값)— 「값이 기대값이어야 한다」고 단정하는 어서션이다. 다르면 그 자리에서 테스트가 실패하고 어느 줄에서 무엇이 달랐는지 알려 준다.response.status()는 HTTP 상태 코드를 숫자로 돌려준다toBeNull()— 값이null이어야 통과한다. 앞의 JSON 예시에서 보듯 이 서버는 값이 빈 필드도"data": null처럼 이름은 남기고null로 보낸다. 그래서 성공 응답의errorCode도null로 온다
처음에는
toBeUndefined()로 적었다. 그런데 자바스크립트에서 undefined는 「필드 자체가 없다」이고,null과는 다른 값이다. 이 서버처럼"errorCode": null을 보내면toBeUndefined()는 실패한다. 그래서toBeNull()로 고쳤다. 서버가 빈 필드를 아예 빼고 보낸다면 반대로toBeUndefined()가 맞다.
2. 단순 조회 API는 데이터가 있는지만 본다
단순 목록 조회나 데이터 읽기는 데이터 존재 여부만 확인한다.
test('상품 목록 조회', async ({ page }) => {
const response = await page.request.get('/api/products');
const json = await response.json();
expect(json.success).toBe(true);
expect(json.status).toBe(200);
// 데이터 존재 여부만 확인
expect(json.data).toBeDefined();
expect(Array.isArray(json.data.items)).toBe(true);
expect(json.data.items.length).toBeGreaterThan(0);
});
- 목록 API의
data는 안에items라는 배열을 담아 온다.{ "data": { "items": [ 상품1, 상품2, ... ] } }모양이다 toBeDefined()— 값이undefined가 아니어야 통과한다.data필드가 응답에 있는지 본다Array.isArray(값)— 값이 배열이면true, 아니면false를 돌려준다.items가 진짜 목록인지 본다toBeGreaterThan(0)— 0보다 커야 통과한다. 목록에 상품이 하나 이상 있는지 본다
상품 이름이나 가격 같은 내용은 보지 않는다. 목록은 다른 테스트가 상품을 추가하거나 지우면 바뀐다. 내용까지 검증하면 이 API가 멀쩡해도 테스트가 깨진다.
3. 주문 · 결제 · 인증 같은 핵심 API는 필드를 하나하나 본다
주문, 결제, 인증(요청한 사람이 누구인지 확인하는 일)처럼 틀리면 돈이나 보안 문제가 되는 API는 핵심 필드를 상세히 검증한다.
test('주문 생성 API', async ({ page }) => {
const orderData = {
productId: 1,
quantity: 2,
totalAmount: 50000
};
const response = await page.request.post('/api/orders', {
data: orderData
});
const json = await response.json();
// 기본 검증
expect(json.success).toBe(true);
expect(json.status).toBe(201);
// 핵심 필드 상세 검증
expect(json.data.orderId).toBeDefined();
expect(typeof json.data.orderId).toBe('string');
expect(json.data.totalAmount).toBe(orderData.totalAmount);
expect(json.data.quantity).toBe(orderData.quantity);
expect(json.data.orderStatus).toBe('PENDING');
expect(json.data.createdAt).toBeDefined();
// timestamp가 최근인지 확인
const orderTime = new Date(json.data.createdAt);
const now = new Date();
expect(now.getTime() - orderTime.getTime()).toBeLessThan(5000); // 5초 이내
});
const orderData = { ... }— 보낼 주문 내용을 담은 객체다. 자바처럼 클래스를 먼저 만들지 않고 중괄호로 바로 만든다page.request.post('/api/orders', { data: orderData })— GET이 「가져오기」라면 POST는 「새로 만들기」 요청이다.data에 넣은 객체는 JSON으로 바뀌어 요청 본문에 실려 서버로 간다expect(json.status).toBe(201)— 201은 「요청한 대로 새것을 만들었다」는 성공 번호다. 앞의 조회는 가져오기라 200이었고, 주문 생성은 새것을 만들었으니 201이다. 둘 다 2로 시작하는 성공이다typeof 값— 값의 종류를'string','number'같은 글자로 돌려준다. 주문 번호가 글자로 오는지 본다totalAmount·quantity— 보낸 값과 서버가 저장한 값이 같은지 본다. 주문 금액이 바뀌어 저장되면 큰 문제라서 값까지 확인한다orderStatus— 새 주문은 「결제 대기(PENDING)」 상태로 시작해야 한다- 마지막 세 줄 —
new Date(글자)는"2026-03-10T14:30:00"같은 날짜 글자를 날짜 객체로 바꾸고, 인자 없이new Date()를 부르면 지금 시각이 된다.getTime()은 그 시각을 밀리초 숫자로 돌려준다. 두 숫자를 빼서, 서버가 적은 주문 시각(createdAt)과 지금 시각의 차이가 5,000밀리초(5초)보다 작은지 본다. 엉뚱한 시각이 찍히지 않았는지 확인하는 것이다
출처: Playwright API Testing 공식 문서
SSR 페이지는 정적인 UI 요소로 렌더링 성공을 확인한다
API 다음은 화면이다. 브라우저로 페이지를 열었을 때 화면이 제대로 그려졌는지(렌더링) 확인해야 한다.
CSR 방식이라면 브라우저가 거의 빈 HTML을 받은 뒤 자바스크립트로 데이터를 따로 받아 와 화면을 채운다. 그래서 「데이터가 다 도착해서 화면이 채워졌는가」를 기다리는 것이 검증의 큰 부분이 된다. SSR은 서버가 이미 데이터를 채운 HTML을 보낸다. 페이지를 받는 순간 내용이 들어 있으니, 데이터 도착을 따로 기다릴 일이 적고, 「필요한 요소가 화면에 있는가」를 보는 것이 검증의 중심이 된다.
바뀌는 데이터가 아니라 고정된 글자를 확인한다
바뀔 수 있는 데이터 대신 늘 그 자리에 있는 UI 요소를 검증한다.
test('상품 목록 페이지 렌더링', async ({ page }) => {
await page.goto('/products');
// 좋은 예: 정적 레이블 확인
await expect(page.getByRole('heading', { name: '상품 목록' })).toBeVisible();
await expect(page.getByText('가격')).toBeVisible();
await expect(page.getByText('재고')).toBeVisible();
// 나쁜 예: 동적 데이터 확인 (테스트가 불안정해짐)
// await expect(page.getByText('노트북 - 1,500,000원')).toBeVisible();
});
page.goto('/products')— 브라우저 탭에서 그 주소를 연다. 기본으로는 페이지의 load 이벤트(페이지와 그림 같은 것을 다 받았다는 신호)가 올 때까지 기다린다page.getByRole('heading', { name: '상품 목록' })— 화면에서 「제목(heading)」 역할을 하는 요소 중 이름이 「상품 목록」인 것을 찾는다. 역할(role)은 화면 읽기 프로그램 같은 보조 기술이 요소를 알아보는 이름표다(접근성).<h1>같은 제목 태그는 heading 역할로,<button>은 button 역할로 따로 적지 않아도 알아본다page.getByText('가격')— 「가격」이라는 글자가 들어 있는 요소를 찾는다toBeVisible()— 그 요소가 화면에 보여야 통과한다. 이런 화면용 어서션은 조건이 맞을 때까지 잠깐 기다렸다가 판단한다- 앞 섹션의
expect에는 없던await가 여기부터 붙는다. 기준은 이것이다 —toBeVisible()처럼 화면 요소나 페이지를 검사하는 어서션은 기다리는 동작이라 Promise를 돌려주므로await를 붙인다.toBe()·toBeNull()처럼 이미 손에 든 값을 비교하는 어서션은 바로 끝나므로 붙이지 않는다
「상품 목록」 제목과 「가격」 · 「재고」 같은 표의 머리글은 데이터가 바뀌어도 그대로다. 반대로 「노트북 - 1,500,000원」은 DB에 무엇이 들어 있느냐에 따라 바뀐다. DB는 테스트 실행을 시작할 때마다 같은 초기 상태로 돌아가지만(뒤의 data.sql 섹션), 실행 도중에 다른 테스트가 상품을 추가하거나 지우고, 누군가 초기 데이터를 고치면 그 글자가 없어진다. 그러면 화면은 멀쩡한데 테스트가 깨진다.
여러 assertion을 조합해 페이지가 제대로 떴는지 본다
어서션(assertion) 하나로는 페이지가 제대로 떴다고 말하기 어렵다. 제목만 맞고 입력 칸이 없을 수도 있다. 그래서 다양한 어서션을 조합한다.
test('로그인 페이지 검증', async ({ page }) => {
await page.goto('/login');
// 페이지 제목 확인
await expect(page).toHaveTitle(/로그인/);
// URL 확인
await expect(page).toHaveURL(/\/login/);
// 필수 UI 요소 확인
await expect(page.getByLabel('이메일')).toBeVisible();
await expect(page.getByLabel('비밀번호')).toBeVisible();
await expect(page.getByRole('button', { name: '로그인' })).toBeEnabled();
// 링크 속성 확인
await expect(page.getByRole('link', { name: '회원가입' }))
.toHaveAttribute('href', '/register');
});
toHaveTitle(/로그인/)— 페이지 제목에 「로그인」이 들어 있어야 통과한다. 페이지 제목은 HTML의<title>태그에 적힌 글자로, 브라우저 탭에 뜨는 이름이다. 슬래시 두 개로 감싼/로그인/은 정규식이고, 여기서는 「이 글자가 어딘가에 들어 있다」는 뜻으로 쓴다toHaveURL(/\/login/)— 주소에/login이 들어 있어야 통과한다. 정규식은 슬래시로 시작과 끝을 표시하므로, 안에 든 슬래시는 끝 표시와 헷갈리지 않게 앞에 역슬래시를 붙여\/로 적는다page.getByLabel('이메일')— 「이메일」이라는 이름표(label)가 붙은 입력 칸을 찾는다. 입력 칸 옆에 「이메일」이라고 적어 두고 그 칸과 연결해 둔 글자가 label이다toBeEnabled()— 버튼이 눌러지는 상태여야 통과한다.toBeVisible()은 「보이는가」만 보고, 보이지만 회색으로 막혀 눌리지 않는 버튼도 통과시킨다. 로그인 버튼은 눌러져야 의미가 있어서 이걸 쓴다toHaveAttribute('href', '/register')— 「회원가입」 링크의href속성이/register여야 통과한다.href는 링크를 눌렀을 때 갈 주소를 적는 곳이다
출처: Playwright Assertions 공식 문서
실패하는 경우도 테스트해야 한다
해피 패스만 테스트하면 절반만 한 것이다. 맞는 값을 넣었을 때 잘 되는 것만 확인하면, 틀린 값이 들어왔을 때 서버가 엉뚱한 응답을 주거나 멈춰도 모른다. 사용자는 실제로 틀린 값을 넣고, 없는 주소를 열고, 로그인 없이 들어오려 한다. 그래서 나는 예외 케이스에서 시스템이 정한 대로 반응하는지를 해피 패스만큼 중요하게 본다.
없는 상품 · 잘못된 요청 · 로그인 없는 접근 · 재고 부족을 각각 확인한다
// 1. 존재하지 않는 리소스 조회
test('404 에러 처리', async ({ page }) => {
const response = await page.request.get('/api/products/999999');
const json = await response.json();
expect(response.status()).toBe(404);
expect(json.success).toBe(false);
expect(json.errorCode).toBe('PRODUCT_NOT_FOUND');
expect(json.message).toContain('상품을 찾을 수 없습니다');
});
// 2. 잘못된 요청 파라미터
test('400 에러 처리', async ({ page }) => {
const response = await page.request.post('/api/orders', {
data: {
productId: 'invalid', // 숫자여야 하는데 문자열
quantity: -1 // 음수 불가
}
});
const json = await response.json();
expect(response.status()).toBe(400);
expect(json.success).toBe(false);
expect(json.errorCode).toBe('INVALID_REQUEST');
});
// 3. 로그인 없이 접근
test('401 에러 처리', async ({ page }) => {
// 로그인 없이 보호된 리소스 접근
const response = await page.request.get('/api/admin/users');
const json = await response.json();
expect(response.status()).toBe(401);
expect(json.success).toBe(false);
expect(json.errorCode).toBe('UNAUTHORIZED');
});
// 4. 비즈니스 로직 실패 (재고 부족 등)
test('재고 부족 에러', async ({ page }) => {
const response = await page.request.post('/api/orders', {
data: {
productId: 1,
quantity: 1000 // 재고보다 많은 수량
}
});
const json = await response.json();
// HTTP는 200이지만 비즈니스 로직 실패
expect(response.status()).toBe(200);
expect(json.success).toBe(false);
expect(json.errorCode).toBe('INSUFFICIENT_STOCK');
});
네 테스트는 모양이 같다. 일부러 틀린 요청을 보내고, 상태 코드 · success · errorCode 세 곳이 서버가 약속한 실패 모양인지 본다.
- 1번(404) — 999999번처럼 없는 상품을 달라고 한다. 404는 「그런 대상이 없다」는 번호다.
toContain('...')은 메시지에 그 글자가 들어 있으면 통과한다 - 2번(400) — 숫자 자리에 글자를, 수량에 음수를 넣는다. 400은 「보낸 값이 잘못됐다」는 번호다
- 3번(401) — 로그인하지 않은 채 관리자 전용 주소(
/api/admin/users)를 부른다. 이 테스트는 로그인 단계를 거치지 않았으니 서버는 요청한 사람이 누구인지 모른다. 401은 「누구인지 확인할 정보가 없다」는 번호다 - 4번(200) — 재고보다 많이 주문한다. 요청 자체는 올바르니 이 서버는 200을 주고, 대신
success: false와INSUFFICIENT_STOCK으로 실패를 알린다. 첫 섹션에서 본 「200인데 실패」가 바로 이것이다
3번은 처음에 403과
UNAUTHORIZED로 적었다. 그런데 403은 「누구인지는 알지만 그 일을 할 권한이 없다」는 번호이고, 「로그인하지 않아 누구인지 모른다」는 401이다(MDN 401). 로그인 없이 부르는 장면에 맞게 401로 고쳤다. 403을 테스트하려면 일반 사용자로 로그인한 뒤 관리자 주소를 불러야 한다.
로그인 실패 시 화면에 에러 메시지가 뜨는지 확인한다
API뿐 아니라 화면에서도 실패를 확인한다. 틀린 비밀번호로 로그인했을 때 사용자에게 알려 주는지 본다.
test('로그인 실패 시 에러 메시지 표시', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('이메일').fill('wrong@example.com');
await page.getByLabel('비밀번호').fill('wrongpassword');
await page.getByRole('button', { name: '로그인' }).click();
// 에러 메시지가 표시되는지 확인
await expect(page.getByRole('alert')).toBeVisible();
await expect(page.getByRole('alert')).toContainText('이메일 또는 비밀번호가 일치하지 않습니다');
// 로그인 페이지에 그대로 있는지 확인
await expect(page).toHaveURL(/\/login/);
});
fill('...')— 입력 칸에 그 글자를 채운다. 사람이 키보드로 치는 것을 대신한다click()— 버튼을 누른다getByRole('alert')— 「경고(alert)」 역할을 가진 요소를 찾는다. 이 화면은 에러 메시지 상자에 이 역할을 붙여 두었다toContainText('...')— 그 요소의 글자에 이 문장이 들어 있어야 통과한다- 마지막 줄 — 로그인에 실패했으니 다른 페이지로 넘어가지 않고
/login에 그대로 있어야 한다
테스트는 실행 순서와 관계없이 같은 결과를 내야 한다
테스트는 어떤 순서로 돌려도, 하나만 따로 돌려도 같은 결과를 내야 한다. 앞 테스트가 만든 데이터에 기대면, 그 테스트가 실패하거나 순서가 바뀌는 순간 뒤 테스트까지 이유 없이 같이 깨진다. 그러면 실패 메시지가 진짜 원인을 가리키지 않아서 고치기 어렵다.
나쁜 예 — 앞 테스트가 만든 데이터를 뒤 테스트가 쓴다
// 나쁜 예: 순서에 의존
let createdProductId: string;
test('상품 생성', async ({ page }) => {
const response = await page.request.post('/api/products', {
data: { name: 'Test Product' }
});
createdProductId = (await response.json()).data.id; // 다음 테스트에서 사용
});
test('상품 조회', async ({ page }) => {
// 위 테스트가 먼저 실행되지 않으면 실패
const response = await page.request.get(`/api/products/${createdProductId}`);
expect((await response.json()).success).toBe(true);
});
test('상품 삭제', async ({ page }) => {
// 위 테스트들에 의존
await page.request.delete(`/api/products/${createdProductId}`);
});
let createdProductId: string;— 나중에 값을 넣을 변수를 만든다.let은const와 달리 값을 다시 넣을 수 있다. TypeScript는 타입을 이름 뒤에: string처럼 붙인다. 자바의String createdProductId;와 같다(await response.json()).data.id— 응답을 객체로 바꾼 뒤(await response.json()) 그 안의data.id를 읽는다. 괄호는await를 먼저 끝내라는 뜻이다`/api/products/${createdProductId}`— 백틱으로 감싼 템플릿 리터럴이다.${ }자리에 변수 값이 들어간다. 자바의"/api/products/" + createdProductId와 같다page.request.delete(...)— DELETE 요청, 즉 「지워 달라」는 요청을 보낸다
세 테스트가 바깥 변수 createdProductId 하나를 같이 쓴다. 「상품 조회」만 따로 돌리면 이 변수가 비어 있어서 실패한다. 조회 기능은 멀쩡한데 말이다.
이 나쁜 예는 지금은 통과할 수도 있다. Playwright는 기본으로 한 파일 안의 테스트를 적힌 순서대로 같은 워커에서 돌린다(공식 문서). 그래서 순서 의존이 숨어 있다가, 테스트 하나만 골라 돌리거나 파일 안의 테스트까지 동시에 돌리도록 설정을 바꾸는 날 드러난다.
좋은 예 — 테스트마다 필요한 데이터를 스스로 만든다
// 좋은 예: 테스트 내부에서 필요한 데이터 생성
test('상품 생성 후 조회', async ({ page }) => {
// 1. 데이터 생성
const createResponse = await page.request.post('/api/products', {
data: {
name: `Test Product ${Date.now()}`, // 고유한 이름
price: 10000,
stock: 100
}
});
const createJson = await createResponse.json();
expect(createJson.success).toBe(true);
const productId = createJson.data.id;
// 2. 생성한 데이터 조회
const getResponse = await page.request.get(`/api/products/${productId}`);
const product = (await getResponse.json()).data;
expect(product.id).toBe(productId);
expect(product.name).toContain('Test Product');
});
test('상품 삭제 기능', async ({ page }) => {
// 이 테스트도 독립적으로 데이터 생성
const createResponse = await page.request.post('/api/products', {
data: {
name: `Delete Test ${Date.now()}`,
price: 5000,
stock: 50
}
});
const productId = (await createResponse.json()).data.id;
// 삭제 테스트
const deleteResponse = await page.request.delete(`/api/products/${productId}`);
expect((await deleteResponse.json()).success).toBe(true);
// 삭제 확인
const getResponse = await page.request.get(`/api/products/${productId}`);
expect(getResponse.status()).toBe(404);
});
Date.now()— 지금 시각을 밀리초 숫자로 돌려준다. 이름 끝에 붙이면 실행할 때마다 다른 이름이 되어, 앞서 돌린 테스트가 남긴 상품과 겹치지 않는다- 첫 테스트는 상품을 직접 만들고, 응답에서 받은
productId로 그 상품을 조회해 같은 상품인지 본다 - 둘째 테스트는 지울 상품을 직접 만들고 지운 뒤, 다시 조회해서 404(없음)가 오는지 본다
이제 각 테스트는 다른 테스트가 무엇을 했든 상관없다. 하나만 따로 돌려도 통과한다. 대신 테스트마다 데이터를 만드는 코드가 붙어서 코드가 길어진다. 모든 테스트가 쓰는 기본 데이터까지 매번 만들 수는 없으니, 그 부분은 아래 data.sql이 맡는다.
초기 데이터] C[테스트 2] --> B D[테스트 3] --> B A --> E[필요시
추가 데이터
생성] C --> F[필요시
추가 데이터
생성] D --> G[필요시
추가 데이터
생성] E --> H[독립 실행] F --> I[독립 실행] G --> J[독립 실행] style B fill:#2d4a5c,stroke:#4a7c9c,stroke-width:2px,color:#e3f2fd style H fill:#2d5016,stroke:#4a7c2c,stroke-width:2px,color:#e8f5e9 style I fill:#2d5016,stroke:#4a7c2c,stroke-width:2px,color:#e8f5e9 style J fill:#2d5016,stroke:#4a7c2c,stroke-width:2px,color:#e8f5e9
그림은 이 구조를 한 장으로 줄인 것이다. 모든 테스트가 같은 초기 데이터(data.sql) 위에서 시작하고, 그 위에 필요한 것만 스스로 더 만든다.
data.sql로 모든 테스트가 같은 초기 데이터에서 시작하게 한다
내 프로젝트는 테스트용 DB로 H2를 쓴다. 따로 설치하지 않고 쓰는 작은 DB다. 서버가 뜰 때 DB가 비어 있는 채로 만들어지고, data.sql 파일에 적어 둔 기본 데이터가 그 안에 들어간다. data.sql은 SQL(DB에 무엇을 하라고 보내는 말)을 적어 둔 파일이다.
-- data.sql (H2 Database 초기 데이터)
INSERT INTO users (id, username, email, password)
VALUES (1, 'testuser', 'test@example.com', 'hashed_password');
INSERT INTO products (id, name, price, stock)
VALUES
(1, 'BaseProduct1', 10000, 50),
(2, 'BaseProduct2', 20000, 30),
(3, 'BaseProduct3', 15000, 100);
INSERT INTO categories (id, name)
VALUES
(1, 'Electronics'),
(2, 'Books');
INSERT INTO 테이블 (컬럼들) VALUES (값들)— 테이블에 줄을 하나 추가한다. 괄호 안의 컬럼들은 그 줄의 어느 칸에 값을 넣을지 적은 것이다.VALUES뒤에 괄호를 여러 개 적으면 여러 줄이 한 번에 들어간다- 첫 문장은 사용자 1명, 둘째는 상품 3개, 셋째는 카테고리 2개를 넣는다
'hashed_password'— 비밀번호는 원래 글자 그대로 저장하지 않고 해시로 뭉개 저장한다. 여기서는 그 자리를 표시만 해 둔 예시 값이다
이 초기 데이터는
- 모든 테스트가 공통으로 쓸 수 있는 기반이다. 앞의 주문 테스트가
productId: 1을 바로 쓸 수 있는 것도 이 데이터 덕분이다 - 서버를 새로 띄울 때마다 DB가 새로 만들어지고 이 데이터가 다시 들어가므로, 테스트 실행을 시작할 때는 항상 같은 상태다. 다만 테스트 하나가 끝날 때마다 되돌리지는 않는다. 실행 도중에는 모든 테스트가 같은 DB를 함께 쓴다 — 그래서 다음 섹션의 충돌 문제가 생긴다
- 테스트마다 필요한 추가 데이터는 각 테스트가 직접 만든다
병렬 실행에서는 테스트끼리 같은 데이터를 건드려 부딪힐 수 있다
Playwright는 테스트를 여러 개 동시에 돌릴 수 있다(병렬 실행). 테스트를 워커라는 별도 프로세스 여러 개에 나눠 맡기고, 워커마다 자기 브라우저를 띄워 동시에 돌린다. 테스트가 많으면 시간이 크게 줄어든다. 기본으로는 서로 다른 파일의 테스트가 동시에 돈다.
문제는 브라우저는 워커마다 따로지만 서버와 DB는 하나를 같이 쓴다는 것이다.
두 테스트가 같은 줄을 동시에 고치거나 같은 값을 넣으면 깨진다
// 동시에 실행되면 충돌 가능
Test A: UPDATE products SET stock=5 WHERE id=1
Test B: DELETE FROM products WHERE id=1
→ Test A 실패 가능 (데이터가 사라짐)
Test A: INSERT INTO users (username) VALUES ('testuser')
Test B: INSERT INTO users (username) VALUES ('testuser')
→ 유니크 제약 위반
테스트 코드가 아니라, 두 테스트가 서버를 거쳐 DB에 보내게 되는 명령을 나란히 적은 것이다.
- 첫 묶음 — 테스트 A는 1번 상품의 재고를 5로 바꾸고, 테스트 B는 1번 상품을 지운다. B가 먼저 끝나면 A가 바꾸려던 상품이 이미 없다. A의 코드는 멀쩡한데 실패한다
- 둘째 묶음 — 두 테스트가 같은 이름
testuser로 사용자를 만든다.username컬럼에 unique 제약(같은 값이 두 번 못 들어오게 막는 규칙)이 있으면 늦게 온 쪽이 거절당한다
이걸 막는 방법으로 셋을 놓고 봤다.
해결책 1 — 한 번에 하나씩 순서대로 돌린다
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
workers: 1, // 한 번에 하나의 테스트만 실행
retries: 2,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
});
playwright.config.ts는 Playwright가 테스트를 시작할 때 읽는 설정 파일이다.
import { defineConfig } from '@playwright/test'— Playwright 라이브러리에서defineConfig함수를 가져온다. 자바의import와 같은 역할이다(import · export)export default defineConfig({ ... })— 이 설정 객체를 이 파일의 대표 값으로 내놓는다. Playwright가 이 값을 읽어 간다workers: 1— 워커를 최대 1개만 쓴다. 테스트가 하나씩 차례로 돈다retries: 2— 테스트가 실패하면 최대 2번까지 다시 돌린다. 한 번 실패했다고 곧바로 실패로 확정하지 않는다baseURL: 'http://localhost:3000'— 앞의 테스트들이'/api/products'처럼 주소 뒷부분만 적을 수 있는 이유다. 앞부분은 이 값이 채운다. localhost는 테스트를 돌리는 이 컴퓨터 자신이고, 3000은 서버가 기다리는 포트 번호다trace: 'on-first-retry'— 실패한 테스트를 처음 다시 돌릴 때 실행 기록(trace)을 모은다. 실패를 나중에 되짚어 볼 자료다
얻는 것은 셋이다.
- 구현이 간단하다. 설정 한 줄이다
- 동시에 도는 테스트가 없으니 데이터 충돌을 걱정할 필요가 없다
- 실패했을 때 무슨 일이 어떤 순서로 일어났는지 따라가기 쉽다
대가는 느린 실행 속도다. 테스트가 늘어날수록 전체 시간이 그대로 늘어난다.
출처: Playwright Test Configuration
해결책 2 — 테스트마다 겹치지 않는 데이터를 만들어 동시에 돌린다
// 각 테스트가 고유한 데이터 생성
test('상품 생성 1', async ({ page }) => {
const uniqueName = `product_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const response = await page.request.post('/api/products', {
data: { name: uniqueName, price: 10000 }
});
expect((await response.json()).success).toBe(true);
});
test('상품 생성 2', async ({ page }) => {
// 다른 고유 이름 사용
const uniqueName = `product_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const response = await page.request.post('/api/products', {
data: { name: uniqueName, price: 20000 }
});
expect((await response.json()).success).toBe(true);
});
Math.random()— 0 이상 1 미만의 무작위 소수를 돌려준다.toString(36)— 그 숫자를 36진법 글자(0~9와 a~z)로 바꾼다. 예:0.1qfqhehxbx1.substr(2, 9)— 앞의0.두 글자를 건너뛰고 9글자를 잘라 낸다. 예:1qfqhehxb- 결과적으로
product_시각_무작위9글자모양의 이름이 된다. 두 테스트가 같은 밀리초에 돌아도 무작위 부분이 달라 겹치지 않는다
얻는 것은 둘이다.
- 동시에 돌리니 빠르다
- 테스트마다 자기만의 데이터를 쓰니 서로의 데이터를 건드리지 않는다
대가도 둘이다.
- 테스트마다 고유 데이터를 만드는 코드가 붙어 코드가 복잡해질 수 있다
- 「data.sql의 1번 상품」처럼 모두가 같이 쓰는 데이터를 고치는 테스트는 여전히 막지 못한다. 한 테스트가 1번 상품의 재고를 바꾸는 동안 다른 테스트가 그 재고를 읽으면 결과가 흔들린다. 이런 테스트는 공용 데이터를 쓰지 말고 자기 상품을 새로 만들어 써야 하는데, 꼭 특정 데이터가 있어야 하는 테스트는 그렇게 바꾸기 어렵다
해결책 3 — 워커마다 자기 전용 데이터를 준다
// fixtures/db-fixture.ts
import { test as baseTest } from '@playwright/test';
export const test = baseTest.extend<{}, { dbUserName: string }>({
// Worker마다 고유한 사용자 생성
dbUserName: [async ({ }, use) => {
const userName = `user_worker_${baseTest.info().workerIndex}`;
// DB에 사용자 생성
await createUserInTestDatabase(userName);
await use(userName);
// 테스트 후 정리
await deleteUserFromTestDatabase(userName);
}, { scope: 'worker' }],
});
// 테스트에서 사용
import { test, expect } from './fixtures/db-fixture';
test('워커별 격리 테스트', async ({ page, dbUserName }) => {
// 각 워커가 고유한 사용자로 테스트
console.log(`Using user: ${dbUserName}`);
await page.goto('/profile');
await expect(page.getByText(dbUserName)).toBeVisible();
});
앞에서 page를 넣어 주던 fixture를 직접 하나 만드는 코드다. 위쪽은 fixture를 만드는 파일, 아래쪽은 그걸 쓰는 테스트다.
import { test as baseTest } ...— Playwright의 기본test를baseTest라는 이름으로 가져온다baseTest.extend<{}, { dbUserName: string }>({ ... })— 기본test에 새 fixturedbUserName을 더한 새test를 만든다. 꺾쇠 안의 두 칸 중 앞 칸은 테스트마다 새로 만드는 fixture, 뒤 칸은 워커마다 한 번 만드는 fixture의 이름과 타입이다.dbUserName은 뒤 칸에 있으니 워커마다 한 번 만든다dbUserName: [함수, { scope: 'worker' }]— 배열 첫 칸에 준비 함수를, 둘째 칸에 옵션을 넣는다.scope: 'worker'가 「워커마다 한 번」이라는 옵션이다async ({ }, use) => { ... }— 준비 함수다. 빈{ }는 이 fixture가 다른 fixture를 하나도 쓰지 않는다는 뜻이다.use는 Playwright가 넘겨주는 함수다baseTest.info().workerIndex— 지금 이 코드를 돌리는 워커의 번호다. 워커마다 다른 번호라user_worker_0,user_worker_1처럼 겹치지 않는 이름이 나온다createUserInTestDatabase·deleteUserFromTestDatabase— 테스트 DB에 사용자를 만들고 지우는 함수다. Playwright가 주는 것이 아니라 프로젝트에서 직접 만들어 두는 함수라 여기서는 생략했다await use(userName)— 이 줄을 경계로 앞은 준비, 뒤는 정리다.use에 넘긴 값이 테스트에 들어가고, 그 워커의 테스트가 다 끝나야use가 돌아와서 아래 정리 코드(사용자 삭제)가 돈다console.log(...)— 글자를 콘솔에 찍는다. 자바의System.out.println과 같다import { test, expect } from './fixtures/db-fixture'— 테스트 파일은 Playwright의 기본test대신 방금 만든test를 가져와 쓴다. 그래서 테스트 함수의({ page, dbUserName })에서dbUserName을 꺼낼 수 있다- 마지막 두 줄 — 프로필 페이지에 그 워커의 사용자 이름이 보이는지 본다. 예시를 짧게 하려고 로그인 단계를 뺐다. 실제로는 그 사용자로 로그인한 뒤에야 프로필에 이름이 뜬다
얻는 것은 워커끼리 데이터가 완전히 갈린다는 것이다. 워커 0번의 테스트는 user_worker_0만, 1번은 user_worker_1만 쓰니 동시에 돌아도 서로의 사용자를 건드리지 않는다. 대가는 fixture를 만들고 DB를 직접 다루는 함수를 따로 갖춰야 한다는 것이라, 셋 중 가장 손이 많이 간다.
출처: Playwright Test Fixtures - Parallel Execution
Worker 1] B2[Test 2
Worker 2] B3[Test 3
Worker 3] B4[Test 4
Worker 4] end C[H2 DB
공유 데이터] A1 --> C A2 --> C A3 --> C A4 --> C B1 -.충돌 가능.-> C B2 -.충돌 가능.-> C B3 -.충돌 가능.-> C B4 -.충돌 가능.-> C style C fill:#2d4a5c,stroke:#4a7c9c,stroke-width:2px,color:#e3f2fd style A1 fill:#2d5016,stroke:#4a7c2c,stroke-width:2px,color:#e8f5e9 style A2 fill:#2d5016,stroke:#4a7c2c,stroke-width:2px,color:#e8f5e9 style A3 fill:#2d5016,stroke:#4a7c2c,stroke-width:2px,color:#e8f5e9 style A4 fill:#2d5016,stroke:#4a7c2c,stroke-width:2px,color:#e8f5e9 style B1 fill:#5c4210,stroke:#8b6820,stroke-width:2px,color:#fff3e0 style B2 fill:#5c4210,stroke:#8b6820,stroke-width:2px,color:#fff3e0 style B3 fill:#5c4210,stroke:#8b6820,stroke-width:2px,color:#fff3e0 style B4 fill:#5c4210,stroke:#8b6820,stroke-width:2px,color:#fff3e0
그림 위쪽은 해결책 1이다. 테스트가 줄을 서서 DB를 하나씩 쓰니 부딪힐 일이 없다. 아래쪽은 아무 대책 없이 병렬로 돌린 모습이다. 네 워커가 같은 DB를 동시에 쓰니 부딪힐 수 있다. 해결책 2와 3은 이 아래쪽 그림에서 워커마다 건드리는 데이터를 갈라 충돌 선을 끊는 방법이다.
Mock API는 외부 서비스나 재현하기 어려운 상황에만 쓴다
Mock은 진짜 대신 쓰는 가짜다. E2E 테스트에서는 서버에 요청이 가는 길목을 가로채 「이 주소로 요청이 오면 이 응답을 돌려줘라」고 미리 정해 둔 가짜 응답을 주는 것을 말한다.
내 프로젝트에서는 Mock API를 거의 쓰지 않았다. 이유는 셋이다.
- H2 DB가 늘 같은 초기 상태로 시작한다 → 진짜 백엔드(서버 쪽 프로그램)로 대부분의 상황을 재현할 수 있다
- 진짜 API로 테스트하는 쪽이 더 믿을 만하다 → 실제 사용자가 쓰는 운영 환경과 같은 코드가 돈다
- Mock은 관리할 거리가 늘어난다 → API가 주고받는 데이터 모양이 바뀌면 Mock도 같이 고쳐야 하고, 깜빡하면 진짜는 깨졌는데 테스트는 가짜 응답으로 통과한다
그래도 아래 세 경우에는 Mock이 낫다.
Mock이 필요한 세 경우 — 외부 결제, 일부러 만든 장애, 아직 없는 API
// 1. 외부 서비스 의존성 제거 (결제, 메일 발송 등)
test('결제 프로세스 테스트', async ({ page }) => {
// 실제 결제 API 호출 대신 Mock
await page.route('**/api/payment/process', route => {
route.fulfill({
status: 200,
body: JSON.stringify({
success: true,
data: { transactionId: 'MOCK_TX_123', status: 'COMPLETED' }
})
});
});
await page.goto('/checkout');
await page.getByRole('button', { name: '결제하기' }).click();
await expect(page.getByText('결제가 완료되었습니다')).toBeVisible();
});
// 2. 특정 에러 상황 재현
test('네트워크 타임아웃 처리', async ({ page }) => {
await page.route('**/api/products', route => {
// 타임아웃 시뮬레이션
return new Promise(() => {}); // 영원히 대기
});
await page.goto('/products');
// 로딩 스피너가 계속 표시되는지 확인
await expect(page.getByTestId('loading-spinner')).toBeVisible();
});
// 3. 서버가 아직 구현되지 않은 API
test('신규 기능 프론트엔드 테스트', async ({ page }) => {
await page.route('**/api/new-feature', route => {
route.fulfill({
status: 200,
body: JSON.stringify({
success: true,
data: { message: '신규 기능 데이터' }
})
});
});
// 프론트엔드 동작만 테스트
});
먼저 세 테스트에 공통으로 나오는 것부터 본다.
page.route('주소 패턴', route => { ... })— 이 브라우저 탭이 그 패턴에 맞는 주소로 요청을 보내면, 서버로 보내지 않고 가로채서 뒤의 함수에 넘긴다.**는 「/까지 포함해 아무 글자나」라는 뜻이라,**/api/payment/process는 주소 앞부분이 무엇이든/api/payment/process로 끝나는 요청을 잡는다route => { ... }— 매개변수가 하나뿐인 화살표 함수는 괄호를 빼고route =>로 적을 수 있다.(route) => { ... }와 같다.route에는 Playwright가 가로챈 요청이 담겨 들어오고, 이 요청에 무엇으로 답할지를 함수 안에서 정한다route.fulfill({ status, body })— 가로챈 요청에 이 응답을 돌려준다.status는 상태 코드,body는 응답 본문이다JSON.stringify(객체)— 객체를 JSON 글자로 바꾼다. 앞에서 본response.json()의 반대 방향이다
1번 결제 — 「결제하기」를 누르면 브라우저가 /api/payment/process로 요청을 보낸다. 그 요청을 page.route가 가로채 「결제 완료」 가짜 응답을 준다. /api/payment/process는 내 서버의 주소이고, 내 서버는 이 요청을 받으면 결제 회사에 다시 요청을 보내는 구조다. 요청이 내 서버에 닿기 전에 가로채니 결제 회사까지 요청이 가지 않는다. 그래서 테스트할 때마다 돈이 나가지 않고, 결제 회사 서버가 느리거나 멈춰도 테스트가 흔들리지 않는다. 마지막 줄은 그 응답을 받은 화면이 「결제가 완료되었습니다」를 띄우는지 본다.
2번 응답이 끝없이 늦는 경우 — 진짜 서버로는 「응답이 영영 안 오는 상황」을 일부러 만들기 어렵다.
return new Promise(() => {})— 결과를 영원히 채우지 않는 Promise를 돌려준다. 가로챈 요청에 끝내 응답하지 않으니 브라우저는 계속 기다린다getByTestId('loading-spinner')— HTML에data-testid="loading-spinner"속성이 붙은 요소를 찾는다. 테스트에서 찾기 쉽게 개발자가 일부러 붙여 두는 표시다. 로딩 스피너는 「불러오는 중」을 알리려고 빙글빙글 도는 그림이다- 마지막 줄 — 응답이 안 오는 동안 그 그림이 계속 보이는지 본다
이 예시는 브라우저가 페이지를 연 뒤 자바스크립트로
/api/products를 따로 부르는 화면이어야 성립한다. 공식 문서는page.route가 잡는 것을 「페이지가 보내는 요청」이라고 적는다(Network Mocking). SSR 화면에서 서버가 HTML을 만들려고 서버 안에서 데이터를 가져오는 요청은 브라우저가 보내는 요청이 아니라서, 이 방법으로 가로챌 수 없다. 그래서 SSR 프로젝트라도 화면 일부를 브라우저가 나중에 채우는 곳에서만 이 테스트를 쓴다.
3번 아직 없는 API — 서버 쪽 API가 아직 만들어지지 않았어도, 화면 쪽(프론트엔드) 개발자는 정해 둔 응답 모양대로 가짜 응답을 주고 화면을 먼저 테스트할 수 있다. 예시는 가짜 응답을 거는 부분만 보여 주려고 화면을 열고 확인하는 부분을 비워 두었다. 실제로는 1번처럼 page.goto로 화면을 열고 그 데이터가 뜨는지 확인하는 줄이 이어진다.
출처: Playwright Network Mocking
지금까지의 기준을 한 테스트 파일에 모으면 이렇게 된다
지금까지 정한 기준을 상품 기능 하나에 모두 적용한 예제다.
// tests/e2e/product.spec.ts
import { test, expect } from '@playwright/test';
test.describe('상품 관리 E2E 테스트', () => {
// 페이지 렌더링 테스트
test('상품 목록 페이지가 정상적으로 렌더링된다', async ({ page }) => {
await page.goto('/products');
// 정적 UI 요소 검증
await expect(page).toHaveTitle(/상품 목록/);
await expect(page.getByRole('heading', { name: '상품 목록' })).toBeVisible();
await expect(page.getByText('상품명')).toBeVisible();
await expect(page.getByText('가격')).toBeVisible();
});
// API 기본 검증
test('상품 목록 API가 정상 응답한다', async ({ page }) => {
const response = await page.request.get('/api/products');
const json = await response.json();
expect(response.status()).toBe(200);
expect(json.success).toBe(true);
expect(json.data.items.length).toBeGreaterThan(0);
});
// 핵심 비즈니스 로직 상세 검증
test('상품 생성 후 조회할 수 있다', async ({ page }) => {
// 고유한 데이터 생성 (병렬 실행 대비)
const uniqueName = `테스트상품_${Date.now()}`;
// 1. 상품 생성
const createResponse = await page.request.post('/api/products', {
data: {
name: uniqueName,
price: 29900,
stock: 100,
categoryId: 1
}
});
const createJson = await createResponse.json();
expect(createResponse.status()).toBe(201);
expect(createJson.success).toBe(true);
expect(createJson.data.id).toBeDefined();
const productId = createJson.data.id;
// 2. 생성된 상품 조회
const getResponse = await page.request.get(`/api/products/${productId}`);
const getJson = await getResponse.json();
expect(getResponse.status()).toBe(200);
expect(getJson.success).toBe(true);
// 핵심 필드 검증
expect(getJson.data.id).toBe(productId);
expect(getJson.data.name).toBe(uniqueName);
expect(getJson.data.price).toBe(29900);
expect(getJson.data.stock).toBe(100);
});
// 실패 케이스: 존재하지 않는 상품
test('존재하지 않는 상품 조회 시 404 에러를 반환한다', async ({ page }) => {
const response = await page.request.get('/api/products/999999');
const json = await response.json();
expect(response.status()).toBe(404);
expect(json.success).toBe(false);
expect(json.errorCode).toBe('PRODUCT_NOT_FOUND');
expect(json.message).toContain('상품을 찾을 수 없습니다');
});
// 실패 케이스: 잘못된 요청
test('잘못된 파라미터로 상품 생성 시 400 에러를 반환한다', async ({ page }) => {
const response = await page.request.post('/api/products', {
data: {
name: '', // 빈 이름
price: -1000, // 음수 가격
stock: 'invalid' // 잘못된 타입
}
});
const json = await response.json();
expect(response.status()).toBe(400);
expect(json.success).toBe(false);
expect(json.errorCode).toBe('INVALID_REQUEST');
});
// 사용자 플로우 테스트
test('상품 생성 → 페이지에서 확인 → 수정 플로우', async ({ page }) => {
const uniqueName = `플로우테스트_${Date.now()}`;
// 1. 백엔드에서 상품 생성
const createResponse = await page.request.post('/api/products', {
data: { name: uniqueName, price: 50000, stock: 10 }
});
const productId = (await createResponse.json()).data.id;
// 2. 페이지에서 생성된 상품 확인
await page.goto('/products');
await page.getByPlaceholder('상품 검색').fill(uniqueName);
await page.getByRole('button', { name: '검색' }).click();
await expect(page.getByText(uniqueName)).toBeVisible();
await expect(page.getByText('50,000원')).toBeVisible();
// 3. 상품 수정 페이지로 이동
await page.getByRole('link', { name: '수정' }).click();
await expect(page).toHaveURL(new RegExp(`/products/${productId}/edit`));
// 4. 수정 폼 입력
await page.getByLabel('가격').fill('45000');
await page.getByRole('button', { name: '저장' }).click();
// 5. 수정 확인
await expect(page.getByText('상품이 수정되었습니다')).toBeVisible();
// 6. API로 수정 확인
const getResponse = await page.request.get(`/api/products/${productId}`);
const product = (await getResponse.json()).data;
expect(product.price).toBe(45000);
});
});
앞에서 본 코드와 다른 곳만 짚는다.
product.spec.ts— 「이 파일은 테스트다」라는 표시로 이름 끝에.spec.ts를 붙였다test.describe('이름', () => { ... })— 테스트 여러 개를 「상품 관리 E2E 테스트」라는 한 묶음으로 묶는다- 첫 네 테스트 — 앞 섹션들의 기준을 차례로 적용했다. 정적 UI 요소로 렌더링 확인, 목록 API는 가볍게, 상품 생성은 필드까지 상세히, 실패 두 가지는 상태 코드 ·
success·errorCode를 본다 - 마지막 플로우 테스트 — API로 상품을 만들고, 화면에서 검색해 보이는지 확인하고, 수정 화면에서 가격을 바꾼 뒤, 다시 API로 바뀐 값을 확인한다.
getByPlaceholder('상품 검색')은 칸이 비어 있을 때 흐리게 보이는 안내 글자(placeholder)가 「상품 검색」인 입력 칸을 찾는다.new RegExp(...)는 글자로 정규식을 만든다./products/5/edit처럼 상품 번호가 들어간 주소인지 보려고, 번호를 변수로 끼워 넣을 수 있는 이 방법을 썼다
플로우 테스트는 상품 이름(
uniqueName)과 「50,000원」이라는 데이터를 화면에서 확인한다. 앞에서 「바뀌는 데이터는 검증하지 않는다」고 했는데 어긋나 보인다. 다른 점은 이 데이터를 이 테스트가 방금 직접 만들었다는 것이다. 이름은 이 테스트만 쓰는 고유한 값이고 가격도 이 테스트가 정했으니, 다른 테스트나 초기 데이터가 바뀌어도 흔들리지 않는다. 검증하지 않는 것은 「누가 넣었는지 모르는 데이터」다.
결론: 실용적인 테스트 성공 기준 정리
지금까지 정한 것을 모으면 이렇다.
API 테스트 성공 기준
- 기본 검증 (모든 API)
- HTTP 상태 코드 확인
success필드 확인errorCode가 비어 있는지(null) 확인
- 일반 조회 (가벼운 검증)
- 데이터 존재 여부 (
data.items.length > 0) - 배열/객체 타입 확인
- 데이터 존재 여부 (
- 핵심 비즈니스 (상세 검증)
- 핵심 필드 상세 검증
- 데이터 타입 확인
- 생성 시간 등 메타데이터 검증
페이지 테스트 성공 기준
- SSR 렌더링 확인
- 정적 UI 요소가 보이는가
- 페이지 제목과 URL
- 필수 버튼/링크 존재 여부
- 누가 넣었는지 모르는 데이터는 검증하지 않음
- 테스트 안정성 확보
- 유지보수 용이성
- 테스트가 직접 만든 데이터는 검증해도 된다
테스트 격리 전략
- 독립성 보장
- 각 테스트가 필요한 데이터 직접 생성
- 실행 순서와 무관하게 성공
data.sql로 공통 초기 데이터 관리
- 병렬 실행 선택
- 간단한 프로젝트:
workers: 1(순차 실행, 해결책 1) - 대규모 프로젝트: 고유 데이터 전략 + 병렬 실행 (해결책 2)
- 워커마다 전용 사용자 같은 데이터가 꼭 있어야 하면: 워커별 fixture (해결책 3)
- 어디서부터 「대규모」로 볼지는 테스트 수 같은 숫자로 정해 두지 않았다
- 간단한 프로젝트:
- 실패 케이스 필수
- 404, 400, 401 등 HTTP 에러
- 비즈니스 로직 실패 (재고 부족처럼 200 +
success: false) - 로그인 없는 접근 (401). 권한이 모자란 경우(403)는 본문에서 테스트하지 않았다
실무 팁
Mock API는 최소한으로
- 실제 백엔드 테스트가 더 믿을 만하다
- 외부 서비스나 재현하기 어려운 상황에만 쓴다
정적 콘텐츠로 검증
- 누가 넣었는지 모르는 데이터는 테스트를 불안정하게 만든다
- UI 레이블, 버튼 같은 고정된 요소를 검증한다
고유한 데이터 생성
const uniqueName = `test_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
테스트 이름은 명확하게
test('존재하지 않는 상품 조회 시 404 에러를 반환한다', ...);
// 무엇을(What), 언제(When), 기대결과(Then)를 명확히
테스트 이름에 「무엇을 · 어떤 상황에서 · 무엇을 기대하는지」가 다 들어 있으면, 실패 목록에 이름만 떠도 어디가 깨졌는지 짐작할 수 있다.
E2E 테스트 기준은 완벽함보다 유지보수와 신뢰를 고른 결과다
E2E 테스트의 성공 기준을 정하는 일은 업무상 꼭 지켜야 할 것과 기술적 제약 사이에서 균형을 잡는 일이었다. 모든 것을 꼼꼼히 검증하면 작은 변경에도 테스트가 깨지고, 너무 느슨하면 진짜 실패를 놓친다.
그래서 나는 완벽한 테스트보다 이런 테스트를 골랐다.
- 유지보수할 수 있는 테스트
- 믿을 수 있는 테스트
- 실패 원인을 분명히 알려 주는 테스트
참고 자료
- Playwright 공식 문서 - API Testing
- Playwright 공식 문서 - Test Assertions
- Playwright 공식 문서 - Network Mocking
- Playwright 공식 문서 - Parallelism and Sharding
- Playwright 공식 문서 - Test Configuration
- MDN - 401 Unauthorized
자신만의 철학을 만들어가는 중입니다.
댓글남기기