From 08b72c38b8931b584d6b97a35fb73bdf4bec3af9 Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Tue, 9 Dec 2025 10:04:14 +0100 Subject: [PATCH] Replace Combine with async/await on Image Comments feature --- .../EssentialApp/CommentsUIComposer.swift | 5 +- EssentialApp/EssentialApp/SceneDelegate.swift | 11 +- .../CommentsUIIntegrationTests.swift | 117 +++++++++--------- .../Image Comments Feature/ImageComment.swift | 2 +- 4 files changed, 65 insertions(+), 70 deletions(-) diff --git a/EssentialApp/EssentialApp/CommentsUIComposer.swift b/EssentialApp/EssentialApp/CommentsUIComposer.swift index d2d3062d..fe440a1b 100644 --- a/EssentialApp/EssentialApp/CommentsUIComposer.swift +++ b/EssentialApp/EssentialApp/CommentsUIComposer.swift @@ -3,7 +3,6 @@ // import UIKit -import Combine import EssentialFeed import EssentialFeediOS @@ -11,10 +10,10 @@ import EssentialFeediOS public final class CommentsUIComposer { private init() {} - private typealias CommentsPresentationAdapter = LoadResourcePresentationAdapter<[ImageComment], CommentsViewAdapter> + private typealias CommentsPresentationAdapter = AsyncLoadResourcePresentationAdapter<[ImageComment], CommentsViewAdapter> public static func commentsComposedWith( - commentsLoader: @escaping () -> AnyPublisher<[ImageComment], Error> + commentsLoader: @escaping () async throws -> [ImageComment] ) -> ListViewController { let presentationAdapter = CommentsPresentationAdapter(loader: commentsLoader) diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index dbe14da2..82b3c6a2 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -5,7 +5,6 @@ import os import UIKit import CoreData -import Combine import EssentialFeed class SceneDelegate: UIResponder, UIWindowSceneDelegate { @@ -83,16 +82,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { private func showComments(for image: FeedImage) { let url = ImageCommentsEndpoint.get(image.id).url(baseURL: baseURL) - let comments = CommentsUIComposer.commentsComposedWith(commentsLoader: makeRemoteCommentsLoader(url: url)) + let comments = CommentsUIComposer.commentsComposedWith(commentsLoader: loadComments(url: url)) navigationController.pushViewController(comments, animated: true) } - private func makeRemoteCommentsLoader(url: URL) -> () -> AnyPublisher<[ImageComment], Error> { + private func loadComments(url: URL) -> () async throws -> [ImageComment] { return { [httpClient] in - return httpClient - .getPublisher(url: url) - .tryMap(ImageCommentsMapper.map) - .eraseToAnyPublisher() + let (data, response) = try await httpClient.get(from: url) + return try ImageCommentsMapper.map(data, from: response) } } diff --git a/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift index 034a08eb..d92f8c53 100644 --- a/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift +++ b/EssentialApp/EssentialAppTests/CommentsUIIntegrationTests.swift @@ -3,7 +3,6 @@ // import XCTest -import Combine import UIKit import EssentialApp import EssentialFeed @@ -12,7 +11,7 @@ import EssentialFeediOS @MainActor class CommentsUIIntegrationTests: XCTestCase { - func test_commentsView_hasTitle() { + func test_commentsView_hasTitle() async { let (sut, _) = makeSUT() sut.simulateAppearance() @@ -20,7 +19,7 @@ class CommentsUIIntegrationTests: XCTestCase { XCTAssertEqual(sut.title, commentsTitle) } - func test_loadCommentsActions_requestCommentsFromLoader() { + func test_loadCommentsActions_requestCommentsFromLoader() async { let (sut, loader) = makeSUT() XCTAssertEqual(loader.loadCommentsCallCount, 0, "Expected no loading requests before view appears") @@ -30,16 +29,16 @@ class CommentsUIIntegrationTests: XCTestCase { sut.simulateUserInitiatedReload() XCTAssertEqual(loader.loadCommentsCallCount, 1, "Expected no request until previous completes") - loader.completeCommentsLoading(at: 0) + await loader.completeCommentsLoading(at: 0) sut.simulateUserInitiatedReload() XCTAssertEqual(loader.loadCommentsCallCount, 2, "Expected another loading request once user initiates a reload") - loader.completeCommentsLoading(at: 1) + await loader.completeCommentsLoading(at: 1) sut.simulateUserInitiatedReload() XCTAssertEqual(loader.loadCommentsCallCount, 3, "Expected yet another loading request once user initiates another reload") } - func test_loadCommentsActions_runsAutomaticallyOnlyOnFirstAppearance() { + func test_loadCommentsActions_runsAutomaticallyOnlyOnFirstAppearance() async { let (sut, loader) = makeSUT() XCTAssertEqual(loader.loadCommentsCallCount, 0, "Expected no loading requests before view appears") @@ -50,23 +49,23 @@ class CommentsUIIntegrationTests: XCTestCase { XCTAssertEqual(loader.loadCommentsCallCount, 1, "Expected no loading request the second time view appears") } - func test_loadingCommentsIndicator_isVisibleWhileLoadingComments() { + func test_loadingCommentsIndicator_isVisibleWhileLoadingComments() async { let (sut, loader) = makeSUT() sut.simulateAppearance() XCTAssertTrue(sut.isShowingLoadingIndicator, "Expected loading indicator once view appears") - loader.completeCommentsLoading(at: 0) + await loader.completeCommentsLoading(at: 0) XCTAssertFalse(sut.isShowingLoadingIndicator, "Expected no loading indicator once loading completes successfully") sut.simulateUserInitiatedReload() XCTAssertTrue(sut.isShowingLoadingIndicator, "Expected loading indicator once user initiates a reload") - loader.completeCommentsLoadingWithError(at: 1) + await loader.completeCommentsLoadingWithError(at: 1) XCTAssertFalse(sut.isShowingLoadingIndicator, "Expected no loading indicator once user initiated loading completes with error") } - func test_loadCommentsCompletion_rendersSuccessfullyLoadedComments() { + func test_loadCommentsCompletion_rendersSuccessfullyLoadedComments() async { let comment0 = makeComment(message: "a message", username: "a username") let comment1 = makeComment(message: "another message", username: "another username") let (sut, loader) = makeSUT() @@ -74,96 +73,97 @@ class CommentsUIIntegrationTests: XCTestCase { sut.simulateAppearance() assertThat(sut, isRendering: [ImageComment]()) - loader.completeCommentsLoading(with: [comment0], at: 0) + await loader.completeCommentsLoading(with: [comment0], at: 0) assertThat(sut, isRendering: [comment0]) sut.simulateUserInitiatedReload() - loader.completeCommentsLoading(with: [comment0, comment1], at: 1) + await loader.completeCommentsLoading(with: [comment0, comment1], at: 1) assertThat(sut, isRendering: [comment0, comment1]) } - func test_loadCommentsCompletion_rendersSuccessfullyLoadedEmptyCommentsAfterNonEmptyComments() { + func test_loadCommentsCompletion_rendersSuccessfullyLoadedEmptyCommentsAfterNonEmptyComments() async { let comment = makeComment() let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeCommentsLoading(with: [comment], at: 0) + await loader.completeCommentsLoading(with: [comment], at: 0) assertThat(sut, isRendering: [comment]) sut.simulateUserInitiatedReload() - loader.completeCommentsLoading(with: [], at: 1) + await loader.completeCommentsLoading(with: [], at: 1) assertThat(sut, isRendering: [ImageComment]()) } - func test_loadCommentsCompletion_doesNotAlterCurrentRenderingStateOnError() { + func test_loadCommentsCompletion_doesNotAlterCurrentRenderingStateOnError() async { let comment = makeComment() let (sut, loader) = makeSUT() sut.simulateAppearance() - loader.completeCommentsLoading(with: [comment], at: 0) + await loader.completeCommentsLoading(with: [comment], at: 0) assertThat(sut, isRendering: [comment]) sut.simulateUserInitiatedReload() - loader.completeCommentsLoadingWithError(at: 1) + await loader.completeCommentsLoadingWithError(at: 1) assertThat(sut, isRendering: [comment]) } - func test_loadCommentsCompletion_rendersErrorMessageOnErrorUntilNextReload() { + func test_loadCommentsCompletion_rendersErrorMessageOnErrorUntilNextReload() async { let (sut, loader) = makeSUT() sut.simulateAppearance() XCTAssertEqual(sut.errorMessage, nil) - loader.completeCommentsLoadingWithError(at: 0) + await loader.completeCommentsLoadingWithError(at: 0) XCTAssertEqual(sut.errorMessage, loadError) sut.simulateUserInitiatedReload() XCTAssertEqual(sut.errorMessage, nil) } - func test_tapOnErrorView_hidesErrorMessage() { + func test_tapOnErrorView_hidesErrorMessage() async { let (sut, loader) = makeSUT() sut.simulateAppearance() XCTAssertEqual(sut.errorMessage, nil) - loader.completeCommentsLoadingWithError(at: 0) + await loader.completeCommentsLoadingWithError(at: 0) XCTAssertEqual(sut.errorMessage, loadError) sut.simulateErrorViewTap() XCTAssertEqual(sut.errorMessage, nil) } - func test_deinit_cancelsRunningRequest() { - var cancelCallCount = 0 - + func test_deinit_cancelsRunningRequest() async throws { + let loader = LoaderSpy() var sut: ListViewController? autoreleasepool { - sut = CommentsUIComposer.commentsComposedWith(commentsLoader: { - PassthroughSubject<[ImageComment], Error>() - .handleEvents(receiveCancel: { - cancelCallCount += 1 - }).eraseToAnyPublisher() - }) - + sut = CommentsUIComposer.commentsComposedWith(commentsLoader: loader.loadComments) + sut?.simulateAppearance() } - XCTAssertEqual(cancelCallCount, 0) + XCTAssertEqual(loader.cancelledCommentsRequestsCount, 0) sut = nil + let result = try await loader.result(at: 0) - XCTAssertEqual(cancelCallCount, 1) + XCTAssertEqual(result, .cancelled) + XCTAssertEqual(loader.cancelledCommentsRequestsCount, 1) } // MARK: - Helpers - private func makeSUT(file: StaticString = #filePath, line: UInt = #line) -> (sut: ListViewController, loader: LoaderSpy) { - let loader = LoaderSpy() - let sut = CommentsUIComposer.commentsComposedWith(commentsLoader: loader.loadPublisher) + private func makeSUT(file: StaticString = #filePath, line: UInt = #line) -> (sut: ListViewController, loader: LoaderSpy) { + let loader = LoaderSpy() + let sut = CommentsUIComposer.commentsComposedWith(commentsLoader: loader.loadComments) trackForMemoryLeaks(loader, file: file, line: line) trackForMemoryLeaks(sut, file: file, line: line) + + addTeardownBlock { [weak loader] in + try await loader?.cancelPendingRequests() + } + return (sut, loader) } @@ -182,28 +182,27 @@ class CommentsUIIntegrationTests: XCTestCase { XCTAssertEqual(sut.commentUsername(at: index), comment.username, "username at \(index)", file: file, line: line) } } +} + +private extension LoaderSpy where Param == Void, Resource == [ImageComment] { + var loadCommentsCallCount: Int { + return requests.count + } - private class LoaderSpy { - private var requests = [PassthroughSubject<[ImageComment], Error>]() - - var loadCommentsCallCount: Int { - return requests.count - } - - func loadPublisher() -> AnyPublisher<[ImageComment], Error> { - let publisher = PassthroughSubject<[ImageComment], Error>() - requests.append(publisher) - return publisher.eraseToAnyPublisher() - } - - func completeCommentsLoading(with comments: [ImageComment] = [], at index: Int = 0) { - requests[index].send(comments) - requests[index].send(completion: .finished) - } - - func completeCommentsLoadingWithError(at index: Int = 0) { - let error = NSError(domain: "an error", code: 0) - requests[index].send(completion: .failure(error)) - } + var cancelledCommentsRequestsCount: Int { + requests.count { $0.result == .cancelled } + } + + func loadComments() async throws -> [ImageComment] { + try await load(()) + } + + func completeCommentsLoading(with comments: [ImageComment] = [], at index: Int = 0) async { + await complete(with: comments, at: index) + } + + func completeCommentsLoadingWithError(at index: Int = 0) async { + let error = NSError(domain: "an error", code: 0) + await fail(with: error, at: index) } } diff --git a/EssentialFeed/EssentialFeed/Image Comments Feature/ImageComment.swift b/EssentialFeed/EssentialFeed/Image Comments Feature/ImageComment.swift index f3a02b06..ea9cc39a 100644 --- a/EssentialFeed/EssentialFeed/Image Comments Feature/ImageComment.swift +++ b/EssentialFeed/EssentialFeed/Image Comments Feature/ImageComment.swift @@ -4,7 +4,7 @@ import Foundation -public struct ImageComment: Equatable { +public struct ImageComment: Equatable, Sendable { public let id: UUID public let message: String public let createdAt: Date