[TDD] URLSession — Exception Handling

Cross-Cutting Concern with Decorator Pattern

Muhammad Alfiansyah
4 min readNov 24, 2024
https://marsner.com/blog/why-test-driven-development-tdd/

On the previous part we creating HTTPClient with TDD base on functionality that we desire.

Now we want to handle error mapping with Decorator Pattern. Why we need separate this ?

Can you imagine when your app use more than one API then each API have their error format, one service is always return 200 but have error status and messages in the json and the other use standard format that return their status code based on error type.

So we need more than handler to handle different response. For solve this we can use Helper or Decorator. But for modularity we want to use Decorator pattern to handle this.

Dependency Diagram

Oke lets start create our test, in the first test we want test if we get status code 500 will throw serverError. Now we create HTTPClientErrorMapperDecorator as SUT then we assert the error type and status code.

final class HTTPClientErrorMapperDecoratorTests: XCTestCase {
func test_load_serverError500() {
let sut = HTTPClientErrorMapperDecorator()
do {
_ = try await sut.load(urlRequest: URLRequest(url: url))
XCTFail()
} catch {
XCTAssertTrue(error is HTTPClientError)
XCTAssertEqual(error as? HTTPClientError, .serverError)
}
}
}

❌ Compiler will complaining because we dont have HTTPClientErrorMapperDecorator and HTTPClientError. Lets create them.

enum HTTPClientError: Swift.Error {
case serverError
}
final class HTTPClientErrorMapperDecorator: HTTPClientProtocol {
func load(urlRequest: URLRequest) async throws -> (Data, URLResponse) {
throw HTTPClientError.serverError
}
}

✅ Run the Test when pass we can go to the next step.

In the last part our HTTPClient not implement HTTPClientProtocol, we need to make HTTPClient implement HTTPClientProtocol so we can decorate it to HTTPClientErrorMapperDecorator.

final class HTTPClient: HTTPClientProtocol {
private let session: URLSession

init(session: URLSession = .shared) {
self.session = session
}

func load(urlRequest: URLRequest) async throws -> (Data, URLResponse) {
try await session.data(for: urlRequest)
}
}

Then we update HTTPClientErrorMapperDecorator to accept HTTPClientProtocol as parameters.

final class HTTPClientErrorMapperDecorator: HTTPClientProtocol {
private let decoratee: HTTPClientProtocol

init(decoratee: HTTPClientProtocol) {
self.decoratee = decoratee
}

func load(urlRequest: URLRequest) async throws -> (Data, URLResponse) {
let result = try await decoratee.load(urlRequest: urlRequest)
guard let httpResponse = result.1 as? HTTPURLResponse else {
throw HTTPClientError.unhandledError
}
switch httpResponse.statusCode {
case 500:
throw HTTPClientError.serverError
default:
throw HTTPClientError.unhandledError
}
}
}

Dont forget to update the Test also. We need to create Spy so we can control the response.

class SpyHTTPClient: HTTPClientProtocol {
private(set) var capturedRequests: [URLRequest] = []
var resultToReturn: Result<(Data, URLResponse), Error> = .failure(URLError(.badServerResponse))

func load(urlRequest: URLRequest) async throws -> (Data, URLResponse) {
capturedRequests.append(urlRequest)
switch resultToReturn {
case .success(let result): return result
case .failure(let error): throw error
}
}
}
final class HTTPClientErrorMapperDecoratorTests: XCTestCase {
func test_load_serverError500() async {
let url = URL(string: "https://example.com")!
let response = HTTPURLResponse(
url: url,
statusCode: 500,
httpVersion: nil,
headerFields: nil
)!
let client = SpyHTTPClient()
client.resultToReturn = .success((data, response))
let sut = HTTPClientErrorMapperDecorator(decoratee: client)
do {
_ = try await sut.load(urlRequest: URLRequest(url: url))
XCTFail()
} catch {
XCTAssertTrue(error is HTTPClientError)
XCTAssertEqual(error as? HTTPClientError, .serverError)
}
}
}

✅ Run the test again, test should pass and we can add another test.

Source Code

final class HTTPClientErrorMapperDecoratorTests: XCTestCase {
func test_load_serverError500() async {
let url = URL(string: "https://example.com")!
let sut = makeSUT(url: url, statusCode: 500)
do {
_ = try await sut.load(urlRequest: URLRequest(url: url))
XCTFail()
} catch {
XCTAssertTrue(error is HTTPClientError)
XCTAssertEqual(error as? HTTPClientError, .serverError)
}
}

func test_load_notFoundError404() async {
let url = URL(string: "https://example.com")!
let sut = makeSUT(url: url, statusCode: 404)
do {
_ = try await sut.load(urlRequest: URLRequest(url: url))
XCTFail()
} catch {
XCTAssertTrue(error is HTTPClientError)
XCTAssertEqual(error as? HTTPClientError, .notFoundError)
}
}

func test_load_unhandledError() async {
let url = URL(string: "https://example.com")!
let sut = makeSUT(url: url, statusCode: 201)
do {
_ = try await sut.load(urlRequest: URLRequest(url: url))
XCTFail()
} catch {
XCTAssertTrue(error is HTTPClientError)
XCTAssertEqual(error as? HTTPClientError, .unhandledError)
}
}

func test_load_unauthorizedError() async {
let url = URL(string: "https://example.com")!
let sut = makeSUT(url: url, statusCode: 401)
do {
_ = try await sut.load(urlRequest: URLRequest(url: url))
XCTFail()
} catch {
XCTAssertTrue(error is HTTPClientError)
XCTAssertEqual(error as? HTTPClientError, .unauthorizedError)
}
}

func test_load_success200() async {
let data = Data()
let url = URL(string: "https://example.com")!
let sut = makeSUT(url: url, statusCode: 200, data: data)
do {
let (d, _) = try await sut.load(urlRequest: URLRequest(url: url))
XCTAssertEqual(d, data)
} catch {
XCTFail()
}
}

private func makeSUT(url: URL, statusCode: Int, data: Data = Data()) -> HTTPClientErrorMapperDecorator {
let response = HTTPURLResponse(
url: url,
statusCode: statusCode,
httpVersion: nil,
headerFields: nil
)!
let client = SpyHTTPClient()
client.resultToReturn = .success((data, response))
let sut = HTTPClientErrorMapperDecorator(decoratee: client)
return sut
}
}
class SpyHTTPClient: HTTPClientProtocol {
private(set) var capturedRequests: [URLRequest] = []
var resultToReturn: Result<(Data, URLResponse), Error> = .failure(URLError(.badServerResponse))

func load(urlRequest: URLRequest) async throws -> (Data, URLResponse) {
capturedRequests.append(urlRequest)
switch resultToReturn {
case .success(let result): return result
case .failure(let error): throw error
}
}
}
enum HTTPClientError: Swift.Error {
case timeoutError
case serverError
case unhandledError
case notFoundError
case unauthorizedError
}

--

--

No responses yet