URLProtocol로 재현 가능한 iOS API 오프라인 테스트 만들기

URLProtocol로 재현 가능한 iOS API 오프라인 테스트 만들기

원격 빌드가 실제 테스트 API에 의존하면 같은 커밋도 오전에는 통과하고 오후에는 실패하기 쉽습니다. 테스트 데이터가 변경되거나, 토큰이 만료되거나, 게이트웨이의 요청 제한에 걸리거나, API 응답이 일시적으로 느려질 수 있기 때문입니다. 더 큰 문제는 실패 현장에 디코딩 오류만 남는 경우가 많아 클라이언트가 실제로 어떤 응답을 받았는지 확인하기 어렵다는 점입니다. 해결책은 재시도 횟수를 늘리는 것이 아니라 네트워크 계층 검증을 두 가지로 나누는 것입니다. 대부분의 회귀 테스트에는 로컬의 고정 응답을 사용하고, 소수의 계약 테스트만 실제 환경에 연결합니다.

오프라인 테스트의 경계부터 명확히 하기

URLProtocol은 URLSession의 요청 처리 경로에 있으며, 실제 요청이 전송되기 전에 사용자 정의 응답을 반환할 수 있습니다. 이를 통해 요청 메서드와 경로가 올바른지, 요청 헤더와 본문이 완전한지, 모델이 응답을 디코딩할 수 있는지, 비즈니스 계층이 상태 코드를 명확한 오류로 매핑하는지 검증할 수 있습니다.

URLProtocol은 실제 서비스에 현재 접근할 수 있음을 보장하지 않으며 게이트웨이 설정 변경도 감지할 수 없습니다. 따라서 테스트를 다음과 같이 계층화하는 것이 좋습니다.

계층 데이터 소스 주요 검증 항목 실행 주기
단위 회귀 테스트 로컬 JSON 픽스처 디코딩 및 오류 매핑 커밋할 때마다
네트워크 계층 회귀 테스트 URLProtocol 요청과 응답의 전체 흐름 커밋할 때마다
계약 테스트 통제된 테스트 API 필드 및 인증 계약 정기 실행 또는 배포 전

오프라인 회귀 테스트의 목적은 무관한 변동을 제거하는 것이지, 항상 성공하는 요청을 꾸며 내는 것이 아닙니다. 404, 429, 500, 빈 응답, 손상된 JSON도 모두 고정 테스트 케이스로 만들어야 합니다.

독립적인 URLSession 주입하기

테스트에서 프로토콜을 전역으로 등록하지 말고, 비즈니스 코드가 URLSession.shared에 직접 접근하게 두어서도 안 됩니다. APIClient에 세션을 주입하고 프로덕션 환경에서는 기본 구성을, 테스트 환경에서는 ephemeral 구성을 사용합니다. 이렇게 하면 디스크 캐시, Cookie 또는 이전 연결 상태를 이어받지 않습니다.

final class StubURLProtocol: URLProtocol {
    static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?

    override class func canInit(with request: URLRequest) -> Bool {
        request.url?.host == "api.test.invalid"
    }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        request
    }

    override func startLoading() {
        guard let handler = Self.handler else {
            client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
            return
        }

        do {
            let (response, data) = try handler(request)
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: data)
            client?.urlProtocolDidFinishLoading(self)
        } catch {
            client?.urlProtocol(self, didFailWithError: error)
        }
    }

    override func stopLoading() {}
}

func makeTestSession() -> URLSession {
    let configuration = URLSessionConfiguration.ephemeral
    configuration.protocolClasses = [StubURLProtocol.self]
    configuration.timeoutIntervalForRequest = 3
    return URLSession(configuration: configuration)
}

canInit은 반드시 호스트나 사용자 정의 요청 헤더로 범위를 제한해야 합니다. 조건 없이 true를 반환하면 테스트 프로세스의 다른 URLSession까지 가로챌 수 있어 오류 원인을 찾기 어려워집니다.

응답 픽스처를 코드처럼 관리하기

픽스처는 테스트 Target의 Fixtures/API/v1/ 아래에 두고 리소스와 시나리오에 따라 이름을 지정하는 것이 좋습니다. 예를 들면 projects-success.json, projects-empty.json, projects-malformed.json과 같습니다. 동적 토큰, 이메일 주소 또는 내부 주소가 포함된 전체 패킷 캡처를 그대로 저장해서는 안 됩니다.

커밋하기 전에 키를 일관된 순서로 정렬하고 구문을 검증할 수 있습니다.

find Tests/Fixtures -name '*.json' -print0 |
while IFS= read -r -d '' file; do
  tmp="${file}.tmp"
  jq -S . "$file" > "$tmp" && mv "$tmp" "$file"
done

성공 케이스에서도 요청 자체를 반드시 검증해야 합니다. 최종 모델만 확인하면 클라이언트가 GETPOST로 잘못 작성했거나, 버전 헤더를 누락했거나, 쿼리 매개변수를 중복 인코딩한 문제를 놓칠 수 있습니다.

