E2E 테스트
단위 테스트와 E2E 테스트의 범위를 나누고, Cypress 16과 타입스크립트로 React 앱의 로그인 흐름을 브라우저에서 검사한 뒤, 재시도하는 단언으로 기다리는 방법과 네트워크 요청을 가로채 응답을 통제하는 방법, 반복할 수 있는 E2E의 조건을 다룹니다.
단위 테스트는 작은 단위를 떼어 내 빠르게 검사하지만, 그 단위들이 실제로 이어져 사용자가 원하는 일을 해내는지는 보여 주지 못합니다. E2E(End-to-End) 테스트는 실제 브라우저로 앱을 열고 사용자처럼 입력하고 클릭해, 화면에서 시작해 서버와 데이터베이스까지 이어진 흐름을 처음부터 끝까지 검사합니다.
이 글은 E2E 테스트 도구인 Cypress로 React 앱의 로그인 흐름을 검사하고, 화면이 바뀌기를 기다리는 방법과 네트워크 요청을 가로채 응답을 통제하는 방법을 봅니다. 다만 예제의 로그인은 클라이언트 안에서 문자열을 비교하고 API 응답은 테스트가 정한 대역이므로, 실제 인증 서버나 데이터베이스와의 연결까지 검사하는 것은 아닙니다.
테스트의 범위 나누기
테스트는 범위가 넓을수록 더 많은 것을 함께 확인하지만, 느리고 실패했을 때 원인을 찾기 어렵습니다. 그래서 종류마다 맡기는 일을 나눕니다.
TEST SCOPE PYRAMID
빠른 계약을 넓게, 비싼 사용자 여정을 좁게 둔다
폭은 테스트 수·비용 배분의 정성적 비유이며 고정 비율이 아닙니다. 빠른 단위 계약을 넓게 두고 실제 경계를 통합·계약 테스트로 확인하며, 비싼 E2E는 중요한 사용자 여정을 고릅니다. 대역 응답은 실제 backend·DB 통합을 검증하지 않습니다.
- 기반 · 단위 테스트
작은 공개 동작·경계·오류를 빠르게 넓게 검사합니다.
- 중간 · 통합·계약
실제 연결된 component와 wire 계약의 일치를 확인합니다.
- 상단 · E2E
중요한 사용자 여정을 선택해 전체 연결 범위를 검증합니다.
그림의 아래쪽인 단위 테스트는 작은 함수와 컴포넌트의 동작, 특이한 입력과 오류를 빠르게 많이 검사합니다. 가운데의 통합 테스트는 실제로 이어진 모듈이나 서버와 데이터베이스가 서로 맞게 동작하는지 확인하고, 맨 위의 E2E 테스트는 회원가입이나 결제처럼 중요한 사용자의 흐름만 골라 전체를 검사합니다. 폭은 테스트의 수와 비용을 어떻게 나눌지에 대한 비유이며, 정해진 비율이 있는 것은 아닙니다.
Cypress 설정
Cypress는 브라우저 안에서 테스트를 실행하며, 요소가 나타날 때까지 자동으로 다시 찾고, 실행 과정을 화면과 스냅샷으로 보여 주는 E2E 테스트 도구입니다. React 컴포넌트와 타입스크립트 통합처럼 Vite의 react-ts 템플릿으로 만든 프로젝트에 설치합니다.
npm install --save-dev cypress
npx cypress open2026년 10월 현재 Cypress는 16이고 Node.js 22 이상을 요구하며, 타입스크립트는 5.x부터 7.x까지를 지원합니다. npx cypress open을 실행하면 Cypress의 창이 열리고, E2E Testing을 고르면 cypress.config.ts와 cypress/support 폴더의 파일을 만들어 줍니다. 만들어진 설정 파일에서 앱의 주소를 정합니다.
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
baseUrl: "http://localhost:5173",
},
});baseUrl은 테스트에서 cy.visit("/")처럼 쓰는 상대 주소의 기준이며, Vite 개발 서버의 기본 주소인 5173 포트를 적었습니다. 테스트 파일은 기본으로 cypress/e2e 폴더의 .cy.ts 파일이고, cypress run으로 실행하다가 실패하면 그 순간의 화면을 저장합니다.
Cypress의 describe, it, expect는 Jest의 전역 함수와 이름이 같아 타입이 부딪힐 수 있으므로, Cypress의 TypeScript 안내는 cypress 폴더에 따로 tsconfig.json을 두라고 권합니다.
{
"compilerOptions": {
"target": "es2023",
"lib": ["es2023", "dom"],
"types": ["cypress", "node"]
},
"include": ["**/*.ts"]
}types를 cypress와 node로 제한하면, 이 폴더의 파일은 Cypress의 전역 타입만 봅니다. Vite 템플릿의 tsconfig.app.json은 src만 포함하므로 앱의 설정과 섞이지 않으며, 루트의 설정이 프로젝트 전체를 포함하는 구조라면 루트에서 cypress 폴더와 cypress.config.ts를 exclude로 빼 둡니다. package.json의 scripts에는 창을 여는 "cy:open": "cypress open"과 창 없이 실행하는 "cy:run": "cypress run"을 더합니다.
E2E 테스트는 앱이 실행 중이어야 합니다. 한 터미널에서 npm run dev로 개발 서버를 띄우고, 다른 터미널에서 npm run cy:open이나 npm run cy:run을 실행합니다.
로그인 흐름 검사하기
검사할 앱은 사용자 이름과 비밀번호가 예제의 값과 같으면 환영 화면을, 다르면 오류 문구를 보여 줍니다. 다음 두 파일 가운데 Login.tsx는 입력값을 상태로 들고 있다가 제출할 때 정해진 값과 비교하고, App.tsx는 로그인한 사용자 이름이 있으면 환영 화면을, 없으면 로그인 폼을 그립니다. 테스트가 찾을 두 입력란에는 username과 password라는 id를 붙여 두었습니다.
import { useState } from "react";
import type { FormEvent } from "react";
export default function Login({ onLogin }: { onLogin: (username: string) => void }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (username === "testuser" && password === "password123") {
onLogin(username);
} else {
setError("Invalid username or password.");
}
};
return (
<form onSubmit={handleSubmit}>
<h2>Login</h2>
<label htmlFor="username">Username</label>
<input id="username" value={username} onChange={(e) => setUsername(e.target.value)} />
<label htmlFor="password">Password</label>
<input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
{error && <p>{error}</p>}
<button type="submit">Login</button>
</form>
);
}import { useState } from "react";
import Login from "./components/Login";
export default function App() {
const [loggedInUser, setLoggedInUser] = useState<string | null>(null);
if (loggedInUser) {
return (
<div>
<h1>Welcome, {loggedInUser}!</h1>
<button onClick={() => setLoggedInUser(null)}>Logout</button>
</div>
);
}
return <Login onLogin={setLoggedInUser} />;
}로그인 상태는 App의 useState에만 있으므로, 페이지를 새로고침하면 다시 로그인 폼으로 돌아갑니다. 다음 테스트의 마지막 예가 이 동작을 확인합니다.
테스트는 사용자가 하는 순서대로 주소를 열고, 입력하고, 버튼을 누른 뒤, 화면에 보이는 결과를 검사합니다.
describe("Login", () => {
beforeEach(() => {
cy.visit("/");
});
it("logs in with valid credentials", () => {
cy.get("#username").type("testuser");
cy.get("#password").type("password123");
cy.contains("button", "Login").click();
cy.contains("h1", "Welcome, testuser!").should("be.visible");
cy.get("form").should("not.exist");
});
it("shows an error with invalid credentials", () => {
cy.get("#username").type("wronguser");
cy.get("#password").type("wrongpass");
cy.contains("button", "Login").click();
cy.contains("Invalid username or password.").should("be.visible");
cy.get("form").should("exist");
});
it("returns to the form after logout and after a full reload", () => {
cy.get("#username").type("testuser");
cy.get("#password").type("password123");
cy.contains("button", "Login").click();
cy.contains("button", "Logout").click();
cy.get("form").should("exist");
cy.get("#username").type("testuser");
cy.get("#password").type("password123");
cy.contains("button", "Login").click();
cy.contains("h1", "Welcome, testuser!").should("be.visible");
cy.reload();
cy.get("#username").should("be.visible");
cy.get("h1").should("not.exist");
});
});cy.get은 CSS 선택자로, cy.contains는 문구로 요소를 찾고, type과 click은 사용자처럼 입력하고 누릅니다. should("be.visible")처럼 should로 적은 단언은 조건이 맞을 때까지 기본 4초 동안 다시 검사하므로, 클릭 뒤에 화면이 바뀌는 것을 따로 기다리지 않아도 됩니다. 선택자는 화면의 문구나 스타일이 바뀌어도 깨지지 않도록 id나 data-cy 같은 테스트용 속성을 쓰는 편이 안정적입니다.
세 번째 테스트는 로그인 상태가 React의 상태로만 존재한다는 것을 보여 줍니다. 로그아웃하면 폼이 다시 나타나고, 로그인한 뒤 cy.reload()로 페이지를 새로 읽으면 상태가 처음의 null로 돌아가 로그인 폼이 보입니다. 이 앱은 서버 세션이나 저장소에 로그인을 남기지 않으므로 이것이 기대한 결과이며, 실제 인증을 붙이면 세션과 쿠키, 만료 정책에 맞게 기대값을 다시 정해야 합니다.
기다리는 방법
E2E 테스트가 불안정해지는 가장 흔한 이유는 기다리는 방법입니다. Cypress에서 cy.get이나 cy.contains 같은 조회와 그 뒤의 should는 조건이 맞을 때까지 다시 시도하지만, .then(() => ...)의 콜백은 한 번만 실행됩니다. 그래서 화면이 준비되었는지는 then 안의 조건문이 아니라 should의 단언으로 확인합니다.
cy.wait(1000)처럼 시간을 정해 기다리는 것도 피합니다. 정한 시간보다 응답이 늦으면 실패하고, 빠르면 그만큼 시간을 버리며, 기다린 뒤에 화면이 준비되었다는 보장도 없습니다. 화면의 결과는 should로, 네트워크 요청은 다음 절의 별칭으로 기다립니다.
네트워크 요청 가로채기
실제 서버 없이 화면의 동작을 검사하거나, 서버 오류처럼 만들기 어려운 상황을 재현하려면 cy.intercept로 브라우저의 요청을 가로채 정해 둔 응답을 돌려줍니다. 예로, /api/users에서 사용자를 받아 보여 주는 목록 화면을 앱에 더합니다.
import { useEffect, useState } from "react";
interface User {
id: number;
name: string;
}
export default function UserList() {
const [users, setUsers] = useState<User[] | null>(null);
useEffect(() => {
let ignore = false;
fetch("/api/users")
.then((response) => response.json())
.then((data: User[]) => {
if (!ignore) setUsers(data);
});
return () => {
ignore = true;
};
}, []);
if (users === null) return <p>Loading users...</p>;
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}src/main.tsx에서 import UserList from "./components/UserList";로 이 컴포넌트를 가져오고, render 안의 <StrictMode> 사이에 있는 <App />을 {window.location.pathname === "/users" ? <UserList /> : <App />}로 바꾸면 /users 주소에서 이 목록이 보입니다. 테스트는 요청이 일어나기 전에 가로채기를 등록하고, 응답을 테스트가 원하는 때에 돌려줍니다.
describe("User list", () => {
it("shows loading and then the users", () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
cy.intercept("GET", "/api/users", (req) =>
gate.then(() => req.reply({ body: [{ id: 1, name: "Mock User 1" }, { id: 2, name: "Mock User 2" }] })),
).as("getUsers");
cy.visit("/users");
cy.contains("Loading users...").should("be.visible");
cy.wrap(null).then(() => release());
cy.wait("@getUsers");
cy.contains("li", "Mock User 1").should("be.visible");
cy.contains("Loading users...").should("not.exist");
});
});cy.intercept의 처리 함수가 Promise를 돌려주면 Cypress는 그것이 끝날 때까지 요청을 붙잡아 둡니다. 그래서 테스트가 release()를 부르기 전까지 응답이 오지 않고, 그동안 Loading users...가 보이는지 확실히 검사할 수 있습니다. 응답을 delay 옵션으로 늦추기만 하면, 검사가 늦게 시작되었을 때 로딩 화면이 이미 사라져 실패할 수 있습니다.
as("getUsers")로 붙인 별칭은 cy.wait("@getUsers")로 그 요청이 끝나기를 기다리게 해 줍니다. 가로채기는 반드시 cy.visit보다 먼저 등록해야 하며, 등록하기 전에 이미 끝난 요청은 잡지 못합니다. 또 cy.intercept는 브라우저가 실제로 보낸 HTTP 요청만 가로채므로, 브라우저의 캐시로 채워진 응답이나 테스트에서 직접 보내는 cy.request()는 대상이 아닙니다.
더 깊이: 가로채기가 여러 개일 때의 순서
같은 요청에 맞는 cy.intercept가 여러 개라면 어느 것이 응답할지가 문제가 됩니다. Cypress의 cy.intercept 문서에 따르면, 기본적으로는 가장 나중에 정의한 경로부터 먼저 검사합니다. 그리고 정해 둔 응답이 있거나 처리 함수가 req.reply()나 req.continue()를 부른 첫 경로에서 검사를 멈춥니다. 그래서 beforeEach에 공통 응답을 등록해 두고, 테스트 안에서 같은 주소에 오류 응답을 다시 등록하면 나중에 등록한 오류 응답이 쓰입니다.
예외는 middleware: true로 등록한 경로입니다. 이런 경로는 다른 모든 경로보다 먼저, 그리고 정의한 순서대로 실행되므로, 응답을 정하지 않고 요청에 헤더를 더하거나 기록만 남기는 일에 알맞습니다. 또 문서는 등록한 가로채기가 테스트마다 시작하기 전에 모두 지워진다고 설명하므로, 한 테스트에서 등록한 가짜 응답이 다음 테스트로 새어 나가지 않습니다.
반복할 수 있는 E2E
E2E 테스트를 믿으려면 같은 조건에서 몇 번을 돌려도 같은 결과가 나와야 합니다.
REPRODUCIBLE E2E
E2E 신뢰도는 환경과 데이터의 반복 가능성에서 나온다
재현 기준입니다. 현재 로그인은 클라이언트 상수 비교이고 users 응답은 intercept 대역입니다. 실제 인증·DB 통합이나 seed/reset·CI는 구현되어 있지 않습니다. 서버 준비·테스트 데이터·요청 범위·실패 증거를 정해 반복합니다.
- Known env
같은 build·설정으로 앱을 먼저 실행하고 readiness를 확인합니다.
- Seed
자신이 소유한 테스트 데이터를 준비합니다. 대역 응답과 실제 DB seed는 구분합니다.
- User journey
핵심 사용자 여정에서 보이는 결과를 검사합니다. matcher를 등록한 뒤 요청을 발생시킵니다.
- Evidence
alias wait·retryable assertion으로 기다리고 지원하는 실패 화면·로그를 남깁니다. 임의 sleep으로 성공을 추정하지 않습니다.
- Diagnosis
최초 불일치를 찾습니다. 캐시 응답과 cy.request는 intercept 대상이 아닙니다.
- Cleanup
정리·reset 후 같은 조건으로 다시 실행합니다. 성공은 선택한 여정과 실제 연결 범위만 검증합니다.
- 핵심 경계
- 진행·선택 경로
그림의 순서대로, 먼저 같은 빌드와 설정으로 앱을 띄우고 준비가 끝났는지 확인합니다. 테스트가 쓸 데이터는 테스트가 직접 만들어 다른 테스트와 나누어 쓰지 않으며, 사용자의 흐름은 화면에 보이는 결과로 검사합니다. 실패하면 화면과 로그를 남겨 처음 어긋난 지점을 찾고, 끝나면 데이터를 정리해 다음 실행이 같은 조건에서 시작하게 합니다.
이 글의 예제는 클라이언트 안의 로그인과 가로챈 응답만 쓰므로, 실제 데이터베이스에 테스트 데이터를 넣고 지우는 일이나 CI에서 실행하는 설정은 구현하지 않았습니다. 테스트가 통과했다는 것은 고른 흐름과 실제로 연결한 범위가 기대대로 동작한다는 뜻이며, 그 밖의 부분까지 보장하는 것은 아닙니다.
정리
- 단위 테스트는 작은 동작을 넓게, 통합 테스트는 실제 연결을, E2E 테스트는 중요한 사용자의 흐름을 좁게 검사합니다.
- Cypress는
cypress폴더에 따로tsconfig.json을 두어, Jest와 같은 이름의 전역 타입이 부딪히지 않게 합니다. - E2E 테스트는 사용자가 하는 순서대로 입력하고 누른 뒤, 화면에 보이는 결과를
should로 검사합니다. should의 단언은 조건이 맞을 때까지 다시 검사하지만then의 콜백은 한 번만 실행되며, 시간을 정해 기다리는 것은 피합니다.cy.intercept는 요청보다 먼저 등록하고, 처리 함수가 돌려주는Promise로 응답의 시점을 통제하며, 별칭으로 요청이 끝나기를 기다립니다.- 반복할 수 있는 E2E는 같은 환경과 테스트가 소유한 데이터에서 시작하고, 실패의 기록을 남기며, 끝나면 정리합니다.
연습 문제
1. 새로고침 뒤의 화면
올바른 계정으로 로그인한 직후와 cy.reload() 뒤의 화면은 각각 무엇인가요? 잘못된 비밀번호를 넣은 테스트가 통과했다면, 서버의 인증 실패까지 검사했다고 말할 수 있나요?
해설 보기
로그인 직후에는 onLogin이 상태를 사용자 이름으로 바꾸어 Welcome, testuser!가 보이고, 새로고침하면 상태가 null로 돌아가 로그인 폼이 보입니다. 잘못된 비밀번호는 Login 안에서 문자열을 비교해 오류 문구를 보여 줄 뿐 서버에 요청하지 않으므로, 이 테스트는 클라이언트의 분기만 검사한 것입니다. 서버의 인증 실패와 세션의 수명은 실제 서버와 연결한 테스트로 따로 검사해야 합니다.
2. 가로채기를 등록하는 순서
cy.visit("/users")를 먼저 하고 그 뒤에 cy.intercept(...).as("getUsers")를 등록한 다음 cy.wait("@getUsers")를 불렀습니다. 어떻게 되나요?
해설 보기
cy.visit을 하는 동안 목록 화면이 이미 /api/users를 요청했으므로, 그 뒤에 등록한 가로채기는 지난 요청을 잡지 못합니다. 그 요청은 실제 개발 서버로 가고, cy.wait("@getUsers")는 새 요청이 오지 않아 시간 초과로 실패합니다. 가로채기와 별칭은 요청을 일으키는 동작보다 먼저 등록합니다.
3. 사라진 로딩 화면
delay: 500으로 응답을 늦추고 Loading users...를 검사했는데, 가끔 실패했습니다. 단언의 시간 제한을 늘리면 해결되나요?
해설 보기
해결되지 않습니다. 시간 제한을 늘리는 것은 앞으로 맞을 수 있는 조건을 더 오래 기다리는 것이지, 이미 사라진 로딩 화면을 되돌리는 것이 아닙니다. 검사가 시작되기 전에 응답이 오면 로딩 화면은 다시 나타나지 않으므로, 이 글처럼 처리 함수가 돌려주는 Promise로 응답을 붙잡아 두고 로딩을 검사한 뒤에 풀어 줍니다.
다음 글 타입스크립트 디버깅 기법에서는 소스맵으로 컴파일된 코드를 원래의 타입스크립트에 맞춰 디버깅하고, 증상에 따라 어떤 도구로 원인을 찾을지 정리합니다.