diff --git a/.github/actions/build-and-run-unit-tests/action.yml b/.github/actions/build-and-run-unit-tests/action.yml new file mode 100644 index 0000000000..54e09d034a --- /dev/null +++ b/.github/actions/build-and-run-unit-tests/action.yml @@ -0,0 +1,25 @@ +name: "Build and Run Unit Tests" +description: Builds and runs unit tests for given scheme and test plan +inputs: + destination: + description: "The destination of the build, can include things like platform, architecture, OS, name etc." + required: true + scheme: + description: "The Xcode scheme to build and test against." + required: true + test-plan: + description: "The test plan to use for testing." + required: true +runs: + using: "composite" + steps: + # Look into caching the xcbeautify installation to instead of installing for each test + - name: Install XCBeautify + shell: bash + run: | + HOMEBREW_NO_AUTO_UPDATE=1 brew install xcbeautify + - name: Build and Test + shell: bash + run: | + xcodebuild clean test -resultBundlePath TestResults/ResultBundle.xcresult -derivedDataPath DerivedData -workspace "ApolloDev.xcworkspace" -scheme "${{ inputs.scheme }}" -destination "${{ inputs.destination }}" -testPlan "${{ inputs.test-plan }}" | xcbeautify + \ No newline at end of file diff --git a/.github/actions/run-cocoapods-integration-tests/action.yml b/.github/actions/run-cocoapods-integration-tests/action.yml new file mode 100644 index 0000000000..e185924cee --- /dev/null +++ b/.github/actions/run-cocoapods-integration-tests/action.yml @@ -0,0 +1,20 @@ +name: "Run CocoaPods Integration Tests" +description: Runs CocoaPods Integration Tests against the apollo-ios repo +runs: + using: "composite" + steps: + - name: CocoaPods - Install + shell: bash + working-directory: Tests/CodegenCLITests/pod-install-test/ + run: | + pod install --verbose + - name: CocoaPods - CLI Test (init) + shell: bash + working-directory: Tests/CodegenCLITests/pod-install-test/ + run: | + ./Pods/Apollo/apollo-ios-cli init --schema-name NewTestSchema --module-type other + - name: CocoaPods - CLI Test (generate) + shell: bash + working-directory: Tests/CodegenCLITests/pod-install-test/ + run: | + ./Pods/Apollo/apollo-ios-cli generate diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml new file mode 100644 index 0000000000..12c5208add --- /dev/null +++ b/.github/workflows/ci-tests.yml @@ -0,0 +1,321 @@ +name: "CI Tests" + +on: + pull_request: + types: [opened, synchronize, reopened] + +env: + XCODE_VERSION: "15.0.1" + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + ios: ${{ steps.filter.outputs.ios }} + codegen: ${{ steps.filter.outputs.codegen }} + pagination: ${{ steps.filter.outputs.pagination }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v2 + id: filter + with: + filters: | + ios: + - 'apollo-ios/**' + - 'Tests/ApolloInternalTestHelpers/**' + - 'Tests/ApolloServerIntegrationTests/**' + - 'Tests/ApolloTests/**' + codegen: + - 'apollo-ios-codegen/**' + - 'Tests/ApolloCodegenInternalTestHelpers/**' + - 'Tests/ApolloCodegenTests/**' + - 'Tests/CodegenCLITests/**' + - 'Tests/CodegenIntegrationTests/**' + - 'Tests/TestCodeGenConfigurations/**' + pagination: + - 'apollo-ios-pagination/**' + - 'Tests/ApolloInternalTestHelpers/**' + - 'apollo-ios/**' + + tuist-generation: + runs-on: macos-13 + needs: [changes] + if: ${{ needs.changes.outputs.ios == 'true' || needs.changes.outputs.codegen == 'true' || needs.changes.outputs.pagination == 'true' }} + timeout-minutes: 8 + name: Run Tuist Generation + steps: + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ env.XCODE_VERSION }} + - name: Checkout Repo + uses: actions/checkout@v3 + - name: Run Tuist Generation + uses: tuist/tuist-action@0.13.0 + with: + command: 'generate' + arguments: '' + - name: Cache Build Dependencies + uses: actions/cache@v3 + with: + path: | + ./ApolloDev.xcodeproj + ./ApolloDev.xcworkspace + ./Derived/* + key: ${{ github.run_id }}-dependencies + + run-swift-builds: + runs-on: macos-13 + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - package: apollo-ios + - package: apollo-ios-codegen + - package: apollo-ios-pagination + name: Run swift build for SPM packages + steps: + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ env.XCODE_VERSION }} + - name: Checkout Repo + uses: actions/checkout@v3 + - name: Run Swift Build + shell: bash + run: | + cd ${{ matrix.package }} && swift build + + build-and-unit-test: + runs-on: macos-13 + needs: [tuist-generation, changes] + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + # macOS_current + - destination: platform=macOS,arch=x86_64 + scheme: ApolloTests + test-plan: Apollo-CITestPlan + name: Apollo Unit Tests - macOS + run-js-tests: false + should-run: ${{ needs.changes.outputs.ios }} + # Codegen CLI Test + - destination: platform=macOS,arch=x86_64 + scheme: CodegenCLITests + test-plan: CodegenCLITestPlan + name: Codegen CLI Unit Tests - macOS + run-js-tests: false + should-run: ${{ needs.changes.outputs.codegen }} + # CodegenLib Test + - destination: platform=macOS,arch=x86_64 + scheme: ApolloCodegenTests + test-plan: Apollo-Codegen-CITestPlan + name: Codegen Lib Unit Tests - macOS + run-js-tests: true + should-run: ${{ needs.changes.outputs.codegen }} + # ApolloPagination Tests + - destination: platform=macOS,arch=x86_64 + scheme: ApolloPaginationTests + test-plan: Apollo-PaginationTestPlan + name: ApolloPagination Unit Tests - macOS + run-js-tests: false + should-run: ${{ needs.changes.outputs.pagination }} + name: ${{ matrix.name }} + steps: + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ env.XCODE_VERSION }} + - name: Checkout Repo + uses: actions/checkout@v3 + - name: Retrieve Build Cache + uses: actions/cache@v3 + with: + path: | + ./ApolloDev.xcodeproj + ./ApolloDev.xcworkspace + ./Derived/* + key: ${{ github.run_id }}-dependencies + fail-on-cache-miss: true + # Caching for apollo-ios and apollo-ios-codegen SPM dependencies + # - uses: actions/cache@v3 + # with: + # path: ./DerivedData/SourcePackages + # key: ${{ runner.os }}-spm-${{ hashFiles('./apollo-ios/Package.resolved') }}-${{ hashFiles('./apollo-ios-codegen/Package.resolved') }} + # - name: Run Tuist Generation + # uses: tuist/tuist-action@0.13.0 + # with: + # command: 'generate' + # arguments: '' + - name: Build and Test + if: ${{ matrix.should-run == true || matrix.should-run == 'true' }} + id: build-and-test + uses: ./.github/actions/build-and-run-unit-tests + with: + destination: ${{ matrix.destination }} + scheme: ${{ matrix.scheme }} + test-plan: ${{ matrix.test-plan }} + - name: Run-JS-Tests + if: ${{ matrix.run-js-tests == true }} + shell: bash + working-directory: apollo-ios-codegen/Sources/GraphQLCompiler/JavaScript/ + run: | + npm install && npm test + - name: Save xcodebuild logs + if: ${{ steps.build-and-test.outcome != 'skipped' }} + uses: actions/upload-artifact@v3 + with: + name: ${{ matrix.name }}-logs + path: | + DerivedData/Logs/Build + - name: Save crash logs + if: ${{ steps.build-and-test.outcome != 'skipped' }} + uses: actions/upload-artifact@v3 + with: + name: ${{ matrix.name }}-crashes + path: | + ~/Library/Logs/DiagnosticReports + - name: Zip Result Bundle + if: ${{ steps.build-and-test.outcome != 'skipped' }} + shell: bash + working-directory: TestResults + run: | + zip -r ResultBundle.zip ResultBundle.xcresult + - name: Save test results + if: ${{ steps.build-and-test.outcome != 'skipped' }} + uses: actions/upload-artifact@v3 + with: + name: ${{ matrix.name }}-results + path: | + TestResults/ResultBundle.zip + + run-codegen-test-configurations: + runs-on: macos-13 + timeout-minutes: 20 + name: Codegen Test Configurations - macOS + steps: + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ env.XCODE_VERSION }} + - name: Checkout Repo + uses: actions/checkout@v3 + - name: Install XCBeautify + shell: bash + run: | + HOMEBREW_NO_AUTO_UPDATE=1 brew install xcbeautify + - name: Test Codegen Configurations + shell: bash + run: | + ./scripts/run-test-codegen-configurations.sh -t + + verify-frontend-bundle-latest: + runs-on: macos-13 + needs: [changes] + if: ${{ needs.changes.outputs.codegen == 'true' }} + timeout-minutes: 5 + name: Verify Frontend Bundle Latest - macOS + steps: + - name: Checkout Repo + uses: actions/checkout@v3 + - name: Build JS Bundle + shell: bash + working-directory: apollo-ios-codegen/Sources/GraphQLCompiler/JavaScript + run: npm install && ./auto_rollup.sh + - name: Verify Latest + shell: bash + run: | + git diff --exit-code + + run-cocoapods-integration-tests: + runs-on: macos-13 + timeout-minutes: 20 + name: Cocoapods Integration Tests - macOS + steps: + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ env.XCODE_VERSION }} + - name: Checkout Repo + uses: actions/checkout@v3 + - name: Export ENV Variables + shell: bash + working-directory: apollo-ios + run: | + apollo_ios_sha=$(git rev-parse HEAD) + echo "APOLLO_IOS_SHA=$apollo_ios_sha" >> ${GITHUB_ENV} + - name: Run CocoaPods Integration Tests + id: run-cocoapods-integration-tests + uses: ./.github/actions/run-cocoapods-integration-tests + + run-integration-tests: + runs-on: macos-13 + needs: [tuist-generation, changes] + if: ${{ needs.changes.outputs.ios == 'true' }} + timeout-minutes: 20 + name: Apollo Integration Tests - macOS + steps: + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ env.XCODE_VERSION }} + - name: Checkout Repo + uses: actions/checkout@v3 + - name: Setup Node 12.22.10 + uses: actions/setup-node@v3 + with: + node-version: 12.22.10 + - name: Setup Upload Server + shell: bash + run: | + sudo chmod -R +rwx SimpleUploadServer + cd SimpleUploadServer && npm install && npm start & + - name: Setup Node 18.15.0 + uses: actions/setup-node@v3 + with: + node-version: 18.15.0 + - name: Setup Subscription Server + shell: bash + run: | + sh ./scripts/install-apollo-server-docs-example-server.sh + cd ../docs-examples/apollo-server/v3/subscriptions-graphql-ws && npm start & + - name: Setup Star Wars Server + shell: bash + run: | + sh ./scripts/install-or-update-starwars-server.sh + cd ../starwars-server && npm start & + - name: Retrieve Build Cache + uses: actions/cache@v3 + with: + path: | + ./ApolloDev.xcodeproj + ./ApolloDev.xcworkspace + ./Derived/* + key: ${{ github.run_id }}-dependencies + fail-on-cache-miss: true + - name: Build and Test + uses: ./.github/actions/build-and-run-unit-tests + with: + destination: platform=macOS,arch=x86_64 + scheme: ApolloServerIntegrationTests + test-plan: Apollo-IntegrationTestPlan + - name: Save xcodebuild logs + uses: actions/upload-artifact@v3 + with: + name: macOS-Integration-logs + path: | + DerivedData/Logs/Build + - name: Save crash logs + uses: actions/upload-artifact@v3 + with: + name: macOS-Integration-crashes + path: | + ~/Library/Logs/DiagnosticReports + - name: Zip Result Bundle + shell: bash + working-directory: TestResults + run: | + zip -r ResultBundle.zip ResultBundle.xcresult + - name: Save test results + uses: actions/upload-artifact@v3 + with: + name: macOS-Integration-results + path: | + TestResults/ResultBundle.zip diff --git a/.github/workflows/pr-close.yml b/.github/workflows/pr-close.yml deleted file mode 100644 index b1829d4d30..0000000000 --- a/.github/workflows/pr-close.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Close Pull Request - -on: - pull_request_target: - types: [opened] - -jobs: - run: - name: Close and Comment PR - runs-on: ubuntu-latest - steps: - - uses: superbrothers/close-pull-request@v3 - with: - comment: "We do not accept PRs directly to the 'apollo-ios' repo. All development is done through the 'apollo-ios-dev' repo, please see the CONTRIBUTING guide for more information." \ No newline at end of file diff --git a/Package.swift b/Package.swift index 7597fbd361..8cdd96a981 100644 --- a/Package.swift +++ b/Package.swift @@ -54,6 +54,17 @@ let package = Package( dependencies: [ "Apollo", "ApolloAPI" + ], + exclude: ["snapshot_0.sqlite3"] + ), + .testTarget( + name: "PeekTests", + dependencies: [ + "Apollo", + "ApolloAPI", + "ApolloSQLite", + "ApolloWebSocket", + "ApolloTestSupport", ] ), .plugin( diff --git a/Tests/PeekTests/CacheResolutionTests.swift b/Tests/PeekTests/CacheResolutionTests.swift new file mode 100644 index 0000000000..cd81e7bc49 --- /dev/null +++ b/Tests/PeekTests/CacheResolutionTests.swift @@ -0,0 +1,269 @@ +@testable import Apollo +import ApolloAPI +import XCTest + +final class CacheTests: XCTestCase, CacheDependentTesting, StoreLoading { + var cacheType: TestCacheProvider.Type { + InMemoryTestCacheProvider.self + } + + static let defaultWaitTimeout: TimeInterval = 5.0 + + var cache: Apollo.NormalizedCache! + var server: MockGraphQLServer! + var store: ApolloStore! + var client: ApolloClient! + + override func setUpWithError() throws { + try super.setUpWithError() + + cache = try makeNormalizedCache() + store = ApolloStore(cache: cache) + + server = MockGraphQLServer() + let networkTransport = MockNetworkTransport(server: server, store: store) + client = ApolloClient(networkTransport: networkTransport, store: store) + } + + override func tearDownWithError() throws { + cache = nil + store = nil + server = nil + client = nil + + try super.tearDownWithError() + } + + func testFetchReturningCacheDataOnErrorReturnsData() throws { + class HeroNameSelectionSet: MockSelectionSet { + override class var __selections: [Selection] { [ + .field("hero", Hero.self) + ]} + + class Hero: MockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("name", String.self) + ]} + } + } + + let query = MockQuery() + + mergeRecordsIntoCache([ + "QUERY_ROOT": ["hero": CacheReference("hero")], + "hero": [ + "name": "R2-D2", + "__typename": "Droid" + ] + ]) + + let serverRequestExpectation = + server.expect(MockQuery.self) { request in + [ + "data": [ + "hero": [:] as JSONObject // incomplete data will cause an error on fetch + ] + ] + } + + let resultObserver = makeResultObserver(for: query) + + let fetchResultFromServerExpectation = resultObserver.expectation(description: "Received result from cache") { result in + try XCTAssertSuccessResult(result) { graphQLResult in + XCTAssertEqual(graphQLResult.source, .cache) + XCTAssertNil(graphQLResult.errors) + + let data = try XCTUnwrap(graphQLResult.data) + XCTAssertEqual(data.hero?.name, "R2-D2") + } + } + + client.fetch(query: query, cachePolicy: .fetchReturningCacheDataOnError, resultHandler: resultObserver.handler) + + wait(for: [serverRequestExpectation, fetchResultFromServerExpectation], timeout: Self.defaultWaitTimeout) + } + + func testResultContextWithDataFromYesterday() throws { + let now = Date() + let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now)! + let aYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: now)! + + let initialRecords = RecordSet([ + "QUERY_ROOT": (["hero": CacheReference("hero")], yesterday), + "hero": (["__typename": "Droid", "name": "R2-D2"], yesterday), + "ignoredData": (["__typename": "Droid", "name": "R2-D3"], aYearAgo) + ]) + try self.testResulContextWhenLoadingHeroNameQueryWithAge(initialRecords: initialRecords, expectedResultAge: yesterday) + } + + func testResultContextWithDataFromMixedDates() throws { + let now = Date() + let oneHourAgo = Calendar.current.date(byAdding: .hour, value: -1, to: now)! + let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now)! + let aYearAgo = Calendar.current.date(byAdding: .year, value: -1, to: now)! + + + let fields = ( + ["hero": CacheReference("hero")], + ["__typename": "Droid", "name": "R2-D2"], + ["__typename": "Droid", "name": "R2-D3"] + ) + + let initialRecords1 = RecordSet([ + "QUERY_ROOT": (fields.0, yesterday), + "hero": (fields.1, yesterday), + "ignoredData": (fields.2, aYearAgo) + ]) + + + try self.testResulContextWhenLoadingHeroNameQueryWithAge(initialRecords: initialRecords1, expectedResultAge: yesterday) + + let initialRecords2 = RecordSet([ + "QUERY_ROOT": (fields.0, yesterday), + "hero": (fields.1, oneHourAgo), + "ignoredData": (fields.2, aYearAgo) + ]) + + try self.testResulContextWhenLoadingHeroNameQueryWithAge(initialRecords: initialRecords2, expectedResultAge: yesterday) + } + + func testReceivedAtAfterUpdateQuery() throws { + // given + struct GivenSelectionSet: MockMutableRootSelectionSet { + public var __data: DataDict = .empty() + init(_dataDict: DataDict) { __data = _dataDict } + + static var __selections: [Selection] { [ + .field("hero", Hero.self) + ]} + + var hero: Hero { + get { __data["hero"] } + set { __data["hero"] = newValue } + } + + struct Hero: MockMutableRootSelectionSet { + public var __data: DataDict = .empty() + init(_dataDict: DataDict) { __data = _dataDict } + + static var __selections: [Selection] { [ + .field("name", String.self) + ]} + + var name: String { + get { __data["name"] } + set { __data["name"] = newValue } + } + } + } + + let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date())! + let initialRecords = RecordSet([ + "QUERY_ROOT": (["hero": CacheReference("QUERY_ROOT.hero")], yesterday), + "QUERY_ROOT.hero": (["__typename": "Droid", "name": "R2-D2"], yesterday) + ]) + let cacheMutation = MockLocalCacheMutation() + mergeRecordsIntoCache(initialRecords) + + runActivity("update mutation") { _ in + + let expectation = self.expectation(description: "transaction'd") + store.withinReadWriteTransaction({ transaction in + try transaction.update(cacheMutation) { data in + data.hero.name = "Artoo" + } + }, completion: { result in + defer { expectation.fulfill() } + XCTAssertSuccessResult(result) + }) + self.wait(for: [expectation], timeout: Self.defaultWaitTimeout) + + let query = MockQuery() + loadFromStore(operation: query) { result in + switch result { + case let .success(success): + // the query age is that of the oldest row read, so still yesterday + XCTAssertEqual( + Calendar.current.compare(yesterday, to: success.metadata.maxAge, toGranularity: .minute), + .orderedSame + ) + case let .failure(error): + XCTFail("Unexpected error: \(error)") + } + + } + } + + runActivity("read object") { _ in + // verify that the age of the modified row is from just now + let cacheReadExpectation = self.expectation(description: "cacheReadExpectation") + store.withinReadTransaction ({ transaction in + let object = try transaction.readObject(ofType: GivenSelectionSet.Hero.self, withKey: "QUERY_ROOT.hero") + XCTAssertTrue(object.0.name == "Artoo") + XCTAssertEqual( + Calendar.current.compare(Date(), to: object.1.maxAge, toGranularity: .minute), + .orderedSame + ) + }, completion: { result in + defer { cacheReadExpectation.fulfill() } + XCTAssertSuccessResult(result) + }) + self.wait(for: [cacheReadExpectation], timeout: Self.defaultWaitTimeout) + } + } +} + +// MARK: - Helpers + +extension CacheTests { + private func testResulContextWhenLoadingHeroNameQueryWithAge( + initialRecords: RecordSet, + expectedResultAge: Date, + file: StaticString = #file, + line: UInt = #line + ) throws { + + class HeroNameSelectionSet: MockSelectionSet { + override class var __selections: [Selection] { [ + .field("hero", Hero.self) + ]} + + class Hero: MockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("name", String.self) + ]} + } + } + + let query = MockQuery() + mergeRecordsIntoCache(initialRecords) + loadFromStore(operation: query) { result in + switch result { + case let .success(result): + XCTAssertNil(result.errors, file: file, line: line) + XCTAssertEqual(result.data?.hero?.name, "R2-D2", file: file, line: line) + XCTAssertEqual( + Calendar.current.compare(expectedResultAge, to: result.metadata.maxAge, toGranularity: .minute), + .orderedSame, + file: file, + line: line + ) + case let .failure(error): + XCTFail("Unexpected error: \(error)", file: file, line: line) + } + } + } +} + +extension RecordSet { + init(_ dictionary: Dictionary) { + self.init(rows: dictionary.map { element in + RecordRow( + record: Record(key: element.key, element.value.fields), + lastReceivedAt: element.value.receivedAt + ) + }) + } +} diff --git a/Tests/PeekTests/SQLiteCacheTests.swift b/Tests/PeekTests/SQLiteCacheTests.swift new file mode 100644 index 0000000000..e92d91dcca --- /dev/null +++ b/Tests/PeekTests/SQLiteCacheTests.swift @@ -0,0 +1,119 @@ +@testable import Apollo +import ApolloAPI +@testable import ApolloSQLite +import SQLite +import XCTest + +final class SQLiteCacheTests: XCTestCase { + func testDatabaseSetup() throws { + // loop through each of the database snapshots to run through migrations + // if a migration fails, then it will throw an error + // we verify the migration is successful by comparing the iteration to the schema version (assigned after the migration) + let testBundle = Bundle(for: Self.self) + try testBundle.paths(forResourcesOfType: "sqlite3", inDirectory: nil) + .sorted() // make sure they run in order + .map(URL.init(fileURLWithPath:)) + .enumerated() + .forEach { previousSchemaVersion, fileURL in + guard FileManager.default.fileExists(atPath: fileURL.path) else { + XCTFail("expected snapshot file '\(fileURL.lastPathComponent)' could not be found") + return + } + // open a connection to the snapshot that is expected to be migrated to the next version + try SQLiteTestCacheProvider.withCache(fileURL: fileURL) { cache in + guard let sqlCache = cache as? SQLiteNormalizedCache else { + XCTFail("The cache is not using SQLite") + return + } + let schemaVersion = try sqlCache.database.readSchemaVersion() + XCTAssertEqual(schemaVersion, Int64(previousSchemaVersion + 1)) + + runTestFetchAndPersist(againstFileAt: fileURL) + } + } + } + + func testPassInConnectionDoesNotThrow() { + do { + let database = try SQLiteDotSwiftDatabase(connection: Connection()) + _ = try SQLiteNormalizedCache(database: database) + + } catch { + XCTFail("Passing in connection failed with error: \(error)") + } + } +} + +// MARK: - Helpers + +extension SQLiteCacheTests { + private func runTestFetchAndPersist( + againstFileAt sqliteFileURL: URL, + file: StaticString = #file, + line: UInt = #line + ) { + // given + class GivenSelectionSet: MockSelectionSet { + override class var __selections: [Selection] { [ + .field("hero", Hero.self) + ]} + class Hero: MockSelectionSet { + override class var __selections: [Selection] {[ + .field("__typename", String.self), + .field("name", String.self) + ]} + } + } + + let query = MockQuery() + try SQLiteTestCacheProvider.withCache(fileURL: sqliteFileURL) { (cache) in + let store = ApolloStore(cache: cache) + let server = MockGraphQLServer() + let networkTransport = MockNetworkTransport(server: server, store: store) + let client = ApolloClient(networkTransport: networkTransport, store: store) + _ = server.expect(MockQuery.self) { request in + [ + "data": [ + "hero": [ + "name": "Luke Skywalker", + "__typename": "Human" + ] + ] + ] + } + + let networkExpectation = self.expectation(description: "Fetching query from network") + let newCacheExpectation = self.expectation(description: "Fetch query from new cache") + + client.fetch(query: query, cachePolicy: .fetchIgnoringCacheData) { outerResult in + defer { networkExpectation.fulfill() } + + switch outerResult { + case .failure(let error): + XCTFail("Unexpected error: \(error)") + return + case .success(let graphQLResult): + XCTAssertEqual(graphQLResult.data?.hero?.name, "Luke Skywalker") + // Do another fetch from cache to ensure that data is cached before creating new cache + client.fetch(query: query, cachePolicy: .returnCacheDataDontFetch) { innerResult in + try! SQLiteTestCacheProvider.withCache(fileURL: sqliteFileURL) { cache in + let newStore = ApolloStore(cache: cache) + let newClient = ApolloClient(networkTransport: networkTransport, store: newStore) + newClient.fetch(query: query, cachePolicy: .returnCacheDataDontFetch) { newClientResult in + defer { newCacheExpectation.fulfill() } + switch newClientResult { + case .success(let newClientGraphQLResult): + XCTAssertEqual(newClientGraphQLResult.data?.hero?.name, "Luke Skywalker") + case .failure(let error): + XCTFail("Unexpected error with new client: \(error)") + } + _ = newClient // Workaround for a bug - ensure that newClient is retained until this block is run + }} + } + } + } + + self.waitForExpectations(timeout: 2, handler: nil) + } + } +} diff --git a/Tests/PeekTests/TestHelpers/AsyncResultObserver.swift b/Tests/PeekTests/TestHelpers/AsyncResultObserver.swift new file mode 100644 index 0000000000..4c6974822c --- /dev/null +++ b/Tests/PeekTests/TestHelpers/AsyncResultObserver.swift @@ -0,0 +1,69 @@ +import XCTest + +/// `AsyncResultObserver` is a helper class that can be used to test `Result` values received through a completion handler against one or more expectations. It is primarily useful if you expect the completion handler to be called multiple times, when receiving a fetch result from the cache and then from the server for example. +/// +/// The main benefit is that it avoids having to manually keep track of expectations and mutable closures (like `verifyResult`), which can make code hard to read and is prone to mistakes. Instead, you can use a result observer to create multiple expectations that will be automatically fulfilled in order when results are received. Often, you'll also want to run assertions against the result, which you can do by passing in an optional handler that is specific to that expectation. These handlers are throwing, which means you can use `result.get()` and `XCTUnwrap` for example. Thrown errors will automatically be recorded as failures in the test case (with the right line numbers, etc.). +/// +/// By default, expectations returned from `AsyncResultObserver` only expect to be called once, which is similar to how other built-in expectations work. Unexpected fulfillments will result in test failures. Usually this is what you want, and you add additional expectations with their own assertions if you expect further results. +/// If multiple fulfillments of a single expectation are expected however, you can use the standard `expectedFulfillmentCount` property to change that. +public class AsyncResultObserver where Failure: Error { + public typealias ResultHandler = (Result) throws -> Void + + private class AsyncResultExpectation: XCTestExpectation { + let file: StaticString + let line: UInt + let handler: ResultHandler + + init(description: String, file: StaticString = #filePath, line: UInt = #line, handler: @escaping ResultHandler) { + self.file = file + self.line = line + self.handler = handler + + super.init(description: description) + } + } + + private let testCase: XCTestCase + + // We keep track of the file and line number associated with the constructor as a fallback, in addition te keeping + // these for each expectation. That way, we can still show a failure within the context of the test in case unexpected + // results are received (which by definition do not have an associated expectation). + private let file: StaticString + private let line: UInt + + private var expectations: [AsyncResultExpectation] = [] + + public init(testCase: XCTestCase, file: StaticString = #filePath, line: UInt = #line) { + self.testCase = testCase + self.file = file + self.line = line + } + + public func expectation(description: String, file: StaticString = #filePath, line: UInt = #line, resultHandler: @escaping ResultHandler) -> XCTestExpectation { + let expectation = AsyncResultExpectation(description: description, file: file, line: line, handler: resultHandler) + expectation.assertForOverFulfill = true + + expectations.append(expectation) + + return expectation + } + + public func handler(_ result: Result) { + guard let expectation = expectations.first else { + XCTFail("Unexpected result received by handler", file: file, line: line) + return + } + + do { + try expectation.handler(result) + } catch { + testCase.record(error, file: expectation.file, line: expectation.line) + } + + expectation.fulfill() + + if expectation.numberOfFulfillments >= expectation.expectedFulfillmentCount { + expectations.removeFirst() + } + } +} diff --git a/Tests/PeekTests/TestHelpers/Matchable.swift b/Tests/PeekTests/TestHelpers/Matchable.swift new file mode 100644 index 0000000000..3e368b5240 --- /dev/null +++ b/Tests/PeekTests/TestHelpers/Matchable.swift @@ -0,0 +1,23 @@ +import Foundation +import ApolloAPI + +public protocol Matchable { + associatedtype Base + static func ~=(pattern: Self, value: Base) -> Bool +} + +extension JSONDecodingError: Matchable { + public typealias Base = Error + public static func ~=(pattern: JSONDecodingError, value: Error) -> Bool { + guard let value = value as? JSONDecodingError else { + return false + } + + switch (value, pattern) { + case (.missingValue, .missingValue), (.nullValue, .nullValue), (.wrongType, .wrongType), (.couldNotConvert, .couldNotConvert): + return true + default: + return false + } + } +} diff --git a/Tests/PeekTests/TestHelpers/MockApolloStore.swift b/Tests/PeekTests/TestHelpers/MockApolloStore.swift new file mode 100644 index 0000000000..103ffcb066 --- /dev/null +++ b/Tests/PeekTests/TestHelpers/MockApolloStore.swift @@ -0,0 +1,28 @@ +@testable import Apollo +@testable import ApolloAPI + +extension ApolloStore { + public static func mock(cache: NormalizedCache = NoCache()) -> ApolloStore { + ApolloStore(cache: cache) + } +} + +/// A `NormalizedCache` that does not cache any data. Used for tests that don't require testing +/// caching behavior. +public class NoCache: NormalizedCache { + public init() { } + + public func loadRecords(forKeys keys: Set) throws -> [String : RecordRow] { + return [:] + } + + public func merge(records: RecordSet) throws -> Set { + return Set() + } + + public func removeRecord(for key: String) throws { } + + public func removeRecords(matching pattern: String) throws { } + + public func clear() throws { } +} diff --git a/Tests/PeekTests/TestHelpers/MockGraphQLServer.swift b/Tests/PeekTests/TestHelpers/MockGraphQLServer.swift new file mode 100644 index 0000000000..2a9639f99b --- /dev/null +++ b/Tests/PeekTests/TestHelpers/MockGraphQLServer.swift @@ -0,0 +1,107 @@ +@testable import Apollo +@testable import ApolloAPI +import XCTest + +/// A `MockGraphQLServer` can be used during tests to check whether expected GraphQL requests are received, and to respond with appropriate test data for a particular request. +/// +/// You usually create a mock server in the test's `setUpWithError`, and use it to initialize a `MockNetworkTransport` that is in turn used to initialize an `ApolloClient`: +/// ``` +/// let server = MockGraphQLServer() +/// let networkTransport = MockNetworkTransport(server: server, store: store) +/// let client = ApolloClient(networkTransport: networkTransport, store: store) +/// ``` +/// A mock server should be configured to expect particular operation types, and invokes the passed in request handler when a request of that type comes in. Because the request allows access to `operation`, you can return different responses based on query variables for example: + +/// ``` +/// let serverExpectation = server.expect(HeroNameQuery.self) { request in +/// [ +/// "data": [ +/// "hero": [ +/// "name": request.operation.episode == .empire ? "Luke Skywalker" : "R2-D2", +/// "__typename": "Droid" +/// ] +/// ] +/// ] +/// } +/// ``` +/// By default, expectations returned from `MockGraphQLServer` only expect to be called once, which is similar to how other built-in expectations work. Unexpected fulfillments will result in test failures. But if multiple fulfillments are expected, you can use the standard `expectedFulfillmentCount` property to change that. For example, some of the concurrent tests expect the server to receive the same number of request as the number of invoked fetch operations, so in that case we can use: + +/// ``` +/// serverExpectation.expectedFulfillmentCount = numberOfFetches +/// ``` +public class MockGraphQLServer { + enum ServerError: Error, CustomStringConvertible { + case unexpectedRequest(String) + + public var description: String { + switch self { + case .unexpectedRequest(let requestDescription): + return "Mock GraphQL server received an unexpected request: \(requestDescription)" + } + } + } + + public typealias RequestHandler = (HTTPRequest) -> JSONObject + + private class RequestExpectation: XCTestExpectation { + let file: StaticString + let line: UInt + let handler: RequestHandler + + init(description: String, file: StaticString = #filePath, line: UInt = #line, handler: @escaping RequestHandler) { + self.file = file + self.line = line + self.handler = handler + + super.init(description: description) + } + } + + private let queue = DispatchQueue(label: "com.apollographql.MockGraphQLServer") + + public init() { } + + // Since RequestExpectation is generic over a specific GraphQLOperation, we can't store these in the dictionary + // directly. Moreover, there is no way to specify the type relationship that holds between the key and value. + // To work around this, we store values as Any and use a generic subscript as a type-safe way to access them. + private var requestExpectations: [AnyHashable: Any] = [:] + + private subscript(_ operationType: Operation.Type) -> RequestExpectation? { + get { + requestExpectations[ObjectIdentifier(operationType)] as! RequestExpectation? + } + + set { + requestExpectations[ObjectIdentifier(operationType)] = newValue + } + } + + public func expect(_ operationType: Operation.Type, file: StaticString = #filePath, line: UInt = #line, requestHandler: @escaping (HTTPRequest) -> JSONObject) -> XCTestExpectation { + return queue.sync { + let expectation = RequestExpectation(description: "Served request for \(String(describing: operationType))", file: file, line: line, handler: requestHandler) + expectation.assertForOverFulfill = true + + self[operationType] = expectation + + return expectation + } + } + + func serve(request: HTTPRequest, completionHandler: @escaping (Result) -> Void) where Operation: GraphQLOperation { + let operationType = type(of: request.operation) + + if let expectation = self[operationType] { + // Dispatch after a small random delay to spread out concurrent requests and simulate somewhat real-world conditions. + queue.asyncAfter(deadline: .now() + .milliseconds(Int.random(in: 10...50))) { + completionHandler(.success(expectation.handler(request))) + expectation.fulfill() + } + + } else { + queue.async { + completionHandler(.failure(ServerError.unexpectedRequest(String(describing: operationType)))) + } + } + + } +} diff --git a/Tests/PeekTests/TestHelpers/MockLocalCacheMutation.swift b/Tests/PeekTests/TestHelpers/MockLocalCacheMutation.swift new file mode 100644 index 0000000000..534ad168ed --- /dev/null +++ b/Tests/PeekTests/TestHelpers/MockLocalCacheMutation.swift @@ -0,0 +1,40 @@ +import Foundation +import ApolloAPI + +open class MockLocalCacheMutation: LocalCacheMutation { + open class var operationType: GraphQLOperationType { .query } + + public typealias Data = SelectionSet + + open var __variables: GraphQLOperation.Variables? + + public init() {} + +} + +open class MockLocalCacheMutationFromMutation: + MockLocalCacheMutation { + override open class var operationType: GraphQLOperationType { .mutation } +} + +open class MockLocalCacheMutationFromSubscription: + MockLocalCacheMutation { + override open class var operationType: GraphQLOperationType { .subscription } +} + +public protocol MockMutableRootSelectionSet: MutableRootSelectionSet +where Schema == MockSchemaMetadata {} + +public extension MockMutableRootSelectionSet { + static var __parentType: ParentType { Object.mock } + + init() { + self.init(_dataDict: DataDict( + data: [:], + fulfilledFragments: [ObjectIdentifier(Self.self)] + )) + } +} + +public protocol MockMutableInlineFragment: MutableSelectionSet, InlineFragment +where Schema == MockSchemaMetadata {} diff --git a/Tests/PeekTests/TestHelpers/MockNetworkTransport.swift b/Tests/PeekTests/TestHelpers/MockNetworkTransport.swift new file mode 100644 index 0000000000..6d9cd2cac9 --- /dev/null +++ b/Tests/PeekTests/TestHelpers/MockNetworkTransport.swift @@ -0,0 +1,98 @@ +import Foundation +@testable import Apollo +@testable import ApolloAPI + +public final class MockNetworkTransport: RequestChainNetworkTransport { + public init( + server: MockGraphQLServer = MockGraphQLServer(), + store: ApolloStore, + clientName: String = "MockNetworkTransport_ClientName", + clientVersion: String = "MockNetworkTransport_ClientVersion" + ) { + super.init(interceptorProvider: TestInterceptorProvider(store: store, server: server), + endpointURL: TestURL.mockServer.url) + self.clientName = clientName + self.clientVersion = clientVersion + } + + struct TestInterceptorProvider: InterceptorProvider { + let store: ApolloStore + let server: MockGraphQLServer + + func interceptors( + for operation: Operation + ) -> [any ApolloInterceptor] where Operation: GraphQLOperation { + return [ + MaxRetryInterceptor(), + CacheReadInterceptor(store: self.store), + MockGraphQLServerInterceptor(server: server), + ResponseCodeInterceptor(), + JSONResponseParsingInterceptor(), + AutomaticPersistedQueryInterceptor(), + CacheWriteInterceptor(store: self.store), + ] + } + } +} + +private final class MockTask: Cancellable { + func cancel() { + // no-op + } +} + +private class MockGraphQLServerInterceptor: ApolloInterceptor { + let server: MockGraphQLServer + + public var id: String = UUID().uuidString + + init(server: MockGraphQLServer) { + self.server = server + } + + public func interceptAsync(chain: RequestChain, request: HTTPRequest, response: HTTPResponse?, completion: @escaping (Result, Error>) -> Void) where Operation: GraphQLOperation { + server.serve(request: request) { result in + let httpResponse = HTTPURLResponse(url: TestURL.mockServer.url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + + switch result { + case .failure(let error): + chain.handleErrorAsync(error, + request: request, + response: response, + completion: completion) + case .success(let body): + let data = try! JSONSerializationFormat.serialize(value: body) + let response = HTTPResponse(response: httpResponse, + rawData: data, + parsedResponse: nil) + chain.proceedAsync(request: request, + response: response, + interceptor: self, + completion: completion) + } + } + } +} + +public class MockWebSocketTransport: NetworkTransport { + public var clientName, clientVersion: String + + public init(clientName: String, clientVersion: String) { + self.clientName = clientName + self.clientVersion = clientVersion + } + + public func send( + operation: Operation, + cachePolicy: CachePolicy, + contextIdentifier: UUID?, + context: RequestContext?, + callbackQueue: DispatchQueue, + completionHandler: @escaping (Result, Error>) -> Void + ) -> Cancellable where Operation : GraphQLOperation { + return MockTask() + } +} diff --git a/Tests/PeekTests/TestHelpers/MockOperation.swift b/Tests/PeekTests/TestHelpers/MockOperation.swift new file mode 100644 index 0000000000..a6d05beab7 --- /dev/null +++ b/Tests/PeekTests/TestHelpers/MockOperation.swift @@ -0,0 +1,109 @@ +import ApolloAPI + +open class MockOperation: GraphQLOperation { + public typealias Data = SelectionSet + + open class var operationType: GraphQLOperationType { .query } + + open class var hasDeferredFragments: Bool { false } + + open class var operationName: String { "MockOperationName" } + + open class var operationDocument: OperationDocument { + .init(definition: .init("Mock Operation Definition")) + } + + open var __variables: Variables? + + public init() {} + +} + +open class MockQuery: MockOperation, GraphQLQuery { + public static func mock() -> MockQuery where SelectionSet == MockSelectionSet { + MockQuery() + } +} + +open class MockMutation: MockOperation, GraphQLMutation { + + public override class var operationType: GraphQLOperationType { .mutation } + + public static func mock() -> MockMutation where SelectionSet == MockSelectionSet { + MockMutation() + } +} + +open class MockSubscription: MockOperation, GraphQLSubscription { + + public override class var operationType: GraphQLOperationType { .subscription } + + public static func mock() -> MockSubscription where SelectionSet == MockSelectionSet { + MockSubscription() + } +} + +open class MockDeferredQuery: MockOperation, GraphQLQuery { + + public override class var operationType: GraphQLOperationType { .query } + public override class var hasDeferredFragments: Bool { true } + + public static func mock() -> MockDeferredQuery where SelectionSet == MockSelectionSet { + MockDeferredQuery() + } +} + +// MARK: - MockSelectionSets + +@dynamicMemberLookup +open class AbstractMockSelectionSet: RootSelectionSet, Hashable { + public typealias Schema = S + public typealias Fragments = F + + open class var __selections: [Selection] { [] } + open class var __parentType: ParentType { Object.mock } + + public var __data: DataDict = .empty() + + public required init(_dataDict: DataDict) { + self.__data = _dataDict + } + + public subscript(dynamicMember key: String) -> T? { + __data[key] + } + + public subscript(dynamicMember key: String) -> T? { + __data[key] + } + + public static func == (lhs: MockSelectionSet, rhs: MockSelectionSet) -> Bool { + lhs.__data == rhs.__data + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(__data) + } +} + +public typealias MockSelectionSet = AbstractMockSelectionSet + +open class MockFragment: MockSelectionSet, Fragment { + public typealias Schema = MockSchemaMetadata + + open class var fragmentDefinition: StaticString { "" } +} + +open class MockTypeCase: MockSelectionSet, InlineFragment { + public typealias RootEntityType = MockSelectionSet +} + +open class ConcreteMockTypeCase: MockSelectionSet, InlineFragment { + public typealias RootEntityType = T +} + +extension DataDict { + public static func empty() -> DataDict { + DataDict(data: [:], fulfilledFragments: []) + } +} diff --git a/Tests/PeekTests/TestHelpers/MockSchemaMetadata.swift b/Tests/PeekTests/TestHelpers/MockSchemaMetadata.swift new file mode 100644 index 0000000000..115acf7b33 --- /dev/null +++ b/Tests/PeekTests/TestHelpers/MockSchemaMetadata.swift @@ -0,0 +1,111 @@ +import Apollo +import ApolloAPI + +extension Object { + public static let mock = Object(typename: "Mock", implementedInterfaces: []) +} + +public class MockSchemaMetadata: SchemaMetadata { + public init() { } + + public static var _configuration: SchemaConfiguration.Type = SchemaConfiguration.self + public static var configuration: ApolloAPI.SchemaConfiguration.Type = SchemaConfiguration.self + + private static let testObserver = TestObserver() { _ in + stub_objectTypeForTypeName = nil + stub_cacheKeyInfoForType_Object = nil + } + + public static var stub_objectTypeForTypeName: ((String) -> Object?)? { + didSet { + if stub_objectTypeForTypeName != nil { testObserver.start() } + } + } + + public static var stub_cacheKeyInfoForType_Object: ((Object, ObjectData) -> CacheKeyInfo?)? { + get { + _configuration.stub_cacheKeyInfoForType_Object + } + set { + _configuration.stub_cacheKeyInfoForType_Object = newValue + if newValue != nil { testObserver.start() } + } + } + + public static func objectType(forTypename __typename: String) -> Object? { + if let stub = stub_objectTypeForTypeName { + return stub(__typename) + } + + return Object(typename: __typename, implementedInterfaces: []) + } + + public class SchemaConfiguration: ApolloAPI.SchemaConfiguration { + static var stub_cacheKeyInfoForType_Object: ((Object, ObjectData) -> CacheKeyInfo?)? + + public static func cacheKeyInfo(for type: Object, object: ObjectData) -> CacheKeyInfo? { + stub_cacheKeyInfoForType_Object?(type, object) + } + } +} + + +// MARK - Mock Cache Key Providers + +public protocol MockStaticCacheKeyProvider { + static func cacheKeyInfo(for type: Object, object: ObjectData) -> CacheKeyInfo? +} + +extension MockStaticCacheKeyProvider { + public static var resolver: (Object, ObjectData) -> CacheKeyInfo? { + cacheKeyInfo(for:object:) + } +} + +public struct IDCacheKeyProvider: MockStaticCacheKeyProvider { + public static func cacheKeyInfo(for type: Object, object: ObjectData) -> CacheKeyInfo? { + try? .init(jsonValue: object["id"]) + } +} + +public struct MockCacheKeyProvider { + let id: String + + public init(id: String) { + self.id = id + } + + public func cacheKeyInfo(for type: Object, object: JSONObject) -> CacheKeyInfo? { + .init(id: id, uniqueKeyGroup: nil) + } +} + +// MARK: - Custom Mock Schemas + +public enum MockSchema1: SchemaMetadata { + public static var configuration: SchemaConfiguration.Type = MockSchema1Configuration.self + + public static func objectType(forTypename __typename: String) -> Object? { + Object(typename: __typename, implementedInterfaces: []) + } +} + +public enum MockSchema1Configuration: SchemaConfiguration { + public static func cacheKeyInfo(for type: Object, object: ObjectData) -> CacheKeyInfo? { + CacheKeyInfo(id: "one") + } +} + +public enum MockSchema2: SchemaMetadata { + public static var configuration: SchemaConfiguration.Type = MockSchema2Configuration.self + + public static func objectType(forTypename __typename: String) -> Object? { + Object(typename: __typename, implementedInterfaces: []) + } +} + +public enum MockSchema2Configuration: SchemaConfiguration { + public static func cacheKeyInfo(for type: Object, object: ObjectData) -> CacheKeyInfo? { + CacheKeyInfo(id: "two") + } +} diff --git a/Tests/PeekTests/TestHelpers/SQLiteTestCacheProvider.swift b/Tests/PeekTests/TestHelpers/SQLiteTestCacheProvider.swift new file mode 100644 index 0000000000..8f01ed7157 --- /dev/null +++ b/Tests/PeekTests/TestHelpers/SQLiteTestCacheProvider.swift @@ -0,0 +1,30 @@ +import Foundation +import Apollo +import ApolloSQLite + +public class SQLiteTestCacheProvider: TestCacheProvider { + /// Execute a test block rather than return a cache synchronously, since cache setup may be + /// asynchronous at some point. + public static func withCache(initialRecords: RecordSet? = nil, fileURL: URL? = nil, execute test: (NormalizedCache) throws -> ()) rethrows { + let fileURL = fileURL ?? temporarySQLiteFileURL() + let cache = try! SQLiteNormalizedCache(fileURL: fileURL, initialRecords: initialRecords) + try test(cache) + } + + public static func makeNormalizedCache(_ completionHandler: (Result, Error>) -> ()) { + let fileURL = temporarySQLiteFileURL() + let cache = try! SQLiteNormalizedCache(fileURL: fileURL) + completionHandler(.success((cache, nil))) + } + + public static func temporarySQLiteFileURL() -> URL { + let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory()) + + // Create a folder with a random UUID to hold the SQLite file, since creating them in the + // same folder this close together will cause DB locks when you try to delete between tests. + let folder = temporaryDirectoryURL.appendingPathComponent(UUID().uuidString) + try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + + return folder.appendingPathComponent("db.sqlite3") + } +} diff --git a/Tests/PeekTests/TestHelpers/TestCacheProvider.swift b/Tests/PeekTests/TestHelpers/TestCacheProvider.swift new file mode 100644 index 0000000000..d32235189f --- /dev/null +++ b/Tests/PeekTests/TestHelpers/TestCacheProvider.swift @@ -0,0 +1,64 @@ +import XCTest +@testable import Apollo + +public typealias TearDownHandler = () throws -> () +public typealias TestDependency = (Resource, TearDownHandler?) + +public protocol TestCacheProvider: AnyObject { + static func makeNormalizedCache(_ completionHandler: (Result, Error>) -> ()) +} + +public class InMemoryTestCacheProvider: TestCacheProvider { + public static func withCache(initialRecords: Apollo.RecordSet?, execute test: (Apollo.NormalizedCache) throws -> ()) rethrows { + let cache = InMemoryNormalizedCache(records: initialRecords ?? [:]) + try test(cache) + } + + public static func makeNormalizedCache(_ completionHandler: (Result, Error>) -> ()) { + let cache = InMemoryNormalizedCache() + completionHandler(.success((cache, nil))) + } +} + +public protocol CacheDependentTesting { + var cacheType: TestCacheProvider.Type { get } + var cache: NormalizedCache! { get } +} + +extension CacheDependentTesting where Self: XCTestCase { + public func makeNormalizedCache() throws -> NormalizedCache { + var result: Result = .failure(XCTestError(.timeoutWhileWaiting)) + + let expectation = XCTestExpectation(description: "Initialized normalized cache") + + cacheType.makeNormalizedCache() { [weak self] testDependencyResult in + guard let self = self else { return } + + result = testDependencyResult.map { testDependency in + let (cache, tearDownHandler) = testDependency + + if let tearDownHandler = tearDownHandler { + self.addTeardownBlock { + do { + try tearDownHandler() + } catch { +// self.record(error) + } + } + } + + return cache + } + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + + return try result.get() + } + + public func mergeRecordsIntoCache(_ records: RecordSet) { + _ = try! cache.merge(records: records) + } +} diff --git a/Tests/PeekTests/TestHelpers/TestObserver.swift b/Tests/PeekTests/TestHelpers/TestObserver.swift new file mode 100644 index 0000000000..55937e8354 --- /dev/null +++ b/Tests/PeekTests/TestHelpers/TestObserver.swift @@ -0,0 +1,43 @@ +import Apollo +import XCTest + +public class TestObserver: NSObject, XCTestObservation { + + private let onFinish: (XCTestCase) -> Void + + @Atomic private var isStarted: Bool = false + let stopAfterEachTest: Bool + + public init( + startOnInit: Bool = true, + stopAfterEachTest: Bool = true, + onFinish: @escaping ((XCTestCase) -> Void) + ) { + self.stopAfterEachTest = stopAfterEachTest + self.onFinish = onFinish + super.init() + + if startOnInit { start() } + } + + public func start() { + guard !isStarted else { return } + $isStarted.mutate { + XCTestObservationCenter.shared.addTestObserver(self) + $0 = true + } + } + + public func stop() { + guard isStarted else { return } + $isStarted.mutate { + XCTestObservationCenter.shared.removeTestObserver(self) + $0 = false + } + } + + public func testCaseDidFinish(_ testCase: XCTestCase) { + onFinish(testCase) + if stopAfterEachTest { stop() } + } +} diff --git a/Tests/PeekTests/TestHelpers/TestURLs.swift b/Tests/PeekTests/TestHelpers/TestURLs.swift new file mode 100644 index 0000000000..820a2f04f1 --- /dev/null +++ b/Tests/PeekTests/TestHelpers/TestURLs.swift @@ -0,0 +1,19 @@ +import Foundation + +/// URLs used in testing +public enum TestURL { + case mockServer + case mockPort8080 + + public var url: URL { + let urlString: String + switch self { + case .mockServer: + urlString = "http://localhost/dummy_url" + case .mockPort8080: + urlString = "http://localhost:8080/graphql" + } + + return URL(string: urlString)! + } +} diff --git a/Tests/PeekTests/TestHelpers/XCTAssertHelpers.swift b/Tests/PeekTests/TestHelpers/XCTAssertHelpers.swift new file mode 100644 index 0000000000..87dfc3829b --- /dev/null +++ b/Tests/PeekTests/TestHelpers/XCTAssertHelpers.swift @@ -0,0 +1,159 @@ +import XCTest +@testable import Apollo + +public func XCTAssertEqual(_ expression1: @autoclosure () throws -> [T : U]?, _ expression2: @autoclosure () throws -> [T : U]?, file: StaticString = #filePath, line: UInt = #line) rethrows { + let optionalValue1 = try expression1() + let optionalValue2 = try expression2() + + let message = { + "(\"\(String(describing: optionalValue1))\") is not equal to (\"\(String(describing: optionalValue2))\")" + } + + switch (optionalValue1, optionalValue2) { + case (.none, .none): + break + case let (value1 as NSDictionary, value2 as NSDictionary): + XCTAssertEqual(value1, value2, message(), file: file, line: line) + default: + XCTFail(message(), file: file, line: line) + } +} + +public func XCTAssertEqualUnordered(_ expression1: @autoclosure () throws -> C1, _ expression2: @autoclosure () throws -> C2, file: StaticString = #filePath, line: UInt = #line) rethrows where Element: Hashable, C1.Element == Element, C2.Element == Element { + let collection1 = try expression1() + let collection2 = try expression2() + + // Convert to sets to ignore ordering and only check whether all elements are accounted for, + // but also check count to detect duplicates. + XCTAssertEqual(collection1.count, collection2.count, file: file, line: line) + XCTAssertEqual(Set(collection1), Set(collection2), file: file, line: line) +} + +public func XCTAssertMatch(_ valueExpression: @autoclosure () throws -> Pattern.Base, _ patternExpression: @autoclosure () throws -> Pattern, file: StaticString = #filePath, line: UInt = #line) rethrows { + let value = try valueExpression() + let pattern = try patternExpression() + + let message = { + "(\"\(value)\") does not match (\"\(pattern)\")" + } + + if case pattern = value { return } + + XCTFail(message(), file: file, line: line) +} + +// We need overloaded versions instead of relying on default arguments +// due to https://bugs.swift.org/browse/SR-1534 + +public func XCTAssertSuccessResult(_ expression: @autoclosure () throws -> Result, file: StaticString = #file, line: UInt = #line) rethrows { + try XCTAssertSuccessResult(expression(), file: file, line: line, {_ in }) +} + +public func XCTAssertSuccessResult(_ expression: @autoclosure () throws -> Result, file: StaticString = #file, line: UInt = #line, _ successHandler: (_ value: Success) throws -> Void) rethrows { + let result = try expression() + + switch result { + case .success(let value): + try successHandler(value) + case .failure(let error): + XCTFail("Expected success, but result was an error: \(String(describing: error))", file: file, line: line) + } +} + +public func XCTAssertFailureResult(_ expression: @autoclosure () throws -> Result, file: StaticString = #file, line: UInt = #line) rethrows { + try XCTAssertFailureResult(expression(), file: file, line: line, {_ in }) +} + +public func XCTAssertFailureResult(_ expression: @autoclosure () throws -> Result, file: StaticString = #file, line: UInt = #line, _ errorHandler: (_ error: Error) throws -> Void) rethrows { + let result = try expression() + + switch result { + case .success(let success): + XCTFail("Expected failure, but result was successful: \(String(describing: success))", file: file, line: line) + case .failure(let error): + try errorHandler(error) + } +} + +/// Checks that the condition is eventually true with a given timeout (default 1 second). +/// +/// This assertion runs the run loop for 0.01 second after each time it checks the condition until +/// the condition is true or the timeout is reached. +/// +/// - Parameters: +/// - test: An autoclosure for the condition to test for truthiness. +/// - timeout: The timeout, at which point the test will fail. Defaults to 1 second. +/// - message: A message to send on failure. +public func XCTAssertTrueEventually(_ test: @autoclosure () -> Bool, timeout: TimeInterval = 1.0, message: String = "", file: StaticString = #file, line: UInt = #line) { + let runLoop = RunLoop.current + let timeoutDate = Date(timeIntervalSinceNow: timeout) + repeat { + if test() { + return + } + runLoop.run(until: Date(timeIntervalSinceNow: 0.01)) + } while Date().compare(timeoutDate) == .orderedAscending + + XCTFail(message, file: file, line: line) +} + +/// Checks that the condition is eventually false with a given timeout (default 1 second). +/// +/// This assertion runs the run loop for 0.01 second after each time it checks the condition until +/// the condition is false or the timeout is reached. +/// +/// - Parameters: +/// - test: An autoclosure for the condition to test for falsiness. +/// - timeout: The timeout, at which point the test will fail. Defaults to 1 second. +/// - message: A message to send on failure. +public func XCTAssertFalseEventually(_ test: @autoclosure () -> Bool, timeout: TimeInterval = 1.0, message: String = "", file: StaticString = #file, line: UInt = #line) { + XCTAssertTrueEventually(!test(), timeout: timeout, message: message, file: file, line: line) +} + +/// Downcast an expression to a specified type. +/// +/// Generates a failure when the downcast doesn't succeed. +/// +/// - Parameters: +/// - expression: An expression to downcast to `ExpectedType`. +/// - file: The file in which failure occurred. Defaults to the file name of the test case in which this function was called. +/// - line: The line number on which failure occurred. Defaults to the line number on which this function was called. +/// - Returns: A value of type `ExpectedType`, the result of evaluating and downcasting the given `expression`. +/// - Throws: An error when the downcast doesn't succeed. It will also rethrow any error thrown while evaluating the given expression. +public func XCTDowncast(_ expression: @autoclosure () throws -> AnyObject, to type: ExpectedType.Type, file: StaticString = #filePath, line: UInt = #line) throws -> ExpectedType { + let object = try expression() + + guard let expected = object as? ExpectedType else { + throw XCTFailure("Expected type to be \(ExpectedType.self), but found \(Swift.type(of: object))", file: file, line: line) + } + + return expected +} + +/// An error which causes the current test to cease executing and fail when it is thrown. +/// Similar to `XCTSkip`, but without marking the test as skipped. +public struct XCTFailure: Error, CustomNSError { + + public init(_ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) { + XCTFail(message(), file: file, line: line) + } + + /// The domain of the error. + public static let errorDomain = XCTestErrorDomain + + /// The error code within the given domain. + public let errorCode: Int = 0 + + /// The user-info dictionary. + public let errorUserInfo: [String : Any] = [ + // Make sure the thrown error doesn't show up as a test failure, because we already record + // a more detailed failure (with the right source location) ourselves. + "XCTestErrorUserInfoKeyShouldIgnore": true + ] +} + +public extension Optional { + func xctUnwrapped(file: StaticString = #filePath, line: UInt = #line) throws -> Wrapped { + try XCTUnwrap(self, file: file, line: line) + } +} diff --git a/Tests/PeekTests/TestHelpers/XCTest+Helpers.swift b/Tests/PeekTests/TestHelpers/XCTest+Helpers.swift new file mode 100644 index 0000000000..58830b37ad --- /dev/null +++ b/Tests/PeekTests/TestHelpers/XCTest+Helpers.swift @@ -0,0 +1,77 @@ +import XCTest + +extension XCTestExpectation { + /// Private API for accessing the number of times an expectation has been fulfilled. + public var numberOfFulfillments: Int { + value(forKey: "numberOfFulfillments") as! Int + } +} + +public extension XCTestCase { + /// Record the specified`error` as an `XCTIssue`. + func record(_ error: Error, compactDescription: String? = nil, file: StaticString = #filePath, line: UInt = #line) { + var issue = XCTIssue(type: .thrownError, compactDescription: compactDescription ?? String(describing: error)) + + issue.associatedError = error + + let location = XCTSourceCodeLocation(filePath: file, lineNumber: line) + issue.sourceCodeContext = XCTSourceCodeContext(location: location) + + record(issue) + } + + /// Invoke a throwing closure, and record any thrown errors without rethrowing. This is useful if you need to run code that may throw + /// in a place where throwing isn't allowed, like `measure` blocks. + func whileRecordingErrors(file: StaticString = #file, line: UInt = #line, _ perform: () throws -> Void) { + do { + try perform() + } catch { + // Respect XCTestErrorUserInfoKeyShouldIgnore key that is used by XCTUnwrap, XCTSkip, and our own XCTFailure. + let shouldIgnore = (((error as NSError).userInfo["XCTestErrorUserInfoKeyShouldIgnore"] as? Bool) == true) + if !shouldIgnore { + record(error, file: file, line: line) + } + } + } + + /// Wrapper around `XCTContext.runActivity` to allow for future extension. + func runActivity(_ name: String, perform: (XCTActivity) throws -> Result) rethrows -> Result { + return try XCTContext.runActivity(named: name, block: perform) + } +} + +@testable import Apollo +@testable import ApolloAPI + +public extension XCTestCase { + /// Make an `AsyncResultObserver` for receiving results of the specified GraphQL operation. + func makeResultObserver(for operation: Operation, file: StaticString = #filePath, line: UInt = #line) -> AsyncResultObserver, Error> { + return AsyncResultObserver(testCase: self, file: file, line: line) + } +} + +public protocol StoreLoading { + static var defaultWaitTimeout: TimeInterval { get } + var store: ApolloStore! { get } +} + +public extension StoreLoading { + static var defaultWaitTimeout: TimeInterval { 1.0 } +} + +extension StoreLoading where Self: XCTestCase { + public func loadFromStore( + operation: Operation, + file: StaticString = #filePath, + line: UInt = #line, + resultHandler: @escaping AsyncResultObserver, Error>.ResultHandler + ) { + let resultObserver = makeResultObserver(for: operation, file: file, line: line) + + let expectation = resultObserver.expectation(description: "Loaded query from store", file: file, line: line, resultHandler: resultHandler) + + store.load(operation, resultHandler: resultObserver.handler) + + wait(for: [expectation], timeout: Self.defaultWaitTimeout) + } +}