[TDD] URLSession — HTTPClient
In the last year actually I feel not improving, this is not to be like this when we creating app or feature. So many uncertainties in our code, there is no assurance that our app is working correctly.
In many years I am not touching TDD because in my mind TDD making programming more slower because we need add more LOC and time when building our app.
But when we deal with production app, I think we need something that we can hold or make sure that our app working properly without just depend to Manual QA.
So I decide to learn what is TDD and how they work in our app. Why TDD is needed in software development.
What is TDD ?
TDD stands for Test-Driven Development, a software development process that involves writing tests before writing code.
TDD is iterative process, first we ensuring test fail first and then we write code until pass.
This cycle repeated until desire functionality is implemented.
Why we need TDD ?
With TDD we gain some benefit :
- Identifying bug earlier
- Creating optimize code
- Reducing project cost by eliminating potential flaws and make code maintenance more manageable
Dependency Diagram
First check this diagram below, We will create HTTPClientProtocol then HTTPClient will implemented this protocol.
Writing our first test:
In this article, we would follow TDD and write the test first and then the production code.
final class HTTPClientTests: XCTestCase {
func test_load_timeout() {
let sut = HTTPClient()
XCTAssertThrowsError(try sut.load())
}
}
❌ We write HTTPClient that not exist yet and the test file will give us error. Lets satisfy our compiler by creating HTTPClient.
final class HTTPClient {
func load() throws {
throw URLError(.timedOut)
}
}
✅ Lets try run our test then test will pass. Next we will create another test that will return another error.
How we dynamically set error for each test ? We need URLProtocol to stub the result.
class MockURLProtocol: URLProtocol {
override class func canInit(with request: URLRequest) -> Bool {
return true
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}
nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override func startLoading() {
guard let handler = MockURLProtocol.requestHandler else {
XCTFail("No request handler set")
return
}
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
With that MockURLProtocol we can set result and response dynamically. Then HOW we use it ?
First we need URLSession on the HTTPClient
final class HTTPClient {
// Add this
private let session: URLSession
// Add this
init(session: URLSession = .shared) {
self.session = session
}
// Change this
func load(urlRequest: URLRequest) async throws -> (Data, URLResponse) {
try await session.data(for: urlRequest)
}
}
final class HTTPClientTests: XCTestCase {
// Change this
func test_load_timeout() {
let url = URL(string: "https://example.com")!
let sut = HTTPClient()
let act = try await sut.load(urlRequest: URLRequest(url: url))
XCTAssertThrowsError(act)
}
}
❌ This change will not make Test pass because we still not in control to change result like we want, next we need to implement MockURLProtocol to our SUT.
final class HTTPClientTests: XCTestCase {
func test_load_timeout() async throws {
// Arrange
let url = URL(string: "https://example.com")!
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockURLProtocol.self]
let session = URLSession(configuration: configuration)
let sut = HTTPClient(session: session)
MockURLProtocol.requestHandler = { request in
throw URLError(.timedOut)
}
// Act & Assert
do {
_ = try await sut.load(urlRequest: URLRequest(url: url))
XCTFail()
} catch {
XCTAssertEqual((error as? URLError)?.code, URLError(.timedOut).code)
}
}
}
✅ By setting URLSessionConfiguration that use MockURLProtocol as protocol classes then we mock request handler on the MockURLProtocol we can control the response.
Next we can test another error response by setting the error
final class HTTPClientTests: XCTestCase {
// ...
func test_load_badServer() async throws {
// Arrange
let url = URL(string: "https://example.com")!
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockURLProtocol.self]
let session = URLSession(configuration: configuration)
let sut = HTTPClient(session: session)
MockURLProtocol.requestHandler = { request in
throw URLError(.badServerResponse)
}
// Act & Assert
do {
_ = try await sut.load(urlRequest: URLRequest(url: url))
XCTFail()
} catch {
XCTAssertEqual((error as? URLError)?.code, URLError(.badServerResponse).code)
}
}
}
We are test with Error so why we don’t test with success response
final class HTTPClientTests: XCTestCase {
// ...
func test_load_emptyData() async throws {
// Arrange
let url = URL(string: "https://example.com")!
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockURLProtocol.self]
let session = URLSession(configuration: configuration)
let mockData = Data()
let sut = HTTPClient(session: session)
MockURLProtocol.requestHandler = { request in
let response = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!
return (response, mockData)
}
// Act & Assert
do {
let (data, response) = try await sut.load(urlRequest: URLRequest(url: url))
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
XCTAssertEqual(data, mockData)
} catch {
XCTFail()
}
}
}
Great our all test pass, so next we want to create Custom Error mapper with Decoration Pattern.
Source Code
final class HTTPClientTests: XCTestCase {
func test_load_timeout() async throws {
let url = URL(string: "https://example.com")!
let sut = makeSUT(url: url, statusCode: 0, error: URLError(.timedOut))
do {
_ = try await sut.load(urlRequest: URLRequest(url: URL(string: "https://example.com")!))
XCTFail()
} catch {
XCTAssertEqual((error as? URLError)?.code, URLError(.timedOut).code)
}
}
func test_load_serverError() async throws {
let url = URL(string: "https://example.com")!
let sut = makeSUT(url: url, statusCode: 0, error: URLError(.badServerResponse))
do {
_ = try await sut.load(urlRequest: URLRequest(url: url))
XCTFail()
} catch {
XCTAssertEqual((error as? URLError)?.code, URLError(.badServerResponse).code)
}
}
func test_load_emptyData() async throws {
let url = URL(string: "https://example.com")!
let sut = makeSUT(url: url, statusCode: 200)
do {
let (data, response) = try await sut.load(urlRequest: URLRequest(url: url))
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
XCTAssertEqual(data, Data())
} catch {
XCTFail()
}
}
func test_load_jsonData() async throws {
let url = URL(string: "https://example.com")!
let valid = """
{
"id": 1,
"name": "valid name"
}
""".data(using: .utf8)!
let sut = makeSUT(url: url, statusCode: 200, data: valid)
do {
let (data, response) = try await sut.load(urlRequest: URLRequest(url: url))
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
XCTAssertEqual(data, valid)
} catch {
XCTFail()
}
}
private func makeSUT(url: URL, statusCode: Int, data: Data = Data(), error: Error? = nil) -> HTTPClient {
MockURLProtocol.requestHandler = { request in
if let error = error {
throw error
}
let response = HTTPURLResponse(
url: url,
statusCode: statusCode,
httpVersion: nil,
headerFields: nil
)!
return (response, data)
}
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockURLProtocol.self]
let session = URLSession(configuration: configuration)
let sut = HTTPClient(session: session)
return sut
}
}
struct DataModel: Decodable {
let id: Int
let name: String
}
class MockURLProtocol: URLProtocol {
override class func canInit(with request: URLRequest) -> Bool {
return true
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}
nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override func startLoading() {
guard let handler = MockURLProtocol.requestHandler else {
XCTFail("No request handler set")
return
}
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}