diff --git a/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/CI_iOS.xcscheme b/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/CI_iOS.xcscheme index b5f14ef4..37505d43 100644 --- a/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/CI_iOS.xcscheme +++ b/EssentialApp/EssentialApp.xcworkspace/xcshareddata/xcschemes/CI_iOS.xcscheme @@ -139,6 +139,12 @@ ReferencedContainer = "container:../EssentialFeed/EssentialFeed.xcodeproj"> + + + + + + + + AnyDispatchQueueScheduler { + CoreDataFeedStoreScheduler(store: store).eraseToAnyScheduler() + } + + private struct CoreDataFeedStoreScheduler: Scheduler { + let store: CoreDataFeedStore + + var now: SchedulerTimeType { .init(.now()) } + + var minimumTolerance: SchedulerTimeType.Stride { .zero } + + func schedule(after date: DispatchQueue.SchedulerTimeType, interval: DispatchQueue.SchedulerTimeType.Stride, tolerance: DispatchQueue.SchedulerTimeType.Stride, options: DispatchQueue.SchedulerOptions?, _ action: @escaping () -> Void) -> any Cancellable { + if store.contextQueue == .main, Thread.isMainThread { + action() + } else { + store.perform(action) + } + return AnyCancellable {} + } + + func schedule(after date: DispatchQueue.SchedulerTimeType, tolerance: DispatchQueue.SchedulerTimeType.Stride, options: DispatchQueue.SchedulerOptions?, _ action: @escaping () -> Void) { + if store.contextQueue == .main, Thread.isMainThread { + action() + } else { + store.perform(action) + } + } + + func schedule(options: DispatchQueue.SchedulerOptions?, _ action: @escaping () -> Void) { + if store.contextQueue == .main, Thread.isMainThread { + action() + } else { + store.perform(action) + } + } + } } extension Scheduler { diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index 32cefcbe..c6c8f296 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -11,11 +11,17 @@ import EssentialFeed class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - private lazy var scheduler: AnyDispatchQueueScheduler = DispatchQueue( - label: "com.essentialdeveloper.infra.queue", - qos: .userInitiated, - attributes: .concurrent - ).eraseToAnyScheduler() + private lazy var scheduler: AnyDispatchQueueScheduler = { + if let store = store as? CoreDataFeedStore { + return .scheduler(for: store) + } + + return DispatchQueue( + label: "com.essentialdeveloper.infra.queue", + qos: .userInitiated, + attributes: .concurrent + ).eraseToAnyScheduler() + }() private lazy var httpClient: HTTPClient = { URLSessionHTTPClient(session: URLSession(configuration: .ephemeral)) @@ -48,11 +54,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { imageLoader: makeLocalImageLoaderWithRemoteFallback, selection: showComments)) - convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore, scheduler: AnyDispatchQueueScheduler) { + convenience init(httpClient: HTTPClient, store: FeedStore & FeedImageDataStore) { self.init() self.httpClient = httpClient self.store = store - self.scheduler = scheduler } func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { diff --git a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift index 43958298..443ea3ae 100644 --- a/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift +++ b/EssentialApp/EssentialAppTests/FeedAcceptanceTests.swift @@ -9,8 +9,8 @@ import EssentialFeediOS class FeedAcceptanceTests: XCTestCase { - func test_onLaunch_displaysRemoteFeedWhenCustomerHasConnectivity() { - let feed = launch(httpClient: .online(response), store: .empty) + func test_onLaunch_displaysRemoteFeedWhenCustomerHasConnectivity() throws { + let feed = try launch(httpClient: .online(response), store: .empty) XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 2) XCTAssertEqual(feed.renderedFeedImageData(at: 0), makeImageData0()) @@ -34,8 +34,8 @@ class FeedAcceptanceTests: XCTestCase { XCTAssertFalse(feed.canLoadMoreFeed) } - func test_onLaunch_displaysCachedRemoteFeedWhenCustomerHasNoConnectivity() { - let sharedStore = InMemoryFeedStore.empty + func test_onLaunch_displaysCachedRemoteFeedWhenCustomerHasNoConnectivity() throws { + let sharedStore = try CoreDataFeedStore.empty let onlineFeed = launch(httpClient: .online(response), store: sharedStore) onlineFeed.simulateFeedImageViewVisible(at: 0) @@ -51,30 +51,30 @@ class FeedAcceptanceTests: XCTestCase { XCTAssertEqual(offlineFeed.renderedFeedImageData(at: 2), makeImageData2()) } - func test_onLaunch_displaysEmptyFeedWhenCustomerHasNoConnectivityAndNoCache() { - let feed = launch(httpClient: .offline, store: .empty) + func test_onLaunch_displaysEmptyFeedWhenCustomerHasNoConnectivityAndNoCache() throws { + let feed = try launch(httpClient: .offline, store: .empty) XCTAssertEqual(feed.numberOfRenderedFeedImageViews(), 0) } - func test_onEnteringBackground_deletesExpiredFeedCache() { - let store = InMemoryFeedStore.withExpiredFeedCache + func test_onEnteringBackground_deletesExpiredFeedCache() throws { + let store = try CoreDataFeedStore.withExpiredFeedCache enterBackground(with: store) - XCTAssertNil(store.feedCache, "Expected to delete expired cache") + XCTAssertNil(try store.retrieve(), "Expected to delete expired cache") } - func test_onEnteringBackground_keepsNonExpiredFeedCache() { - let store = InMemoryFeedStore.withNonExpiredFeedCache + func test_onEnteringBackground_keepsNonExpiredFeedCache() throws { + let store = try CoreDataFeedStore.withNonExpiredFeedCache enterBackground(with: store) - XCTAssertNotNil(store.feedCache, "Expected to keep non-expired cache") + XCTAssertNotNil(try store.retrieve(), "Expected to keep non-expired cache") } - func test_onFeedImageSelection_displaysComments() { - let comments = showCommentsForFirstImage() + func test_onFeedImageSelection_displaysComments() throws { + let comments = try showCommentsForFirstImage() XCTAssertEqual(comments.numberOfRenderedComments(), 1) XCTAssertEqual(comments.commentMessage(at: 0), makeCommentMessage()) @@ -84,9 +84,9 @@ class FeedAcceptanceTests: XCTestCase { private func launch( httpClient: HTTPClientStub = .offline, - store: InMemoryFeedStore = .empty + store: CoreDataFeedStore ) -> ListViewController { - let sut = SceneDelegate(httpClient: httpClient, store: store, scheduler: .immediateOnMainThread) + let sut = SceneDelegate(httpClient: httpClient, store: store) sut.window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 1)) sut.configureWindow() @@ -96,13 +96,13 @@ class FeedAcceptanceTests: XCTestCase { return vc } - private func enterBackground(with store: InMemoryFeedStore) { - let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store, scheduler: .immediateOnMainThread) + private func enterBackground(with store: CoreDataFeedStore) { + let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store) sut.sceneWillResignActive(UIApplication.shared.connectedScenes.first!) } - private func showCommentsForFirstImage() -> ListViewController { - let feed = launch(httpClient: .online(response), store: .empty) + private func showCommentsForFirstImage() throws -> ListViewController { + let feed = try launch(httpClient: .online(response), store: .empty) feed.simulateTapOnFeedImage(at: 0) RunLoop.current.run(until: Date()) @@ -180,3 +180,27 @@ class FeedAcceptanceTests: XCTestCase { } } + +extension CoreDataFeedStore { + static var empty: CoreDataFeedStore { + get throws { + try CoreDataFeedStore(storeURL: URL(fileURLWithPath: "/dev/null"), contextQueue: .main) + } + } + + static var withExpiredFeedCache: CoreDataFeedStore { + get throws { + let store = try CoreDataFeedStore.empty + try store.insert([], timestamp: .distantPast) + return store + } + } + + static var withNonExpiredFeedCache: CoreDataFeedStore { + get throws { + let store = try CoreDataFeedStore.empty + try store.insert([], timestamp: Date()) + return store + } + } +} diff --git a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI_macOS.xcscheme b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI_macOS.xcscheme index 5453256e..52b5a384 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI_macOS.xcscheme +++ b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI_macOS.xcscheme @@ -106,6 +106,12 @@ ReferencedContainer = "container:EssentialFeed.xcodeproj"> + + + + + + + + + + + + Data? { - try performSync { context in - Result { - try ManagedFeedImage.data(with: url, in: context) - } - } + try ManagedFeedImage.data(with: url, in: context) } } diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedStore.swift index 88b272c7..46db2f89 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore+FeedStore.swift @@ -7,32 +7,20 @@ import CoreData extension CoreDataFeedStore: FeedStore { public func retrieve() throws -> CachedFeed? { - try performSync { context in - Result { - try ManagedCache.find(in: context).map { - CachedFeed(feed: $0.localFeed, timestamp: $0.timestamp) - } - } + try ManagedCache.find(in: context).map { + CachedFeed(feed: $0.localFeed, timestamp: $0.timestamp) } } public func insert(_ feed: [LocalFeedImage], timestamp: Date) throws { - try performSync { context in - Result { - let managedCache = try ManagedCache.newUniqueInstance(in: context) - managedCache.timestamp = timestamp - managedCache.feed = ManagedFeedImage.images(from: feed, in: context) - try context.save() - } - } + let managedCache = try ManagedCache.newUniqueInstance(in: context) + managedCache.timestamp = timestamp + managedCache.feed = ManagedFeedImage.images(from: feed, in: context) + try context.save() } public func deleteCachedFeed() throws { - try performSync { context in - Result { - try ManagedCache.deleteCache(in: context) - } - } + try ManagedCache.deleteCache(in: context) } } diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift index 64e016a0..8b935c4b 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift @@ -9,31 +9,37 @@ public final class CoreDataFeedStore { private static let model = NSManagedObjectModel.with(name: modelName, in: Bundle(for: CoreDataFeedStore.self)) private let container: NSPersistentContainer - private let context: NSManagedObjectContext + let context: NSManagedObjectContext enum StoreError: Error { case modelNotFound case failedToLoadPersistentContainer(Error) } - public init(storeURL: URL) throws { + public enum ContextQueue { + case main + case background + } + + public var contextQueue: ContextQueue { + context == container.viewContext ? .main : .background + } + + public init(storeURL: URL, contextQueue: ContextQueue = .background) throws { guard let model = CoreDataFeedStore.model else { throw StoreError.modelNotFound } do { container = try NSPersistentContainer.load(name: CoreDataFeedStore.modelName, model: model, url: storeURL) - context = container.newBackgroundContext() + context = contextQueue == .main ? container.viewContext : container.newBackgroundContext() } catch { throw StoreError.failedToLoadPersistentContainer(error) } } - func performSync(_ action: (NSManagedObjectContext) -> Result) throws -> R { - let context = self.context - var result: Result! - context.performAndWait { result = action(context) } - return try result.get() + public func perform(_ action: @escaping () -> Void) { + context.perform(action) } private func cleanUpReferencesToPersistentStores() { diff --git a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift index 79cd64d4..5eb3f5ca 100644 --- a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift +++ b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift @@ -107,7 +107,7 @@ class EssentialFeedCacheIntegrationTests: XCTestCase { private func makeFeedLoader(currentDate: Date = Date(), file: StaticString = #filePath, line: UInt = #line) throws -> LocalFeedLoader { let storeURL = testSpecificStoreURL() - let store = try CoreDataFeedStore(storeURL: storeURL) + let store = try CoreDataFeedStore(storeURL: storeURL, contextQueue: .main) let sut = LocalFeedLoader(store: store, currentDate: { currentDate }) trackForMemoryLeaks(store, file: file, line: line) trackForMemoryLeaks(sut, file: file, line: line) @@ -116,7 +116,7 @@ class EssentialFeedCacheIntegrationTests: XCTestCase { private func makeImageLoader(file: StaticString = #filePath, line: UInt = #line) throws -> LocalFeedImageDataLoader { let storeURL = testSpecificStoreURL() - let store = try CoreDataFeedStore(storeURL: storeURL) + let store = try CoreDataFeedStore(storeURL: storeURL, contextQueue: .main) let sut = LocalFeedImageDataLoader(store: store) trackForMemoryLeaks(store, file: file, line: line) trackForMemoryLeaks(sut, file: file, line: line) diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift index 6f1f1194..b49a2a4c 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift @@ -8,84 +8,93 @@ import EssentialFeed class CoreDataFeedImageDataStoreTests: XCTestCase { func test_retrieveImageData_deliversNotFoundWhenEmpty() throws { - let sut = try makeSUT() - - expect(sut, toCompleteRetrievalWith: notFound(), for: anyURL()) + try makeSUT { sut in + expect(sut, toCompleteRetrievalWith: notFound(), for: anyURL()) + } } func test_retrieveImageData_deliversNotFoundWhenStoredDataURLDoesNotMatch() throws { - let sut = try makeSUT() - let url = URL(string: "http://a-url.com")! - let nonMatchingURL = URL(string: "http://another-url.com")! - - insert(anyData(), for: url, into: sut) - - expect(sut, toCompleteRetrievalWith: notFound(), for: nonMatchingURL) + try makeSUT { sut in + let url = URL(string: "http://a-url.com")! + let nonMatchingURL = URL(string: "http://another-url.com")! + + insert(anyData(), for: url, into: sut) + + expect(sut, toCompleteRetrievalWith: notFound(), for: nonMatchingURL) + } } func test_retrieveImageData_deliversFoundDataWhenThereIsAStoredImageDataMatchingURL() throws { - let sut = try makeSUT() - let storedData = anyData() - let matchingURL = URL(string: "http://a-url.com")! - - insert(storedData, for: matchingURL, into: sut) - - expect(sut, toCompleteRetrievalWith: found(storedData), for: matchingURL) + try makeSUT { sut in + let storedData = anyData() + let matchingURL = URL(string: "http://a-url.com")! + + insert(storedData, for: matchingURL, into: sut) + + expect(sut, toCompleteRetrievalWith: found(storedData), for: matchingURL) + } } func test_retrieveImageData_deliversLastInsertedValue() throws { - let sut = try makeSUT() - let firstStoredData = Data("first".utf8) - let lastStoredData = Data("last".utf8) - let url = URL(string: "http://a-url.com")! - - insert(firstStoredData, for: url, into: sut) - insert(lastStoredData, for: url, into: sut) - - expect(sut, toCompleteRetrievalWith: found(lastStoredData), for: url) + try makeSUT { sut in + let firstStoredData = Data("first".utf8) + let lastStoredData = Data("last".utf8) + let url = URL(string: "http://a-url.com")! + + insert(firstStoredData, for: url, into: sut) + insert(lastStoredData, for: url, into: sut) + + expect(sut, toCompleteRetrievalWith: found(lastStoredData), for: url) + } } // - MARK: Helpers - private func makeSUT(file: StaticString = #filePath, line: UInt = #line) throws -> CoreDataFeedStore { + private func makeSUT(_ test: @escaping (CoreDataFeedStore) -> Void, file: StaticString = #filePath, line: UInt = #line) throws { let storeURL = URL(fileURLWithPath: "/dev/null") let sut = try CoreDataFeedStore(storeURL: storeURL) trackForMemoryLeaks(sut, file: file, line: line) - return sut - } - - private func notFound() -> Result { - return .success(.none) - } - - private func found(_ data: Data) -> Result { - return .success(data) - } - - private func localImage(url: URL) -> LocalFeedImage { - return LocalFeedImage(id: UUID(), description: "any", location: "any", url: url) + + let exp = expectation(description: "wait for operation") + sut.perform { + test(sut) + exp.fulfill() + } + wait(for: [exp], timeout: 0.1) } - private func expect(_ sut: CoreDataFeedStore, toCompleteRetrievalWith expectedResult: Result, for url: URL, file: StaticString = #filePath, line: UInt = #line) { - let receivedResult = Result { try sut.retrieve(dataForURL: url) } +} - switch (receivedResult, expectedResult) { - case let (.success( receivedData), .success(expectedData)): - XCTAssertEqual(receivedData, expectedData, file: file, line: line) - - default: - XCTFail("Expected \(expectedResult), got \(receivedResult) instead", file: file, line: line) - } +private func notFound() -> Result { + return .success(.none) +} + +private func found(_ data: Data) -> Result { + return .success(data) +} + +private func localImage(url: URL) -> LocalFeedImage { + return LocalFeedImage(id: UUID(), description: "any", location: "any", url: url) +} + +private func expect(_ sut: CoreDataFeedStore, toCompleteRetrievalWith expectedResult: Result, for url: URL, file: StaticString = #filePath, line: UInt = #line) { + let receivedResult = Result { try sut.retrieve(dataForURL: url) } + + switch (receivedResult, expectedResult) { + case let (.success( receivedData), .success(expectedData)): + XCTAssertEqual(receivedData, expectedData, file: file, line: line) + + default: + XCTFail("Expected \(expectedResult), got \(receivedResult) instead", file: file, line: line) } - - private func insert(_ data: Data, for url: URL, into sut: CoreDataFeedStore, file: StaticString = #filePath, line: UInt = #line) { - do { - let image = localImage(url: url) - try sut.insert([image], timestamp: Date()) - try sut.insert(data, for: url) - } catch { - XCTFail("Failed to insert \(data) with error \(error)", file: file, line: line) - } +} + +private func insert(_ data: Data, for url: URL, into sut: CoreDataFeedStore, file: StaticString = #filePath, line: UInt = #line) { + do { + let image = localImage(url: url) + try sut.insert([image], timestamp: Date()) + try sut.insert(data, for: url) + } catch { + XCTFail("Failed to insert \(data) with error \(error)", file: file, line: line) } - } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift index f048a963..643b6933 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreTests.swift @@ -8,78 +8,84 @@ import EssentialFeed class CoreDataFeedStoreTests: XCTestCase, FeedStoreSpecs { func test_retrieve_deliversEmptyOnEmptyCache() throws { - let sut = try makeSUT() - - assertThatRetrieveDeliversEmptyOnEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatRetrieveDeliversEmptyOnEmptyCache(on: sut) + } } func test_retrieve_hasNoSideEffectsOnEmptyCache() throws { - let sut = try makeSUT() - - assertThatRetrieveHasNoSideEffectsOnEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatRetrieveHasNoSideEffectsOnEmptyCache(on: sut) + } } func test_retrieve_deliversFoundValuesOnNonEmptyCache() throws { - let sut = try makeSUT() - - assertThatRetrieveDeliversFoundValuesOnNonEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatRetrieveDeliversFoundValuesOnNonEmptyCache(on: sut) + } } func test_retrieve_hasNoSideEffectsOnNonEmptyCache() throws { - let sut = try makeSUT() - - assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on: sut) + } } func test_insert_deliversNoErrorOnEmptyCache() throws { - let sut = try makeSUT() - - assertThatInsertDeliversNoErrorOnEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatInsertDeliversNoErrorOnEmptyCache(on: sut) + } } func test_insert_deliversNoErrorOnNonEmptyCache() throws { - let sut = try makeSUT() - - assertThatInsertDeliversNoErrorOnNonEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatInsertDeliversNoErrorOnNonEmptyCache(on: sut) + } } func test_insert_overridesPreviouslyInsertedCacheValues() throws { - let sut = try makeSUT() - - assertThatInsertOverridesPreviouslyInsertedCacheValues(on: sut) + try makeSUT { sut in + self.assertThatInsertOverridesPreviouslyInsertedCacheValues(on: sut) + } } func test_delete_deliversNoErrorOnEmptyCache() throws { - let sut = try makeSUT() - - assertThatDeleteDeliversNoErrorOnEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatDeleteDeliversNoErrorOnEmptyCache(on: sut) + } } func test_delete_hasNoSideEffectsOnEmptyCache() throws { - let sut = try makeSUT() - - assertThatDeleteHasNoSideEffectsOnEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatDeleteHasNoSideEffectsOnEmptyCache(on: sut) + } } func test_delete_deliversNoErrorOnNonEmptyCache() throws { - let sut = try makeSUT() - - assertThatDeleteDeliversNoErrorOnNonEmptyCache(on: sut) + try makeSUT { sut in + self.assertThatDeleteDeliversNoErrorOnNonEmptyCache(on: sut) + } } func test_delete_emptiesPreviouslyInsertedCache() throws { - let sut = try makeSUT() - - assertThatDeleteEmptiesPreviouslyInsertedCache(on: sut) + try makeSUT { sut in + self.assertThatDeleteEmptiesPreviouslyInsertedCache(on: sut) + } } // - MARK: Helpers - private func makeSUT(file: StaticString = #filePath, line: UInt = #line) throws -> FeedStore { + private func makeSUT(_ test: @escaping (CoreDataFeedStore) -> Void, file: StaticString = #filePath, line: UInt = #line) throws { let storeURL = URL(fileURLWithPath: "/dev/null") let sut = try CoreDataFeedStore(storeURL: storeURL) trackForMemoryLeaks(sut, file: file, line: line) - return sut + + let exp = expectation(description: "wait for operation") + sut.perform { + test(sut) + exp.fulfill() + } + wait(for: [exp], timeout: 0.1) } }