REST API 테스트 설계 완전 가이드|pytest × requests로 정상 계통・이상 계통・인증 테스트를 구현

테스트 자동화

REST API 테스트에서 가장 어려운 것은 「어디까지 테스트해야 하는가」입니다.

특히 QA 엔지니어 초보자는 다음과 같은 의문으로 헤매기 쉽습니다.

  • 정상 계통만으로 충분한가?
  • 인증・이상 계통은 어디까지 필요한가?
  • API 테스트 케이스를 어떻게 설계해야 하는가?

이 글에서는 REST API 테스트의 설계 4축(정상 계통・이상 계통・인증・경계값)을 pytest × requests의 구현 코드 付로 체계적으로 해설합니다.

이 글에서 알 수 있는 것

  • ✅ REST API 테스트의 설계 4축(정상 계통・이상 계통・인증・경계값)
  • ✅ pytest × requests의 구현 패턴(코드 付)
  • ✅ API 테스트 케이스의 작성 방법과 템플릿
  • ✅ 인증・경계값・이상 계통 테스트의 사고방식
  • ✅ 현장에서 자주 있는 실패 패턴과 대책

⚠️ 코드 샘플에 대하여

이 글의 샘플 URL에는 jsonplaceholder.typicode.com(학습용 페이크 API)를 사용하고 있습니다. 실제로는 인증・유효성 검사・DELETE의 영구 삭제 등은 구현되어 있지 않습니다. 글 전체를 「실무 API를 상정한 테스트 설계 예시」로서 읽어 주세요.

API 테스트는 일반적으로 E2E 테스트보다 고속・안정적으로 실행할 수 있어 CI/CD와의 친화성이 매우 높습니다. 이 글의 4축으로 테스트 설계를 체계화함으로써 버그의 조기 발견과 보수성 향상을 실현할 수 있습니다.

📌 이런 분께 추천합니다

  • REST API 테스트를 시작하고 싶지만 설계 방법을 모르는 분
  • pytest × requests로의 API 테스트 구현을 배우고 싶은 분
  • 정상 계통만이 아니라 이상 계통・인증・경계값도 망라하고 싶은 분
  • 테스트 자동화 로드맵의 API 테스트(STEP 4)를 학습 중인 분

✅ 이 글을 읽으면 얻을 수 있는 것

  • REST API 테스트 설계 4축의 전체 그림을 알 수 있다
  • pytest × requests를 사용한 구현 패턴을 코드 付로 알 수 있다
  • 실무에서 사용할 수 있는 API 테스트 케이스 설계의 사고방식이 몸에 밴다

👤 이 글을 쓴 사람

QA 엔지니어・테스트 자동화 엔지니어로서 15년 이상의 실무 경험을 가진 Yoshi가 집필. REST API 테스트는 실무 프로젝트에서 매일 실시하고 있으며 이 글에서 소개하는 패턴은 팀의 표준으로 사용하고 있습니다. 구현 코드는 GitHub에 공개 중입니다: github.com/YOSHITSUGU728/automated-testing-portfolio

REST API 테스트란?E2E 테스트와의 차이

REST API 테스트란 API 엔드포인트에 대해 HTTP 리퀘스트를 보내 리스폰스가 기대한 대로인지를 검증하는 테스트입니다. 브라우저를 사용하지 않고 HTTP 리퀘스트를 직접 보내기 때문에 E2E 테스트보다 훨씬 고속입니다.

비교 항목API 테스트E2E(Selenium)테스트
실행 속도⚡ 수초🐢 수분
안정성✅ 높다(UI 변경의 영향 없음)⚠️ UI 변경으로 깨지기 쉽다
환경브라우저 불필요브라우저 필수
테스트 범위비즈니스 로직・데이터 정합성유저 조작 플로우 전체
CI/CD와의 친화성✅ 매우 높다⚠️ 헤드리스 설정이 필요
💡 테스트 피라미드의 사고방식:이상적인 테스트 구성은 「단위 테스트(많음)→ API 테스트(중간)→ E2E 테스트(적음)」의 피라미드형입니다. API 테스트를 두텁게 함으로써 고속이면서 안정된 테스트 스위트를 실현할 수 있습니다.

REST API 테스트 설계의 4축이란?

REST API 테스트는 이하의 4축으로 테스트 케이스를 설계합니다. 이 4축을 망라함으로써 테스트의 누락을 방지하고 품질을 망라적으로 담보할 수 있습니다.

🎯 REST API 테스트 설계의 4축

