Lab 8.1 — TDD Feature: NetworkClient
Goal: build a generic NetworkClient from zero using strict test-first discipline. By the end you’ll have a typed, async, retry-capable client with ~95% coverage and a clean injectable design.
Time: 90–150 minutes.
Prereqs: Xcode 16+, Swift 6.
Setup
- New iOS App → SwiftUI → name
NetworkClientLab. - Add a unit test target if not present (
File → New → Target → Unit Testing Bundle). - Create empty files:
App/NetworkClient.swift,App/HTTPClient.swift,Tests/NetworkClientTests.swift. - In tests file:
@testable import NetworkClientLab.
The test list (write these on paper before coding)
1. GET request returns decoded JSON
2. 404 throws .notFound
3. 500 throws .server with status code
4. Network error throws .transport
5. Decoding error throws .decoding
6. POST sends body
7. POST sets Content-Type: application/json
8. Custom headers are merged with defaults
9. Authorization header attached via TokenProvider
10. Retries 3 times on transient failures
11. Doesn't retry on 4xx
You’ll TDD the first 6. The rest are stretch.
Build (RED-GREEN-REFACTOR cycles)
Cycle 1 — RED
Tests/NetworkClientTests.swift:
import XCTest
@testable import NetworkClientLab
final class NetworkClientTests: XCTestCase {
func test_get_returnsDecodedJSON() async throws {
let mock = MockHTTPClient()
mock.stub = (
data: #"{"id":"1","name":"Ada"}"#.data(using: .utf8)!,
response: HTTPURLResponse(url: URL(string: "https://x")!,
statusCode: 200, httpVersion: nil, headerFields: nil)!
)
let sut = NetworkClient(http: mock)
let user: User = try await sut.get("/users/1")
XCTAssertEqual(user.id, "1")
XCTAssertEqual(user.name, "Ada")
}
}
struct User: Codable, Equatable { let id: String; let name: String }
Compile fails — nothing exists. Good.
Cycle 1 — GREEN
App/HTTPClient.swift:
import Foundation
protocol HTTPClient {
func send(_ request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: HTTPClient {
func send(_ request: URLRequest) async throws -> (Data, URLResponse) {
try await data(for: request)
}
}
App/NetworkClient.swift:
import Foundation
enum NetworkError: Error, Equatable {
case notFound
case server(status: Int)
case transport(message: String)
case decoding(message: String)
}
struct NetworkClient {
let baseURL = URL(string: "https://api.example.com")!
let http: HTTPClient
let decoder = JSONDecoder()
func get<T: Decodable>(_ path: String) async throws -> T {
var req = URLRequest(url: baseURL.appendingPathComponent(path))
req.httpMethod = "GET"
let (data, _) = try await http.send(req)
return try decoder.decode(T.self, from: data)
}
}
Tests/MockHTTPClient.swift:
@testable import NetworkClientLab
import Foundation
final class MockHTTPClient: HTTPClient {
var stub: (data: Data, response: HTTPURLResponse)?
var error: Error?
var receivedRequests: [URLRequest] = []
func send(_ request: URLRequest) async throws -> (Data, URLResponse) {
receivedRequests.append(request)
if let error { throw error }
guard let stub else { fatalError("Stub not set") }
return (stub.data, stub.response)
}
}
Run. GREEN.
Cycle 2 — RED
Add to NetworkClientTests:
func test_404_throwsNotFound() async {
let mock = MockHTTPClient()
mock.stub = (data: Data(), response: response(404))
let sut = NetworkClient(http: mock)
do {
let _: User = try await sut.get("/users/missing")
XCTFail("Expected throw")
} catch let error as NetworkError {
XCTAssertEqual(error, .notFound)
} catch {
XCTFail("Wrong error type: \(error)")
}
}
private func response(_ status: Int) -> HTTPURLResponse {
HTTPURLResponse(url: URL(string: "https://x")!,
statusCode: status, httpVersion: nil, headerFields: nil)!
}
Run. RED.
Cycle 2 — GREEN
Update NetworkClient.get:
func get<T: Decodable>(_ path: String) async throws -> T {
var req = URLRequest(url: baseURL.appendingPathComponent(path))
req.httpMethod = "GET"
let (data, response) = try await http.send(req)
guard let http = response as? HTTPURLResponse else {
throw NetworkError.transport(message: "Non-HTTP response")
}
switch http.statusCode {
case 200...299: break
case 404: throw NetworkError.notFound
case 400...499, 500...599: throw NetworkError.server(status: http.statusCode)
default: break
}
do { return try decoder.decode(T.self, from: data) }
catch { throw NetworkError.decoding(message: error.localizedDescription) }
}
GREEN.
Cycle 3 — RED → 500 status
func test_500_throwsServer() async {
let mock = MockHTTPClient()
mock.stub = (Data(), response(500))
let sut = NetworkClient(http: mock)
do {
let _: User = try await sut.get("/users/1")
XCTFail("Expected throw")
} catch let error as NetworkError {
XCTAssertEqual(error, .server(status: 500))
} catch { XCTFail() }
}
Already GREEN from previous cycle. Refactor opportunity: extract validate(_:) into a separate function.
Cycle 4 — RED → transport error
func test_transportError_wraps() async {
let mock = MockHTTPClient()
mock.error = URLError(.notConnectedToInternet)
let sut = NetworkClient(http: mock)
do {
let _: User = try await sut.get("/users/1")
XCTFail()
} catch let error as NetworkError {
if case .transport = error { return }
XCTFail("Wrong case")
} catch { XCTFail() }
}
GREEN: wrap the call.
func get<T: Decodable>(_ path: String) async throws -> T {
var req = URLRequest(url: baseURL.appendingPathComponent(path))
req.httpMethod = "GET"
let result: (Data, URLResponse)
do { result = try await http.send(req) }
catch { throw NetworkError.transport(message: error.localizedDescription) }
let (data, response) = result
try validate(response)
do { return try decoder.decode(T.self, from: data) }
catch { throw NetworkError.decoding(message: error.localizedDescription) }
}
private func validate(_ response: URLResponse) throws {
guard let http = response as? HTTPURLResponse else {
throw NetworkError.transport(message: "Non-HTTP response")
}
switch http.statusCode {
case 200...299: return
case 404: throw NetworkError.notFound
case 400...499, 500...599: throw NetworkError.server(status: http.statusCode)
default: return
}
}
Cycle 5 — RED → decoding error
func test_badJSON_throwsDecoding() async {
let mock = MockHTTPClient()
mock.stub = (Data("not json".utf8), response(200))
let sut = NetworkClient(http: mock)
do {
let _: User = try await sut.get("/users/1")
XCTFail()
} catch let error as NetworkError {
if case .decoding = error { return }
XCTFail()
} catch { XCTFail() }
}
GREEN from existing implementation.
Cycle 6 — RED → POST with body
func test_post_sendsJSONBody() async throws {
let mock = MockHTTPClient()
mock.stub = (#"{"id":"42","name":"X"}"#.data(using: .utf8)!, response(201))
let sut = NetworkClient(http: mock)
let body = User(id: "", name: "Posted")
let result: User = try await sut.post("/users", body: body)
XCTAssertEqual(result.id, "42")
let req = try XCTUnwrap(mock.receivedRequests.first)
XCTAssertEqual(req.httpMethod, "POST")
XCTAssertEqual(req.value(forHTTPHeaderField: "Content-Type"), "application/json")
let sentBody = try JSONDecoder().decode(User.self, from: req.httpBody ?? Data())
XCTAssertEqual(sentBody.name, "Posted")
}
GREEN — add to NetworkClient:
let encoder = JSONEncoder()
func post<Body: Encodable, T: Decodable>(_ path: String, body: Body) async throws -> T {
var req = URLRequest(url: baseURL.appendingPathComponent(path))
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try encoder.encode(body)
let result: (Data, URLResponse)
do { result = try await http.send(req) }
catch { throw NetworkError.transport(message: error.localizedDescription) }
let (data, response) = result
try validate(response)
do { return try decoder.decode(T.self, from: data) }
catch { throw NetworkError.decoding(message: error.localizedDescription) }
}
Refactor — merge GET/POST
struct NetworkClient {
let baseURL = URL(string: "https://api.example.com")!
let http: HTTPClient
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
func get<T: Decodable>(_ path: String) async throws -> T {
try await perform(path: path, method: "GET", body: Optional<String>.none)
}
func post<Body: Encodable, T: Decodable>(_ path: String, body: Body) async throws -> T {
try await perform(path: path, method: "POST", body: body)
}
private func perform<Body: Encodable, T: Decodable>(
path: String, method: String, body: Body?
) async throws -> T {
var req = URLRequest(url: baseURL.appendingPathComponent(path))
req.httpMethod = method
if let body {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try encoder.encode(body)
}
let result: (Data, URLResponse)
do { result = try await http.send(req) }
catch { throw NetworkError.transport(message: error.localizedDescription) }
try validate(result.1)
do { return try decoder.decode(T.self, from: result.0) }
catch { throw NetworkError.decoding(message: error.localizedDescription) }
}
private func validate(_ response: URLResponse) throws { /* as before */ }
}
Tests all GREEN. Coverage should be ≥ 90%.
Stretch
- Custom headers — accept
headers: [String: String]per call; merge with defaults. TokenProviderprotocol — inject; auto-attachAuthorization: Bearer ….- Retry with exponential backoff — TDD this: test retries 3 times on
.transport, doesn’t retry on.notFound. - Cancellation — verify
Task.cancel()propagates; assert with a custom slow-mock. - Real-world — swap
MockHTTPClientfor the realURLSessionagainsthttps://jsonplaceholder.typicode.comand run the same tests.
Notes
- Each cycle should be under 5 minutes. If a RED is hard to write, your test list is too big — break the case down.
- When you refactor (merge GET/POST), tests stay GREEN throughout. That’s the safety net TDD gives you.
- Resist the urge to write the whole
NetworkClientat once. The pain of going slow now pays off in cleaner design and fewer bugs.