iOS App 接收壓縮 JSON 時,封包擷取工具顯示的資料可能只有幾十 KB,交給解析器的內容卻大得多。要查清 App 實際接受了什麼,不必等線上 API 發生故障:在雲端 Mac 上啟動一個只監聽本機的測試服務,再讓同一台 Mac 上的 iOS 模擬器向它發出請求。以下透過正常、校驗值損毀及解壓後內容較大的三種回應,檢查 URLSession 傳回的資料,以及 App 在解析前設定的邊界。
先區分傳輸長度與 App 收到的資料
gzip 壓縮的是傳輸中的回應本文。Content-Length 可以表示壓縮後送出的位元組數;業務程式碼拿到的 Data,則可能是已解壓縮的本文。只限制前者,無法阻止重複字元或高度相似的 JSON 片段在解壓後占用大量記憶體。本文以 256 KiB 作為範例 API 的解壓後上限。這是測試門檻,並非適用於所有 iOS API 的通用值;你應依實際回應大小另行設定。
測試也必須區分「網路呼叫成功」與「本文可用」。狀態碼正確、請求沒有逾時,都不能取代內容驗證。尤其當壓縮串流損毀時,不要預設系統一定會以某一種錯誤回報;真正需要驗收的是 App 最終不會把損毀資料當成完整的業務結果。
測試斷言應針對 App 實際使用的資料,而不只是傳輸標頭或網路面板顯示的壓縮後大小。
在本機產生三種 gzip 回應
將以下腳本儲存為 /tmp/gzip_fixture.py,並在雲端 Mac 執行 python3 /tmp/gzip_fixture.py。服務只綁定 127.0.0.1:8765,不對公網開放。/ok 傳回短本文;/broken 修改 gzip 尾端的校驗值;/large 傳送可高度壓縮、解壓後為 1 MiB 的本文。三個路徑都傳送實際壓縮資料,並以壓縮後的長度填寫回應標頭。
import gzip
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
SMALL = b'{"status":"ready"}'
LARGE = b"A" * 1_048_576
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path not in ("/ok", "/broken", "/large"):
self.send_error(404)
return
payload = LARGE if self.path == "/large" else SMALL
wire = bytearray(gzip.compress(payload))
if self.path == "/broken":
wire[-8] ^= 0xFF
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Encoding", "gzip")
self.send_header("Content-Length", str(len(wire)))
self.end_headers()
self.wfile.write(wire)
ThreadingHTTPServer(("127.0.0.1", 8765), Handler).serve_forever()
這裡刻意修改校驗值,而不是任意中斷 TCP 連線,好讓「傳輸完整但內容損毀」與連線中斷可以分開觀察。腳本提供的是固定輸入,並不宣稱能代表真實 API 的壓縮率或吞吐量。執行測試前,使用 lsof -nP -iTCP:8765 -sTCP:LISTEN 確認監聽者確實是這次啟動的程序;若連接埠已被占用,先停止舊服務,避免測試案例誤連其他程式。
在模擬器中檢查實際本文
將以下 XCTest 範例放進專案現有的測試 target。它只適用於與服務執行在同一台 Mac 上的模擬器:這裡的 127.0.0.1 指向本機;若改用實體裝置,就必須重新設計服務位址與存取控制。測試專案需允許此開發環境中的本機 HTTP 請求;不要為了執行測試案例而放寬正式建置對遠端請求的安全設定。
import Foundation
import XCTest
final class GzipResponseTests: XCTestCase {
private let base = URL(string: "http://127.0.0.1:8765")!
private func fetch(_ path: String) async throws -> Data {
let url = base.appendingPathComponent(path)
let (data, response) = try await URLSession.shared.data(from: url)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
return data
}
func testValidBody() async throws {
let data = try await fetch("ok")
XCTAssertEqual(data, Data(#"{"status":"ready"}"#.utf8))
}
func testLargeDecodedBodyExceedsAppLimit() async throws {
let data = try await fetch("large")
XCTAssertGreaterThan(data.count, 256 * 1024)
}
func testDamagedBodyIsNotAcceptedAsComplete() async {
do {
let data = try await fetch("broken")
XCTAssertNotEqual(
data,
Data(#"{"status":"ready"}"#.utf8)
)
} catch {
return
}
}
}
先執行正常案例,再執行大本文與損毀案例。如果正常案例收到的是原始 gzip 位元組,而非預期文字,應先檢查回應標頭、測試環境與請求實作,不要直接把斷言改成「只要有資料就通過」。損毀案例接受兩種可觀察結果:呼叫擲出錯誤,或傳回的資料不等於完整的預期本文。它不保證系統會在某個固定階段拒絕校驗值錯誤,也不能取代業務層對 JSON 欄位的驗證。
在解析前檢查大小限制
在 App 程式碼中,先檢查 Data.count,再交給解碼器。以下示範的是收到完整回應後的大小門檻;它能避免繼續解析過大的本文,無法避免下載及解壓縮過程中已產生的記憶體占用。若 API 可能傳回極大的內容,還需要在 App 架構中採用分塊接收、暫存至磁碟或伺服器端限制,不能只依賴這一行判斷。
enum ResponseError: Error {
case tooLarge
}
func checkedBody(_ data: Data) throws -> Data {
guard data.count <= 256 * 1024 else {
throw ResponseError.tooLarge
}
return data
}
接著修改大本文案例,斷言 checkedBody(data) 會擲出 tooLarge,而正常本文應能通過。這樣測試目標就從「觀察到差異」推進到「確認 App 確實執行邊界檢查」。如果你的 API 傳回 JSON,應在通過大小檢查後再進行 JSON 解碼,並核對必要欄位;不要把「能解析」誤認為「內容來自預期 API」。
收尾時保留可重現的檢查項目
提交這組測試前,核對五件事:服務只監聽本機;測試明確使用同機模擬器;正常回應會比對完整本文;損毀回應不會被接受為完整內容;大小限制針對 URLSession 交給 App 的 Data。最後停止暫時服務,確認 8765 連接埠不再處於監聽狀態,並檢查測試 target 沒有把本機 HTTP 的例外設定帶進正式建置。
這組測試夾具刻意保持精簡。後續若要涵蓋空本文、錯誤狀態碼或不同編碼,應新增獨立路徑與獨立斷言,不要把多種故障塞進一個結果偶爾變動的回應。使用固定輸入、明確界定邊界,並檢查 App 實際使用的位元組,比只憑封包擷取工具中的壓縮長度判斷可靠得多。
常見問題
只檢查 Content-Length 夠嗎?
不夠。它表示傳輸時壓縮內容的長度;應用程式的大小上限應套用在 URLSession 回傳的 Data。
校驗值損毀時,是否一定要斷言 URLSession 擲出錯誤?
不用依賴特定的失敗形式。測試應確保應用程式不會將損毀回應當作完整內容接受;擲錯或資料比對失敗都符合目的。
想親自測試看看?
租用專屬 Mac mini,直接執行你的建置工作
從兩款 M4 實體主機中挑選,並依需求選擇租用期間與節點。實際供應情況以控制台即時顯示為準。