정상 계통 테스트올바른 리퀘스트로 기대한 대로의 리스폰스가 반환되는가
이상 계통 테스트부정한 리퀘스트로 적절한 에러가 반환되는가
인증・인가 테스트인증이 필요한 엔드포인트에 올바르게 액세스 제어되어 있는가
경계값・엣지 케이스극단적인 값・공백값・형식 위반으로 올바르게 처리되는가

STEP 1:환경 구축——pytest × requests의 셋업

pip install requests pytest pytest-html
💡 pytest-html란:테스트 결과를 HTML 형식의 리포트로 출력할 수 있는 플러그인입니다. 실행 예:pytest --html=report.html → report.html 이 생성됩니다.

프로젝트 구성:

api-test-project/
├── tests/
│   ├── conftest.py         # base_url・인증 토큰・공통 fixture
│   ├── utils.py            # safe_json 헬퍼
│   ├── test_users.py       # 유저 API 테스트
│   ├── test_posts.py       # 투고 API 테스트
│   └── test_auth.py        # 인증 API 테스트
├── requirements.txt
└── pytest.ini

conftest.py

# tests/conftest.py
import pytest
import requests

@pytest.fixture(scope="session")
def base_url():
    """베이스 URL을 fixture화(환경별로 전환하기 쉽다)"""
    # ※ 샘플 URL입니다. 실제 API로 변경하세요.
    return "https://jsonplaceholder.typicode.com"

@pytest.fixture(scope="session")
def auth_headers():
    """인증 헤더를 fixture화"""
    return {
        "Authorization": "Bearer YOUR_TOKEN_HERE",
        "Content-Type": "application/json"
    }

class TimeoutSession(requests.Session):
    """디폴트 timeout을 설정한 Session.
    requests는 Session 단체로 timeout을 설정할 수 없기 때문에 wrapper로 실현합니다.
    """
    def request(self, *args, **kwargs):
        # connect timeout=3초 / read timeout=10초
        # timeout=None 을 건네면 개별로 무효화 가능
        kwargs.setdefault("timeout", (3, 10))
        return super().request(*args, **kwargs)

@pytest.fixture(scope="session")
def api_session():
    """requests의 Session을 fixture화(접속 재사용+디폴트 timeout 설정)

    requests.get() 을 직접 사용할 수도 있지만,
    많은 프로젝트에서 Session으로 접속을 재사용하는 케이스가 일반적입니다.
    """
    session = TimeoutSession()
    session.headers.update({"Content-Type": "application/json"})
    yield session
    session.close()

tests/utils.py — safe_json 헬퍼

💡 safe_json 헬퍼와 raise_for_status() 의 사용 구분

실무에서는 safe_jsontests/utils.py 에 분리하면 재사용하기 쉬워집니다.

# tests/utils.py
import pytest

def safe_json(response):
    """JSON 파스를 안전하게 실행하는 공통 헬퍼"""
    try:
        return response.json()
    except ValueError:
        # JSONDecodeError는 ValueError의 서브클래스이므로 ValueError로 포착할 수 있습니다
        pytest.fail("리스폰스가 JSON 형식이 아닙니다")

# 각 테스트 파일에서의 import
from tests.utils import safe_json

# raise_for_status() 는 스테이터스 코드가 4xx/5xx 일 때 예외를 발생시킵니다
# 단 「이상 계통 테스트」에서는 스테이터스 코드 자체를 검증하고 싶기 때문에 사용하지 않도록 주의

# 사용하는 경우(정상 계통의 전제 확인)
response = api_session.get(f"{base_url}/users/1")
response.raise_for_status()  # 4xx/5xx 라면 예외 발생
data = safe_json(response)

# 사용하지 않는 경우(이상 계통 테스트)
response = api_session.get(f"{base_url}/users/99999")
assert response.status_code == 404  # ← raise_for_status() 를 사용하면 예외가 됩니다

STEP 2:정상 계통 테스트 설계——HTTP 메서드별 검증 포인트

정상 계통 테스트에서는 「기대한 스테이터스 코드」와 「리스폰스의 내용」 양쪽을 검증합니다.

GET 리퀘스트

# tests/test_users.py
import pytest
import requests
from tests.utils import safe_json

