From 15a20a7a45e0c0690181865135230e89a16568fb Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Wed, 27 Mar 2024 10:43:43 +0200 Subject: [PATCH 1/4] Extract reusable FeedImageDataStoreSpecs --- .../EssentialFeed.xcodeproj/project.pbxproj | 16 ++++ .../CoreDataFeedImageDataStoreTests.swift | 72 ++++---------- .../FeedImageDataStoreSpecs.swift | 12 +++ .../XCTestCase+FeedImageDataStoreSpecs.swift | 93 +++++++++++++++++++ 4 files changed, 137 insertions(+), 56 deletions(-) create mode 100644 EssentialFeed/EssentialFeedTests/Feed Cache/FeedImageDataStoreSpecs/FeedImageDataStoreSpecs.swift create mode 100644 EssentialFeed/EssentialFeedTests/Feed Cache/FeedImageDataStoreSpecs/XCTestCase+FeedImageDataStoreSpecs.swift diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 3f99dd98..09684254 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -86,6 +86,8 @@ 087546942272387900542C32 /* XCTestCase+FailableDeleteFeedStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 087546932272387900542C32 /* XCTestCase+FailableDeleteFeedStoreSpecs.swift */; }; 08805A19233A9F8600509F19 /* URLProtocolStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08805A18233A9F8600509F19 /* URLProtocolStub.swift */; }; 0886893A221DAA34007BC3E7 /* LocalFeedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08868939221DAA34007BC3E7 /* LocalFeedImage.swift */; }; + 088887B62BB40BA100E3723E /* FeedImageDataStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088887B52BB40BA100E3723E /* FeedImageDataStoreSpecs.swift */; }; + 088887B82BB40C2700E3723E /* XCTestCase+FeedImageDataStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088887B72BB40C2700E3723E /* XCTestCase+FeedImageDataStoreSpecs.swift */; }; 08897BE623390F4E0029F5E4 /* FeedImageDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08FC5AA922D5F47900148E12 /* FeedImageDataLoader.swift */; }; 08897BE8233910040029F5E4 /* FeedImageDataMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08897BE7233910040029F5E4 /* FeedImageDataMapperTests.swift */; }; 08897BEF233A1C8E0029F5E4 /* HTTPURLResponse+StatusCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08897BEE233A1C8E0029F5E4 /* HTTPURLResponse+StatusCode.swift */; }; @@ -247,6 +249,8 @@ 087546932272387900542C32 /* XCTestCase+FailableDeleteFeedStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+FailableDeleteFeedStoreSpecs.swift"; sourceTree = ""; }; 08805A18233A9F8600509F19 /* URLProtocolStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLProtocolStub.swift; sourceTree = ""; }; 08868939221DAA34007BC3E7 /* LocalFeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedImage.swift; sourceTree = ""; }; + 088887B52BB40BA100E3723E /* FeedImageDataStoreSpecs.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedImageDataStoreSpecs.swift; sourceTree = ""; }; + 088887B72BB40C2700E3723E /* XCTestCase+FeedImageDataStoreSpecs.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "XCTestCase+FeedImageDataStoreSpecs.swift"; sourceTree = ""; }; 08897BE7233910040029F5E4 /* FeedImageDataMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageDataMapperTests.swift; sourceTree = ""; }; 08897BEE233A1C8E0029F5E4 /* HTTPURLResponse+StatusCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HTTPURLResponse+StatusCode.swift"; sourceTree = ""; }; 0889B0F92530B0AA00FEAB5A /* ImageCommentsEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCommentsEndpoint.swift; sourceTree = ""; }; @@ -702,6 +706,15 @@ path = FeedStoreSpecs; sourceTree = ""; }; + 088887B42BB40B8800E3723E /* FeedImageDataStoreSpecs */ = { + isa = PBXGroup; + children = ( + 088887B52BB40BA100E3723E /* FeedImageDataStoreSpecs.swift */, + 088887B72BB40C2700E3723E /* XCTestCase+FeedImageDataStoreSpecs.swift */, + ); + path = FeedImageDataStoreSpecs; + sourceTree = ""; + }; 08897BE9233939AA0029F5E4 /* Helpers */ = { isa = PBXGroup; children = ( @@ -732,6 +745,7 @@ children = ( 086BE48222327254004CDC26 /* Helpers */, 08754695227238B100542C32 /* FeedStoreSpecs */, + 088887B42BB40B8800E3723E /* FeedImageDataStoreSpecs */, 089C40D32216C37400DE552E /* CacheFeedUseCaseTests.swift */, 086BE480223270A3004CDC26 /* LoadFeedFromCacheUseCaseTests.swift */, 081C0DAE22491A2400AC754E /* ValidateFeedCacheUseCaseTests.swift */, @@ -1209,6 +1223,7 @@ 087148BE232BEDFD00D6BE1A /* FeedLocalizationTests.swift in Sources */, 082DB86421F763F900A71F69 /* URLSessionHTTPClientTests.swift in Sources */, 08C4E984233E037200D939F8 /* LoadFeedImageDataFromCacheUseCaseTests.swift in Sources */, + 088887B62BB40BA100E3723E /* FeedImageDataStoreSpecs.swift in Sources */, 08346CEB24F93B0A00A9F17C /* ImageCommentsMapperTests.swift in Sources */, 086BE4842232725E004CDC26 /* FeedStoreSpy.swift in Sources */, 081C0DB322491EEF00AC754E /* SharedTestHelpers.swift in Sources */, @@ -1222,6 +1237,7 @@ 086BE481223270A3004CDC26 /* LoadFeedFromCacheUseCaseTests.swift in Sources */, 080F9A3F2531DDFB00EAD475 /* FeedEndpointTests.swift in Sources */, 0875468C2272340500542C32 /* FeedStoreSpecs.swift in Sources */, + 088887B82BB40C2700E3723E /* XCTestCase+FeedImageDataStoreSpecs.swift in Sources */, 08EF9D17250BB0E4004539C2 /* LoadResourcePresenterTests.swift in Sources */, 0844768F21FCB24E00439BE9 /* XCTestCase+MemoryLeakTracking.swift in Sources */, 08285AEF228BF7E3000A8987 /* CoreDataFeedStoreTests.swift in Sources */, diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift index b49a2a4c..623abd38 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedImageDataStoreTests.swift @@ -5,59 +5,44 @@ import XCTest import EssentialFeed -class CoreDataFeedImageDataStoreTests: XCTestCase { +class CoreDataFeedImageDataStoreTests: XCTestCase, FeedImageDataStoreSpecs { func test_retrieveImageData_deliversNotFoundWhenEmpty() throws { - try makeSUT { sut in - expect(sut, toCompleteRetrievalWith: notFound(), for: anyURL()) + try makeSUT { sut, imageDataURL in + self.assertThatRetrieveImageDataDeliversNotFoundOnEmptyCache(on: sut, imageDataURL: imageDataURL) } } func test_retrieveImageData_deliversNotFoundWhenStoredDataURLDoesNotMatch() throws { - 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) + try makeSUT { sut, imageDataURL in + self.assertThatRetrieveImageDataDeliversNotFoundWhenStoredDataURLDoesNotMatch(on: sut, imageDataURL: imageDataURL) } } func test_retrieveImageData_deliversFoundDataWhenThereIsAStoredImageDataMatchingURL() throws { - 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) + try makeSUT { sut, imageDataURL in + self.assertThatRetrieveImageDataDeliversFoundDataWhenThereIsAStoredImageDataMatchingURL(on: sut, imageDataURL: imageDataURL) } } func test_retrieveImageData_deliversLastInsertedValue() throws { - 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) + try makeSUT { sut, imageDataURL in + self.assertThatRetrieveImageDataDeliversLastInsertedValueForURL(on: sut, imageDataURL: imageDataURL) } } // - MARK: Helpers - private func makeSUT(_ test: @escaping (CoreDataFeedStore) -> Void, file: StaticString = #filePath, line: UInt = #line) throws { + private func makeSUT(_ test: @escaping (CoreDataFeedStore, URL) -> 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) let exp = expectation(description: "wait for operation") sut.perform { - test(sut) + let imageDataURL = URL(string: "http://a-url.com")! + insertFeedImage(with: imageDataURL, into: sut, file: file, line: line) + test(sut, imageDataURL) exp.fulfill() } wait(for: [exp], timeout: 0.1) @@ -65,36 +50,11 @@ class CoreDataFeedImageDataStoreTests: XCTestCase { } -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) { +private func insertFeedImage(with url: URL, into sut: CoreDataFeedStore, file: StaticString = #filePath, line: UInt = #line) { do { - let image = localImage(url: url) + let image = LocalFeedImage(id: UUID(), description: "any", location: "any", 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) + XCTFail("Failed to insert feed image with URL \(url) - error: \(error)", file: file, line: line) } } diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/FeedImageDataStoreSpecs/FeedImageDataStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedImageDataStoreSpecs/FeedImageDataStoreSpecs.swift new file mode 100644 index 00000000..55f3d47b --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedImageDataStoreSpecs/FeedImageDataStoreSpecs.swift @@ -0,0 +1,12 @@ +// +// Copyright © Essential Developer. All rights reserved. +// + +import Foundation + +protocol FeedImageDataStoreSpecs { + func test_retrieveImageData_deliversNotFoundWhenEmpty() throws + func test_retrieveImageData_deliversNotFoundWhenStoredDataURLDoesNotMatch() throws + func test_retrieveImageData_deliversFoundDataWhenThereIsAStoredImageDataMatchingURL() throws + func test_retrieveImageData_deliversLastInsertedValue() throws +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/FeedImageDataStoreSpecs/XCTestCase+FeedImageDataStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedImageDataStoreSpecs/XCTestCase+FeedImageDataStoreSpecs.swift new file mode 100644 index 00000000..eb63fcab --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/FeedImageDataStoreSpecs/XCTestCase+FeedImageDataStoreSpecs.swift @@ -0,0 +1,93 @@ +// +// Copyright © Essential Developer. All rights reserved. +// + +import XCTest +import Foundation +import EssentialFeed + +extension FeedImageDataStoreSpecs where Self: XCTestCase { + + func assertThatRetrieveImageDataDeliversNotFoundOnEmptyCache( + on sut: FeedImageDataStore, + imageDataURL: URL = anyURL(), + file: StaticString = #filePath, + line: UInt = #line + ) { + expect(sut, toCompleteRetrievalWith: notFound(), for: imageDataURL, file: file, line: line) + } + + func assertThatRetrieveImageDataDeliversNotFoundWhenStoredDataURLDoesNotMatch( + on sut: FeedImageDataStore, + imageDataURL: URL = anyURL(), + file: StaticString = #filePath, + line: UInt = #line + ) { + let nonMatchingURL = URL(string: "http://a-non-matching-url.com")! + + insert(anyData(), for: imageDataURL, into: sut, file: file, line: line) + + expect(sut, toCompleteRetrievalWith: notFound(), for: nonMatchingURL, file: file, line: line) + } + + func assertThatRetrieveImageDataDeliversFoundDataWhenThereIsAStoredImageDataMatchingURL( + on sut: FeedImageDataStore, + imageDataURL: URL = anyURL(), + file: StaticString = #filePath, + line: UInt = #line + ) { + let storedData = anyData() + + insert(storedData, for: imageDataURL, into: sut, file: file, line: line) + + expect(sut, toCompleteRetrievalWith: found(storedData), for: imageDataURL, file: file, line: line) + } + + func assertThatRetrieveImageDataDeliversLastInsertedValueForURL( + on sut: FeedImageDataStore, + imageDataURL: URL = anyURL(), + file: StaticString = #filePath, + line: UInt = #line + ) { + let firstStoredData = Data("first".utf8) + let lastStoredData = Data("last".utf8) + + insert(firstStoredData, for: imageDataURL, into: sut, file: file, line: line) + insert(lastStoredData, for: imageDataURL, into: sut, file: file, line: line) + + expect(sut, toCompleteRetrievalWith: found(lastStoredData), for: imageDataURL, file: file, line: line) + } + +} + +extension FeedImageDataStoreSpecs where Self: XCTestCase { + + func notFound() -> Result { + .success(.none) + } + + func found(_ data: Data) -> Result { + .success(data) + } + + func expect(_ sut: FeedImageDataStore, 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) + } + } + + func insert(_ data: Data, for url: URL, into sut: FeedImageDataStore, file: StaticString = #filePath, line: UInt = #line) { + do { + try sut.insert(data, for: url) + } catch { + XCTFail("Failed to insert image data: \(data) - error: \(error)", file: file, line: line) + } + } + +} From fdee7d7a44aff25b5e05cfc7fba23117fc7c2640 Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Wed, 27 Mar 2024 11:00:49 +0200 Subject: [PATCH 2/4] Promote InMemoryFeedStore to production Replaced Dictionary with NSCache to support automatic eviction when resources are low. --- .../EssentialApp.xcodeproj/project.pbxproj | 4 - .../Helpers/InMemoryFeedStore.swift | 53 ------------ .../EssentialFeed.xcodeproj/project.pbxproj | 22 ++++- .../InMemory/InMemoryFeedStore.swift | 36 ++++++++ .../InMemoryFeedImageDataStoreTests.swift | 42 ++++++++++ .../Feed Cache/InMemoryFeedStoreTests.swift | 84 +++++++++++++++++++ 6 files changed, 183 insertions(+), 58 deletions(-) delete mode 100644 EssentialApp/EssentialAppTests/Helpers/InMemoryFeedStore.swift create mode 100644 EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/InMemory/InMemoryFeedStore.swift create mode 100644 EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedImageDataStoreTests.swift create mode 100644 EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedStoreTests.swift diff --git a/EssentialApp/EssentialApp.xcodeproj/project.pbxproj b/EssentialApp/EssentialApp.xcodeproj/project.pbxproj index 3258b825..759e39dc 100644 --- a/EssentialApp/EssentialApp.xcodeproj/project.pbxproj +++ b/EssentialApp/EssentialApp.xcodeproj/project.pbxproj @@ -27,7 +27,6 @@ 0835BF6D24850F9800A793D2 /* CombineHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0835BF6C24850F9800A793D2 /* CombineHelpers.swift */; }; 08367CD82486FB51009CD536 /* UIView+TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08367CD72486FB51009CD536 /* UIView+TestHelpers.swift */; }; 0851CDAC239AB13100C19B1D /* HTTPClientStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0851CDAB239AB13100C19B1D /* HTTPClientStub.swift */; }; - 0851CDAE239AB19000C19B1D /* InMemoryFeedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0851CDAD239AB19000C19B1D /* InMemoryFeedStore.swift */; }; 088B441925309AA300D75AAD /* CommentsUIIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088B441825309AA300D75AAD /* CommentsUIIntegrationTests.swift */; }; 088B441C25309B6E00D75AAD /* CommentsUIComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088B441B25309B6E00D75AAD /* CommentsUIComposer.swift */; }; 0895DA87234B3B950031BB2D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0895DA86234B3B950031BB2D /* AppDelegate.swift */; }; @@ -87,7 +86,6 @@ 0835BF6C24850F9800A793D2 /* CombineHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineHelpers.swift; sourceTree = ""; }; 08367CD72486FB51009CD536 /* UIView+TestHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+TestHelpers.swift"; sourceTree = ""; }; 0851CDAB239AB13100C19B1D /* HTTPClientStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClientStub.swift; sourceTree = ""; }; - 0851CDAD239AB19000C19B1D /* InMemoryFeedStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryFeedStore.swift; sourceTree = ""; }; 088B441825309AA300D75AAD /* CommentsUIIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsUIIntegrationTests.swift; sourceTree = ""; }; 088B441B25309B6E00D75AAD /* CommentsUIComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsUIComposer.swift; sourceTree = ""; }; 0895DA83234B3B950031BB2D /* EssentialApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EssentialApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -141,7 +139,6 @@ 082C00032359E46C008927D3 /* XCTestCase+MemoryLeakTracking.swift */, 082C00052359E4C6008927D3 /* SharedTestHelpers.swift */, 0851CDAB239AB13100C19B1D /* HTTPClientStub.swift */, - 0851CDAD239AB19000C19B1D /* InMemoryFeedStore.swift */, ); path = Helpers; sourceTree = ""; @@ -336,7 +333,6 @@ 0832C9D0238D2811002314C9 /* SceneDelegateTests.swift in Sources */, 08073B5B238D2E1000A75DC6 /* ListViewController+TestHelpers.swift in Sources */, 08073B53238D2E1000A75DC6 /* UIButton+TestHelpers.swift in Sources */, - 0851CDAE239AB19000C19B1D /* InMemoryFeedStore.swift in Sources */, 08073B59238D2E1000A75DC6 /* UIImage+TestHelpers.swift in Sources */, 0851CDAC239AB13100C19B1D /* HTTPClientStub.swift in Sources */, 08073B5A238D2E1000A75DC6 /* UIRefreshControl+TestHelpers.swift in Sources */, diff --git a/EssentialApp/EssentialAppTests/Helpers/InMemoryFeedStore.swift b/EssentialApp/EssentialAppTests/Helpers/InMemoryFeedStore.swift deleted file mode 100644 index 8f74b6aa..00000000 --- a/EssentialApp/EssentialAppTests/Helpers/InMemoryFeedStore.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// Copyright © Essential Developer. All rights reserved. -// - -import Foundation -import EssentialFeed - -class InMemoryFeedStore { - private(set) var feedCache: CachedFeed? - private var feedImageDataCache: [URL: Data] = [:] - - private init(feedCache: CachedFeed? = nil) { - self.feedCache = feedCache - } -} - -extension InMemoryFeedStore: FeedStore { - func deleteCachedFeed() throws { - feedCache = nil - } - - func insert(_ feed: [LocalFeedImage], timestamp: Date) throws { - feedCache = CachedFeed(feed: feed, timestamp: timestamp) - } - - func retrieve() throws -> CachedFeed? { - feedCache - } -} - -extension InMemoryFeedStore: FeedImageDataStore { - func insert(_ data: Data, for url: URL) throws { - feedImageDataCache[url] = data - } - - func retrieve(dataForURL url: URL) throws -> Data? { - feedImageDataCache[url] - } -} - -extension InMemoryFeedStore { - static var empty: InMemoryFeedStore { - InMemoryFeedStore() - } - - static var withExpiredFeedCache: InMemoryFeedStore { - InMemoryFeedStore(feedCache: CachedFeed(feed: [], timestamp: Date.distantPast)) - } - - static var withNonExpiredFeedCache: InMemoryFeedStore { - InMemoryFeedStore(feedCache: CachedFeed(feed: [], timestamp: Date())) - } -} diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 09684254..44f4e9c9 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -86,6 +86,8 @@ 087546942272387900542C32 /* XCTestCase+FailableDeleteFeedStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 087546932272387900542C32 /* XCTestCase+FailableDeleteFeedStoreSpecs.swift */; }; 08805A19233A9F8600509F19 /* URLProtocolStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08805A18233A9F8600509F19 /* URLProtocolStub.swift */; }; 0886893A221DAA34007BC3E7 /* LocalFeedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08868939221DAA34007BC3E7 /* LocalFeedImage.swift */; }; + 088887B12BB3229900E3723E /* InMemoryFeedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088887B02BB3229900E3723E /* InMemoryFeedStore.swift */; }; + 088887B32BB322F600E3723E /* InMemoryFeedStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088887B22BB322F600E3723E /* InMemoryFeedStoreTests.swift */; }; 088887B62BB40BA100E3723E /* FeedImageDataStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088887B52BB40BA100E3723E /* FeedImageDataStoreSpecs.swift */; }; 088887B82BB40C2700E3723E /* XCTestCase+FeedImageDataStoreSpecs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088887B72BB40C2700E3723E /* XCTestCase+FeedImageDataStoreSpecs.swift */; }; 08897BE623390F4E0029F5E4 /* FeedImageDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08FC5AA922D5F47900148E12 /* FeedImageDataLoader.swift */; }; @@ -119,6 +121,7 @@ 08DB54FC254465BA00B98C3A /* FEED_WITH_LOAD_MORE_ERROR_extraExtraExtraLarge.png in Resources */ = {isa = PBXBuildFile; fileRef = 08DB54F9254465BA00B98C3A /* FEED_WITH_LOAD_MORE_ERROR_extraExtraExtraLarge.png */; }; 08DB5510254467FC00B98C3A /* Paginated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DB550F254467FC00B98C3A /* Paginated.swift */; }; 08DDC13A21BEA99E00F490ED /* FeedItemsMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DDC13921BEA99E00F490ED /* FeedItemsMapperTests.swift */; }; + 08E53C692BB41454002F3D93 /* InMemoryFeedImageDataStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08E53C682BB41454002F3D93 /* InMemoryFeedImageDataStoreTests.swift */; }; 08E546CC24F9631300C64FAE /* FeedImageDataMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08E546CB24F9631300C64FAE /* FeedImageDataMapper.swift */; }; 08E5941522523FCC00E2D213 /* FeedCachePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08E5941422523FCC00E2D213 /* FeedCachePolicy.swift */; }; 08EF9D17250BB0E4004539C2 /* LoadResourcePresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08EF9D16250BB0E4004539C2 /* LoadResourcePresenterTests.swift */; }; @@ -249,6 +252,8 @@ 087546932272387900542C32 /* XCTestCase+FailableDeleteFeedStoreSpecs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+FailableDeleteFeedStoreSpecs.swift"; sourceTree = ""; }; 08805A18233A9F8600509F19 /* URLProtocolStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLProtocolStub.swift; sourceTree = ""; }; 08868939221DAA34007BC3E7 /* LocalFeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedImage.swift; sourceTree = ""; }; + 088887B02BB3229900E3723E /* InMemoryFeedStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InMemoryFeedStore.swift; sourceTree = ""; }; + 088887B22BB322F600E3723E /* InMemoryFeedStoreTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InMemoryFeedStoreTests.swift; sourceTree = ""; }; 088887B52BB40BA100E3723E /* FeedImageDataStoreSpecs.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedImageDataStoreSpecs.swift; sourceTree = ""; }; 088887B72BB40C2700E3723E /* XCTestCase+FeedImageDataStoreSpecs.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "XCTestCase+FeedImageDataStoreSpecs.swift"; sourceTree = ""; }; 08897BE7233910040029F5E4 /* FeedImageDataMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageDataMapperTests.swift; sourceTree = ""; }; @@ -284,6 +289,7 @@ 08DB54F9254465BA00B98C3A /* FEED_WITH_LOAD_MORE_ERROR_extraExtraExtraLarge.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = FEED_WITH_LOAD_MORE_ERROR_extraExtraExtraLarge.png; sourceTree = ""; }; 08DB550F254467FC00B98C3A /* Paginated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Paginated.swift; sourceTree = ""; }; 08DDC13921BEA99E00F490ED /* FeedItemsMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemsMapperTests.swift; sourceTree = ""; }; + 08E53C682BB41454002F3D93 /* InMemoryFeedImageDataStoreTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InMemoryFeedImageDataStoreTests.swift; sourceTree = ""; }; 08E546CB24F9631300C64FAE /* FeedImageDataMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedImageDataMapper.swift; sourceTree = ""; }; 08E5941422523FCC00E2D213 /* FeedCachePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCachePolicy.swift; sourceTree = ""; }; 08EF9D16250BB0E4004539C2 /* LoadResourcePresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadResourcePresenterTests.swift; sourceTree = ""; }; @@ -537,6 +543,7 @@ 08285AF7228C16D7000A8987 /* Infrastructure */ = { isa = PBXGroup; children = ( + 08E53C6A2BB4165A002F3D93 /* InMemory */, 08285AF8228C16E5000A8987 /* CoreData */, ); path = Infrastructure; @@ -749,10 +756,12 @@ 089C40D32216C37400DE552E /* CacheFeedUseCaseTests.swift */, 086BE480223270A3004CDC26 /* LoadFeedFromCacheUseCaseTests.swift */, 081C0DAE22491A2400AC754E /* ValidateFeedCacheUseCaseTests.swift */, - 08285AEE228BF7E3000A8987 /* CoreDataFeedStoreTests.swift */, 08C4E983233E037200D939F8 /* LoadFeedImageDataFromCacheUseCaseTests.swift */, 086044FD233E1A90005ECD22 /* CacheFeedImageDataUseCaseTests.swift */, + 08285AEE228BF7E3000A8987 /* CoreDataFeedStoreTests.swift */, 086044FF233E2294005ECD22 /* CoreDataFeedImageDataStoreTests.swift */, + 088887B22BB322F600E3723E /* InMemoryFeedStoreTests.swift */, + 08E53C682BB41454002F3D93 /* InMemoryFeedImageDataStoreTests.swift */, ); path = "Feed Cache"; sourceTree = ""; @@ -831,6 +840,14 @@ path = EssentialFeediOSTests; sourceTree = ""; }; + 08E53C6A2BB4165A002F3D93 /* InMemory */ = { + isa = PBXGroup; + children = ( + 088887B02BB3229900E3723E /* InMemoryFeedStore.swift */, + ); + path = InMemory; + sourceTree = ""; + }; 08EF9D15250BB0C2004539C2 /* Shared Presentation */ = { isa = PBXGroup; children = ( @@ -1186,6 +1203,7 @@ 080F9A372531DDC600EAD475 /* FeedEndpoint.swift in Sources */, 080EDF0C21B6DAE800813479 /* FeedImage.swift in Sources */, 0889B0FA2530B0AA00FEAB5A /* ImageCommentsEndpoint.swift in Sources */, + 088887B12BB3229900E3723E /* InMemoryFeedStore.swift in Sources */, 0804862E236345A40087ED48 /* FeedImageDataCache.swift in Sources */, 08C4E988233E13CC00D939F8 /* FeedImageDataStore.swift in Sources */, 087148B6232BEAAF00D6BE1A /* FeedPresenter.swift in Sources */, @@ -1213,6 +1231,7 @@ 080F9A2F2531DD6200EAD475 /* ImageCommentsEndpointTests.swift in Sources */, 08805A19233A9F8600509F19 /* URLProtocolStub.swift in Sources */, 081C0DAF22491A2400AC754E /* ValidateFeedCacheUseCaseTests.swift in Sources */, + 08E53C692BB41454002F3D93 /* InMemoryFeedImageDataStoreTests.swift in Sources */, 087148AC232BDF6900D6BE1A /* FeedPresenterTests.swift in Sources */, 08897BE8233910040029F5E4 /* FeedImageDataMapperTests.swift in Sources */, 08DDC13A21BEA99E00F490ED /* FeedItemsMapperTests.swift in Sources */, @@ -1230,6 +1249,7 @@ 089C40D42216C37400DE552E /* CacheFeedUseCaseTests.swift in Sources */, 08EF9D1E250BB77A004539C2 /* SharedLocalizationTests.swift in Sources */, 086044FC233E1A3B005ECD22 /* FeedImageDataStoreSpy.swift in Sources */, + 088887B32BB322F600E3723E /* InMemoryFeedStoreTests.swift in Sources */, 08EF9D33250BCA28004539C2 /* ImageCommentsLocalizationTests.swift in Sources */, 08604500233E2294005ECD22 /* CoreDataFeedImageDataStoreTests.swift in Sources */, 087546942272387900542C32 /* XCTestCase+FailableDeleteFeedStoreSpecs.swift in Sources */, diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/InMemory/InMemoryFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/InMemory/InMemoryFeedStore.swift new file mode 100644 index 00000000..dab31c7b --- /dev/null +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/InMemory/InMemoryFeedStore.swift @@ -0,0 +1,36 @@ +// +// Copyright © Essential Developer. All rights reserved. +// + +import Foundation + +public class InMemoryFeedStore { + private var feedCache: CachedFeed? + private var feedImageDataCache = NSCache() + + public init() {} +} + +extension InMemoryFeedStore: FeedStore { + public func deleteCachedFeed() throws { + feedCache = nil + } + + public func insert(_ feed: [LocalFeedImage], timestamp: Date) throws { + feedCache = CachedFeed(feed: feed, timestamp: timestamp) + } + + public func retrieve() throws -> CachedFeed? { + feedCache + } +} + +extension InMemoryFeedStore: FeedImageDataStore { + public func insert(_ data: Data, for url: URL) throws { + feedImageDataCache.setObject(data as NSData, forKey: url as NSURL) + } + + public func retrieve(dataForURL url: URL) throws -> Data? { + feedImageDataCache.object(forKey: url as NSURL) as Data? + } +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedImageDataStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedImageDataStoreTests.swift new file mode 100644 index 00000000..049db611 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedImageDataStoreTests.swift @@ -0,0 +1,42 @@ +// +// Copyright © Essential Developer. All rights reserved. +// + +import XCTest +import EssentialFeed + +class InMemoryFeedImageDataStoreTests: XCTestCase, FeedImageDataStoreSpecs { + + func test_retrieveImageData_deliversNotFoundWhenEmpty() throws { + let sut = makeSUT() + + assertThatRetrieveImageDataDeliversNotFoundOnEmptyCache(on: sut) + } + + func test_retrieveImageData_deliversNotFoundWhenStoredDataURLDoesNotMatch() throws { + let sut = makeSUT() + + assertThatRetrieveImageDataDeliversNotFoundWhenStoredDataURLDoesNotMatch(on: sut) + } + + func test_retrieveImageData_deliversFoundDataWhenThereIsAStoredImageDataMatchingURL() throws { + let sut = makeSUT() + + assertThatRetrieveImageDataDeliversFoundDataWhenThereIsAStoredImageDataMatchingURL(on: sut) + } + + func test_retrieveImageData_deliversLastInsertedValue() throws { + let sut = makeSUT() + + assertThatRetrieveImageDataDeliversLastInsertedValueForURL(on: sut) + } + + // - MARK: Helpers + + private func makeSUT(file: StaticString = #filePath, line: UInt = #line) -> InMemoryFeedStore { + let sut = InMemoryFeedStore() + trackForMemoryLeaks(sut, file: file, line: line) + return sut + } + +} diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedStoreTests.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedStoreTests.swift new file mode 100644 index 00000000..ca7b7735 --- /dev/null +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/InMemoryFeedStoreTests.swift @@ -0,0 +1,84 @@ +// +// Copyright © Essential Developer. All rights reserved. +// + +import XCTest +import EssentialFeed + +class InMemoryFeedStoreTests: XCTestCase, FeedStoreSpecs { + + func test_retrieve_deliversEmptyOnEmptyCache() throws { + let sut = makeSUT() + + assertThatRetrieveDeliversEmptyOnEmptyCache(on: sut) + } + + func test_retrieve_hasNoSideEffectsOnEmptyCache() throws { + let sut = makeSUT() + + assertThatRetrieveHasNoSideEffectsOnEmptyCache(on: sut) + } + + func test_retrieve_deliversFoundValuesOnNonEmptyCache() throws { + let sut = makeSUT() + + assertThatRetrieveDeliversFoundValuesOnNonEmptyCache(on: sut) + } + + func test_retrieve_hasNoSideEffectsOnNonEmptyCache() throws { + let sut = makeSUT() + + assertThatRetrieveHasNoSideEffectsOnNonEmptyCache(on: sut) + } + + func test_insert_deliversNoErrorOnEmptyCache() throws { + let sut = makeSUT() + + assertThatInsertDeliversNoErrorOnEmptyCache(on: sut) + } + + func test_insert_deliversNoErrorOnNonEmptyCache() throws { + let sut = makeSUT() + + assertThatInsertDeliversNoErrorOnNonEmptyCache(on: sut) + } + + func test_insert_overridesPreviouslyInsertedCacheValues() throws { + let sut = makeSUT() + + assertThatInsertOverridesPreviouslyInsertedCacheValues(on: sut) + } + + func test_delete_deliversNoErrorOnEmptyCache() throws { + let sut = makeSUT() + + assertThatDeleteDeliversNoErrorOnEmptyCache(on: sut) + } + + func test_delete_hasNoSideEffectsOnEmptyCache() throws { + let sut = makeSUT() + + assertThatDeleteHasNoSideEffectsOnEmptyCache(on: sut) + } + + func test_delete_deliversNoErrorOnNonEmptyCache() throws { + let sut = makeSUT() + + assertThatDeleteDeliversNoErrorOnNonEmptyCache(on: sut) + } + + func test_delete_emptiesPreviouslyInsertedCache() throws { + let sut = makeSUT() + + assertThatDeleteEmptiesPreviouslyInsertedCache(on: sut) + } + + // - MARK: Helpers + + private func makeSUT(file: StaticString = #filePath, line: UInt = #line) -> InMemoryFeedStore { + let sut = InMemoryFeedStore() + trackForMemoryLeaks(sut, file: file, line: line) + return sut + } + +} From ff62369c924c72cd830ea645e53b9483b62f73e2 Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Wed, 27 Mar 2024 11:13:09 +0200 Subject: [PATCH 3/4] Replace NullStore with InMemoryFeedStore to improve UX - The InMemoryStore can provide better UX as it supports pagination and image caching - Made the infra queue serial - instead of concurrent - since the InMemoryFeedStore is not thread safe --- EssentialApp/EssentialApp/SceneDelegate.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/EssentialApp/EssentialApp/SceneDelegate.swift b/EssentialApp/EssentialApp/SceneDelegate.swift index c6c8f296..e0d61efc 100644 --- a/EssentialApp/EssentialApp/SceneDelegate.swift +++ b/EssentialApp/EssentialApp/SceneDelegate.swift @@ -18,8 +18,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { return DispatchQueue( label: "com.essentialdeveloper.infra.queue", - qos: .userInitiated, - attributes: .concurrent + qos: .userInitiated ).eraseToAnyScheduler() }() @@ -38,7 +37,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } catch { assertionFailure("Failed to instantiate CoreData store with error: \(error.localizedDescription)") logger.fault("Failed to instantiate CoreData store with error: \(error.localizedDescription)") - return NullStore() + return InMemoryFeedStore() } }() From 001455e5c5ae3c075a08d9e1597f8a9976c0def6 Mon Sep 17 00:00:00 2001 From: Caio Zullo Date: Wed, 27 Mar 2024 11:13:55 +0200 Subject: [PATCH 4/4] Delete NullStore --- .../EssentialApp.xcodeproj/project.pbxproj | 4 ---- EssentialApp/EssentialApp/NullStore.swift | 22 ------------------- 2 files changed, 26 deletions(-) delete mode 100644 EssentialApp/EssentialApp/NullStore.swift diff --git a/EssentialApp/EssentialApp.xcodeproj/project.pbxproj b/EssentialApp/EssentialApp.xcodeproj/project.pbxproj index 759e39dc..b781d606 100644 --- a/EssentialApp/EssentialApp.xcodeproj/project.pbxproj +++ b/EssentialApp/EssentialApp.xcodeproj/project.pbxproj @@ -37,7 +37,6 @@ 0895DAAC234B3F7E0031BB2D /* EssentialFeed.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0895DAA9234B3F7E0031BB2D /* EssentialFeed.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 0895DAAD234B3F7E0031BB2D /* EssentialFeediOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0895DAAA234B3F7E0031BB2D /* EssentialFeediOS.framework */; }; 0895DAAE234B3F7E0031BB2D /* EssentialFeediOS.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0895DAAA234B3F7E0031BB2D /* EssentialFeediOS.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 08CF92F62555A15B006B7E7D /* NullStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CF92F52555A15B006B7E7D /* NullStore.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -101,7 +100,6 @@ 08B5033725346BAC003FF218 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/LaunchScreen.strings; sourceTree = ""; }; 08B5033925346BE1003FF218 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/LaunchScreen.strings"; sourceTree = ""; }; 08B5033B25346BFE003FF218 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/LaunchScreen.strings; sourceTree = ""; }; - 08CF92F52555A15B006B7E7D /* NullStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NullStore.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -168,7 +166,6 @@ children = ( 0895DA86234B3B950031BB2D /* AppDelegate.swift */, 0895DA88234B3B950031BB2D /* SceneDelegate.swift */, - 08CF92F52555A15B006B7E7D /* NullStore.swift */, 0835BF6C24850F9800A793D2 /* CombineHelpers.swift */, 08073B42238D2DF900A75DC6 /* FeedUIComposer.swift */, 088B441B25309B6E00D75AAD /* CommentsUIComposer.swift */, @@ -312,7 +309,6 @@ 08073B44238D2DFA00A75DC6 /* FeedUIComposer.swift in Sources */, 0895DA87234B3B950031BB2D /* AppDelegate.swift in Sources */, 08073B45238D2DFA00A75DC6 /* LoadResourcePresentationAdapter.swift in Sources */, - 08CF92F62555A15B006B7E7D /* NullStore.swift in Sources */, 08073B48238D2DFA00A75DC6 /* WeakRefVirtualProxy.swift in Sources */, 0895DA89234B3B950031BB2D /* SceneDelegate.swift in Sources */, 08073B49238D2DFA00A75DC6 /* FeedViewAdapter.swift in Sources */, diff --git a/EssentialApp/EssentialApp/NullStore.swift b/EssentialApp/EssentialApp/NullStore.swift deleted file mode 100644 index 54737357..00000000 --- a/EssentialApp/EssentialApp/NullStore.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// Copyright © Essential Developer. All rights reserved. -// - -import Foundation -import EssentialFeed - -class NullStore {} - -extension NullStore: FeedStore { - func deleteCachedFeed() throws {} - - func insert(_ feed: [LocalFeedImage], timestamp: Date) throws {} - - func retrieve() throws -> CachedFeed? { .none } -} - -extension NullStore: FeedImageDataStore { - func insert(_ data: Data, for url: URL) throws {} - - func retrieve(dataForURL url: URL) throws -> Data? { .none } -}