2025년 8월 09일·5분 읽기

Go REST 핸들러 테스트: httptest와 테이블 기반 검사

httptest와 테이블 기반 케이스로 Go REST 핸들러를 테스트하면 인증, 검증, 상태 코드, 엣지 케이스를 릴리스 전에 반복 가능하게 확인할 수 있습니다.

Go REST 핸들러 테스트: httptest와 테이블 기반 검사

릴리스 전에 확실히 해두어야 할 것들

REST 핸들러는 컴파일되고 빠른 수동 점검을 통과해도 프로덕션에서 실패할 수 있습니다. 대부분의 실패는 문법 문제가 아니라 계약 문제입니다: 핸들러가 거부해야 할 것을 받아들이거나, 잘못된 상태 코드를 반환하거나, 오류에서 세부 정보를 누설합니다.

수동 테스트가 도움이 되긴 하지만 엣지 케이스와 회귀를 놓치기 쉽습니다. 보통 행복 경로(happy path)와 하나의 명백한 오류만 확인하고 넘어갑니다. 그러다 검증이나 미들웨어의 작은 변경이 조용히 예상하던 동작을 깨뜨립니다.

핸들러 테스트의 목표는 간단합니다: 핸들러가 보장하는 것을 반복 가능하게 만드는 것. 여기엔 인증 규칙, 입력 검증, 예측 가능한 상태 코드, 클라이언트가 안전하게 의존할 수 있는 오류 바디가 포함됩니다.

Go의 httptest 패키지는 실제 서버를 띄우지 않고 핸들러를 직접 실행할 수 있으므로 훌륭한 선택입니다. HTTP 요청을 만들고 핸들러에 전달한 뒤 응답 본문, 헤더, 상태 코드를 검사합니다. 테스트는 빠르고 격리되며 커밋마다 실행하기 쉽습니다.

릴리스 전에 다음을 "확실히 알고" 있어야 합니다(그냥 바라지 말고):

  • 누락된 토큰, 잘못된 토큰, 잘못된 역할에 대해 인증 동작이 일관적인가
  • 입력이 검증되는가: 필수 필드, 타입, 범위, (엄격하게 처리하면) 알 수 없는 필드
  • 상태 코드가 계약과 일치하는가(예: 401 vs 403, 400 vs 422)
  • 오류 응답이 안전하고 일관적인가(스택 트레이스 없음, 항상 같은 형태)
  • 타임아웃, 하위 시스템 실패, 빈 결과 등 비행복 경로를 처리하는가

예를 들어 “티켓 생성(Create ticket)” 엔드포인트는 관리자가 완벽한 JSON을 보냈을 때는 작동할 수 있습니다. 테스트는 만료된 토큰, 클라이언트가 실수로 보낸 추가 필드, 음수 우선순위, 또는 종속성이 실패했을 때의 “찾을 수 없음(not found)”과 “내부 오류(internal error)”의 차이를 잡아냅니다.

각 엔드포인트의 계약 정의

테스트를 쓰기 전에 핸들러가 무엇을 보장하는지 적어두세요. 명확한 계약은 테스트를 집중시켜주고 테스트가 코드가 "의도한" 것을 추측하지 않게 합니다. 또한 내부를 바꿔도 동작을 유지한다면 리팩터가 안전해집니다.

입력으로 시작하세요. 각 값이 어디서 오고 무엇이 필수인지 구체적으로 적으세요. 엔드포인트는 경로에서 id를 받고, 쿼리 문자열에서 limit을 받고, Authorization 헤더와 JSON 바디를 받을 수 있습니다. 허용 형식, 최소/최대 값, 필수 필드, 항목이 없을 때의 동작 등 중요한 규칙을 적으세요.

그다음 출력물을 정의하세요. "JSON을 반환한다"에서 멈추지 마세요. 성공의 모습, 중요한 헤더, 오류의 형태를 결정하세요. 클라이언트가 안정적인 오류 코드와 예측 가능한 JSON 형태를 의존한다면 그것도 계약의 일부로 취급하세요.