class TestGetUser:

    def test_get_user_returns_200(self, base_url, api_session):
        """정상 계통:유저 취득에서 스테이터스 200이 반환된다"""
        # TimeoutSession에 의해 전체 리퀘스트에 timeout=(3, 10) 이 자동 적용됩니다
        response = api_session.get(f"{base_url}/users/1")
        assert response.status_code == 200

    def test_get_user_returns_expected_fields(self, base_url, api_session):
        """정상 계통:리스폰스에 필수 필드가 포함된다"""
        response = api_session.get(f"{base_url}/users/1")
        data = safe_json(response)

        assert "id" in data
        assert "name" in data
        assert "email" in data

    def test_get_user_id_matches_request(self, base_url, api_session):
        """정상 계통:취득한 유저 ID가 리퀘스트와 일치한다"""
        user_id = 1
        response = api_session.get(f"{base_url}/users/{user_id}")
        data = safe_json(response)

        assert data["id"] == user_id

    def test_get_users_list_returns_array(self, base_url, api_session):
        """정상 계통:유저 일람이 리스트 형식으로 반환된다"""
        response = api_session.get(f"{base_url}/users")
        data = safe_json(response)

        assert isinstance(data, list)
        assert len(data) > 0

    @pytest.mark.performance  # CI에서는 분리 실행 추천(pytest.ini에 markers = performance: ... 등록 필요)
    def test_response_time_is_acceptable(self, base_url, api_session):
        """정상 계통:리스폰스 타임이 2초 이내"""
        response = api_session.get(f"{base_url}/users/1")

        # ※ response.elapsed 는 HTTP 통신 전체의 시간을 포함합니다
        # (DNS 해결・TCP 접속・TLS 핸드셰이크・서버 응답 등)
        # 애플리케이션 처리 시간 자체는 아닌 점에 주의하세요
        # network jitter에 의해 CI에서 flaky해지기 쉽기 때문에 「pytest -m 'not performance'」로 제외하는 운용도 일반적
        assert response.elapsed.total_seconds() < 2.0

    def test_response_is_valid_json(self, base_url, api_session):
        """정상 계통:리스폰스가 JSON 형식으로 반환된다"""
        response = api_session.get(f"{base_url}/users/1")

        # HTML 에러 페이지가 반환되는 경우 response.json() 은 JSONDecodeError가 됩니다
        # Content-Type을 먼저 확인함으로써 안전하게 처리할 수 있습니다
        # ※ RFC 7807 대응 API에서는 application/problem+json 을 반환하는 경우도 있습니다
        content_type = response.headers.get("Content-Type", "")
        assert (
            "application/json" in content_type
            or "application/problem+json" in content_type
        )

        data = safe_json(response)
        assert "id" in data

POST 리퀘스트

class TestCreatePost:

    def test_create_post_returns_201(self, base_url, api_session):
        """정상 계통:투고 작성에서 스테이터스 201이 반환된다"""
        payload = {"title": "테스트 투고", "body": "테스트 본문", "userId": 1}
        response = api_session.post(f"{base_url}/posts", json=payload)

        assert response.status_code == 201

    def test_create_post_returns_created_data(self, base_url, api_session):
        """정상 계통:작성한 투고 데이터가 리스폰스에 포함된다"""
        payload = {"title": "제목", "body": "본문", "userId": 1}
        response = api_session.post(f"{base_url}/posts", json=payload)
        data = safe_json(response)

        assert data["title"] == payload["title"]
        assert data["body"] == payload["body"]
        assert "id" in data

STEP 3:이상 계통 테스트 설계——에러 패턴의 망라

이상 계통 테스트는 「어떤 부정한 리퀘스트를 보내도 적절한 에러가 반환되는 것」을 확인합니다. parametrize를 사용하면 패턴을 효율적으로 망라할 수 있습니다.

class TestErrorHandling:

    def test_get_nonexistent_user_returns_404(self, base_url, api_session):
        """이상 계통:존재하지 않는 유저 ID로 404가 반환된다"""
        response = api_session.get(f"{base_url}/users/99999")

        assert response.status_code == 404

    @pytest.mark.parametrize("invalid_id", [
        "abc",      # 문자열
        "!@#",      # 기호
        "-1",       # 음수
        "0",        # 제로
        "9" * 20,   # 극단적으로 긴 수치
    ])
    def test_get_user_with_invalid_id(self, base_url, api_session, invalid_id):
        """이상 계통:무효한 ID로 400 또는 404가 반환된다"""
        response = api_session.get(f"{base_url}/users/{invalid_id}")

        # API 사양에 따라 400 / 404 어느 쪽을 반환하는가는 다릅니다
        # OpenAPI / API 사양서를 기준으로 기대값을 정의합시다
        # ※ 이상적으로는 사양서에 맞춰 하나로 고정하는 것입니다. 복수 허용은 임시 대응으로서 취급하세요
        assert response.status_code in [400, 404]

    @pytest.mark.parametrize("missing_field, payload", [
        ("title",  {"body": "본문만",  "userId": 1}),
        ("body",   {"title": "제목만", "userId": 1}),
        ("userId", {"title": "제목",   "body": "본문"}),
    ])
    def test_create_post_with_missing_required_field(
        self, base_url, api_session, missing_field, payload
    ):
        """이상 계통:필수 필드가 빠진 경우에 400 또는 422가 반환된다"""
        response = api_session.post(f"{base_url}/posts", json=payload)

        assert response.status_code in [400, 422]

