Test iOS URL query encoding contracts on a cloud Mac
You enter C++ 入门 in a search box, but the server receives C 入门. You send two filters with the same key, but only one survives. These failures usually point to a mismatch between client encoding and server decoding, not dropped network packets. On a cloud Mac, extracting the URL builder into a pure function and pinning down edge cases with XCTest makes the problem easier to isolate than repeatedly inspecting production requests—and requires no simulator.
Define the parameter contract first
A query string is not a dictionary. The API contract must specify whether keys can repeat, whether order matters, whether a missing parameter, a parameter with no value, and an empty string are equivalent, and whether the server applies form-decoding rules. The client and server each “using the standard library” does not mean they agree on any of these points.
For example, flag, flag=, and omitting flag entirely are three distinct inputs. The client can represent them with a URLQueryItem whose value is nil, an empty string, or no item at all. Whether the server framework preserves those distinctions must be checked against the actual parser.
A literal plus sign in an ordinary URL query does not inherently mean a space. Interpreting plus signs as spaces is a form-encoding rule, not a rule that applies to every query endpoint.
Make these agreements explicit in test names. Do not keep only a single “normal search succeeds” case: Chinese text may work while plus signs and repeated filter keys still fail.
Build the URL with a pure function
Create a macOS unit test target and add the Swift file below. The example address is used only for local parsing; no request is sent. When integrating it into the application, replace the fixed address with the project's API configuration. Pass parameter values as raw text, without manually percent-encoding them first.
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)
}
}
The first test checks that reserved characters have not created extra parameters or a fragment. The second confirms that duplicate keys and array order have been preserved. The third simulates a single pass of form decoding. It is not a complete server parser: it checks only the plus-sign edge case for one value and cannot replace API integration tests.
Add failure cases, not more network calls
Extend the same fixture with Chinese text, combining-character sequences, emoji, empty values, leading and trailing spaces, and the literal string %2B. That last case is especially important: a percent sign entered by the user needs to be encoded, not treated as an already-encoded plus sign.
When you encounter %252B, first check whether the input was a raw plus sign or the literal string %2B. The former usually indicates double encoding; the latter may be exactly the right result. Treating every occurrence of %25 as an error can introduce a new bug in the attempted fix.
Do not use a broad character set to encode an entire URL after assembling it. Parameter names, parameter values, and paths belong to different contexts; directly concatenating &, =, or # can change the structure. Let the builder handle that structure, then check the parsed item count, names, and values separately.
Set a separate input-length limit for exceptionally long search terms. The encoded byte count differs from the character count. Test the limit policy against the API contract rather than assuming that a Chinese character occupies just one byte.
Add byte-level checks for signed requests
Semantically equivalent URLs do not necessarily have identical bytes. Parameter order, the representation of spaces, and the case of percent escapes can all affect a signature. Ordinary query tests can check that values survive an encoding-and-decoding round trip; signature tests must also verify the canonical string required by the protocol.
If the protocol requires sorting, first define how duplicate keys are sorted, then sign the final string. If it requires preserving order, keep the item array rather than rebuilding it through a dictionary. Do not switch plus-sign handling modes after signing, or the transmitted content will diverge from the signing input.
Fixed fixtures should include duplicate keys, empty values, and non-ASCII text, with assertions on the final encoded string. Here, “fixed” means the format agreed upon for the project—not treating one system-generated output as an unconditional, permanent standard. The server should retain the same fixtures to confirm that both sides use identical signing input.
Run on the cloud node and preserve the evidence
The test target only needs Foundation; there is no need to configure application signing. Run it through the test target's shared Scheme. Replace the names below with the project's actual names, and make sure the results directory does not already exist.
set -euo pipefail
mkdir -p artifacts
result="artifacts/query-$(uuidgen).xcresult"
xcodebuild test \
-project QueryContract.xcodeproj \
-scheme QueryContract \
-destination 'platform=macOS' \
-resultBundlePath "$result"
Before running, record the Xcode version, commit identifier, and builder mode. Keep the toolchain consistent within a test batch. After upgrading the toolchain, recheck the signing bytes rather than looking only at the number of passing tests.
For acceptance, confirm four things: raw values were not pre-encoded; duplicate keys were not discarded by a dictionary; the form-decoding switch is determined by the API contract; and the actual server parser passes the same set of fixtures. Failure reports need only retain the input category and encoded result. If real search terms or tokens are involved, use synthetic samples instead.
Ultimately, pure-function tests should protect the client-side structure, while server contract tests protect the decoding boundary. Both layers must pass before you can conclude that parameters were transmitted correctly—not merely that two client-side parses happened to produce the same result.
Frequently asked questions
Does URLComponents always encode a plus sign as %2B?
No. A literal plus is valid in a URL query, but a form decoder may interpret it as a space. If the endpoint uses form decoding, replace remaining literal plus signs in percentEncodedQuery with %2B after encoding, and test the server decoder.
Can query parameters become a dictionary before signing?
Only when the protocol forbids duplicate keys, ignores ordering and specifies canonicalization. Otherwise, retain a URLQueryItem array and test the final encoded bytes required by the signing protocol separately.
Choose a Dedicated Cloud Mac for Your Development Work
Apple Silicon physical machines, not virtual machines. Rent by the day, week, month, or quarter. Models and prices are published transparently; check real-time availability in the console.