搜索框输入 C++ 入门,服务端却收到 C 入门;筛选条件传了两个同名键,最后只剩一个。这类故障通常不是网络丢包,而是客户端编码与服务端解码约定不一致。在云端 Mac 上把 URL 构造器拆成纯函数,用 XCTest 固定边界,比反复抓线上请求更容易定位,也不需要启动模拟器。
先写清楚参数契约
查询字符串不是字典。接口需要先约定:同名键能否重复,顺序是否有意义,缺少参数、无值参数与空字符串是否等价,以及服务端是否采用表单解码规则。前端与后端分别“使用标准库”,并不代表这些答案相同。
例如 flag、flag=、完全没有 flag 是三种不同输入。客户端可以用 URLQueryItem 的 nil、空字符串和不添加项目分别表达;服务端框架是否保留区别,要用实际解析器验证。
普通 URL 查询中的字面加号不天然等于空格。把加号解释为空格,是表单编码体系中的规则,不能直接推广到所有查询接口。
将这些约定写进测试名称。不要只保存一条“正常搜索成功”的用例,否则中文能通过,加号和重复筛选仍可能出错。
用纯函数构造 URL
创建一个 macOS 单元测试目标,加入下面的 Swift 文件。示例地址只用于本地解析,不会发出请求。业务接入时,将固定地址换成工程里的接口配置;参数值必须传入原文,不能先手动百分号编码。
import Foundation
import XCTest
func makeQueryURL(
items: [URLQueryItem],
formDecoder: Bool = false
) throws -> URL {
var parts = URLComponents()
parts.scheme = "https"
parts.host = "xcodenode.com"
parts.path = "/query-fixture"
parts.queryItems = items
if formDecoder, let encoded = parts.percentEncodedQuery {
parts.percentEncodedQuery = encoded.replacingOccurrences(
of: "+",
with: "%2B"
)
}
return try XCTUnwrap(parts.url)
}
final class QueryEncodingTests: XCTestCase {
func testReservedCharactersRoundTrip() throws {
let value = "C++ 入门 & x=1 # 50% / ?"
let url = try makeQueryURL(items: [
URLQueryItem(name: "q", value: value)
])
let parts = try XCTUnwrap(
URLComponents(url: url, resolvingAgainstBaseURL: false)
)
let items = try XCTUnwrap(parts.queryItems)
XCTAssertEqual(items.count, 1)
XCTAssertEqual(items.first?.value, value)
XCTAssertNil(parts.fragment)
}
func testDuplicatesAndEmptyValues() throws {
let url = try makeQueryURL(items: [
URLQueryItem(name: "tag", value: "swift"),
URLQueryItem(name: "tag", value: "ios"),
URLQueryItem(name: "flag", value: nil),
URLQueryItem(name: "empty", value: "")
])
let parts = try XCTUnwrap(
URLComponents(url: url, resolvingAgainstBaseURL: false)
)
let items = try XCTUnwrap(parts.queryItems)
XCTAssertEqual(items.map(\.name), [
"tag", "tag", "flag", "empty"
])
XCTAssertEqual(items.map(\.value), [
"swift", "ios", nil, ""
])
}
func testFormDecoderReceivesLiteralPlus() throws {
let value = "C++ 50%"
let url = try makeQueryURL(
items: [URLQueryItem(name: "q", value: value)],
formDecoder: true
)
let parts = try XCTUnwrap(
URLComponents(url: url, resolvingAgainstBaseURL: false)
)
let query = try XCTUnwrap(parts.percentEncodedQuery)
XCTAssertFalse(query.contains("+"))
let pair = try XCTUnwrap(
query.split(separator: "=", maxSplits: 1).last
)
let decoded = String(pair)
.replacingOccurrences(of: "+", with: " ")
.removingPercentEncoding
XCTAssertEqual(decoded, value)
}
}
第一项检查保留字符没有生成额外参数或片段;第二项确认数组中的重复键与顺序未丢失;第三项模拟表单解码的一次处理。它不是完整服务端解析器,只验证单个值的加号边界,不能替代接口集成测试。
扩充失败样本而不是扩大联网范围
在相同夹具中补充中文、组合字符、表情、空值、前后空格,以及字面字符串 %2B。最后一项尤其重要:用户输入的百分号需要被编码,不能被当成已经编码的加号。
遇到 %252B,先检查输入究竟是原始加号还是字面 %2B。前者通常说明二次编码,后者可能正是正确结果。仅凭看到 %25 就判错,会制造新的修复故障。
不要使用一个宽泛字符集合,对拼好的整条 URL 执行编码。参数名、参数值、路径属于不同上下文;直接拼接 &、=、# 可能改变结构。让构造器负责结构,再分别检查解析后的项目数量、名称和值。
超长搜索词另设输入长度限制。编码后的字节数与字符数不同,限制策略应按接口约定测试,而不是假设中文一个字符只占一个字节。
对签名请求增加字节层验收
语义相同的 URL 不一定有相同字节。参数顺序、空格的表示方式、百分号转义的大小写,都可能影响签名。普通查询测试可以验证往返值相等;签名测试还必须验证协议规定的规范化字符串。
若协议要求排序,先明确重复键如何排序,再对最终字符串签名。若要求保持顺序,就保留项目数组,不能借字典重建。签名完成后不要再切换加号处理模式,否则传输内容与签名输入会分离。
固定样本应包含同名键、空值与非 ASCII 文本,并断言最终编码字符串。这里的“固定”指工程约定的格式,不是把某次系统输出无条件当成永久标准。服务端也要保存相同样本,确认双方签名输入一致。
在云端节点执行并保存证据
测试目标使用 Foundation 即可,不必配置应用签名。通过测试目标的共享 Scheme 执行;下面的名称应替换为工程实际名称,结果目录必须尚不存在。
set -euo pipefail
mkdir -p artifacts
result="artifacts/query-$(uuidgen).xcresult"
xcodebuild test \
-project QueryContract.xcodeproj \
-scheme QueryContract \
-destination 'platform=macOS' \
-resultBundlePath "$result"
运行前记录 Xcode 版本、提交标识及构造器模式。同一批测试保持工具链一致;升级工具链后重新检查签名字节,不要只看绿色结果数量。
验收时确认四件事:原始值没有预编码;重复键没有被字典吞掉;表单解码开关由接口契约决定;实际服务端解析器通过同一组样本。失败报告保存输入类别和编码结果即可,涉及真实查询词或令牌时改用合成样本。
最终让纯函数测试守住客户端结构,让服务端契约测试守住解码边界。两层都通过,才能说明参数被正确传递,而不只是两次客户端解析碰巧相等。
常见问题
URLComponents 会把查询值里的加号编码成 %2B 吗?
不能依赖它自动这么做。普通 URL 查询允许保留加号,但表单解码器可能将其解释为空格。若接口明确采用表单解码,应在 URLComponents 完成编码后,把 percentEncodedQuery 中剩余的字面加号替换为 %2B,并增加服务端解码测试。
查询参数能否先转成字典再参与签名?
只有协议明确禁止重复键、忽略顺序并定义了规范化规则时才适合这样做。否则字典会丢失重复项或顺序。应保留 URLQueryItem 数组,并对签名协议要求的最终编码字节建立独立测试。