STEP 4:인증・인가 테스트 설계

# tests/test_auth.py
import pytest

class TestAuthentication:
    """인증・인가 테스트
    ※ /protected/users 등의 엔드포인트는 실무 API를 상정한 샘플 예시입니다.
    jsonplaceholder.typicode.com에는 존재하지 않으므로 실제 인증 API로 변경하여 실행하세요.
    """

    def test_protected_endpoint_without_token_returns_401(
        self, base_url, api_session
    ):
        """인증 없음:401 Unauthorized가 반환된다"""
        response = api_session.get(f"{base_url}/protected/users")
        assert response.status_code == 401

    def test_protected_endpoint_with_invalid_token_returns_401(
        self, base_url, api_session
    ):
        """무효 토큰:401 Unauthorized가 반환된다"""
        headers = {"Authorization": "Bearer INVALID_TOKEN"}
        response = api_session.get(f"{base_url}/protected/users", headers=headers)
        assert response.status_code == 401

    def test_protected_endpoint_with_valid_token_returns_200(
        self, base_url, api_session, auth_headers
    ):
        """유효 토큰:200 OK가 반환된다"""
        response = api_session.get(
            f"{base_url}/protected/users", headers=auth_headers
        )
        assert response.status_code == 200

    def test_user_cannot_access_other_users_data(
        self, base_url, api_session, auth_headers
    ):
        """인가:다른 유저의 데이터에 403 Forbidden이 반환된다"""
        response = api_session.get(
            f"{base_url}/users/999/private", headers=auth_headers
        )
        assert response.status_code == 403

    @pytest.mark.parametrize("expired_token", [
        "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.expired",
        "Bearer ",       # 빈 토큰
        "InvalidFormat", # Bearer 프리픽스 없음
    ])
    def test_invalid_token_formats_return_401(
        self, base_url, api_session, expired_token
    ):
        """각종 무효 토큰 형식으로 401이 반환된다"""
        headers = {"Authorization": expired_token}
        response = api_session.get(f"{base_url}/protected/users", headers=headers)
        assert response.status_code == 401

STEP 5:경계값・엣지 케이스의 테스트 설계

BOUNDARY_TITLES = [
    ("empty_string",    "",                                    [400, 422]),
    ("single_char",     "A",                                   [200, 201]),
    ("max_length",      "A" * 255,                             [200, 201]),
    ("over_max_length", "A" * 256,                             [400, 422]),
    ("special_chars",   "!@#$%^&*()",                          [200, 400]),
    ("emoji",           "📝 테스트 투고 🚀",                    [200, 201]),
    # SQL 인젝션 테스트——목적:안전하게 처리되는지 확인(거부하는 것만이 목적이 아님)
    # ✅ 500 에러가 안 됨  ✅ DB 에러 상세를 반환하지 않음  ✅ 인증 돌파 안 됨
    # 💡 새니타이즈된 API에서는 SQL 문자열이 통상 문자열로 취급되어 200이 되는 케이스도 있습니다
    # ⚠️ 본번 환경이나 제3자 API로의 SQL 인젝션 문자열 송신은 엄금입니다. 참고:OWASP Testing Guide
    ("sql_injection",   "' OR '1'='1",                         [200, 400, 422]),
    # XSS 스크립트가 안전하게 처리되는지도 확인(500이 안 됨이 목적)
    ("xss_script",      "<script>alert('xss')</script>",       [200, 400, 422]),
]

class TestBoundaryValues:

    @pytest.mark.parametrize("case_name, title, expected_statuses", BOUNDARY_TITLES)
    def test_post_title_boundary(
        self, base_url, api_session, case_name, title, expected_statuses
    ):
        """경계값 테스트:제목 필드의 다양한 케이스"""
        payload = {"title": title, "body": "본문", "userId": 1}
        response = api_session.post(f"{base_url}/posts", json=payload)

        assert response.status_code in expected_statuses, \
            f"Case: {case_name}, got {response.status_code}"

    def test_pagination_first_page(self, base_url, api_session):
        """페이지네이션:최초 페이지는 요청 건수 이하가 반환된다"""
        response = api_session.get(f"{base_url}/posts?_page=1&_limit=10")
        data = safe_json(response)

        assert response.status_code == 200
        assert len(data) <= 10

    def test_pagination_beyond_last_page_returns_empty(self, base_url, api_session):
        """페이지네이션:존재하지 않는 페이지는 빈 리스트가 반환된다"""
        response = api_session.get(f"{base_url}/posts?_page=99999&_limit=10")

        assert response.status_code == 200
        assert safe_json(response) == []

