개발자를 위한 이메일 테스트 완전 가이드
실제 이메일 플로우를 종단 간 테스트 — 인증 코드, 비밀번호 재설정, OTP, 매직 링크. 일회용 메일함과 API만 있으면 됩니다.
이메일은 대부분의 테스트 스위트가 결국 포기하는 단계입니다. 그 직전까지는 모든 것이 내 손안에 있습니다 — 폼을 채우고, 버튼을 누르고, 리다이렉트를 검증합니다. 그런데 거기서 흐름은 애플리케이션을 완전히 떠나, 내가 소유하지 않은 인프라를 지나, 테스트가 볼 수 없는 곳에 도착합니다. 그래서 스위트는 발송 호출을 스텁으로 바꾸고, 앱이 "보내려 했다"는 것만 검증한 뒤 커버했다고 칩니다.
그렇게 빠지는 부분이 정작 실제로 깨지는 부분입니다. 메일이 정말 나갔는지, 템플릿이 제대로 렌더링됐는지, 그리고 그 안에 적힌 코드가 백엔드가 받아들일 코드와 같은지.
이 문서는 그것을 제대로 테스트하기 위한 지도입니다.
먼저, 도구의 절반을 걸러내는 구분
무엇을 고르기 전에 자신이 가진 문제가 둘 중 어느 쪽인지부터 파악하세요. 이 분야의 도구는 깔끔하게 둘로 갈리지만, 각 제품의 마케팅 페이지만 봐서는 그 차이가 드러나지 않습니다.
실제 수신용 받은편지함은 인터넷 어디에서 보낸 메일이든 받을 수 있는 라우팅 가능한 주소를 제공하고, API로 읽거나 webhook으로 밀어 줍니다. 내 코드가 보내지 않은 메일 — 가입 확인, 비밀번호 재설정, 서드파티가 발급한 2FA 코드 — 을 테스트할 수 있는 건 이 종류뿐입니다.
SMTP 캐처는 앱이 바라보도록 설정하는 가짜 SMTP 서버입니다. 내 앱이 보낸 메일을 붙잡아 UI로 보여 줍니다. 로컬 개발 루프나 자사 발송 템플릿을 검증하는 데는 아주 훌륭합니다. 하지만 외부 메일은 받을 수 없습니다. 인터넷 쪽에서 도달할 경로가 아예 없기 때문입니다.
Mailpit, MailHog, MailCatcher, MailDev, 그리고 Mailtrap의 Email Testing 샌드박스는 모두 후자입니다. 이들은 "이메일 테스트 도구" 목록에서 전자와 나란히 소개되지만, 서로 다른 질문에 답한다는 안내는 대개 붙지 않습니다. 한쪽을 다른 쪽 용도로 고르는 것이 이 영역에서 가장 흔한 실수이고, 보통 통합 3일 차에 발견됩니다.
대부분의 팀은 결국 둘 다 쓰게 됩니다. 개발 루프에는 캐처를, 종단 간 CI에는 실제 받은편지함을.
핵심 패턴: 테스트마다 받은편지함 하나
수신 쪽에 관한 한, 이 분야 전체가 규칙 하나로 압축됩니다. 스위트마다가 아니라 테스트마다 새 받은편지함을 만들 것.
스위트 전체가 메일박스 하나를 공유하는 것은, 없애려던 불안정성을 그대로 다시 불러들이는 일입니다. 두 테스트가 병렬로 도는 순간 한쪽이 다른 쪽의 메일을 읽고, 동시 실행에서만 재현되는 실패가 생깁니다. 혼자 돌리면 통과하는데 스위트에 넣으면 깨지는 테스트는, 그것이 커버하는 기능보다 디버깅 비용이 더 듭니다.
순서대로 네 가지 요소가 있습니다.
- 이번 테스트 실행에만 쓰이는 고유한 주소
- 발송을 유발하는 브라우저 또는 API 흐름
- 도착한 메시지 읽기
- 코드나 링크 추출, 그리고 검증
도구 선택이 실제로 작용하는 지점은 1번과 3번뿐이고, 나머지는 평범한 테스트 코드입니다.
동작하는 최소 구현
엔드포인트 두 개면 끝납니다. 주소를 만들고, 무언가 도착할 때까지 폴링합니다.
const API = 'https://moemail.app/api'
const H = { 'X-API-Key': process.env.MAIL_KEY, 'Content-Type': 'application/json' }
// expiryTime 단위는 밀리초입니다. name을 생략하면 로컬 파트가
// 무작위로 생성되므로 병렬 테스트끼리 충돌하지 않습니다.
export async function createInbox() {
const res = await fetch(`${API}/emails/generate`, {
method: 'POST',
headers: H,
body: JSON.stringify({ expiryTime: 3_600_000, domain: 'moemail.app' }),
})
if (!res.ok) throw new Error(`create inbox failed: ${res.status}`)
return res.json() // { id, email }
}
export async function waitForMessage(inboxId, timeoutMs = 30_000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const res = await fetch(`${API}/emails/${inboxId}`, { headers: H })
const { messages } = await res.json()
if (messages?.length) return messages[0]
await new Promise(r => setTimeout(r, 1500))
}
throw new Error(`no email arrived within ${timeoutMs}ms`)
}
돌아오는 메시지는 { id, from_address, to_address, subject, content, html, received_at } 형태이고, content가 일반 텍스트 본문, html이 HTML 파트입니다.
대기는 재시도 횟수가 아니라 데드라인으로 묶으세요. 횟수로 고정해 두면 누군가 간격을 조정하는 순간 실질적인 타임아웃 값이 조용히 달라집니다.
그리고 추출 자체에도 검증을 겁니다.
const code = msg.content.match(/\b\d{6}\b/)?.[0]
expect(code, `no 6-digit code in: ${msg.subject}`).toBeDefined()
정규식이 빗나가면 match(...)[0]은 Cannot read properties of null을 던지고, 스택 트레이스는 정규식을 가리킵니다. 하지만 진짜 문제는 메일 내용이 예상과 달랐다는 것입니다. 실패 메시지에 제목을 넣어 두면 10분짜리 혼란이 리포트 한 번 보는 일로 바뀝니다.
폴링이냐 webhook이냐
폴링은 단순하고, 테스트가 몇 개뿐이라면 충분합니다. webhook이 가져다주는 것은 지연과 낭비되는 요청의 제거이며, 스위트가 "1.5초 간격 × 테스트 100개"가 실제 벽시계 시간으로 느껴질 만큼 커졌을 때부터 의미가 생깁니다.
어느 쪽이든 타임아웃은 짧게, 그러나 정직하게 잡으세요. 테스트 환경의 트랜잭션 메일은 몇 초 안에 도착해야 하고, 30초면 여유가 충분합니다. 90초가 필요한 스위트가 있다면 그건 전달 문제를 고치는 대신 가리고 있는 것입니다.
HTML 파트도 읽으세요
미리 피해 둘 만한 함정이 하나 있습니다. 템플릿이 인증 링크를 HTML 본문에만 넣는다면 content를 읽어서는 아무것도 찾지 못합니다. 일반 텍스트 대체본이 HTML과 어긋나는 경우는 생각보다 훨씬 흔하고, 사용자가 실제로 누르는 것은 HTML 쪽입니다. content에 링크가 없으면 msg.html을 읽고, 되도록 사용자가 실제로 받는 쪽을 테스트하세요.
도구 고르기
유료 서비스를 검토 중이라면 비교할 것은 기능 개수가 아닙니다. 실제 수신이 되는지, 무료 등급으로 실제로 무엇을 할 수 있는지, 그리고 받은편지함 단위 과금에서 벗어나기 위해 직접 호스팅할 수 있는지 — 이 세 가지입니다.
이를 자세히 다룬 글이 두 편 있고, 상용 제품을 사는 편이 맞는 경우도 분명히 적어 두었습니다. Mailosaur 대안은 위의 "수신 / 캐처" 경계로 이 분야 전체를 정리한 글이고, MailSlurp 대안은 테스트마다 받은편지함을 만드는 것이 올바른 방식일 때 받은편지함 과금이 무엇을 뜻하는지 계산해 본 글입니다.
MoeMail의 자리
MoeMail은 실제 수신용 받은편지함입니다. 라우팅 가능한 주소, 키 인증이 붙은 OpenAPI, 그리고 webhook을 제공합니다. 오픈소스이고 Cloudflare에 직접 호스팅하면 커스텀 도메인을 쓰면서 외부 쿼터에서도 자유로워집니다.
못 하는 것도 솔직히 적습니다. SMS·전화 OTP 테스트는 없고, 메일 클라이언트별 렌더링 미리보기도 없으며, 바로 쓸 수 있는 프레임워크 통합도 유료 제품보다 훨씬 적습니다 — HTTP 호출은 직접 짜야 하고, 그게 위의 스무 줄입니다. 같은 도구 안에서 SMS까지 필요하다면 Mailosaur를 사세요. 이건 사소한 거칠음이 아니라 진짜 빈틈입니다.
프로필에서 API 키를 받아 OpenAPI 문서를 읽어 보거나, 메일박스를 만들어 데이터 모양을 확인해 보세요.
아래의 각 가이드는 이 패턴을 구체적인 프레임워크·플로우·언어·CI 환경에 적용한 것입니다.