[TDD] URLSession — Model Adapter
Converting HTTPClientProtocol to ModelMapperAdapterProtocol
[TDD] URLSession — Exception Handling
Cross-Cutting Concern with Decorator Pattern
alpiopio.medium.com
Dependency Diagram
Because we use modular Error mapper, so we need something like HTTPClientErrorMapperDecorator to layering our Adapter.
Why ?
When we use layering method we can detachable the code and structure the code like lego.
I give you sample image so you can visualize it.
let client = HTTPClient()
let wrapper = HTTPClientErrorMapperDecorator(decoratee: client)
let adapter = ModelMapperAdapter(client: wrapper)
Then we can directly call adapter to fetch API.
let result = try await sut.load(urlRequest: urlRequest, model: DataModel.self)
Or we can directly structure fetch without error mapper or use another error mapper
let client = HTTPClient()
let adapter = ModelMapperAdapter(docoratee: client)
let client = HTTPClient()
let wrapper = // Another Error Mapper
let adapter = ModelMapperAdapter(client: wrapper)
Oke lets create out TDD for this Adapter.
final class ModelMapperAdapterProtocolTests: XCTestCase {
func test_load_invalidData() async {
let sut = ModelMapperAdapter()
XCTAssertThrowsError(sut.load())
}
}
❌ Like we do before, we create unavailable class and desire Assert. We just create ModelMapperAdapter and assert load method.
✅ Lets make it pass
protocol ModelMapperAdapterProtocol {
func load() throws
}
final class ModelMapperAdapter: ModelMapperAdapterProtocol {
func load() throws {
throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "invalid data"))
}
}
After test pass we can continue to build our business logic here, because we want convert HTTPClientProtocol result to ModelMapperAdapterProtocol we need to init HTTPClientProtocol to our Adapter, we also add JSONDecoder as parameter because some API use different format on their key (snake_case).
And on the load method we need Decodable type also, so we can update our implementation like this :
protocol ModelMapperAdapterProtocol {
func load<T: Decodable>(urlRequest: URLRequest, model: T.Type) async throws -> T
}
final class ModelMapperAdapter: ModelMapperAdapterProtocol {
private let client: HTTPClientProtocol
private let decoder: JSONDecoder
init(client: HTTPClientProtocol, decoder: JSONDecoder = JSONDecoder()) {
self.client = client
self.decoder = decoder
}
func load<T: Decodable>(urlRequest: URLRequest, model: T.Type) async throws -> T {
let (data, _) = try await client.load(urlRequest: urlRequest)
return try decoder.decode(T.self, from: data)
}
}
✅ Oke, implementation is DONE then we update our test to assert the result.
final class ModelMapperAdapterProtocolTests: XCTestCase {
func test_load_invalidData() async {
let url = URL(string: "https://example.com")!
let client = SpyHTTPClient()
client.resultToReturn = .success((Data(), URLResponse()))
let sut = ModelMapperAdapter(client: client)
do {
_ = try await sut.load(urlRequest: URLRequest(url: url), model: DataModel.self)
} catch {
XCTAssertTrue(error is DecodingError)
}
}
}
struct DataModel: Decodable {
let id: Int
let name: String
}
Our first test is DONE then we can create another test to make sure mapping work correctly.
Source Code
@available(iOS 14.0, *)
final class ModelMapperAdapterProtocolTests: XCTestCase {
func test_load_invalidData() async {
let url = URL(string: "https://example.com")!
let invalid = """
{
"id": 1,
"nama": "invalid name"
}
""".data(using: .utf8)!
let sut = makeSUT(url: url, statusCode: 0, data: invalid)
let urlRequest = URLRequest(url: url)
do {
_ = try await sut.load(urlRequest: urlRequest, model: DataModel.self)
} catch {
XCTAssertTrue(error is DecodingError)
if case let DecodingError.keyNotFound(key, context) = error {
XCTAssertEqual(key.stringValue, "name")
XCTAssertEqual(context.debugDescription, "No value associated with key CodingKeys(stringValue: \"name\", intValue: nil) (\"name\").")
} else {
XCTFail()
}
}
}
func test_load_invalidType() async {
let url = URL(string: "https://example.com")!
let invalid = """
{
"id": "1",
"name": "invalid name"
}
""".data(using: .utf8)!
let sut = makeSUT(url: url, statusCode: 0, data: invalid)
let urlRequest = URLRequest(url: url)
do {
_ = try await sut.load(urlRequest: urlRequest, model: DataModel.self)
} catch {
XCTAssertTrue(error is DecodingError)
if case let DecodingError.typeMismatch(type, context) = error {
XCTAssertEqual("\(type)", "Int")
XCTAssertEqual(context.debugDescription, "Expected to decode Int but found a string instead.")
} else {
XCTFail()
}
}
}
func test_load_invalidJSON() async {
let url = URL(string: "https://example.com")!
let invalid = """
{
"id": 1,
"nama: "invalid name"
}
""".data(using: .utf8)!
let sut = makeSUT(url: url, statusCode: 0, data: invalid)
let urlRequest = URLRequest(url: url)
do {
_ = try await sut.load(urlRequest: urlRequest, model: DataModel.self)
} catch {
XCTAssertTrue(error is DecodingError)
if case let DecodingError.dataCorrupted(context) = error {
XCTAssertEqual(context.debugDescription, "The given data was not valid JSON.")
} else {
XCTFail()
}
}
}
func test_load_invalidStructureData() async {
let url = URL(string: "https://example.com")!
let invalid = """
{
"id": 1,
"nama": ["invalid name"]
}
""".data(using: .utf8)!
let sut = makeSUT(url: url, statusCode: 0, data: invalid)
let urlRequest = URLRequest(url: url)
do {
_ = try await sut.load(urlRequest: urlRequest, model: DataModel.self)
} catch {
XCTAssertTrue(error is DecodingError)
if case let DecodingError.keyNotFound(key, context) = error {
XCTAssertEqual(key.stringValue, "name")
XCTAssertEqual(context.debugDescription, "No value associated with key CodingKeys(stringValue: \"name\", intValue: nil) (\"name\").")
} else {
XCTFail()
}
}
}
func test_load_validData() async {
let url = URL(string: "https://example.com")!
let invalid = """
{
"id": 1,
"name": "valid name"
}
""".data(using: .utf8)!
let sut = makeSUT(url: url, statusCode: 0, data: invalid)
let urlRequest = URLRequest(url: url)
do {
let result = try await sut.load(urlRequest: urlRequest, model: DataModel.self)
XCTAssertEqual(result.id, 1)
XCTAssertEqual(result.name, "valid name")
} catch {
XCTFail()
}
}
func makeSUT(url: URL, statusCode: Int, data: Data = Data()) -> ModelMapperAdapterProtocol {
let response = HTTPURLResponse(
url: url,
statusCode: statusCode,
httpVersion: nil,
headerFields: nil
)!
let client = SpyHTTPClient()
client.resultToReturn = .success((data, response))
let sut = ModelMapperAdapter(client: client)
return sut
}
}