STEP 6:스키마 검증——jsonschema로 리스폰스를 일괄 검증한다

# pip install jsonschema
from jsonschema import validate, ValidationError, FormatChecker
import pytest

# 💡 보충:jsonschema의 format(email・uri 등)은
# FormatChecker() 를 지정하지 않으면 엄밀하게 체크되지 않습니다

USER_SCHEMA = {
    "type": "object",
    "required": ["id", "name", "email", "username"],
    "additionalProperties": False,  # 미정의 필드를 거부(엄밀 검증하고 싶은 경우에만 사용)
    # ⚠️ API에 새 필드가 추가되면(후방 호환성이 있는 변경이라도)테스트가 깨집니다
    # 초보자는 생략 가능. Consumer-Driven Contract Testing(Pact 등)의 문맥에서도 중요한 관점입니다
    "properties": {
        "id":       {"type": "integer"},
        "name":     {"type": "string", "minLength": 1},
        "email":    {"type": "string", "format": "email"},
        "username": {"type": "string", "minLength": 1},
    }
}

class TestResponseSchema:

    def test_user_response_schema(self, base_url, api_session):
        """유저 리스폰스의 스키마가 올바르다"""
        response = api_session.get(f"{base_url}/users/1")
        data = safe_json(response)

        try:
            validate(
                instance=data,
                schema=USER_SCHEMA,
                format_checker=FormatChecker()
            )
        except ValidationError as e:
            pytest.fail(f"스키마 검증 실패: {e.message}")

STEP 7:PUT/PATCH 리퀘스트의 테스트 설계

업데이트계 API 테스트에서는 「업데이트 후의 데이터가 올바르게 반영되어 있는 것」을 반드시 확인합니다.

💡 PUT과 PATCH의 차이(REST 원칙):REST 원칙에서는 PUT은 같은 리퀘스트를 여러 번 보내도 결과가 바뀌지 않는 「멱등성」을 가지는 업데이트로서 취급됩니다. 한편 PATCH는 RFC상 「멱등일 필요는 없다」HTTP 메서드입니다만 실제 API에서는 멱등으로서 구현되는 케이스도 있습니다. API 사양서를 기준으로 설계합시다.
class TestUpdatePost:

    def test_patch_post_returns_200(self, base_url, api_session):
        """정상 계통:부분 업데이트에서 스테이터스 200이 반환된다"""
        payload = {"title": "업데이트 후 제목"}
        response = api_session.patch(f"{base_url}/posts/1", json=payload)
        data = safe_json(response)

        assert response.status_code == 200
        assert data["title"] == "업데이트 후 제목"

    def test_put_post_returns_200(self, base_url, api_session):
        """정상 계통:전체 업데이트에서 스테이터스 200이 반환된다"""
        payload = {
            "id": 1,
            "title": "전체 업데이트 제목",
            "body":  "전체 업데이트 본문",
            "userId": 1
        }
        response = api_session.put(f"{base_url}/posts/1", json=payload)
        data = safe_json(response)

        assert response.status_code == 200
        assert data["title"] == payload["title"]

    def test_patch_nonexistent_post_returns_404(self, base_url, api_session):
        """이상 계통:존재하지 않는 투고의 업데이트에서 404가 반환된다"""
        response = api_session.patch(
            f"{base_url}/posts/99999", json={"title": "업데이트"}
        )
        assert response.status_code == 404

STEP 8:모크 테스트로 외부 API 의존을 없앤다

# pip install responses
import responses
import requests
import pytest

@responses.activate
def test_external_api_mock():
    """외부 API를 모크화하여 테스트(네트워크 불필요)"""
    responses.add(
        responses.GET,
        "https://api.example.com/users/1",
        json={"id": 1, "name": "테스트 유저"},
        status=200
    )

    response = requests.get("https://api.example.com/users/1")

    assert response.status_code == 200
    assert safe_json(response)["name"] == "테스트 유저"
    assert len(responses.calls) == 1  # 1번만 호출된 것을 확인
