An integration test checks that real components honor their shared contract. A test using a fake repository can establish how a screen handles a conflict, but it cannot establish that a database detects the conflict. A test of decoded fixture bytes cannot establish that the networking adapter handles HTTP status codes correctly.
Choose the boundary before choosing the fixture. The test should use real behavior where it makes its claim and controlled dependencies outside that boundary.
This Swift 5.7 article uses XCTest, an isolated URLSession with a URLProtocol fixture, and real files in unique temporary directories. The HTTP tests exercise request construction, session delivery, status handling, and decoding. They do not exercise DNS, TCP, TLS, or the remote server.
Keep the client contract independent of the fixture
import Foundation
import XCTest
struct CatalogBook: Decodable, Equatable, Sendable {
let id: Int
let title: String
}
enum CatalogFailure: Error, Equatable { case nonHTTP, status(Int), invalidPayload }
struct CatalogClient {
let baseURL: URL
let session: URLSession
func books() async throws -> [CatalogBook] {
var request = URLRequest(url: baseURL.appendingPathComponent("books"))
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw CatalogFailure.nonHTTP }
guard http.statusCode == 200 else { throw CatalogFailure.status(http.statusCode) }
let books: [CatalogBook]
do { books = try JSONDecoder().decode([CatalogBook].self, from: data) }
catch { throw CatalogFailure.invalidPayload }
guard books.allSatisfy({ $0.id > 0 && !$0.title.isEmpty }),
Set(books.map(\.id)).count == books.count else { throw CatalogFailure.invalidPayload }
return books
}
}
The client accepts its base URL and session. It distinguishes transport failure, non-HTTP response, rejected status, and invalid content instead of converting all of them into an empty successful catalog.
A valid empty JSON array is different from unavailable data. Whether a particular status represents absence, permission failure, or retryable service failure belongs in the application contract.
Give each HTTP fixture its own identity
One static mutable requestHandler shared by every test can race under parallel execution. Instead, register immutable responses under unique hosts and protect the registry. Every session handles only reserved fixture hosts, so missing fixture data fails locally rather than falling through to a live service.
struct HTTPFixture: Sendable {
let path: String
let status: Int
let data: Data
}
final class FixtureRegistry: @unchecked Sendable {
private let lock = NSLock()
private var values: [String: HTTPFixture] = [:]
func install(_ fixture: HTTPFixture, host: String) {
lock.lock(); defer { lock.unlock() }
values[host] = fixture
}
func fixture(host: String) -> HTTPFixture? {
lock.lock(); defer { lock.unlock() }
return values[host]
}
func remove(host: String) {
lock.lock(); defer { lock.unlock() }
values.removeValue(forKey: host)
}
}
final class FixtureURLProtocol: URLProtocol, @unchecked Sendable {
static let registry = FixtureRegistry()
override class func canInit(with request: URLRequest) -> Bool {
request.url?.host?.hasSuffix(".example.invalid") == true
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let url = request.url, let host = url.host,
let fixture = Self.registry.fixture(host: host),
request.httpMethod == "GET", url.path == fixture.path,
request.value(forHTTPHeaderField: "Accept") == "application/json",
let response = HTTPURLResponse(url: url, statusCode: fixture.status,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"]) else {
client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
return
}
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: fixture.data)
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
struct CatalogFixture {
let client: CatalogClient
let session: URLSession
let host: String
init(status: Int = 200, body: String) {
host = UUID().uuidString.lowercased() + ".example.invalid"
FixtureURLProtocol.registry.install(HTTPFixture(path: "/books", status: status,
data: Data(body.utf8)), host: host)
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [FixtureURLProtocol.self]
configuration.urlCache = nil
configuration.httpCookieStorage = nil
session = URLSession(configuration: configuration)
client = CatalogClient(baseURL: URL(string: "https://" + host)!, session: session)
}
func close() {
session.invalidateAndCancel()
FixtureURLProtocol.registry.remove(host: host)
}
}
The registry’s unchecked sendability is backed by a lock around every access to its mutable dictionary. Responses are immutable values. The protocol subclass adds no mutable instance state and completes delivery synchronously inside startLoading. These tests await request completion before closing the fixture; this fixture does not model cancellation races.
A delayed protocol fixture needs its own cancellation state so it cannot continue delivering after stop. Do not copy the empty stop implementation into an asynchronous transport stub.
Test status, decoding, and parallel isolation
final class CatalogIntegrationTests: XCTestCase {
func testSessionsReceiveOnlyTheirOwnFixtures() async throws {
let first = CatalogFixture(body: #"[{"id":1,"title":"First"}]"#)
let second = CatalogFixture(body: #"[{"id":2,"title":"Second"}]"#)
defer { first.close(); second.close() }
async let firstBooks = first.client.books()
async let secondBooks = second.client.books()
let (a, b) = try await (firstBooks, secondBooks)
XCTAssertEqual(a, [CatalogBook(id: 1, title: "First")])
XCTAssertEqual(b, [CatalogBook(id: 2, title: "Second")])
}
func testValidEmptyCatalogRemainsASuccess() async throws {
let fixture = CatalogFixture(body: "[]")
defer { fixture.close() }
let books = try await fixture.client.books()
XCTAssertTrue(books.isEmpty)
}
func testMalformedJSONIsRejected() async throws {
let fixture = CatalogFixture(body: "not json")
defer { fixture.close() }
do {
_ = try await fixture.client.books()
XCTFail("Malformed data must remain a failure")
} catch let error as CatalogFailure {
XCTAssertEqual(error, .invalidPayload)
}
}
func testRejectedStatusIsNotAnEmptyCatalog() async throws {
let fixture = CatalogFixture(status: 503, body: "[]")
defer { fixture.close() }
do {
_ = try await fixture.client.books()
XCTFail("An unavailable response must remain a failure")
} catch let error as CatalogFailure {
XCTAssertEqual(error, .status(503))
}
}
func testDuplicateIdentitiesAreRejectedAfterDecoding() async throws {
let fixture = CatalogFixture(body: #"[{"id":1,"title":"A"},{"id":1,"title":"B"}]"#)
defer { fixture.close() }
do {
_ = try await fixture.client.books()
XCTFail("Rows require unique stable identities")
} catch let error as CatalogFailure {
XCTAssertEqual(error, .invalidPayload)
}
}
}
The concurrent test proves fixture isolation without relying on test method order. The status test provides valid JSON deliberately: it catches a client that decodes first and accidentally treats a failed HTTP request as successful content.
Add malformed JSON and transport-error scenarios when those adapter paths matter. A server contract suite should separately check that a real controlled endpoint returns the schema, status meanings, and authentication behavior the client expects.
Test a real storage adapter in a fresh directory
struct StoredNote: Codable, Equatable { let title: String; let revision: Int }
enum NoteStorageFailure: Error { case invalid, corrupt }
struct NoteFileStore {
let file: URL
func save(_ note: StoredNote) throws {
guard !note.title.isEmpty, note.revision > 0 else { throw NoteStorageFailure.invalid }
let data = try JSONEncoder().encode(note)
try data.write(to: file, options: .atomic)
}
func load() throws -> StoredNote {
let data = try Data(contentsOf: file)
do {
let note = try JSONDecoder().decode(StoredNote.self, from: data)
guard !note.title.isEmpty, note.revision > 0 else { throw NoteStorageFailure.corrupt }
return note
}
catch { throw NoteStorageFailure.corrupt }
}
}
final class NoteFileIntegrationTests: XCTestCase {
private func makeDirectory() throws -> URL {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("testing-" + UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
addTeardownBlock { try FileManager.default.removeItem(at: directory) }
return directory
}
func testSavedNoteCanBeReadByANewAdapterInstance() throws {
let file = try makeDirectory().appendingPathComponent("note.json")
let expected = StoredNote(title: "Swift", revision: 2)
try NoteFileStore(file: file).save(expected)
let reloaded = try NoteFileStore(file: file).load()
XCTAssertEqual(reloaded, expected)
}
func testInvalidWritePreservesPreviouslyStoredContent() throws {
let file = try makeDirectory().appendingPathComponent("note.json")
let store = NoteFileStore(file: file)
let expected = StoredNote(title: "Original", revision: 1)
try store.save(expected)
XCTAssertThrowsError(try store.save(StoredNote(title: "", revision: 2)))
XCTAssertEqual(try store.load(), expected)
}
func testDecodedValuesMustAlsoSatisfyTheStorageContract() throws {
let file = try makeDirectory().appendingPathComponent("note.json")
try Data(#"{"title":"Swift","revision":0}"#.utf8).write(to: file)
XCTAssertThrowsError(try NoteFileStore(file: file).load()) { error in
guard case NoteStorageFailure.corrupt = error else {
return XCTFail("Expected invalid persisted state to be rejected")
}
}
}
func testCorruptionRemainsAFailure() throws {
let file = try makeDirectory().appendingPathComponent("note.json")
try Data("not json".utf8).write(to: file)
XCTAssertThrowsError(try NoteFileStore(file: file).load()) { error in
guard case NoteStorageFailure.corrupt = error else {
return XCTFail("Expected corrupt storage, received \(error)")
}
}
}
}
Reading through a new adapter instance helps establish persistence rather than an accidental in-memory cache. The invalid-write test also reads the old content afterward; an exception alone would not prove storage stayed unchanged.
Atomic replacement of one file is not a multi-file transaction or a complete crash-durability guarantee. It also does not solve concurrent conditional edits. Those requirements need their own storage protocol and tests.
Keep the integration claims precise
flowchart LR
accTitle: Different integration fixtures prove different boundaries
Client[Real CatalogClient] --> Session[Real URLSession]
Session --> Fixture[Controlled URLProtocol responses]
Store[Real NoteFileStore] --> Files[Fresh real directory]
Server[Controlled server contract suite] --> Transport[Actual HTTP and server behavior]
A unit suite, adapter integration suite, and end-to-end suite provide complementary evidence. Keep their dependencies and runtime costs visible so developers know what a failure means and which lane should run for a change.
Exercise: add an adapter conformance suite
Define a storage capability with revision conflicts, then run the same contract cases against a memory adapter and a persistent adapter. Use separate fixtures for each test, check state after rejected writes, and add two concurrent writes with the same base revision to verify the real atomic boundary.
Apple documents URLProtocol as the URL-loading customization point used by the HTTP fixture.
Return to the Testing roadmap.