실용적인 체크리스트:

  • 입력: 경로/쿼리 값, 필수 헤더, JSON 필드, 검증 규칙
  • 출력: 상태 코드, 응답 헤더, 성공 및 오류의 JSON 형태
  • 부작용: 어떤 데이터가 변경되는지, 무엇이 생성되는지
  • 종속성: 데이터베이스 호출, 외부 서비스, 현재 시간, 생성된 ID

또한 핸들러 테스트의 범위를 결정하세요. 핸들러 테스트는 HTTP 경계에서 가장 강합니다: 인증, 파싱, 검증, 상태 코드, 오류 바디. 더 깊은 관심사는 통합 테스트로 밀어넣으세요: 실제 데이터베이스 쿼리, 네트워크 호출, 전체 라우팅 등.

백엔드가 생성되는 경우(예: AppMaster가 Go 핸들러와 비즈니스 로직을 생성하는 경우) 계약 우선(contract-first) 접근은 더 유용합니다. 코드를 재생성해도 각 엔드포인트의 공개 동작이 동일한지 검증할 수 있습니다.

최소한의 httptest 하니스 설정

좋은 핸들러 테스트는 실제 요청을 보내는 느낌을 주되 서버를 띄우지 않습니다. Go에서는 보통 httptest.NewRequest로 요청을 만들고, httptest.NewRecorder로 응답을 캡처한 뒤 핸들러를 호출합니다.

핸들러를 직접 호출하면 테스트가 빠르고 집중적입니다. 인증 검사, 검증 규칙, 상태 코드, 오류 바디 등 핸들러 내부 동작을 검증할 때 이상적입니다. 경로 매개변수, 라우트 매칭, 미들웨어 순서에 계약이 의존하면 테스트에서 라우터를 사용하는 것이 도움이 됩니다. 먼저 직접 호출로 시작하고 필요할 때 라우터를 추가하세요.

헤더는 많은 사람들보다 더 중요합니다. 누락된 Content-Type은 핸들러가 바디를 읽는 방식을 바꿀 수 있습니다. 실패가 로직이 아닌 테스트 설정 문제를 가리키도록 모든 경우에 예상하는 헤더를 설정하세요.

재사용 가능한 최소 패턴은 다음과 같습니다:

req := httptest.NewRequest(http.MethodPost, "/v1/widgets", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
rec := httptest.NewRecorder()

handler.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()

일관된 단언(assertion)을 유지하려면 응답 본문을 읽고 디코드하는 작은 헬퍼 하나를 사용하는 것이 좋습니다. 대부분의 테스트에서는 먼저 상태 코드를 확인하세요(그래야 실패 원인을 빠르게 파악할 수 있습니다), 그다음 약속한 주요 헤더(종종 Content-Type), 마지막으로 본문을 검사하세요.

백엔드가 생성되는 경우(예: AppMaster가 생성한 Go 백엔드 포함)에도 이 하니스는 그대로 적용됩니다. 여러분이 테스트하는 것은 HTTP 계약이며, 그 뒤의 코드 스타일이 아닙니다.

읽기 쉬운 테이블 기반 케이스 설계

테이블 기반 테스트는 각 케이스가 작은 이야기처럼 읽힐 때 가장 잘 작동합니다: 당신이 보낸 요청과 기대하는 응답. 테이블을 스캔하면 파일을 여기저기 찾아보지 않아도 커버리지를 이해할 수 있어야 합니다.

좋은 케이스는 보통 명확한 이름, 요청(method, path, headers, body), 기대 상태 코드, 응답 검사로 구성됩니다. JSON 바디의 경우 계약에서 엄격한 출력을 요구하지 않는다면 전체 JSON 문자열을 비교하기보다 몇 개의 안정적인 필드(예: 오류 코드)만 단언하는 편이 낫습니다.

재사용 가능한 단순한 케이스 구조

케이스 구조체는 집중되게 유지하세요. 일회성 설정은 헬퍼로 빼서 테이블을 작게 유지하세요.

type tc struct {
	name       string
	method     string
	path       string
	headers    map[string]string
	body       string
	wantStatus int
	wantBody   string // substring or compact JSON
}

다른 입력에 대해서는 차이를 한눈에 보여주는 작은 바디 문자열을 사용하세요: 유효한 페이로드, 필드가 하나 빠진 경우, 잘못된 타입, 빈 문자열 등. 테이블에 많은 포매팅된 JSON을 넣으면 금방 시끄러워집니다.

토큰 생성, 공통 헤더, 기본 바디 같은 반복되는 설정은 newRequest(tc)baseHeaders() 같은 헬퍼로 빼세요.

하나의 테이블이 너무 많은 아이디어를 섞는다면 분리하세요. 성공 경로용 테이블과 오류 경로용 테이블로 나누면 읽기와 디버깅이 더 쉽습니다.

인증 검사: 보통 건너뛰는 케이스들

Export real source code
완전한 제어가 필요할 때 Go, Vue3, Kotlin 또는 SwiftUI 소스 코드를 가져가세요.
소스 내보내기

인증 테스트는 보통 행복 경로에서는 괜찮아 보이지만 프로덕션에서는 한 "작은" 케이스 때문에 실패합니다. 인증을 계약처럼 다루세요: 클라이언트가 보내는 것, 서버가 반환하는 것, 절대 노출하면 안 되는 것.

토큰 존재와 유효성부터 시작하세요. 보호된 엔드포인트는 헤더가 없을 때와 잘못되었을 때 다른 동작을 해야 합니다. 단명 토큰을 사용한다면 만료도 테스트하세요(만료를 반환하는 검증기를 주입해 시뮬레이션해도 됩니다).

대부분의 간극은 다음 케이스로 커버됩니다:

  • Authorization 헤더 없음 -> 401과 안정적인 오류 응답
  • 잘못된 헤더(잘못된 접두사) -> 401
  • 잘못된 토큰(잘못된 서명) -> 401
  • 만료된 토큰 -> 401(또는 선택한 코드)과 예측 가능한 메시지
  • 유효한 토큰이지만 잘못된 역할/권한 -> 403

401과 403의 구분은 중요합니다. 호출자가 인증되지 않았을 때는 401을 사용하세요. 인증되었지만 허용되지 않을 때는 403을 사용하세요. 이 둘을 흐리게 하면 클라이언트가 불필요하게 재시도하거나 잘못된 UI를 보여줄 수 있습니다.

역할 검사만으로는 "사용자 소유" 엔드포인트(예: GET /orders/{id})에 충분하지 않습니다. 소유권을 테스트하세요: 사용자 A가 사용자 B의 주문을 유효한 토큰으로 보더라도 볼 수 없어야 합니다. 이는 깔끔한 403(또는 존재를 숨기려면 404)이어야 하고, 바디는 아무 것도 누설하지 않아야 합니다. "주문이 사용자 42에 속한다" 같은 힌트를 주지 마세요.

입력 규칙: 검증, 거부, 명확한 설명

사전 릴리스 버그의 많은 부분은 입력 관련입니다: 필드 누락, 잘못된 타입, 예기치 않은 형식, 너무 큰 페이로드 등.

핸들러가 받는 모든 입력을 명확히 하세요: JSON 몸체 필드, 쿼리 파라미터, 경로 파라미터. 각 항목에 대해 필수일 때, 빈 값일 때, 잘못되었을 때, 범위를 벗어났을 때의 동작을 결정하고, 잘못된 입력을 조기에 거부하고 동일한 유형의 오류를 항상 반환한다는 것을 증명하는 케이스를 작성하세요.

작은 검증 케이스 집합으로 대부분의 위험을 커버할 수 있습니다:

  • 필수 필드: 누락 vs 빈 문자열 vs null(허용하면)
  • 타입과 형식: 숫자 vs 문자열, 이메일/날짜/UUID 형식, 불리언 파싱
  • 크기 제한: 최대 길이, 최대 항목 수, 페이로드 과다
  • 알 수 없는 필드: 무시 vs 거부(엄격 디코딩 시)
  • 쿼리 및 경로 파라미터: 누락, 파싱 불가, 기본 동작

예: POST /users 핸들러가 { "email": "...", "age": 0 }을 받는다면 email 누락, email123인 경우, email이 "not-an-email"인 경우, age-1인 경우, age"20"인 경우를 테스트하세요. 엄격한 JSON을 요구한다면 { "email":"[email protected]", "extra":"x" }가 실패하는지도 확인하세요.

검증 실패는 예측 가능해야 합니다. 검증 오류에 대해 상태 코드를 정하고(팀마다 400 또는 422를 선택함) 오류 바디 형태를 일관되게 유지하세요. 테스트는 상태 코드와 실패한 정확한 입력을 가리키는 메시지(또는 details 필드)를 단언해야 합니다.

상태 코드와 오류 바디: 예측 가능하게 만들기

Deploy where you run
준비되면 AppMaster Cloud, AWS, Azure 또는 Google Cloud에 앱을 배포하세요.
앱 배포

API 실패가 단순하고 일관적일수록 핸들러 테스트가 쉬워집니다. 모든 오류는 명확한 상태 코드에 매핑되고 같은 JSON 형태를 반환하길 원합니다.

오류 유형과 HTTP 상태 코드 간의 작은 합의를 먼저 정하세요:

  • 400 Bad Request: 잘못된 JSON, 필수 쿼리 파라미터 누락
  • 404 Not Found: 리소스 ID가 없음
  • 409 Conflict: 고유 제약 또는 상태 충돌
  • 422 Unprocessable Entity: 유효한 JSON이지만 비즈니스 규칙 위반
  • 500 Internal Server Error: 예상치 못한 실패(DB 다운, nil 포인터, 제3자 장애)

그다음 오류 바디를 안정적으로 유지하세요. 메시지 텍스트가 나중에 바뀌어도 클라이언트가 의존할 수 있는 예측 가능한 필드가 있어야 합니다:

{ "code": "user_not_found", "message": "User was not found", "details": { "id": "123" } }

테스트에서는 형태를 단언하세요, 단순히 상태 라인만이 아니라. 흔한 실패는 오류 시 HTML, 일반 텍스트, 또는 빈 바디를 반환하는 것으로, 이는 클라이언트를 깨뜨리고 버그를 숨깁니다.

오류 응답에 대한 헤더와 인코딩도 테스트하세요:

  • Content-Typeapplication/json인지(캐릭터셋을 설정한다면 일관된지)
  • 실패 시에도 바디는 유효한 JSON인지
  • code, message, details가 존재하는지(빈 객체여도 무작위일 수는 없음)
  • 패닉과 예기치 않은 오류는 스택 트레이스를 누설하지 않고 안전한 500을 반환하는지

recover 미들웨어를 추가했다면 패닉을 강제하고 깨끗한 JSON 오류 응답을 받는지 확인하는 테스트를 포함하세요.

엣지 케이스: 실패, 시간, 비행복 경로

Add auth without guesswork
내장된 인증 모듈을 사용하고 일관된 계약으로 401과 403 동작을 검증하세요.
인증 추가

행복 경로 테스트는 핸들러가 작동함을 증명합니다. 엣지 케이스 테스트는 세상이 혼란스러울 때도 동작을 유지함을 증명합니다.

핸들러가 데이터베이스, 캐시, 외부 API를 호출한다면 그 계층들이 제어할 수 없는 방식으로 오류를 반환할 때 핸들러가 어떻게 동작하는지 확인하고 싶습니다.

엔드포인트별로 적어도 한 번은 다음을 시뮬레이션해 볼 가치가 있습니다:

  • 다운스트림 호출 타임아웃(context deadline exceeded)
  • 스토리지에서 기대한 데이터가 없음
  • 생성 시 고유 제약 위반(중복 이메일, 중복 슬러그)
  • 네트워크 또는 전송 오류(연결 거부, broken pipe)
  • 예기치 않은 내부 오류(일반적인 "문제가 발생했습니다")

테스트를 안정적으로 유지하려면 실행 간에 달라질 수 있는 모든 것을 제어하세요. 플래키한 테스트는 없는 것보다 더 해로워서 사람들이 실패를 무시하게 만듭니다.

시간과 무작위성을 예측 가능하게 만들기

핸들러가 time.Now()나 ID, 무작위 값을 사용한다면 이를 주입하세요. 핸들러나 서비스에 시계 함수와 ID 생성기를 전달하세요. 테스트에서는 고정 값을 반환해 정확한 JSON 필드와 헤더를 단언할 수 있게 하세요.

작은 페이크(fakes)를 사용하고 "부작용 없음"을 단언하기

전체 목(mock)보다 작은 페이크나 스텁을 선호하세요. 페이크는 호출을 기록하고 실패 후에 아무 일도 일어나지 않았다는 것을 단언하게 합니다.

예를 들어 "사용자 생성" 핸들러에서 데이터베이스 삽입이 고유 제약 오류로 실패하면 상태 코드가 올바르고 오류 바디가 안정적이며 환영 이메일이 전송되지 않았음을 단언하세요. 페이크 메일러는 카운터(sent=0)를 노출해 실패 경로에서 이메일이 트리거되지 않았음을 증명할 수 있습니다.

핸들러 테스트를 신뢰할 수 없게 만드는 일반적인 실수

핸들러 테스트는 종종 잘못된 이유로 실패합니다. 테스트에서 만든 요청이 실제 클라이언트 요청과 모양이 다릅니다. 이는 시끄러운 실패와 잘못된 확신으로 이어집니다.

한 가지 흔한 문제는 핸들러가 기대하는 헤더 없이 JSON을 보내는 것입니다. 코드가 Content-Type: application/json을 확인하면 이를 빼먹으면 핸들러가 JSON 디코딩을 건너뛰거나 다른 상태 코드를 반환하거나 프로덕션에서는 발생하지 않는 분기(branch)를 타게 됩니다. 인증도 마찬가지입니다: Authorization 헤더가 없다는 것과 잘못된 토큰을 보냈다는 것은 다른 경우여야 합니다.

또 다른 함정은 전체 JSON 응답을 원시 문자열로 단언하는 것입니다. 필드 순서, 공백, 새 필드로 인한 작은 변경이 테스트를 깨트리며 API가 여전히 올바른 경우에도 실패하게 합니다. 바디를 구조체나 map[string]any로 디코딩한 뒤 중요한 것들(상태, 오류 코드, 메시지, 몇몇 핵심 필드)만 단언하세요.

테스트는 또한 케이스가 가변 상태를 공유할 때 신뢰할 수 없게 됩니다. 동일한 인메모리 스토어, 전역 변수, 싱글턴 라우터를 테이블 로우 간에 재사용하면 데이터가 새어나갑니다. 각 테스트 케이스는 깨끗하게 시작하거나 t.Cleanup에서 상태를 리셋하세요.

취약한 테스트를 만드는 패턴들:

  • 실제 클라이언트가 사용하는 동일한 헤더와 인코딩 없이 요청을 만드는 것
  • 전체 JSON 문자열을 단언하는 것 대신 디코딩해 필드를 확인하지 않는 것
  • 테스트 케이스 간에 공유되는 데이터베이스/캐시/전역 핸들러 상태 재사용
  • 인증, 검증, 비즈니스 로직 단언을 하나의 지나치게 큰 테스트에 넣는 것

각 테스트를 집중시키세요. 하나의 케이스가 실패하면 그것이 인증 때문인지, 입력 규칙 때문인지, 오류 포맷팅 때문인지 몇 초 안에 알아야 합니다.

재사용 가능한 릴리스 전 체크리스트

Connect common integrations
Stripe 결제, 메시징, OpenAI 통합을 추가하고 API 동작을 일관되게 유지하세요.
통합 기능 살펴보기

배포 전에 테스트는 두 가지를 증명해야 합니다: 엔드포인트가 계약을 따르고, 실패 시 안전하고 예측 가능한 방식으로 실패한다는 것.

다음 항목들을 테이블 기반 케이스로 실행하고 각 케이스가 응답과 부작용을 모두 단언하게 하세요:

  • 인증: 토큰 없음, 잘못된 토큰, 잘못된 역할, 올바른 역할(그리고 "잘못된 역할" 케이스가 세부 정보를 누설하지 않는지 확인)
  • 입력: 필수 필드 누락, 잘못된 타입, 경계 크기(min/max), 거부하려는 알 수 없는 필드
  • 출력: 상태 코드, 주요 헤더(예: Content-Type), 필수 JSON 필드, 일관된 오류 형태
  • 종속성: 하나의 다운스트림 실패(DB, 큐, 결제, 이메일)를 강제하고 안전한 메시지를 검증하며 부분적 쓰기가 없는지 확인
  • 멱등성(Idempotency): 동일한 요청을 반복하거나(또는 타임아웃 후 재시도) 중복 생성이 발생하지 않는지 확인

그 후, 일반적으로 건너뛰어지는 간단한 단언을 하나 추가하세요: 핸들러가 만지면 안 되는 것을 건드리지 않았는지 확인합니다. 예를 들어 검증 실패 케이스에서는 레코드가 생성되지 않았고 이메일이 전송되지 않았음을 검증하세요.

AppMaster 같은 도구로 API를 구성하더라도 이 동일한 체크리스트가 적용됩니다. 핵심은 동일합니다: 공개 동작이 안정적으로 유지된다는 것을 증명하는 것입니다.

예: 한 엔드포인트, 작은 테이블, 그리고 잡아내는 것들

간단한 엔드포인트가 있다고 가정합시다: POST /login. emailpassword를 가진 JSON을 받고, 성공 시 토큰과 함께 200을 반환하고, 잘못된 입력은 400, 잘못된 자격증명은 401, 인증 서비스가 다운이면 500을 반환합니다.

다음과 같은 컴팩트한 테이블은 프로덕션에서 주로 깨지는 대부분의 경우를 커버합니다.

func TestLoginHandler(t *testing.T) {
	// Fake dependency so we can force 200/401/500 without hitting real systems.
	auth := &FakeAuth{ /* configure per test */ }
	h := NewLoginHandler(auth)

	tests := []struct {
		name       string
		body       string
		authHeader string
		setup      func()
		wantStatus int
		wantBody   string
	}{
		{"success", `{"email":"[email protected]","password":"secret"}`, "", func() { auth.Mode = "ok" }, 200, `"token"`},
		{"missing password", `{"email":"[email protected]"}`, "", func() { auth.Mode = "ok" }, 400, "password"},
		{"bad email format", `{"email":"not-an-email","password":"secret"}`, "", func() { auth.Mode = "ok" }, 400, "email"},
		{"invalid JSON", `{`, "", func() { auth.Mode = "ok" }, 400, "invalid JSON"},
		{"unauthorized", `{"email":"[email protected]","password":"wrong"}`, "", func() { auth.Mode = "unauthorized" }, 401, "unauthorized"},
		{"server error", `{"email":"[email protected]","password":"secret"}`, "", func() { auth.Mode = "error" }, 500, "internal"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			tt.setup()
			req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(tt.body))
			req.Header.Set("Content-Type", "application/json")
			if tt.authHeader != "" {
				req.Header.Set("Authorization", tt.authHeader)
			}

			rr := httptest.NewRecorder()
			h.ServeHTTP(rr, req)

			if rr.Code != tt.wantStatus {
				t.Fatalf("status = %d, want %d, body=%s", rr.Code, tt.wantStatus, rr.Body.String())
			}
			if tt.wantBody != "" && !strings.Contains(rr.Body.String(), tt.wantBody) {
				t.Fatalf("body %q does not contain %q", rr.Body.String(), tt.wantBody)
			}
		})
	}
}

'missing password' 케이스를 끝에서 끝까지 따라가 보면: email만 있는 바디를 보내고 Content-Type을 설정한 뒤 ServeHTTP로 실행하고 400password를 명확히 가리키는 오류를 단언합니다. 이 단일 케이스로 디코더, 검증기, 오류 응답 형식이 함께 작동함을 증명합니다.

API 계약, 인증 모듈, 통합을 표준화하는 더 빠른 방법을 원한다면 AppMaster (appmaster.io)는 그런 목적을 위해 설계되어 있습니다. 그럼에도 불구하고 이러한 테스트는 클라이언트가 의존하는 동작을 고정(lock in)하기 때문에 계속 유용합니다.

쉬운 시작
멋진만들기

무료 요금제로 AppMaster를 사용해 보세요.
준비가 되면 적절한 구독을 선택할 수 있습니다.

시작하다