💡 모크 테스트를 사용하는 장면:외부 결제 API・SMS 인증・이메일 송신 등 테스트 환경에서 직접 두드릴 수 없는 외부 서비스에 의존하는 테스트에서 특히 유효합니다. CI에서도 안정적으로 동작합니다.

STEP 9:DELETE 리퀘스트의 테스트 설계

💡 fixture에 의한 테스트 데이터의 클린업 예시

@pytest.fixture
def test_post(base_url, api_session):
    """테스트용 투고를 작성하고 테스트 후에 삭제하는 fixture"""
    payload = {"title": "테스트 투고", "body": "fixture로 작성", "userId": 1}
    response = api_session.post(f"{base_url}/posts", json=payload)
    post_id = safe_json(response)["id"]

    yield post_id  # 테스트 실행

    # teardown:테스트 후에 클린업
    # try/except로 보호함으로써 teardown의 실패가 테스트 결과에 영향하지 않도록 합니다
    try:
        api_session.delete(f"{base_url}/posts/{post_id}")
    except Exception as e:
        print(f"Cleanup failed for post_id={post_id}: {e}")
class TestDeletePost:

    def test_delete_post_returns_204(self, base_url, api_session):
        """정상 계통:투고 삭제에서 스테이터스 204가 반환된다"""
        response = api_session.delete(f"{base_url}/posts/1")
        assert response.status_code == 204

    def test_delete_post_response_body_is_empty(self, base_url, api_session):
        """정상 계통:삭제 성공의 리스폰스 보디는 비어 있다"""
        response = api_session.delete(f"{base_url}/posts/1")

        # RFC상 204는 리스폰스 보디를 갖지 않는 상정이지만,
        # 일부 API 구현에서는 빈 JSON・빈 배열・whitespace를 반환하는 케이스도 있습니다.
        # 실제 API 사양에 따라 검증하세요.
        assert response.status_code == 204
        assert not response.content
        # ※ API에 따라서는 이쪽이 안전한 경우가 있습니다:
        # assert response.content in [b"", b"null", b"{}"]

    def test_delete_nonexistent_post_returns_404(self, base_url, api_session):
        """이상 계통:존재하지 않는 투고의 삭제에서 404가 반환된다"""
        response = api_session.delete(f"{base_url}/posts/99999")
        assert response.status_code == 404

    def test_deleted_post_cannot_be_fetched(self, base_url, api_session, test_post):
        """삭제 후에 재취득하면 404가 된다(fixture로 데이터를 관리)"""
        # NOTE: jsonplaceholder.typicode.com에서는 영구 삭제되지 않습니다.
        # 실무 API를 상정한 샘플입니다.
        delete_response = api_session.delete(f"{base_url}/posts/{test_post}")
        assert delete_response.status_code == 204

        get_response = api_session.get(f"{base_url}/posts/{test_post}")
        assert get_response.status_code == 404

STEP 10:네트워크 예외・타임아웃의 테스트 설계

import pytest
import requests
from unittest.mock import patch

class TestNetworkErrorHandling:

    @patch.object(requests.Session, "get")
    def test_timeout_exception(self, mock_get):
        """Timeout 예외를 Session 베이스의 모크로 재현(CI 환경에서도 안정 동작)"""
        mock_get.side_effect = requests.exceptions.Timeout

        session = requests.Session()
        with pytest.raises(requests.exceptions.Timeout):
            session.get("https://example.com")

    @patch.object(requests.Session, "get")
    def test_connection_error(self, mock_get):
        """ConnectionError를 모크로 재현(DNS 의존을 배제하여 CI 안정화)"""
        mock_get.side_effect = requests.exceptions.ConnectionError

        session = requests.Session()
        with pytest.raises(requests.exceptions.ConnectionError):
            session.get("https://example.com")
⚠️ timeout은 반드시 설정합시다timeout 을 지정하지 않으면 API가 응답하지 않는 경우에 테스트가 영구히 행업됩니다. TimeoutSession 에 의해 모든 리퀘스트에 timeout=(3, 10) 이 자동 적용됩니다.

API 테스트에서 자주 사용하는 HTTP 스테이터스 코드 일람이란?