StubURLProtocol.handler = { request in
    XCTAssertEqual(request.httpMethod, "GET")
    XCTAssertEqual(request.url?.path, "/v1/projects")
    XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json")

    let data = try Data(contentsOf: fixtureURL("projects-success"))
    let response = HTTPURLResponse(
        url: try XCTUnwrap(request.url),
        statusCode: 200,
        httpVersion: "HTTP/1.1",
        headerFields: ["Content-Type": "application/json"]
    )!
    return (response, data)
}

오류 분기를 유형별로 검증하기

최소한 전송 실패, HTTP 실패, 콘텐츠 실패를 모두 다뤄야 합니다. 전송 실패는 URLError(.timedOut)을 throw하도록 만들 수 있습니다. HTTP 실패는 실제 상태 코드와 구조화된 오류 본문을 반환해야 합니다. 콘텐츠 실패는 200을 반환하되 필드가 누락되거나 타입이 잘못되었거나 중간에 잘린 JSON을 제공합니다. 비즈니스 계층에서 이 세 가지가 모두 동일한 “알 수 없는 오류”로 처리되어서는 안 됩니다.

타임아웃 테스트에서 실제로 수십 초를 기다릴 필요는 없습니다. handler가 해당 오류를 즉시 throw하게 하고 뷰 모델이 재시도 가능한 상태로 전환되는지 검증합니다. 429의 경우 클라이언트가 Retry-After를 읽는지도 확인해야 하지만 테스트 중에 실제로 대기해서는 안 됩니다. 백오프 계산을 순수 함수로 분리한 뒤 입력과 결과를 별도로 검증합니다.

각 테스트가 끝나면 handler를 nil로 설정합니다. 테스트 스위트가 병렬 실행되면 단일 정적 handler가 다른 테스트 케이스에 의해 덮어써질 수 있습니다. 가장 안전한 출발점은 이 테스트 스위트를 직렬로 실행하는 것입니다. 병렬 실행이 반드시 필요하다면 요청 ID와 응답 클로저의 매핑을 사용하고, 잠금이나 actor로 레지스트리를 보호합니다.

클라우드 Mac에서 안정적으로 실행하기

DplyMini의 클라우드 Mac에서는 먼저 사용할 Xcode를 고정한 다음 동일한 workspace, scheme, 테스트 계획으로 실행합니다. 전용 물리 머신은 가상 머신이 아니지만, 서로 다른 브랜치가 중간 산출물을 공유하지 않도록 테스트에서 DerivedData를 명시적으로 격리해야 합니다.

set -euo pipefail

sudo xcode-select -s /Applications/Xcode.app
rm -rf "$PWD/.derived-data"

xcodebuild test \
  -workspace App.xcworkspace \
  -scheme App \
  -testPlan NetworkRegression \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -derivedDataPath "$PWD/.derived-data" \
  -resultBundlePath "$PWD/TestResults/NetworkRegression.xcresult"

처음 실행하기 전에 xcodebuild -showdestinations로 현재 실행 환경에 설치된 시뮬레이터 이름을 확인해야 합니다. 개인 머신의 기기 이름을 스크립트에 그대로 복사하지 마십시오. 실패하면 xcresult, 테스트 로그, 해당 픽스처 버전을 보존해야 하며 콘솔 출력의 마지막 몇 줄만 잘라서 남겨서는 안 됩니다.

최종 검수에서는 다음 조건을 충족해야 합니다. 외부 네트워크 연결을 끊어도 오프라인 테스트 스위트가 완료되고, 반복 실행 결과가 일관되며, 픽스처 변경 사항을 코드 리뷰에서 명확히 확인할 수 있어야 합니다. 또한 실제 계약 테스트가 실패하더라도 개발자가 클라이언트 로직에 회귀가 발생했는지 판단하는 과정이 막혀서는 안 됩니다. 이 네 가지를 충족해야 네트워크 테스트가 비로소 “API 상태에 운을 맡기는 작업”에서 유지보수 가능한 엔지니어링 자산으로 바뀝니다.

자주 묻는 질문

URLProtocol 테스트가 실제 API 통합 테스트를 완전히 대체할 수 있나요?

아닙니다. 요청 구성, 디코딩, 오류 변환은 안정적으로 검증하지만 게이트웨이, 인증, 서버 스키마 변경을 찾기 위한 소규모 실제 계약 테스트는 별도로 필요합니다.

테스트에 별도의 URLSession을 주입해야 하는 이유는 무엇인가요?

ephemeral 설정을 주입하면 가로채기 범위를 테스트 대상에 한정하고 공유 캐시, Cookie, 전역 프로토콜 등록이 다른 테스트에 영향을 주는 일을 막을 수 있습니다.

병렬 테스트에서 공유 handler 충돌은 어떻게 방지하나요?

단순 구현은 직렬로 실행합니다. 병렬 실행이 필요하면 요청 식별자별 응답 저장소를 만들고 잠금이나 actor로 보호해야 합니다.

독점 물리 Mac mini

작업 기간에 맞는 클라우드 Mac 이용 기간 선택

두 가지 M4 구성을 일·주·월·분기 단위로 이용할 수 있으며, 노드와 실제 사용 가능 여부는 콘솔에서 실시간으로 확인되는 정보를 기준으로 합니다.

구성 선택 및 주문