코드의미주요 유스 케이스테스트 관점
200 OK성공GET・PUT・PATCH의 성공정상 계통
201 Created작성 성공POST로 리소스 작성정상 계통
204 No Content삭제 성공DELETE로 리소스 삭제정상 계통
400 Bad Request부정한 리퀘스트파라미터 형식 에러이상 계통・경계값
401 Unauthorized인증 에러토큰 없음・무효한 토큰인증 테스트
403 Forbidden권한 없음액세스 권한이 없는 리소스로의 액세스인가 테스트
404 Not Found리소스 미존재존재하지 않는 ID로의 액세스이상 계통
422 Unprocessable유효성 검사 에러필수 필드 부족・형 에러이상 계통・경계값
500 Server Error서버 에러예기치 않은 에러테스트에서 500이 반환되면 버그
⚠️ 중요:테스트에서 500 Internal Server Error 가 반환된 경우는 즉시 버그 보고가 필요합니다. 부정한 입력이라도 500을 반환하는 API는 안전하지 않습니다.

⚠️ API 테스트에서 자주 있는 실수 5가지

① 스테이터스 코드만 테스트한다

스테이터스 코드 200이 반환되어도 리스폰스 보디가 비어 있거나 틀린 데이터의 경우가 있습니다. 반드시 보디의 내용도 검증합시다: assert data["id"] == expected_id

② 정상 계통밖에 테스트하지 않는다

많은 버그는 이상 계통(무효한 입력・인증 에러・존재하지 않는 리소스)에서 발견됩니다. parametrize를 사용하여 이상 계통의 패턴을 최저 5〜10 케이스는 준비합시다.

③ 테스트 간에 데이터가 의존하고 있다

「테스트 A에서 작성한 데이터를 테스트 B에서 사용한다」는 구성은 테스트 순서 의존 버그를 만들어냅니다. 각 테스트는 독립하여 실행할 수 있도록 설계하고 테스트용 데이터는 fixture로 관리합시다.

④ Content-Type 헤더를 잊는다

requests.post(url, json=data) 는 자동으로 Content-Type: application/json 을 설정합니다만 requests.post(url, data=data) 는 설정하지 않습니다. API가 415 Unsupported Media Type 을 반환하는 경우는 이것이 원인인 경우가 많습니다.

⑤ 본번 환경을 직접 테스트해 버린다

API 테스트는 실제 데이터를 작성・변경・삭제합니다. 잘못해서 본번 환경을 향해 실행하면 데이터 파손의 리스크가 있습니다. base_url fixture로 환경을 관리하고 CI/CD에서는 환경 변수로 전환하는 것을 철저히 합시다.

📋 API 테스트 케이스 설계 템플릿

관점테스트 케이스 예기대 스테이터스
정상 계통유효한 파라미터로 리퀘스트200 / 201 / 204
이상 계통필수 항목 부족・형 부정・존재하지 않는 ID400 / 404 / 422
인증토큰 없음・무효・유효・다른 유저 액세스401 / 403
경계값최대 문자수・공백 문자・특수 문자・수치의 상한200 / 400 / 422
스키마필수 필드의 존재・형・포맷 확인200
에러 안전성SQL 인젝션・XSS 문자열(검증 환경에서만)500이 반환되면 버그

📋 API 테스트 관점 체크리스트

✅ 스테이터스 코드 검증(200/201/204/400/401/403/404/422/500)
✅ 리스폰스 보디・필수 필드의 존재 확인
✅ 스키마 검증(필드의 형・포맷)
✅ 인증 테스트:토큰 없음・무효・유효의 3패턴
✅ 인가 테스트:다른 유저의 데이터에 403이 반환되는가
✅ 리스폰스 타임(SLA가 정의되어 있는 경우)
✅ 경계값・SQL 인젝션・XSS로 500이 안 됨
✅ timeout 예외 처리 테스트

FAQ

Q. requests와 Playwright API Testing 중 어느 것을 사용해야 하나요?

먼저 requests 부터 시작하는 것을 추천합니다. 심플하고 학습 코스트가 낮으며 REST API 테스트의 대부분은 requests로 대응할 수 있습니다. 이미 Playwright로 E2E 테스트를 쓰고 있는 경우는 같은 테스트 스위트 내에서 API 테스트도 쓸 수 있는 Playwright API Testing이 편리합니다.

Q. 테스트 데이터의 작성・삭제는 어떻게 관리하면 좋을까요?

테스트용 데이터는 fixture로 관리하고 yield 의 후에 클린업하는 것이 베스트 프랙티스입니다. teardown은 try/except 로 보호함으로써 클린업의 실패가 테스트 결과에 영향하지 않도록 합니다. 테스트 환경에는 테스트 전용 데이터베이스를 준비하는 것이 이상적입니다.

Q. 외부 API에 의존하는 테스트는 어떻게 하면 좋을까요?

unittest.mock 이나 responses(pip install responses)라이브러리를 사용하여 API 리스폰스를 모크화합니다. 외부 API로의 의존을 없앰으로써 네트워크 장애나 API의 변경에 영향받지 않는 안정된 테스트가 실현됩니다.

Q. 스테이터스 코드의 사용 구분을 모르겠습니다

자주 사용하는 코드의 기준:200(GET의 성공)・201(POST=리소스 작성 성공)・204(DELETE=삭제 성공・보디 없음)・400(부정한 리퀘스트)・401(인증 에러)・403(인가 에러)・404(리소스가 존재하지 않음)・422(유효성 검사 에러)・500(서버 에러=버그)입니다.

Q. API 테스트를 CI/CD에 조합하는 방법은?

GitHub Actions에서 pytest tests/ 를 실행하는 것만으로 API 테스트도 E2E 테스트도 동시에 실행할 수 있습니다. API 테스트는 브라우저 불필요이므로 헤드리스 모드 설정도 불필요합니다. 환경 변수로 BASE_URL 을 전환함으로써 스테이징・본번 환경으로의 대응도 쉽습니다.

Q. API 테스트와 단위 테스트의 차이는?

단위 테스트는 개개의 함수나 클래스를 테스트합니다. API 테스트는 HTTP 엔드포인트 전체(라우팅・유효성 검사・DB 조작・리스폰스)를 외부에서 검증합니다. 단위 테스트보다 결합도가 높고 백엔드의 통합 품질을 확인할 수 있는 점이 특징입니다.

Q. Postman만으로는 부족한가요?

Postman은 수동 확인・사양 공유에는 우수합니다. 단 「대량의 테스트 케이스를 CI/CD에서 자동 실행한다」「fixture로 테스트 데이터를 관리한다」「parametrize로 이상 계통 패턴을 망라한다」용도에서는 pytest + requests가 유리합니다. Postman으로 동작 확인하고 pytest + requests로 자동화하는 사용 구분이 일반적입니다.

Q. API 테스트는 어디까지 자동화해야 하나요?

최소한 「정상 계통・인증(401/403)・주요 이상 계통(400/404)」의 자동화를 추천합니다. 전패턴을 자동화하려고 하면 보수 비용이 증대하므로 리스크가 높은 곳(인증・결제・개인정보)을 우선합시다. E2E 테스트로 확인할 수 없는 「유효성 검사・인가・데이터 정합성」의 관점이 API 테스트에 맞습니다.

Q. API 테스트에서 모크(Mock)는 필요한가요?

외부 API나 결제 서비스 등 「테스트 환경에서 직접 두드릴 수 없는」 의존이 있는 경우에 유효합니다. responses 라이브러리나 unittest.mock 을 사용하면 실제 API를 부르지 않고 리스폰스를 시뮬레이트할 수 있습니다.

Q. API 테스트와 Contract Testing의 차이는?

API 테스트는 「자신들의 API가 올바르게 동작하는가」를 검증합니다. Contract Testing(Pact 등)은 「소비자(Consumer)와 제공자(Provider)간의 계약을 검증하는」 접근으로 마이크로서비스간의 호환성 보증에 사용됩니다. API 테스트는 단체에서의 품질 검증, Contract Testing은 서비스간의 통합 품질 보증과 역할이 다릅니다.

이 글에서 소개한 4축(정상 계통・이상 계통・인증・경계값)을 체크리스트로서 사용하고 테스트 설계 리뷰를 실시하고 있습니다. 특히 인증 테스트의 누락은 본번 장애에 직결하기 쉽기 때문에 반드시 전패턴을 확인하도록 하고 있습니다.

📋 이 글의 정리

  • REST API 테스트는 정상 계통・이상 계통・인증・경계값의 4축으로 설계한다
  • pytest × requests + TimeoutSession + safe_json 의 조합이 실무 표준
  • parametrize 로 이상 계통 패턴을 효율적으로 망라할 수 있다
  • jsonschema + FormatChecker 로 필드의 형・포맷을 일괄 검증할 수 있다
  • 스테이터스 코드만 테스트・정상 계통만・데이터 의존・timeout 없음・본번 환경 실행에 주의

먼저 GET 테스트를 1개 동작시켜 보세요. 리퀘스트를 보내 스테이터스 코드와 필드를 확인하는 것만으로 API 테스트의 기본적인 흐름이 몸에 밥니다. 거기에서 이상 계통・인증・경계값으로 테스트를 넓혀가는 것이 효율적입니다. 막혔을 때는 이 글의 체크리스트와 FAQ가 도움이 됩니다.

제목과 URL을 복사했습니다