├── .github
├── funding.yml
├── workflows
│ ├── swiftlint.yml
│ ├── documentation.yml
│ └── ci-swiftpm.yml
├── dependabot.yml
├── PULL_REQUEST_TEMPLATE
└── ISSUE_TEMPLATE
├── .gitignore
├── Sources
└── Fakes
│ ├── EmptyError.swift
│ ├── Fakes.docc
│ ├── Tutorials
│ │ └── WritingFakes
│ │ │ ├── Resources
│ │ │ └── code
│ │ │ │ └── RecipeService
│ │ │ │ ├── NetworkInterface-01.swift
│ │ │ │ ├── NetworkInterface-02.swift
│ │ │ │ ├── NetworkInterface-03.swift
│ │ │ │ ├── FakeNetworkInterface-01.swift
│ │ │ │ ├── FakeNetworkInterface-04.swift
│ │ │ │ ├── RecipeService-01.swift
│ │ │ │ ├── RecipeService-02.swift
│ │ │ │ ├── RecipeService-03.swift
│ │ │ │ ├── FakeNetworkInterface-03.swift
│ │ │ │ ├── FakeNetworkInterface-02.swift
│ │ │ │ ├── RecipeServiceTests-03.swift
│ │ │ │ ├── RecipeServiceTests-02.swift
│ │ │ │ └── RecipeServiceTests-01.swift
│ │ │ ├── WritingFakes.tutorial
│ │ │ └── RecipeService.tutorial
│ ├── Fakes.md
│ └── Guides
│ │ ├── NimbleIntegration.md
│ │ ├── VerifyingCallbacks.md
│ │ └── DependencyInjection.md
│ ├── PrivacyInfo.xcprivacy
│ ├── Spy
│ ├── Spy+AsyncSequence.swift
│ ├── Spy+Testing.swift
│ ├── Spy+Pendable.swift
│ ├── Spy+Result.swift
│ ├── Spy.swift
│ ├── Spy+ThrowingPendable.swift
│ └── Spy+Nimble.swift
│ ├── Pendable
│ ├── PendableDefaults.swift
│ └── Pendable.swift
│ ├── PropertySpy
│ └── PropertySpy.swift
│ └── DynamicResult.swift
├── .swiftlint.yml
├── script
├── build_docs
└── release
├── Package.swift
├── Tests
└── FakesTests
│ ├── PendableTests.swift
│ ├── SpyTests+Nimble.swift
│ ├── DynamicResultTests.swift
│ ├── SpyTests+TestHelpers.swift
│ ├── PropertySpyTests.swift
│ └── SpyTests.swift
├── Package.resolved
├── README.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
└── LICENSE
/.github/funding.yml:
--------------------------------------------------------------------------------
1 | github: [younata]
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm
7 | .netrc
8 |
9 | docs/
10 |
--------------------------------------------------------------------------------
/Sources/Fakes/EmptyError.swift:
--------------------------------------------------------------------------------
1 | /// An error that can be used as a default error in tests
2 | public struct EmptyError: Error, Sendable {
3 | public init() {}
4 | }
5 |
--------------------------------------------------------------------------------
/Sources/Fakes/Fakes.docc/Tutorials/WritingFakes/Resources/code/RecipeService/NetworkInterface-01.swift:
--------------------------------------------------------------------------------
1 | protocol NetworkInterface {
2 | func get(from url: URL) -> Data
3 | func post(data: Data, to url: URL)
4 | }
5 |
--------------------------------------------------------------------------------
/Sources/Fakes/Fakes.docc/Tutorials/WritingFakes/Resources/code/RecipeService/NetworkInterface-02.swift:
--------------------------------------------------------------------------------
1 | protocol NetworkInterface {
2 | func get(from url: URL) throws -> Data
3 | func post(data: Data, to url: URL) throws
4 | }
5 |
--------------------------------------------------------------------------------
/.swiftlint.yml:
--------------------------------------------------------------------------------
1 | included:
2 | - Sources
3 | - Tests
4 |
5 | disabled_rules:
6 | opt_in_rules:
7 | - yoda_condition
8 |
9 | line_length:
10 | warning: 160
11 | error: 240
12 |
13 | trailing_comma:
14 | mandatory_comma: true
15 |
--------------------------------------------------------------------------------
/Sources/Fakes/Fakes.docc/Tutorials/WritingFakes/Resources/code/RecipeService/NetworkInterface-03.swift:
--------------------------------------------------------------------------------
1 | protocol NetworkInterface {
2 | func get(from url: URL) async throws -> Data
3 | func post(data: Data, to url: URL) async throws
4 | }
5 |
--------------------------------------------------------------------------------
/Sources/Fakes/Fakes.docc/Tutorials/WritingFakes/Resources/code/RecipeService/FakeNetworkInterface-01.swift:
--------------------------------------------------------------------------------
1 | final class FakeNetworkInterface: NetworkInterface {
2 | func get(from url: URL) -> Data {
3 | Data()
4 | }
5 |
6 | func post(data: Data, to url: URL) {}
7 | }
8 |
--------------------------------------------------------------------------------
/Sources/Fakes/PrivacyInfo.xcprivacy:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSPrivacyTracking
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.github/workflows/swiftlint.yml:
--------------------------------------------------------------------------------
1 | name: SwiftLint
2 |
3 | on:
4 | pull_request:
5 | paths:
6 | - '.github/workflows/swiftlint.yml'
7 | - '.swiftlint.yml'
8 | - '**/*.swift'
9 |
10 | jobs:
11 | lint:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v6
15 | - uses: norio-nomura/action-swiftlint@3.2.1
16 |
--------------------------------------------------------------------------------
/script/build_docs:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | set -e
4 |
5 | export DOCC_JSON_PRETTYPRINT="YES"
6 |
7 | mkdir -p docs
8 |
9 | swift package --allow-writing-to-directory docs \
10 | generate-documentation --target Fakes \
11 | --disable-indexing \
12 | --transform-for-static-hosting \
13 | --hosting-base-path 'swift-fakes' \
14 | --output-path docs
15 |
--------------------------------------------------------------------------------
/Sources/Fakes/Spy/Spy+AsyncSequence.swift:
--------------------------------------------------------------------------------
1 | extension Spy {
2 | public func record(_ sequence: AS) async where Arguments == Result, Returning == Void {
3 | do {
4 | for try await value in sequence {
5 | self(.success(value))
6 | }
7 | } catch {
8 | self(.failure(error))
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Sources/Fakes/Fakes.docc/Tutorials/WritingFakes/WritingFakes.tutorial:
--------------------------------------------------------------------------------
1 | @Tutorials(name: "Writing Fakes") {
2 | @Intro(title: "Creating a Fake") {
3 | A tutorial showing you how to create and use a Fake, within the context
4 | of testing network calls.
5 | }
6 |
7 | @Chapter(name: "Creating and Testing RecipeService") {
8 | @TutorialReference(tutorial: "doc:RecipeService")
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "swift"
9 | directory: "/"
10 | schedule:
11 | interval: "weekly"
12 | - package-ecosystem: "github-actions"
13 | directory: "/"
14 | schedule:
15 | interval: "weekly"
16 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE:
--------------------------------------------------------------------------------
1 | The PR should summarize what was changed and why. Here are some questions to
2 | help you if you're not sure:
3 |
4 | - What behavior was changed?
5 | - What code was refactored / updated to support this change?
6 | - What issues are related to this PR? Or why was this change introduced?
7 |
8 | Checklist - While not every PR needs it, new features should consider this list:
9 |
10 | - [ ] Does this have tests?
11 | - [ ] Does this have documentation?
12 | - [ ] Does this break the public API (Requires major version bump)?
13 | - [ ] Is this a new feature (Requires minor version bump)?
14 |
--------------------------------------------------------------------------------
/.github/workflows/documentation.yml:
--------------------------------------------------------------------------------
1 | name: Build Documentation
2 | on:
3 | push:
4 | branches:
5 | - main
6 | tags:
7 | - "*"
8 | pull_request:
9 | branches:
10 | - "*"
11 |
12 | permissions:
13 | contents: write
14 |
15 | jobs:
16 | build-documentation:
17 | runs-on: ubuntu-latest
18 | steps:
19 | - name: Checkout
20 | uses: actions/checkout@v6
21 |
22 | - name: Build Docs
23 | run: |
24 | ./script/build_docs
25 |
26 | - name: Deploy Docs
27 | if: github.ref == 'refs/heads/main'
28 | uses: JamesIves/github-pages-deploy-action@v4
29 | with:
30 | folder: docs
31 |
--------------------------------------------------------------------------------
/Sources/Fakes/Fakes.docc/Tutorials/WritingFakes/Resources/code/RecipeService/FakeNetworkInterface-04.swift:
--------------------------------------------------------------------------------
1 | final class FakeNetworkInterface: NetworkInterface {
2 | // a `ThrowingPendableSpy` is just a typealias for `Spy<..., ThrowingPendable<..., ...>>`.
3 | // Thus, it is still used exactly same way that `Spy` is.
4 | let getSpy = ThrowingPendableSpy() // PendableSpy and
5 | // ThrowingPendableSpy default to stub with `.pending`.
6 | func get(from url: URL) throws -> Data {
7 | try await getSpy(url)
8 | }
9 |
10 | let postSpy = ThrowingPendableSpy<(data: Data, url: URL), Void, Error>()
11 | func post(data: Data, to url: URL) throws {
12 | try await postSpy((data, url))
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/Sources/Fakes/Fakes.docc/Tutorials/WritingFakes/Resources/code/RecipeService/RecipeService-01.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct Recipe: Equatable, Codable {
4 | // ...
5 | }
6 |
7 | struct RecipeService {
8 | let networkInterface: NetworkInterface
9 |
10 | func recipes() throws -> [Recipe] {
11 | let url = URL(string: "https://example.com/recipes")!
12 | let data = networkInterface.get(from: url)
13 | return try JSONDecoder().decode([Recipe].self, from: data)
14 | }
15 |
16 | func store(recipe: Recipe) throws {
17 | let url = URL(string: "https://example.com/recipes/store")!
18 | let data = try JSONEncoder().encode(recipe)
19 | networkInterface.post(data: data, to: url)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Sources/Fakes/Fakes.docc/Tutorials/WritingFakes/Resources/code/RecipeService/RecipeService-02.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct Recipe: Equatable, Codable {
4 | // ...
5 | }
6 |
7 | struct RecipeService {
8 | let networkInterface: NetworkInterface
9 |
10 | func recipes() throws -> [Recipe] {
11 | let url = URL(string: "https://example.com/recipes")!
12 | let data = try networkInterface.get(from: url)
13 | return try JSONDecoder().decode([Recipe].self, from: data)
14 | }
15 |
16 | func store(recipe: Recipe) throws {
17 | let url = URL(string: "https://example.com/recipes/store")!
18 | let data = try JSONEncoder().encode(recipe)
19 | try networkInterface.post(data: data, to: url)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Sources/Fakes/Fakes.docc/Tutorials/WritingFakes/Resources/code/RecipeService/RecipeService-03.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct Recipe: Equatable, Codable {
4 | // ...
5 | }
6 |
7 | struct RecipeService {
8 | let networkInterface: NetworkInterface
9 |
10 | func recipes() async throws -> [Recipe] {
11 | let url = URL(string: "https://example.com/recipes")!
12 | let data = try await networkInterface.get(from: url)
13 | return try JSONDecoder().decode([Recipe].self, from: data)
14 | }
15 |
16 | func store(recipe: Recipe) async throws {
17 | let url = URL(string: "https://example.com/recipes/store")!
18 | let data = try JSONEncoder().encode(recipe)
19 | try await networkInterface.post(data: data, to: url)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Sources/Fakes/Fakes.docc/Tutorials/WritingFakes/Resources/code/RecipeService/FakeNetworkInterface-03.swift:
--------------------------------------------------------------------------------
1 | final class FakeNetworkInterface: NetworkInterface {
2 | // a `ThrowingSpy` is just a typealias for `Spy<..., Result<..., ...>>`.
3 | // Thus, it is still used exactly same way that `Spy` is.
4 | let getSpy = ThrowingSpy(Data())
5 | func get(from url: URL) throws -> Data {
6 | try getSpy(url) // This now uses the overload of `callAsFunction` to one
7 | // that can either return `Success` (in this case, `Data`) or throw an error
8 | }
9 |
10 | let postSpy = ThrowingSpy<(data: Data, url: URL), Void, Error>()
11 | func post(data: Data, to url: URL) throws {
12 | return try postSpy((data, url)) // the return statement here is necessary
13 | // to force swift to use the correct form of Spy's `callAsFunction`.
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/Fakes/Fakes.docc/Fakes.md:
--------------------------------------------------------------------------------
1 | # ``Fakes``
2 |
3 | **Swift Fakes** is an open source collection of Test Doubles for Swift
4 |
5 | ## Overview
6 |
7 | Fakes aims to provide easy-to-use and powerful test double infrastructure for
8 | Swift.
9 |
10 | Currently, Fakes offers the ``Spy`` object.
11 |
12 | ## Terms
13 |
14 | - Term Test Double: Any object used to replace a production object for testing
15 | purposes.
16 | - Term Fake: A non-production implementation of a protocol.
17 | - Term Spy: A test double that records calls to a method, and returns a
18 | preconfigured response.
19 | - Term Subject: A generic term for the object being tested. See [Test Double's
20 | description](https://github.com/testdouble/contributing-tests/wiki/Subject).
21 |
22 | ## Topics
23 |
24 | ### Guides
25 |
26 |
27 |
28 |
29 | ### Tutorials
30 |
31 |
32 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.0
2 |
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "swift-fakes",
7 | platforms: [
8 | .macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .visionOS(.v1)
9 | ],
10 | products: [
11 | .library(
12 | name: "Fakes",
13 | targets: ["Fakes"]
14 | ),
15 | ],
16 | dependencies: [
17 | .package(url: "https://github.com/Quick/Nimble.git", from: "14.0.0"),
18 | .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"),
19 | ],
20 | targets: [
21 | .target(
22 | name: "Fakes",
23 | dependencies: ["Nimble"],
24 | resources: [
25 | .copy("PrivacyInfo.xcprivacy")
26 | ]
27 | ),
28 | .testTarget(
29 | name: "FakesTests",
30 | dependencies: ["Fakes", "Nimble"]
31 | ),
32 | ]
33 | )
34 |
--------------------------------------------------------------------------------
/Sources/Fakes/Fakes.docc/Tutorials/WritingFakes/Resources/code/RecipeService/FakeNetworkInterface-02.swift:
--------------------------------------------------------------------------------
1 | final class FakeNetworkInterface: NetworkInterface {
2 | // by convention, the name of a spy is the first part of the method name,
3 | // followed by Spy.
4 | let getSpy = Spy(Data()) // Spies that do not return Void or
5 | // Pendable must be initialized with a default value.
6 | func get(from url: URL) -> Data {
7 | getSpy(url)
8 | }
9 |
10 | let postSpy = Spy<(data: Data, url: URL), Void>() // The first type for
11 | // a Spy is either a tuple of the arguments to the method, or the singular
12 | // argument to the method. When you use a tuple, it is most helpful to
13 | // create a named tuple. We will see more when we write the test.
14 | func post(data: Data, to url: URL) {
15 | postSpy((data, url)) // Swift allows us to pass an unnamed tuple in
16 | // place of a named tuple with the same types.
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Sources/Fakes/Pendable/PendableDefaults.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// Default values for use with Pendable.
4 | public final class PendableDefaults: @unchecked Sendable {
5 | public static let shared = PendableDefaults()
6 | private let lock = NSLock()
7 |
8 | public init() {}
9 |
10 | /// The amount of time to delay before resolving a pending Pendable with the fallback value.
11 | /// By default this is 2 seconds. Conveniently, just long enough to be twice Nimble's default polling timeout.
12 | /// In general, you should keep this set to some number greater than Nimble's default polling timeout,
13 | /// in order to allow polling matchers to work correctly.
14 | public static var delay: TimeInterval {
15 | get {
16 | PendableDefaults.shared.delay
17 | }
18 | set {
19 | PendableDefaults.shared.delay = newValue
20 | }
21 | }
22 |
23 | private var _delay: TimeInterval = 2
24 | public var delay: TimeInterval {
25 | get {
26 | lock.lock()
27 | defer { lock.unlock() }
28 | return _delay
29 | }
30 | set {
31 | lock.lock()
32 | _delay = newValue
33 | lock.unlock()
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE:
--------------------------------------------------------------------------------
1 | - [ ] I have read [CONTRIBUTING](https://github.com/Quick/swift-fakes/blob/main/CONTRIBUTING.md) and have done my best to follow them.
2 |
3 | ### What did you do?
4 |
5 | Please replace this with what you did.
6 |
7 | ### What did you expect to happen?
8 |
9 | Please replace this with what you expected to happen.
10 |
11 | ### What actually happened instead?
12 |
13 | Please replace this with what happened instead.
14 |
15 | ### Environment
16 |
17 | List the software versions you're using:
18 |
19 | - Quick: *?.?.?*
20 | - Nimble: *?.?.?*
21 | - Swift Fakes: *?.?.?*
22 | - Xcode Version: *?.? (????)* (Open Xcode; In menubar: Xcode > About Xcode)
23 | - Swift Version: *?.?* (Open Xcode Preferences; Components > Toolchains. If none, use `Xcode Default`.)
24 |
25 | Please also mention which package manager you used and its version. Delete the
26 | other package managers in this list:
27 |
28 | - Swift Package Manager *?.?.? (swiftpm-???)* (Use `swift build --version` in Terminal)
29 |
30 | ### Project that demonstrates the issue
31 |
32 | Please link to a project we can download that reproduces the issue. Feel free
33 | to delete this section if it's not relevant to the issue (eg - feature request).
34 |
35 | The project should be [short, self-contained, and correct example](http://sscce.org/).
36 |
--------------------------------------------------------------------------------
/Tests/FakesTests/PendableTests.swift:
--------------------------------------------------------------------------------
1 | import Fakes
2 | import Nimble
3 | import XCTest
4 |
5 | final class PendableTests: XCTestCase {
6 | func testSingleCall() async throws {
7 | let subject = Pendable.pending(fallback: 0)
8 |
9 | async let result = subject.call()
10 |
11 | try await Task.sleep(nanoseconds: UInt64(0.01 * 1_000_000_000))
12 |
13 | subject.resolve(with: 2)
14 |
15 | let value = await result
16 | expect(value).to(equal(2))
17 | }
18 |
19 | func testMultipleCalls() async throws {
20 | let subject = Pendable.pending(fallback: 0)
21 |
22 | async let result = withTaskGroup(of: Int.self, returning: [Int].self) { taskGroup in
23 | for _ in 0..<100 {
24 | taskGroup.addTask { await subject.call() }
25 | }
26 |
27 | var results = [Int]()
28 | for await value in taskGroup {
29 | results.append(value)
30 | }
31 | return results
32 | }
33 |
34 | try await Task.sleep(nanoseconds: UInt64(0.1 * 1_000_000_000))
35 |
36 | subject.resolve(with: 3)
37 |
38 | let value = await result
39 | expect(value).to(equal(Array(repeating: 3, count: 100)))
40 | }
41 |
42 | func testAutoresolve() async {
43 | let subject = Pendable.pending(fallback: 3)
44 |
45 | await waitUntil(timeout: .milliseconds(500)) { done in
46 | Task {
47 | _ = await subject.call(fallbackDelay: 0.1)
48 | done()
49 | }
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "5bc9c42a661634bd36bc39a230eb3807665c4762f9170c426c649f738d2edde6",
3 | "pins" : [
4 | {
5 | "identity" : "cwlcatchexception",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/mattgallagher/CwlCatchException.git",
8 | "state" : {
9 | "revision" : "07b2ba21d361c223e25e3c1e924288742923f08c",
10 | "version" : "2.2.1"
11 | }
12 | },
13 | {
14 | "identity" : "cwlpreconditiontesting",
15 | "kind" : "remoteSourceControl",
16 | "location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git",
17 | "state" : {
18 | "revision" : "0139c665ebb45e6a9fbdb68aabfd7c39f3fe0071",
19 | "version" : "2.2.2"
20 | }
21 | },
22 | {
23 | "identity" : "nimble",
24 | "kind" : "remoteSourceControl",
25 | "location" : "https://github.com/Quick/Nimble.git",
26 | "state" : {
27 | "revision" : "035b88ad6ae8035f5ce2b50b0a6d69c3b16d2120",
28 | "version" : "14.0.0"
29 | }
30 | },
31 | {
32 | "identity" : "swift-docc-plugin",
33 | "kind" : "remoteSourceControl",
34 | "location" : "https://github.com/apple/swift-docc-plugin",
35 | "state" : {
36 | "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06",
37 | "version" : "1.4.5"
38 | }
39 | },
40 | {
41 | "identity" : "swift-docc-symbolkit",
42 | "kind" : "remoteSourceControl",
43 | "location" : "https://github.com/swiftlang/swift-docc-symbolkit",
44 | "state" : {
45 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34",
46 | "version" : "1.0.0"
47 | }
48 | }
49 | ],
50 | "version" : 3
51 | }
52 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # swift-fakes
2 |
3 | Swift Fakes aims to improve the testability of Swift by providing standardized
4 | [test doubles](https://martinfowler.com/bliki/TestDouble.html).
5 |
6 | Test doubles are objects used to replace production objects for testing purposes.
7 |
8 | ## Installation
9 |
10 | To use the `Fakes` library in a SwiftPM project, add the following line to the
11 | dependencies in your `Package.swift` file:
12 |
13 | ```swift
14 | .package(url: "https://github.com/Quick/swift-fakes", from: "0.1.1"),
15 | ```
16 |
17 | Include `"Fakes"` as a dependency for your test target:
18 |
19 | ```swift
20 | .testTarget(name: "", dependencies: [
21 | .product(name: "Fakes", package: "swift-fakes"),
22 | ]),
23 | ```
24 |
25 | ## Motivation
26 |
27 | When writing tests, we want to write one thing at a time. This is best done by
28 | providing _fakes_ or non-production test-controllable objects to the thing being
29 | tested (the [subject](https://github.com/testdouble/contributing-tests/wiki/Subject)).
30 | This is typically done by writing fakes that implement the protocols that the
31 | subject depends on. Swift Fakes aims to make writing Fakes as easy as possible.
32 |
33 | ## Contents
34 |
35 | For the time being, Swift Fakes only offers the `Spy` object. `Spy`s are a kind
36 | of test double that record calls to the object, and return a preset response.
37 |
38 | ### Spy
39 |
40 | Spies are meant to be used in Fake objects to record arguments to a call, and
41 | return pre-stubbed responses.
42 |
43 | See the [documentation](https://quick.github.io/swift-fakes/documentation/fakes).
44 |
45 | ## Source Stability
46 |
47 | Swift Fakes is currently available as an Alpha. By 1.0, we aim to have it source
48 | stable and following Semantic Versioning.
49 |
--------------------------------------------------------------------------------
/Sources/Fakes/Fakes.docc/Tutorials/WritingFakes/Resources/code/RecipeService/RecipeServiceTests-03.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | final class RecipeServiceTests: XCTestCase {
4 | var networkInterface: FakeNetworkInterface!
5 | var subject: RecipeService!
6 |
7 | override func setUp() {
8 | super.setUp()
9 |
10 | networkInterface = FakeNetworkInterface()
11 | subject = RecipeService(networkInterface: networkInterface)
12 | }
13 |
14 | func testFetchRecipes() async throws {
15 | // Arrange step
16 | networkInterface.getSpy.stub(success: recipesArrayAsData)
17 | // `ThrowingPendableSpy` basically has the same interface as
18 | // `ThrowingSpy`.
19 |
20 | // Act step
21 | let recipes = try await subject.recipes()
22 |
23 | XCTAssertEqual(recipes, [...])
24 | XCTAssertEqual(
25 | networkInterface.getSpy.calls,
26 | [URL(string: "https://example.com/recipes")!]
27 | )
28 | }
29 |
30 | func testFetchRecipesRethrowsErrors() async throws {
31 | // ...
32 | }
33 |
34 | func testStoreRecipe() async throws {
35 | // Arrange step
36 | networkInterface.postSpy.stub(success: ()) // if we don't restub
37 | // `postSpy`, then we will end up blocking the test and throwing a
38 | // `PendableInProgressError`.
39 |
40 | // Act step
41 | try await subject.store(recipe: Recipe(...))
42 |
43 | // Assert Step
44 | XCTAssertEqual(networkInterface.postSpy.calls.count, 1)
45 | XCTAssertEqual(
46 | networkInterface.postSpy.calls.last?.data,
47 | expectedRecipeData
48 | )
49 | XCTAssertEqual(
50 | networkInterface.postSpy.calls.last?.url,
51 | URL(string: "https://example.com/recipes/store")!
52 | )
53 | }
54 |
55 | func testStoreRecipeRethrowsErrors() async throws {
56 | // ...
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/Sources/Fakes/Fakes.docc/Tutorials/WritingFakes/Resources/code/RecipeService/RecipeServiceTests-02.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | final class RecipeServiceTests: XCTestCase {
4 | var networkInterface: FakeNetworkInterface!
5 | var subject: RecipeService!
6 |
7 | override func setUp() {
8 | super.setUp()
9 |
10 | networkInterface = FakeNetworkInterface()
11 | subject = RecipeService(networkInterface: networkInterface)
12 | }
13 |
14 | func testFetchRecipes() throws {
15 | // Arrange step
16 | networkInterface.getSpy.stub(success: recipesArrayAsData) // when using
17 | // `ThrowingSpy`, to set the Success stub, use the `stub(success:)`
18 | // overload. You could also use `stub(.success(...))` to do the same
19 | // thing, though.
20 |
21 | // Act step
22 | let recipes = try subject.recipes()
23 |
24 | XCTAssertEqual(recipes, [...])
25 | XCTAssertEqual(
26 | networkInterface.getSpy.calls,
27 | [URL(string: "https://example.com/recipes")!]
28 | )
29 | }
30 |
31 | func testFetchRecipesRethrowsErrors() {
32 | // Arrange step
33 | let expectedError = TestError()
34 | networkInterface.getSpy.stub(failure: expectedError)
35 |
36 | // Act step
37 | let result = Result { try subject.recipes() }
38 |
39 | // Assert step
40 | switch result {
41 | case .success:
42 | XCTFail("Expected `recipes` to throw an error, but succeeded.")
43 | case .failure(let failure):
44 | XCTAssertEqual(failure as TestError, expectedError) // because
45 | // we are specifically testing that the error from
46 | // `NetworkInterface.get` is rethrown, we should specifically
47 | // test that the thrown error is the same error we stubbed
48 | // networkInterface with.
49 | }
50 | }
51 |
52 | func testStoreRecipe() throws {
53 | // Act step
54 | try subject.store(recipe: Recipe(...))
55 |
56 | // Assert Step
57 | XCTAssertEqual(networkInterface.postSpy.calls.count, 1)
58 | XCTAssertEqual(
59 | networkInterface.postSpy.calls.last?.data,
60 | expectedRecipeData
61 | )
62 | XCTAssertEqual(
63 | networkInterface.postSpy.calls.last?.url,
64 | URL(string: "https://example.com/recipes/store")!
65 | )
66 | }
67 |
68 | func testStoreRecipeRethrowsErrors() {
69 | // (You get the idea from `testFetchRecipesRethrowsErrors`)
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/.github/workflows/ci-swiftpm.yml:
--------------------------------------------------------------------------------
1 | name: CI (SwiftPM)
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | tags:
8 | - "*"
9 | pull_request:
10 |
11 | jobs:
12 | filter:
13 | runs-on: ubuntu-latest
14 | outputs:
15 | should_skip: ${{ steps.skip_check.outputs.should_skip }}
16 | steps:
17 | - id: skip_check
18 | uses: fkirc/skip-duplicate-actions@v5.3.1
19 | with:
20 | paths: '[".github/workflows/ci-swiftpm.yml", "Sources/**", "Tests/**", "Package.*"]'
21 | do_not_skip: '["push", "workflow_dispatch", "schedule"]'
22 |
23 | swiftpm_darwin:
24 | name: SwiftPM, Darwin, Xcode ${{ matrix.xcode }}
25 | needs: filter
26 | runs-on: macos-14
27 | strategy:
28 | matrix:
29 | xcode: ["16.1"]
30 | fail-fast: false
31 | env:
32 | DEVELOPER_DIR: "/Applications/Xcode_${{ matrix.xcode }}.app"
33 | steps:
34 | - uses: actions/checkout@v6
35 | if: ${{ needs.filter.outputs.should_skip != 'true' }}
36 | - run: swift build
37 | if: ${{ needs.filter.outputs.should_skip != 'true' }}
38 | - run: swift test
39 | if: ${{ needs.filter.outputs.should_skip != 'true' }}
40 |
41 | swiftpm_darwin_15:
42 | name: SwiftPM, Darwin 15, Xcode ${{ matrix.xcode }}
43 | needs: filter
44 | runs-on: macos-15
45 | strategy:
46 | matrix:
47 | xcode: ["16.4"]
48 | fail-fast: false
49 | env:
50 | DEVELOPER_DIR: "/Applications/Xcode_${{ matrix.xcode }}.app"
51 | steps:
52 | - uses: actions/checkout@v6
53 | if: ${{ needs.filter.outputs.should_skip != 'true' }}
54 | - run: swift build
55 | if: ${{ needs.filter.outputs.should_skip != 'true' }}
56 | - run: swift test
57 | if: ${{ needs.filter.outputs.should_skip != 'true' }}
58 |
59 | swiftpm_linux:
60 | name: SwiftPM, Linux
61 | needs: filter
62 | runs-on: ubuntu-latest
63 | strategy:
64 | matrix:
65 | container:
66 | - swift:6.0
67 | - swift:6.1
68 | - swift:6.2
69 | fail-fast: false
70 | container: ${{ matrix.container }}
71 | steps:
72 | - uses: actions/checkout@v6
73 | if: ${{ needs.filter.outputs.should_skip != 'true' }}
74 | - run: swift build
75 | if: ${{ needs.filter.outputs.should_skip != 'true' }}
76 | - run: swift test
77 | if: ${{ needs.filter.outputs.should_skip != 'true' }}
78 |
79 | swiftpm_android:
80 | name: SwiftPM, Android
81 | needs: filter
82 | runs-on: ubuntu-latest
83 | steps:
84 | - uses: actions/checkout@v6
85 | - uses: skiptools/swift-android-action@v2
86 |
87 |
--------------------------------------------------------------------------------
/Sources/Fakes/Spy/Spy+Testing.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Spy+Testing.swift
3 | // swift-fakes
4 | //
5 | // Created by Rachel Brindle on 9/29/25.
6 | //
7 |
8 | extension Spy {
9 | /// Returns true if this spy has been called at least once.
10 | public var wasCalled: Bool {
11 | calls.isEmpty == false
12 | }
13 |
14 | /// Returns true if this spy has been called exactly as many times as specified.
15 | public func wasCalled(times: Int) -> Bool {
16 | calls.count == times
17 | }
18 |
19 | /// Returns true if this spy has not been called.
20 | public var wasNotCalled: Bool {
21 | calls.isEmpty
22 | }
23 |
24 | /// Returns whether this spy called at any time with the given value.
25 | public func wasCalled(with value: Arguments) -> Bool where Arguments: Equatable {
26 | calls.contains { call in
27 | call == value
28 | }
29 | }
30 |
31 | /// Returns whether this spy called with precisely these values, in this order.
32 | public func wasCalled(with values: [Arguments]) -> Bool where Arguments: Equatable {
33 | let currentCalls = calls
34 | guard currentCalls.count == values.count else { return false }
35 |
36 | for idx in 0.. Bool) -> Bool {
44 | calls.contains { call in
45 | matcher(call)
46 | }
47 | }
48 |
49 | /// Returns whether this spy was called with values that correspond to the order of closures given.
50 | ///
51 | /// For example, if this spy was called with `[1, 2]`, then `wasCalled(matching: [{ $0 == 1 }, { $0 == 2}])` would return true.
52 | public func wasCalled(matching matchers: [(Arguments) -> Bool]) -> Bool {
53 | let currentCalls = calls
54 | guard currentCalls.count == matchers.count else { return false }
55 |
56 | for idx in 0.. Bool where Arguments: Equatable {
64 | calls.last == value
65 | }
66 |
67 | /// Returns whether the most recent call to the spy matches the given closure.
68 | public func wasMostRecentlyCalled(matching matcher: (Arguments) -> Bool) -> Bool {
69 | guard let lastCall = calls.last else { return false }
70 | return matcher(lastCall)
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Sources/Fakes/Fakes.docc/Tutorials/WritingFakes/Resources/code/RecipeService/RecipeServiceTests-01.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | final class RecipeServiceTests: XCTestCase {
4 | var networkInterface: FakeNetworkInterface!
5 | var subject: RecipeService!
6 |
7 | override func setUp() {
8 | super.setUp()
9 |
10 | networkInterface = FakeNetworkInterface()
11 | subject = RecipeService(networkInterface: networkInterface)
12 | }
13 |
14 | func testFetchRecipes() throws {
15 | // Arrange step
16 | // First, we stub the networkInterface with some data that
17 | // converts to an array of recipes. Because we are using a real
18 | // JSONDecoder in RecipeService, we must provide data that can be
19 | // converted to [Recipe].
20 | // Whatever you do, DO NOT start with an Array of Recipe, and convert
21 | // it to Data using `JSONEncoder`. That creates a tautology, and doesn't
22 | // actually check that your Recipe data can convert to actual Recipes.
23 | // If we update Recipe and forget to update the fixture, we want the
24 | // test to fail in order to let us know we either need to update the
25 | // fixture, or that we made a breaking change.
26 | networkInterface.getSpy.stub(recipesArrayAsData)
27 |
28 | // Act step
29 | let recipes = try subject.recipes()
30 |
31 | // Assert step
32 | XCTAssertEqual(
33 | recipes,
34 | [...]
35 | ) // verify that we retrieved and decoded the expected recipes.
36 | XCTAssertEqual(
37 | networkInterface.getSpy.calls,
38 | [URL(string: "https://example.com/recipes")!]
39 | ) // Verify that we actually made the call to networkInterface.get(from:)
40 | }
41 |
42 | func testStoreRecipe() throws {
43 | // Arrange step
44 | // Because NetworkInterface.post returns Void, we don't need
45 | // to stub anything.
46 | let recipe = Recipe(...)
47 |
48 | // Act step
49 | try subject.store(recipe: recipe)
50 |
51 | // Assert Step
52 | XCTAssertEqual(
53 | networkInterface.postSpy.calls.count,
54 | 1
55 | ) // Verify that only one call to post was made.
56 | XCTAssertEqual(
57 | networkInterface.postSpy.calls.last?.data,
58 | expectedRecipeData
59 | ) // Verify that the Recipe was converted to Data correctly. As with
60 | // `testFetchRecipes`, we want to have an actual fixture we can compare
61 | // with, and not just use JSONEncoder().encode(recipe). Again, if we
62 | // did so, that would create a tautology, and doesn't actually check
63 | // that we converted to Data correctly.
64 | XCTAssertEqual(
65 | networkInterface.postSpy.calls.last?.url,
66 | URL(string: "https://example.com/recipes/store")!
67 | )
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/Sources/Fakes/Fakes.docc/Guides/NimbleIntegration.md:
--------------------------------------------------------------------------------
1 | # Nimble Integration
2 |
3 | Nimble Matchers to make asserting on ``Spy`` significantly nicer.
4 |
5 | ## Contents
6 |
7 | - The ``beCalled(_:)-82qlg`` without any arguments matches if the `Spy` has been
8 | called at least once. This is especially useful for verifying there are no
9 | interactions with the `Spy`, by using `expect(spy).toNot(beCalled())`.
10 | - The ``beCalled(_:times:)-6125c`` without any matcher arguments, matches if the
11 | `Spy` has been called exactly the amount of times specified in the `times`
12 | argument. For example, `expect(spy).to(beCalled(times: 3))` will pass if the
13 | `Spy` has been called with any arguments exactly 3 times.
14 | - The ``beCalled(_:)-82qlg`` matcher with any non-zero amount of matchers will
15 | match if at least one of the calls to the `Spy` matches all of the passed-in
16 | matchers. That is, `expect(spy).to(beCalled(equal(1), equal(2)))` will never
17 | pass, because no single call to the `Spy` can pass both `equal(1)` and
18 | `equal(2)`.
19 | - The ``beCalled(_:times:)-6125c`` matcher with a non-zero matcher arguments
20 | is effectively the same as `satisfyAllOf(beCalled(times:), beCalled(...))`. That
21 | is, it matches if the `Spy` has been called exactly `times` times, and at least
22 | one of the calls to the `Spy` matches all of the passed-in matchers.
23 | For example:
24 |
25 | ```swift
26 | let spy = Spy()
27 | spy(1)
28 | spy(2)
29 |
30 | expect(spy).to(beCalled(equal(1), times: 2))
31 | ```
32 |
33 | will match because the `Spy` has been called twice, and at least one of those
34 | calls is equal to 1.
35 |
36 | - The ``mostRecentlyBeCalled(_:)-9i9t9`` matcher will match if the last recorded
37 | call to the `Spy` matches all of the passed in matchers. Unlike the `beCalled`
38 | matchers, ``mostRecentlyBeCalled(_:)-9i9t9`` will unconditionally fail if you
39 | don't pass in any matchers.
40 |
41 | - For all of variants of `beCalled` and `mostRecentlyBeCalled`: If the
42 | `Arguments` to the `Spy` conforms to `Equatable`, you can directly pass in a
43 | value of the same type to `beCalled` or `mostRecentlyBeCalled`. For example,
44 | if you have a `Spy`, you can use `beCalled(123)` in place of
45 | `beCalled(equal(123))`. See ``beCalled(_:)-7sn1o``,
46 | ``beCalled(_:times:)-9320x``, and ``mostRecentlyBeCalled(_:)-91ves``.
47 |
48 |
49 | > Tip: If your `Spy` takes multiple `Arguments` - that is, the `Arguments` generic is a
50 | Tuple -, then you can make use of Nimble's built-in
51 | [`map` matcher](https://quick.github.io/Nimble/documentation/nimble/map(_:_:)-6ykjm)
52 | to easily verify a call to the matcher. For example:
53 |
54 | ```swift
55 | let spy = Spy<(arg1: Double, arg2: String), Void>()
56 |
57 | spy((1337.001, "hello world"))
58 |
59 | expect(spy).to(beCalled(
60 | map(\.arg1, beCloseTo(1337, within: 0.01)),
61 | map(\.arg2, beginWith("hello"))
62 | ))
63 | ```
64 |
65 | > Note: If you want to check that a Spy is not ever called as part of code
66 | running on a background thread, use `expect(spy).toNever(beCalled())`.
67 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, gender identity and expression, level of experience,
9 | nationality, personal appearance, race, religion, or sexual identity and
10 | orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at [quickorg@icloud.com](mailto:quickorg@icloud.com). All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at [http://contributor-covenant.org/version/1/4][version]
72 |
73 | [homepage]: http://contributor-covenant.org
74 | [version]: http://contributor-covenant.org/version/1/4/
75 |
--------------------------------------------------------------------------------
/Sources/Fakes/PropertySpy/PropertySpy.swift:
--------------------------------------------------------------------------------
1 | /// An immutable property spy.
2 | @propertyWrapper public struct PropertySpy {
3 | public var wrappedValue: U {
4 | mapping(projectedValue())
5 | }
6 |
7 | /// the ``Spy`` recording the getter calls.
8 | public let projectedValue: Spy
9 |
10 | public let mapping: @Sendable (T) -> U
11 |
12 | /// Creates an immutable PropertySpy stubbed with the given value, which lets you map from one type to another
13 | ///
14 | /// - parameter value: The initial value to be stubbed
15 | /// - parameter mapping: A closure to map from the initial value to the property's return type
16 | ///
17 | /// - Note: This initializer is particularly useful when the property is returning a protocol of some value, but you want to stub it with a particular instance of the protocol.
18 | public init(_ value: T, as mapping: @escaping @Sendable (T) -> U) {
19 | projectedValue = Spy(value)
20 | self.mapping = mapping
21 | }
22 |
23 | /// Creates an immutable PropertySpy stubbed with the given value
24 | ///
25 | /// - parameter value: The initial value to be stubbed
26 | public init(_ value: T) where T == U {
27 | projectedValue = Spy(value)
28 | self.mapping = { $0 }
29 | }
30 | }
31 |
32 | /// A mutable property spy.
33 | @propertyWrapper public struct SettablePropertySpy {
34 | public var wrappedValue: U {
35 | get {
36 | getMapping(projectedValue.getter())
37 | }
38 | set {
39 | projectedValue.setter(newValue)
40 | projectedValue.getter.stub(setMapping(newValue))
41 | }
42 | }
43 |
44 | public struct ProjectedValue {
45 | /// A ``Spy`` recording every time the property has been set, with whatever the new value is, prior to mapping
46 | public let setter: Spy
47 | /// A ``Spy`` recording every time the property has been called. It is re-stubbed whenever the property's setter is called.
48 | public let getter: Spy
49 | }
50 |
51 | /// The spies recording the setter and getter calls.
52 | public let projectedValue: ProjectedValue
53 |
54 | public let getMapping: @Sendable (T) -> U
55 | public let setMapping: @Sendable (U) -> T
56 |
57 | /// Creates a mutable PropertySpy stubbed with the given value, which lets you map from one type to another and back again
58 | ///
59 | /// - parameter value: The initial value to be stubbed
60 | /// - parameter getMapping: A closure to map from the initial value to the property's return type
61 | /// - parameter setMapping: A closure to map from the property's return type back to the initial value's type.
62 | ///
63 | /// - Note: This initializer is particularly useful when the property is returning a protocol of some value, but you want to stub it with a particular instance of the protocol.
64 | public init(_ value: T, getMapping: @escaping @Sendable (T) -> U, setMapping: @escaping @Sendable (U) -> T) {
65 | projectedValue = ProjectedValue(setter: Spy(), getter: Spy(value))
66 | self.getMapping = getMapping
67 | self.setMapping = setMapping
68 | }
69 |
70 | /// Creatse a mutable PropertySpy stubbed with the given value
71 | ///
72 | /// - parameter value: The inital value to be stubbed
73 | public init(_ value: T) where T == U {
74 | projectedValue = ProjectedValue(setter: Spy(), getter: Spy(value))
75 | self.getMapping = { $0 }
76 | self.setMapping = { $0 }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/Sources/Fakes/Spy/Spy+Pendable.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public typealias PendableSpy<
4 | Arguments,
5 | Value
6 | > = Spy<
7 | Arguments,
8 | Pendable
9 | >
10 |
11 | extension Spy {
12 | /// Create a pendable Spy that is pre-stubbed to return return a a pending that will block for a bit before returning the fallback value.
13 | public convenience init(pendingFallback: Value) where Returning == Pendable {
14 | self.init(.pending(fallback: pendingFallback))
15 | }
16 |
17 | /// Create a pendable Spy that is pre-stubbed to return return a a pending that will block for a bit before returning Void.
18 | public convenience init() where Returning == Pendable {
19 | self.init(.pending(fallback: ()))
20 | }
21 |
22 | /// Create a pendable Spy that is pre-stubbed to return a finished value.
23 | public convenience init(finished: Value) where Returning == Pendable {
24 | self.init(.finished(finished))
25 | }
26 | }
27 |
28 | extension Spy {
29 | /// Resolve the pendable Spy's stub with Void
30 | public func resolveStub() where Returning == Pendable {
31 | self.resolveStub(with: ())
32 | }
33 | }
34 |
35 | extension Spy {
36 | /// Update the pendable Spy's stub to be in a pending state.
37 | public func stub(pendingFallback: Value) where Returning == Pendable {
38 | self.stub(.pending(fallback: pendingFallback))
39 | }
40 |
41 | /// Update the pendable Spy's stub to be in a pending state.
42 | public func stubPending() where Returning == Pendable {
43 | self.stub(.pending(fallback: ()))
44 | }
45 |
46 | /// Update the pendable Spy's stub to be in a pending state.
47 | public func stubPending() where Returning == Pendable> {
48 | // swiftlint:disable:previous syntactic_sugar
49 | self.stub(.pending(fallback: nil))
50 | }
51 |
52 | /// Update the pendable Spy's stub to return the given value.
53 | ///
54 | /// - parameter finished: The value to return when `callAsFunction` is called.
55 | public func stub(finished: Value) where Returning == Pendable {
56 | self.stub(.finished(finished))
57 | }
58 |
59 | /// Update the pendable Spy's stub to be in a pending state.
60 | public func stubFinished() where Returning == Pendable {
61 | self.stub(.finished(()))
62 | }
63 | }
64 |
65 | extension Spy {
66 | /// Records the arguments and handles the result according to ``Pendable/call(fallbackDelay:)``.
67 | ///
68 | /// - parameter arguments: The arguments to record.
69 | /// - parameter fallbackDelay: The amount of seconds to delay if the `Pendable` is pending before
70 | /// returning its fallback value. If the `Pendable` is finished, then this value is ignored.
71 | public func callAsFunction(
72 | _ arguments: Arguments,
73 | fallbackDelay: TimeInterval = PendableDefaults.delay
74 | ) async -> Value where Returning == Pendable {
75 | return await call(arguments).call(fallbackDelay: fallbackDelay)
76 | }
77 |
78 | /// Records that a call was made and handles the result according to ``Pendable/call(fallbackDelay:)``.
79 | ///
80 | /// - parameter fallbackDelay: The amount of seconds to delay if the `Pendable` is pending before
81 | /// returning its fallback value. If the `Pendable` is finished, then this value is ignored.
82 | public func callAsFunction(
83 | fallbackDelay: TimeInterval = PendableDefaults.delay
84 | ) async -> Value where Arguments == Void, Returning == Pendable {
85 | return await call(()).call(fallbackDelay: fallbackDelay)
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/Sources/Fakes/Spy/Spy+Result.swift:
--------------------------------------------------------------------------------
1 | public typealias ThrowingSpy = Spy>
2 |
3 | extension Spy {
4 | /// Create a throwing Spy that is pre-stubbed with some Success values.
5 | public convenience init(success: Success, _ successes: Success...) where Returning == Result {
6 | self.init(Array(success, successes).map { .success($0) })
7 | }
8 |
9 | /// Create a throwing Spy that is pre-stubbed with a Void Success value
10 | public convenience init() where Returning == Result {
11 | self.init(.success(()))
12 | }
13 |
14 | /// Create a throwing Spy that is pre-stubbed with some Failure error.
15 | public convenience init(failure: Failure) where Returning == Result {
16 | self.init(.failure(failure))
17 | }
18 |
19 | public convenience init() where Returning == Result {
20 | self.init(.failure(EmptyError()))
21 | }
22 |
23 | public convenience init(_ closure: @escaping @Sendable (Arguments) throws(Failure) -> Success) where Returning == Result {
24 | self.init { args in
25 | do {
26 | return .success(try closure(args))
27 | } catch let error as Failure {
28 | return .failure(error)
29 | } catch let error {
30 | fatalError("closure was typed to throw only errors of type \(Failure.self), got \(error). This shouldn't happen.")
31 | }
32 | }
33 | }
34 | }
35 |
36 | extension Spy {
37 | /// Update the throwing Spy's stub to be successful, with the given value.
38 | ///
39 | /// - parameter success: The success state to set the stub to, returned when `callAsFunction` is called.
40 | public func stub(success: Success, _ successes: Success...) where Returning == Result {
41 | self.stub(Array(success, successes).map { .success($0) })
42 | }
43 |
44 | /// Update the throwing Spy's stub to be successful, with the given value.
45 | ///
46 | /// - parameter success: The success state to set the stub to, returned when `callAsFunction` is called.
47 | public func stub() where Returning == Result {
48 | self.stub(.success(()))
49 | }
50 |
51 | /// Update the throwing Spy's stub to throw the given error.
52 | ///
53 | /// - parameter failure: The error to throw when `callAsFunction` is called.
54 | public func stub(failure: Failure) where Returning == Result {
55 | self.stub(.failure(failure))
56 | }
57 |
58 | public func stub(_ closure: @escaping @Sendable (Arguments) throws(Failure) -> Success) where Returning == Result {
59 | self.stub { args in
60 | do {
61 | return .success(try closure(args))
62 | } catch let error as Failure {
63 | return .failure(error)
64 | } catch let error {
65 | fatalError("closure was typed to throw only errors of type \(Failure.self), got \(error). This shouldn't happen.")
66 | }
67 | }
68 | }
69 | }
70 |
71 | extension Spy {
72 | /// Records the arguments and returns the success (or throws an error), as defined by the current stub.
73 | public func callAsFunction(_ arguments: Arguments) throws -> Success where Returning == Result {
74 | return try call(arguments).get()
75 | }
76 |
77 | /// Records that a call was made and returns the success (or throws an error), as defined by the current stub.
78 | public func callAsFunction() throws -> Success where Arguments == Void, Returning == Result {
79 | return try call(()).get()
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/Tests/FakesTests/SpyTests+Nimble.swift:
--------------------------------------------------------------------------------
1 | import Nimble
2 | import XCTest
3 | import Fakes
4 |
5 | final class SpyNimbleMatchersTest: XCTestCase {
6 | func testBeCalledWithoutArguments() {
7 | let spy = Spy()
8 |
9 | // beCalled should match if spy has been called any number of times.
10 | expect(spy).toNot(beCalled())
11 |
12 | spy(1)
13 | expect(spy).to(beCalled())
14 |
15 | spy(2)
16 | expect(spy).to(beCalled())
17 | }
18 |
19 | func testBeCalledWithArguments() {
20 | let spy = Spy()
21 |
22 | expect(spy).toNot(beCalled())
23 |
24 | spy(3)
25 | expect(spy).toNot(beCalled(equal(2)))
26 | expect(spy).to(beCalled(equal(3)))
27 |
28 | // beCalled without a matcher argument is available when Arguments conforms to Equatable.
29 | expect(spy).toNot(beCalled(2))
30 | expect(spy).to(beCalled(3))
31 | }
32 |
33 | func testBeCalledWithMultipleArguments() {
34 | let spy = Spy()
35 |
36 | spy(3)
37 | expect(spy).toNot(beCalled(
38 | equal(2),
39 | beLessThan(4)
40 | ))
41 | expect(spy).to(beCalled(
42 | equal(3),
43 | beLessThan(4)
44 | ))
45 | }
46 |
47 | func testBeCalledWithTimes() {
48 | let spy = Spy()
49 |
50 | expect(spy).to(beCalled(times: 0))
51 | expect(spy).toNot(beCalled(times: 2))
52 | expect(spy).toNot(beCalled(times: 1))
53 |
54 | spy(1)
55 | expect(spy).to(beCalled(times: 1))
56 | expect(spy).toNot(beCalled(times: 2))
57 | expect(spy).toNot(beCalled(times: 0))
58 |
59 | spy(2)
60 | expect(spy).to(beCalled(times: 2))
61 | expect(spy).toNot(beCalled(times: 0))
62 | expect(spy).toNot(beCalled(times: 1))
63 | }
64 |
65 | func testBeCalledWithArgumentsAndTimes() {
66 | let spy = Spy()
67 |
68 | spy(1)
69 |
70 | expect(spy).toNot(beCalled(equal(2), times: 1))
71 | expect(spy).toNot(beCalled(3, times: 1))
72 | expect(spy).to(beCalled(equal(1), times: 1))
73 | expect(spy).to(beCalled(1, times: 1))
74 |
75 | spy(3)
76 | expect(spy).toNot(beCalled(equal(2), times: 2))
77 | expect(spy).to(beCalled(equal(3), times: 2))
78 |
79 | expect(spy).toNot(beCalled(2, times: 2))
80 | expect(spy).to(beCalled(3, times: 2))
81 | }
82 |
83 | func testBeCalledWithMultipleArgumentsAndTimes() {
84 | let spy = Spy()
85 |
86 | spy(1)
87 |
88 | expect(spy).toNot(beCalled(equal(2), beLessThan(3), times: 1))
89 | expect(spy).to(beCalled(equal(1), beLessThan(3), times: 1))
90 |
91 | spy(3)
92 | expect(spy).toNot(beCalled(equal(2), beLessThan(4), times: 2))
93 | expect(spy).to(beCalled(equal(3), beLessThan(4), times: 2))
94 | }
95 |
96 | func testMostRecentlyBeCalled() {
97 | let spy = Spy()
98 |
99 | spy(1)
100 | expect(spy).to(mostRecentlyBeCalled(equal(1)))
101 | expect(spy).toNot(mostRecentlyBeCalled(equal(2)))
102 | expect(spy).to(mostRecentlyBeCalled(1))
103 | expect(spy).toNot(mostRecentlyBeCalled(2))
104 |
105 | spy(2)
106 | expect(spy).toNot(mostRecentlyBeCalled(equal(1)))
107 | expect(spy).to(mostRecentlyBeCalled(equal(2)))
108 |
109 | expect(spy).toNot(mostRecentlyBeCalled(1))
110 | expect(spy).to(mostRecentlyBeCalled(2))
111 | }
112 |
113 | func testMostRecentlyBeCalledWithMultipleArguments() {
114 | let spy = Spy()
115 |
116 | spy(1)
117 | expect(spy).to(mostRecentlyBeCalled(
118 | equal(1),
119 | beLessThan(3)
120 | ))
121 | expect(spy).toNot(mostRecentlyBeCalled(
122 | equal(2),
123 | beLessThan(3)
124 | ))
125 |
126 | spy(2)
127 | expect(spy).toNot(mostRecentlyBeCalled(
128 | equal(1),
129 | beLessThan(3)
130 | ))
131 | expect(spy).to(mostRecentlyBeCalled(
132 | equal(2),
133 | beLessThan(3)
134 | ))
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | **Table of Contents** *generated with [DocToc](http://doctoc.herokuapp.com/)*
4 |
5 | - [Welcome to Swift Fakes!](#welcome-to-swift-fakes)
6 | - [Reporting Bugs](#reporting-bugs)
7 | - [Building the Project](#building-the-project)
8 | - [Pull Requests](#pull-requests)
9 | - [Style Conventions](#style-conventions)
10 | - [Core Members](#core-members)
11 | - [Creating a Release](#creating-a-release)
12 |
13 |
14 |
15 | # Welcome to Swift Fakes!
16 |
17 | We're building standardized test doubles for Swift.
18 |
19 | Swift Fakes should be easy to use and easy to maintain. Let's keep things
20 | simple and well-tested.
21 |
22 | ## Reporting Bugs
23 |
24 | Nothing is off-limits. If you're having a problem, we want to hear about
25 | it.
26 |
27 | - See a crash? File an issue.
28 | - Code isn't compiling, but you don't know why? Sounds like you should
29 | submit a new issue, friend.
30 | - Went to the kitchen, only to forget why you went in the first place?
31 | Better submit an issue.
32 |
33 | Be sure to include in your issue:
34 |
35 | - Your Xcode version (eg - Xcode 15.3.0 15E204A)
36 | - Your version of Quick / Nimble / Swift Fakes (eg - v0.0.0 or git sha `7d0b8c21357839a8c5228863b77faecf709254a9`)
37 | - What are the steps to reproduce this issue?
38 | - What platform are you using? (eg - OS X, iOS, watchOS, tvOS, visionOS)
39 | - If the problem is on a UI Testing Bundle, Unit Testing Bundle, or some other target configuration
40 | - Are you using Swift PM or some other package manager?
41 |
42 | ## Building the Project
43 |
44 | - Open the `Package.swift` in xcode, or run `swift build`.
45 |
46 | ## Pull Requests
47 |
48 | - Nothing is trivial. Submit pull requests for anything: typos,
49 | whitespace, you name it.
50 | - Not all pull requests will be merged, but all will be acknowledged. If
51 | no one has provided feedback on your request, ping one of the owners
52 | by name.
53 | - Make sure your pull request includes any necessary updates to the
54 | README or other documentation.
55 | - Be sure the unit tests pass before submitting your pull request. You can run all
56 | the unit tests using `swift test`.
57 | - The `main` branch will always support the stable Xcode version. Other
58 | branches will point to their corresponding versions they support.
59 |
60 | ### Style Conventions
61 |
62 | - Indent using 4 spaces.
63 | - Keep lines 100 characters or shorter. Break long statements into
64 | shorter ones over multiple lines.
65 |
66 | ## Core Members
67 |
68 | If a few of your pull requests have been merged, and you'd like a
69 | controlling stake in the project, file an issue asking for write access
70 | to the repository.
71 |
72 | Your conduct as a core member is your own responsibility, but here are
73 | some "ground rules":
74 |
75 | - Feel free to push whatever you want to main, and (if you have
76 | ownership permissions) to create any repositories you'd like.
77 |
78 | Ideally, however, all changes should be submitted as GitHub pull
79 | requests. No one should merge their own pull request, unless no
80 | other core members respond for at least a few days.
81 |
82 | Pull requests should be issued from personal forks. The Quick repo
83 | should be reserved for long-running feature branches.
84 |
85 | If you'd like to create a new repository, it'd be nice if you created
86 | a GitHub issue and gathered some feedback first.
87 |
88 | - It'd be awesome if you could review, provide feedback on, and close
89 | issues or pull requests submitted to the project. Please provide kind,
90 | constructive feedback. Please don't be sarcastic or snarky.
91 |
92 | Read [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for more in depth guidelines.
93 |
94 | ## Creating a Release
95 |
96 | The process is relatively straight forward, but here's is a useful checklist for tagging:
97 |
98 | - Run the release script: `./script/release A.B.C`
99 | - The script create a new [GitHub release](https://github.com/Quick/swift-fakes/releases) with autogenerated release notes.
100 | - Edit the release notes, and add highlights. Be especially sure to call out any breaking changes.
101 | - Announce!
102 |
--------------------------------------------------------------------------------
/Tests/FakesTests/DynamicResultTests.swift:
--------------------------------------------------------------------------------
1 | import Fakes
2 | import Nimble
3 | import XCTest
4 |
5 | final class DynamicResultTests: XCTestCase {
6 | func testSingleStaticValue() {
7 | let subject = DynamicResult(1)
8 |
9 | expect(subject.call(1)).to(equal(1))
10 | expect(subject.call(1)).to(equal(1))
11 | expect(subject.call(1)).to(equal(1))
12 | }
13 |
14 | func testMultipleStaticValues() {
15 | let subject = DynamicResult(1, 2, 3)
16 |
17 | expect(subject.call()).to(equal(1))
18 | expect(subject.call()).to(equal(2))
19 | expect(subject.call()).to(equal(3))
20 | // After the last call, we continue to return the last stub in the list
21 | expect(subject.call()).to(equal(3))
22 | expect(subject.call()).to(equal(3))
23 | }
24 |
25 | func testClosure() {
26 | let subject = DynamicResult({ $0 + 1 })
27 |
28 | expect(subject.call(1)).to(equal(2))
29 | expect(subject.call(2)).to(equal(3))
30 | expect(subject.call(3)).to(equal(4))
31 | }
32 |
33 | func testStubs() {
34 | let subject = DynamicResult(
35 | .value(1),
36 | .closure({ $0 * 3}),
37 | .value(4)
38 | )
39 |
40 | expect(subject.call(1)).to(equal(1))
41 | expect(subject.call(2)).to(equal(6))
42 | expect(subject.call(3)).to(equal(4))
43 | expect(subject.call(3)).to(equal(4))
44 | }
45 |
46 | // MARK: - Replacement Tests
47 |
48 | func testReplacementStaticValue() {
49 | let subject = DynamicResult(1)
50 |
51 | subject.replace(2, 3, 4, 6)
52 |
53 | expect(subject.call()).to(equal(2))
54 | expect(subject.call()).to(equal(3))
55 | expect(subject.call()).to(equal(4))
56 | expect(subject.call()).to(equal(6))
57 | // After the last call, we continue to return the last stub in the list
58 | expect(subject.call()).to(equal(6))
59 | }
60 |
61 | func testReplacementClosure() {
62 | let subject = DynamicResult(1)
63 |
64 | subject.replace({ $0 + 4})
65 |
66 | expect(subject.call(1)).to(equal(5))
67 | expect(subject.call(2)).to(equal(6))
68 | expect(subject.call(3)).to(equal(7))
69 | }
70 |
71 | func testReplacementStubs() {
72 | let subject = DynamicResult(1)
73 |
74 | subject.replace(
75 | .value(2),
76 | .closure({ $0 * 3}),
77 | .value(4)
78 | )
79 |
80 | expect(subject.call(1)).to(equal(2))
81 | expect(subject.call(2)).to(equal(6))
82 | expect(subject.call(3)).to(equal(4))
83 | expect(subject.call(3)).to(equal(4))
84 | }
85 |
86 | // MARK: - Appending Tests
87 |
88 | func testAppendingStaticValue() {
89 | let subject = DynamicResult(1, 2, 3)
90 |
91 | subject.append(4, 5, 6)
92 |
93 | expect(subject.call()).to(equal(1))
94 | expect(subject.call()).to(equal(2))
95 | expect(subject.call()).to(equal(3))
96 | expect(subject.call()).to(equal(4))
97 | expect(subject.call()).to(equal(5))
98 | expect(subject.call()).to(equal(6))
99 | // After the last call, we continue to return the last stub in the list
100 | expect(subject.call()).to(equal(6))
101 | }
102 |
103 | func testAppendingClosure() {
104 | let subject = DynamicResult(1, 2, 3)
105 |
106 | subject.append({ $0 + 10})
107 |
108 | expect(subject.call(1)).to(equal(1))
109 | expect(subject.call(2)).to(equal(2))
110 | expect(subject.call(3)).to(equal(3))
111 | expect(subject.call(4)).to(equal(14))
112 | expect(subject.call(5)).to(equal(15))
113 | }
114 |
115 | func testAppendingStubs() {
116 | let subject = DynamicResult(1, 2, 3)
117 |
118 | subject.append(
119 | .value(4),
120 | .closure({ $0 * 3}),
121 | .value(6)
122 | )
123 |
124 | expect(subject.call(1)).to(equal(1))
125 | expect(subject.call(2)).to(equal(2))
126 | expect(subject.call(3)).to(equal(3))
127 | expect(subject.call(4)).to(equal(4))
128 | expect(subject.call(5)).to(equal(15))
129 | expect(subject.call(6)).to(equal(6))
130 | expect(subject.call(7)).to(equal(6))
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/Tests/FakesTests/SpyTests+TestHelpers.swift:
--------------------------------------------------------------------------------
1 | import Fakes
2 | import Testing
3 |
4 | struct SpyTestHelpersTest {
5 | @Test func wasCalledWithoutArguments() {
6 | let spy = Spy()
7 |
8 | // beCalled should match if spy has been called any number of times.
9 | #expect(spy.wasCalled == false)
10 | #expect(spy.wasNotCalled)
11 |
12 | spy(1)
13 | #expect(spy.wasCalled)
14 | #expect(spy.wasNotCalled == false)
15 |
16 | spy(2)
17 | #expect(spy.wasCalled)
18 | #expect(spy.wasNotCalled == false)
19 | }
20 |
21 | @Test func wasCalledWithArguments() {
22 | let spy = Spy()
23 | let otherSpy = Spy()
24 |
25 | spy(3)
26 | otherSpy(ANonEquatable(value: 3))
27 | #expect(spy.wasCalled(with: 3))
28 | #expect(spy.wasCalled(with: 2) == false)
29 |
30 | #expect(spy.wasCalled(matching: { $0 == 3 }))
31 | #expect(spy.wasCalled(matching: { $0 == 2 }) == false)
32 |
33 | #expect(otherSpy.wasCalled(matching: { $0.value == 3 }))
34 | #expect(otherSpy.wasCalled(matching: { $0.value == 2 }) == false)
35 |
36 | spy(4)
37 | otherSpy(ANonEquatable(value: 4))
38 | #expect(spy.wasCalled(with: 3))
39 | #expect(spy.wasCalled(matching: { $0 == 3 }))
40 |
41 | #expect(spy.wasCalled(with: 4))
42 | #expect(spy.wasCalled(matching: { $0 == 4 }))
43 |
44 | #expect(otherSpy.wasCalled(matching: { $0.value == 4 }))
45 | #expect(otherSpy.wasCalled(matching: { $0.value == 3 }))
46 | }
47 |
48 | @Test func wasCalledWithTimes() {
49 | let spy = Spy()
50 |
51 | #expect(spy.wasCalled(times: 0))
52 | #expect(spy.wasCalled(times: 1) == false)
53 | #expect(spy.wasCalled(times: 2) == false)
54 |
55 | spy(1)
56 | #expect(spy.wasCalled(times: 0) == false)
57 | #expect(spy.wasCalled(times: 1))
58 | #expect(spy.wasCalled(times: 2) == false)
59 |
60 | spy(2)
61 | #expect(spy.wasCalled(times: 0) == false)
62 | #expect(spy.wasCalled(times: 1) == false)
63 | #expect(spy.wasCalled(times: 2))
64 | }
65 |
66 | @Test func wasCalledWithMultipleArgumentsAndTimes() {
67 | let spy = Spy()
68 | let otherSpy = Spy()
69 |
70 | spy(1)
71 | otherSpy(ANonEquatable(value: 1))
72 | spy(3)
73 | otherSpy(ANonEquatable(value: 3))
74 |
75 | #expect(spy.wasCalled(with: [1, 3]))
76 | #expect(spy.wasCalled(with: [3, 1]) == false) // order matters
77 |
78 | #expect(spy.wasCalled(
79 | matching: [
80 | { $0 == 1 },
81 | { $0 == 3 }
82 | ]
83 | ))
84 | #expect(spy.wasCalled(
85 | matching: [
86 | { $0 == 3 },
87 | { $0 == 1 }
88 | ]
89 | ) == false)
90 |
91 | #expect(otherSpy.wasCalled(
92 | matching: [
93 | { $0.value == 1 },
94 | { $0.value == 3 }
95 | ]
96 | ))
97 | #expect(otherSpy.wasCalled(
98 | matching: [
99 | { $0.value == 3 },
100 | { $0.value == 1 }
101 | ]
102 | ) == false)
103 | }
104 |
105 | @Test func testMostRecentlyBeCalled() {
106 | let spy = Spy()
107 | let otherSpy = Spy()
108 |
109 | spy(1)
110 | otherSpy(ANonEquatable(value: 1))
111 | #expect(spy.wasMostRecentlyCalled(with: 1))
112 | #expect(spy.wasMostRecentlyCalled(with: 2) == false)
113 |
114 | #expect(spy.wasMostRecentlyCalled(matching: { $0 == 1}))
115 | #expect(spy.wasMostRecentlyCalled(matching: { $0 == 2}) == false)
116 |
117 | #expect(otherSpy.wasMostRecentlyCalled(matching: { $0.value == 1}))
118 | #expect(otherSpy.wasMostRecentlyCalled(matching: { $0.value == 2}) == false)
119 |
120 | spy(2)
121 | otherSpy(ANonEquatable(value: 2))
122 | #expect(spy.wasMostRecentlyCalled(with: 1) == false)
123 | #expect(spy.wasMostRecentlyCalled(with: 2))
124 |
125 | #expect(spy.wasMostRecentlyCalled(matching: { $0 == 1}) == false)
126 | #expect(spy.wasMostRecentlyCalled(matching: { $0 == 2}))
127 |
128 | #expect(otherSpy.wasMostRecentlyCalled(matching: { $0.value == 1}) == false)
129 | #expect(otherSpy.wasMostRecentlyCalled(matching: { $0.value == 2}))
130 | }
131 | }
132 |
133 | struct ANonEquatable {
134 | let value: Int
135 | }
136 |
--------------------------------------------------------------------------------
/Tests/FakesTests/PropertySpyTests.swift:
--------------------------------------------------------------------------------
1 | import Fakes
2 | import Nimble
3 | import XCTest
4 |
5 | final class SettablePropertySpyTests: XCTestCase {
6 | func testGettingPropertyWhenTypesMatch() {
7 | struct AnObject {
8 | @SettablePropertySpy(1)
9 | var value: Int
10 | }
11 |
12 | let object = AnObject()
13 |
14 | expect(object.value).to(equal(1))
15 | // because we called it, we should expect for the getter spy to be called
16 | expect(object.$value.getter).to(beCalled())
17 |
18 | // We never interacted with the setter, so it shouldn't have been called.
19 | expect(object.$value.setter).toNot(beCalled())
20 | }
21 |
22 | func testSettingPropertyWhenTypesMatch() {
23 | struct AnObject {
24 | @SettablePropertySpy(1)
25 | var value: Int
26 | }
27 |
28 | var object = AnObject()
29 | object.value = 3
30 |
31 | expect(object.$value.getter).toNot(beCalled())
32 | expect(object.$value.setter).to(beCalled(3))
33 |
34 | // the returned value should now be updated with the new value
35 | expect(object.value).to(equal(3))
36 |
37 | // and because we called the getter, the getter spy should be called.
38 | expect(object.$value.getter).to(beCalled())
39 | }
40 |
41 | func testGettingPropertyProtocolInheritence() {
42 | struct ImplementedProtocol: SomeProtocol {
43 | var value: Int = 1
44 | }
45 |
46 | struct AnObject {
47 | @SettablePropertySpy(ImplementedProtocol(value: 2))
48 | var value: SomeProtocol
49 | }
50 |
51 | let object = AnObject()
52 |
53 | expect(object.value).to(beAKindOf(ImplementedProtocol.self))
54 | // because we called it, we should expect for the getter spy to be called
55 | expect(object.$value.getter).to(beCalled())
56 |
57 | // We never interacted with the setter, so it shouldn't have been called.
58 | expect(object.$value.setter).toNot(beCalled())
59 | }
60 |
61 | func testSettingPropertyProtocolInheritence() {
62 | struct ImplementedProtocol: SomeProtocol, Equatable {
63 | var value: Int = 1
64 | }
65 |
66 | struct AnObject {
67 | @SettablePropertySpy(ImplementedProtocol())
68 | var value: SomeProtocol
69 | }
70 |
71 | var object = AnObject()
72 | object.value = ImplementedProtocol(value: 2)
73 |
74 | expect(object.$value.getter).toNot(beCalled())
75 | expect(object.$value.setter).to(beCalled(satisfyAllOf(
76 | beAKindOf(ImplementedProtocol.self),
77 | map(\.value, equal(2))
78 | )))
79 |
80 | // the returned value should now be updated with the new value
81 | expect(object.value).to(satisfyAllOf(
82 | beAKindOf(ImplementedProtocol.self),
83 | map(\.value, equal(2))
84 | ))
85 | // and because we called the getter, the getter spy should be called.
86 | expect(object.$value.getter).to(beCalled(times: 1))
87 | }
88 | }
89 |
90 | final class PropertySpyTests: XCTestCase {
91 | func testGettingPropertyWhenTypesMatch() {
92 | struct AnObject {
93 | @PropertySpy(1)
94 | var value: Int
95 | }
96 |
97 | let object = AnObject()
98 |
99 | expect(object.value).to(equal(1))
100 | // because we called it, we should expect for the getter spy to be called
101 | expect(object.$value).to(beCalled())
102 | }
103 |
104 | func testGettingPropertyProtocolInheritence() {
105 | struct ImplementedProtocol: SomeProtocol {
106 | var value: Int = 1
107 | }
108 |
109 | struct ObjectUsingProtocol {
110 | @PropertySpy(ImplementedProtocol(value: 2))
111 | var value: SomeProtocol
112 | }
113 |
114 | struct ObjectUsingDirectInstance {
115 | @PropertySpy(ImplementedProtocol(value: 2), as: { $0 })
116 | var value: SomeProtocol
117 | }
118 |
119 | let object = ObjectUsingProtocol()
120 |
121 | expect(object.value).to(beAnInstanceOf(ImplementedProtocol.self))
122 | // because we called it, we should expect for the getter spy to be called
123 | expect(object.$value).to(beCalled())
124 | expect(object.$value).to(beAnInstanceOf(Spy.self))
125 |
126 | let otherObject = ObjectUsingDirectInstance()
127 |
128 | expect(otherObject.$value).to(beAnInstanceOf(Spy.self))
129 | }
130 | }
131 |
132 | protocol SomeProtocol {
133 | var value: Int { get }
134 | }
135 |
--------------------------------------------------------------------------------
/script/release:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | REMOTE_BRANCH=main
3 | GH=${GH:-"gh"}
4 |
5 | function help {
6 | echo "Usage: release VERSION [-f]"
7 | echo
8 | echo "VERSION should be the version to release, should not include the 'v' prefix"
9 | echo
10 | echo "FLAGS"
11 | echo " -f Forces override of tag"
12 | echo
13 | echo " Example: ./release 1.0.0-rc.2"
14 | echo
15 | exit 2
16 | }
17 |
18 | function die {
19 | echo "[ERROR] $@"
20 | echo
21 | exit 1
22 | }
23 |
24 | if [ $# -lt 1 ]; then
25 | help
26 | fi
27 |
28 | VERSION=$1
29 | FORCE_TAG=$2
30 |
31 | VERSION_TAG="v$VERSION"
32 |
33 | if [ -z "`which $GH`" ]; then
34 | die "gh (github CLI) is required to produce a release. Install with brew using 'brew install gh'. Aborting."
35 | fi
36 |
37 | echo " > Verifying you are authenticated with the github CLI"
38 | $GH auth status > /dev/null || die "You are not authenticated with the github CLI. Please authenticate using '$GH auth login'."
39 | echo " > Logged in with github CLI"
40 |
41 | echo " > Is this a reasonable tag?"
42 |
43 | echo $VERSION_TAG | grep -q "^vv"
44 | if [ $? -eq 0 ]; then
45 | die "This tag ($VERSION) is an incorrect format. You should remove the 'v' prefix."
46 | fi
47 |
48 | echo $VERSION_TAG | grep -q -E "^v\d+\.\d+\.\d+(-\w+(\.\d)?)?\$"
49 | if [ $? -ne 0 ]; then
50 | die "This tag ($VERSION) is an incorrect format. It should be in 'v{MAJOR}.{MINOR}.{PATCH}(-{PRERELEASE_NAME}.{PRERELEASE_VERSION})' form."
51 | fi
52 |
53 | echo " > Is this version ($VERSION) unique?"
54 | git describe --exact-match "$VERSION_TAG" > /dev/null 2>&1
55 | if [ $? -eq 0 ]; then
56 | if [ -z "$FORCE_TAG" ]; then
57 | die "This tag ($VERSION) already exists. Aborting. Append '-f' to override"
58 | else
59 | echo " > NO, but force was specified."
60 | fi
61 | else
62 | echo " > Yes, tag is unique"
63 | fi
64 |
65 | git config --get user.signingkey > /dev/null || {
66 | echo "[ERROR] No PGP found to sign tag. Aborting."
67 | echo
68 | echo " Creating a release requires signing the tag for security purposes. This allows users to verify the git cloned tree is from a trusted source."
69 | echo " From a security perspective, it is not considered safe to trust the commits (including Author & Signed-off fields). It is easy for any"
70 | echo " intermediate between you and the end-users to modify the git repository."
71 | echo
72 | echo " While not all users may choose to verify the PGP key for tagged releases. It is a good measure to ensure 'this is an official release'"
73 | echo " from the official maintainers."
74 | echo
75 | echo " If you're creating your PGP key for the first time, use RSA with at least 4096 bits."
76 | echo
77 | echo "Related resources:"
78 | echo " - Configuring your system for PGP: https://git-scm.com/book/tr/v2/Git-Tools-Signing-Your-Work"
79 | echo " - Why: http://programmers.stackexchange.com/questions/212192/what-are-the-advantages-and-disadvantages-of-cryptographically-signing-commits-a"
80 | echo
81 | exit 2
82 | }
83 | echo " > Found PGP key for git"
84 |
85 | echo "-> Ensuring no differences to origin/$REMOTE_BRANCH"
86 | git fetch origin || die "Failed to fetch origin"
87 | git diff --quiet HEAD "origin/$REMOTE_BRANCH" || die "HEAD is not aligned to origin/$REMOTE_BRANCH. Cannot update version safely"
88 |
89 | RELEASE_NOTES="Version ${VERSION}. Open https://github.com/Quick/swift-fakes/releases/tag/$VERSION_TAG for full release notes."
90 |
91 | if [ -z "$FORCE_TAG" ]; then
92 | echo "-> Tagging version"
93 | git tag -s "$VERSION_TAG" -m "$RELEASE_NOTES" || die "Failed to tag version"
94 | echo "-> Pushing tag to origin"
95 | git push origin "$VERSION_TAG" || die "Failed to push tag '$VERSION_TAG' to origin"
96 | else
97 | echo "-> Tagging version (force)"
98 | git tag -s -f "$VERSION_TAG" -m "$RELEASE_NOTES" || die "Failed to tag version"
99 | echo "-> Pushing tag to origin (force)"
100 | git push origin "$VERSION_TAG" -f || die "Failed to push tag '$VERSION_TAG' to origin"
101 | fi
102 |
103 | # Check version tag to determine whether to mark the release as a prerelease version or not.
104 | echo $VERSION_TAG | grep -q -E "^v\d+\.\d+\.\d+\$"
105 | if [ $? -eq 0 ]; then
106 | PRERELEASE_FLAGS=""
107 | else
108 | PRERELEASE_FLAGS="-p"
109 | fi
110 |
111 | echo "-> Creating a github release using auto-generated notes."
112 |
113 | $GH release create -R Quick/swift-fakes $VERSION_TAG --generate-notes $PRERELEASE_FLAGS
114 |
115 | echo
116 | echo "================ Finalizing the Release ================"
117 | echo
118 | echo " - Opening GitHub to allow for any edits to the release notes."
119 | echo " - You should add a Highlights section at the top to call out any notable changes or fixes."
120 | echo " - In particular, any breaking changes should be listed under Highlights."
121 | echo " - Carthage archive frameworks will be automatically uploaded after the release is published."
122 | echo " - Announce!"
123 |
124 | open "https://github.com/Quick/swift-fakes/releases/tag/$VERSION_TAG"
125 |
--------------------------------------------------------------------------------
/Sources/Fakes/Spy/Spy.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// A Spy is a test double for recording calls to methods, and returning stubbed results.
4 | ///
5 | /// Spies should be used to verify that Fakes are called correctly, and to provide pre-stubbed values
6 | /// that the Fake returns to the caller.
7 | public final class Spy {
8 | public typealias Stub = DynamicResult.Stub
9 |
10 | private let lock = NSRecursiveLock()
11 |
12 | private var _calls: [Arguments] = []
13 | public var calls: [Arguments] {
14 | lock.lock()
15 | defer { lock.unlock() }
16 | return _calls
17 | }
18 |
19 | private var _stub: DynamicResult
20 |
21 | // MARK: - Initializers
22 | /// Create a Spy with the given stubbed values.
23 | public init(_ value: Returning, _ values: Returning...) {
24 | _stub = DynamicResult(Array(value, values))
25 | }
26 |
27 | internal init(_ values: [Returning]) {
28 | _stub = DynamicResult(values)
29 | }
30 |
31 | public init(_ closure: @escaping @Sendable (Arguments) -> Returning) {
32 | _stub = DynamicResult(closure)
33 | }
34 |
35 | public init(_ stub: Stub, _ stubs: Stub...) {
36 | _stub = DynamicResult(Array(stub, stubs))
37 | }
38 |
39 | internal init(_ stubs: [Stub]) {
40 | _stub = DynamicResult(stubs)
41 | }
42 |
43 | /// Create a Spy that returns Void
44 | public convenience init() where Returning == Void {
45 | self.init(())
46 | }
47 |
48 | /// Create a Spy that returns nil
49 | public convenience init() where Returning == Optional {
50 | // swiftlint:disable:previous syntactic_sugar
51 | self.init(nil)
52 | }
53 |
54 | /// Clear out existing call records.
55 | ///
56 | /// This removes all previously recorded calls from the spy. It does not otherwise
57 | /// mutate the spy.
58 | public func clearCalls() {
59 | lock.lock()
60 | defer { lock.unlock () }
61 | _calls = []
62 | }
63 |
64 | // MARK: Stubbing
65 | /// Replaces the Spy's stubs with the given values.
66 | ///
67 | /// - parameter value: The first value to return when `callAsFunction()` is called.
68 | /// - parameter values: The list of other values (in order) to return when `callAsFunction()` is called.
69 | ///
70 | /// - Note: This resolves any pending Pendables during replacement.
71 | public func stub(_ value: Returning, _ values: Returning...) {
72 | stub(Array(value, values))
73 | }
74 |
75 | internal func stub(_ values: [Returning]) {
76 | lock.lock()
77 | defer { lock.unlock () }
78 | _stub.replace(values)
79 | }
80 |
81 | /// Replaces the Spy's stubs with the given closure.
82 | ///
83 | /// - parameter closure: The closure to call with the arguments when `callAsFunction()` is called.
84 | ///
85 | /// - Note: This resolves any pending Pendables during replacement.
86 | public func stub(_ closure: @escaping @Sendable (Arguments) -> Returning) {
87 | lock.lock()
88 | defer { lock.unlock () }
89 | _stub.replace(closure)
90 | }
91 |
92 | /// Replace the Spy's stubs with the new list of stubs
93 | ///
94 | /// - parameter stub: The first stub to call when `callAsFunction()` is called.
95 | /// - parameter stubs: The list of other stubs (in order) to return when `callAsFunction()` is called.
96 | ///
97 | /// - Note: This resolves any pending Pendables during replacement.
98 | public func replace(
99 | _ stub: Stub,
100 | _ stubs: Stub...
101 | ) {
102 | lock.lock()
103 | defer { lock.unlock () }
104 | _stub.replace(Array(stub, stubs))
105 | }
106 |
107 | /// Append the values to the Spy's stubs
108 | ///
109 | /// - parameter value: The first value to append to the list of stubs
110 | /// - parameter values: The remaining values to append to the list of stubs
111 | public func append(_ value: Returning, _ values: Returning...) {
112 | lock.lock()
113 | defer { lock.unlock () }
114 | _stub.append(Array(value, values))
115 | }
116 |
117 | /// Append the closure to the list of Spy's stubs
118 | ///
119 | /// - parameter closure: The new closure to call at the end of the list of stubs
120 | public func append(_ closure: @escaping @Sendable (Arguments) -> Returning) {
121 | lock.lock()
122 | defer { lock.unlock () }
123 | _stub.append(closure)
124 | }
125 |
126 | /// Append the stubs to the list of stubs.
127 | ///
128 | /// - parameter value: The first stub to append to the list of stubs
129 | /// - parameter values: The remaining stubs to append to the list of stubs
130 | public func append(
131 | _ stub: Stub,
132 | _ stubs: Stub...
133 | ) {
134 | lock.lock()
135 | defer { lock.unlock () }
136 | _stub.append(Array(stub, stubs))
137 | }
138 |
139 | internal func call(_ arguments: Arguments) -> Returning {
140 | lock.lock()
141 | defer { lock.unlock() }
142 |
143 | _calls.append(arguments)
144 |
145 | return _stub.call(arguments)
146 | }
147 | }
148 |
149 | extension Spy {
150 | // MARK: - Calling
151 | /// Records the arguments and returns the value stubbed in the initializer, or using one of the `stub()` methods.
152 | public func callAsFunction(_ arguments: Arguments) -> Returning {
153 | return call(arguments)
154 | }
155 |
156 | /// Records that a call was made and returns the value stubbed in the initializer, or using one of the `stub()` methods.
157 | public func callAsFunction() -> Returning where Arguments == Void {
158 | return call(())
159 | }
160 | }
161 |
162 | extension Spy {
163 | public func resolveStub(with value: Value) where Returning == Pendable {
164 | lock.lock()
165 | defer { lock.unlock() }
166 | _stub.stubHistory.forEach {
167 | $0.resolve(with: value)
168 | }
169 | }
170 | }
171 |
172 | extension Spy: @unchecked Sendable where Arguments: Sendable, Returning: Sendable {}
173 |
--------------------------------------------------------------------------------
/Sources/Fakes/DynamicResult.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// A DynamicResult is specifically for mocking out when multiple results for a call can happen.
4 | ///
5 | /// DynamicResult is intended to be an implementation detail of ``Spy``,
6 | /// but is exposed publicly to be composed with other types as desired.
7 | public final class DynamicResult {
8 | /// A value or closure to be used by the DynamicResult
9 | public enum Stub {
10 | /// A static value
11 | case value(Returning)
12 | /// A closure to be called.
13 | case closure(@Sendable (Arguments) -> Returning)
14 |
15 | /// Call the stub.
16 | /// If the stub is a `.value`, then return the value.
17 | /// If the stub is a `.closure`, then call the closure with the arguments.
18 | func call(_ arguments: Arguments) -> Returning {
19 | switch self {
20 | case .value(let returning):
21 | return returning
22 | case .closure(let closure):
23 | return closure(arguments)
24 | }
25 | }
26 | }
27 |
28 | private let lock = NSRecursiveLock()
29 | private var stubs: [Stub]
30 |
31 | private var _stubHistory: [Returning] = []
32 | var stubHistory: [Returning] {
33 | lock.lock()
34 | defer { lock.unlock () }
35 | return _stubHistory
36 | }
37 |
38 | /// Create a new DynamicResult stubbed to return the values in the given order.
39 | /// That is, given `DynamicResult(1, 2, 3)`,
40 | /// if you call `.call` 5 times, you will get back `1, 2, 3, 3, 3`.
41 | public init(_ value: Returning, _ values: Returning...) {
42 | self.stubs = Array(value, values).map { Stub.value($0) }
43 | }
44 |
45 | internal init(_ values: [Returning]) {
46 | self.stubs = values.map { Stub.value($0) }
47 | }
48 |
49 | /// Create a new DynamicResult stubbed to call the given closure.
50 | public init(_ closure: @escaping @Sendable (Arguments) -> Returning) {
51 | self.stubs = [.closure(closure)]
52 | }
53 |
54 | /// Create a new DynamicResult stubbed to call the given stubs.
55 | public init(_ stub: Stub, _ stubs: Stub...) {
56 | self.stubs = Array(stub, stubs)
57 | }
58 |
59 | internal init(_ stubs: [Stub]) {
60 | self.stubs = stubs
61 | }
62 |
63 | /// Call the DynamicResult, returning the next stub in the list of stubs.
64 | public func call(_ arguments: Arguments) -> Returning {
65 | lock.lock()
66 | defer { lock.unlock () }
67 | let value = nextStub().call(arguments)
68 | _stubHistory.append(value)
69 | return value
70 | }
71 |
72 | /// Call the DynamicResult, returning the next stub in the list of stubs.
73 | public func call() -> Returning where Arguments == Void {
74 | call(())
75 | }
76 |
77 | /// Replace the stubs with the new static values
78 | public func replace(_ value: Returning, _ values: Returning...) {
79 | replace(Array(value, values))
80 | }
81 |
82 | /// Replace the stubs with the new static values
83 | internal func replace(_ values: [Returning]) {
84 | lock.lock()
85 | defer { lock.unlock () }
86 | self.resolvePendables()
87 | self.stubs = values.map { .value($0) }
88 | }
89 |
90 | /// Replace the stubs with the new closure.
91 | public func replace(_ closure: @escaping @Sendable (Arguments) -> Returning) {
92 | lock.lock()
93 | defer { lock.unlock () }
94 | self.resolvePendables()
95 | self.stubs = [.closure(closure)]
96 | }
97 |
98 | /// Replace the stubs with the new list of stubs
99 | public func replace(_ stub: Stub, _ stubs: Stub...) {
100 | lock.lock()
101 | defer { lock.unlock () }
102 | self.resolvePendables()
103 | self.stubs = Array(stub, stubs)
104 | }
105 |
106 | /// Replace the stubs with the new list of stubs
107 | internal func replace(_ stubs: [Stub]) {
108 | lock.lock()
109 | defer { lock.unlock () }
110 | self.resolvePendables()
111 | self.stubs = stubs
112 | }
113 |
114 | /// Append the values to the list of stubs.
115 | public func append(_ value: Returning, _ values: Returning...) {
116 | append(Array(value, values))
117 | }
118 |
119 | internal func append(_ values: [Returning]) {
120 | lock.lock()
121 | defer { lock.unlock () }
122 | stubs.append(contentsOf: values.map { .value($0) })
123 | }
124 |
125 | /// Append the closure to the list of stubs.
126 | public func append(_ closure: @escaping @Sendable (Arguments) -> Returning) {
127 | lock.lock()
128 | defer { lock.unlock () }
129 | stubs.append(.closure(closure))
130 | }
131 |
132 | /// Append the stubs to the list of stubs.
133 | public func append(_ stub: Stub, _ stubs: Stub...) {
134 | append(Array(stub, stubs))
135 | }
136 |
137 | internal func append(_ stubs: [Stub]) {
138 | lock.lock()
139 | defer { lock.unlock () }
140 | self.stubs.append(contentsOf: stubs)
141 | }
142 |
143 | private func nextStub() -> Stub {
144 | guard let stub = stubs.first else {
145 | fatalError("Fakes: DynamicResult \(self) has 0 stubs. This should never happen. File a bug at https://github.com/Quick/swift-fakes/issues/new")
146 | }
147 | if stubs.count > 1 {
148 | stubs.removeFirst()
149 | }
150 | return stub
151 | }
152 |
153 | private func resolvePendables() {
154 | stubs.forEach {
155 | guard case .value(let value) = $0 else { return }
156 | if let resolvable = value as? ResolvableWithFallback {
157 | resolvable.resolveWithFallback()
158 | }
159 | }
160 | }
161 | }
162 |
163 | extension DynamicResult: @unchecked Sendable where Arguments: Sendable, Returning: Sendable {}
164 |
165 | internal extension Array {
166 | init(_ value: Element, _ values: [Element]) {
167 | self = [value] + values
168 | }
169 |
170 | mutating func append(_ value: Element, _ values: [Element]) {
171 | self.append(contentsOf: Array(value, values))
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/Sources/Fakes/Spy/Spy+ThrowingPendable.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public typealias ThrowingPendableSpy<
4 | Arguments,
5 | Success,
6 | Failure: Error
7 | > = Spy<
8 | Arguments,
9 | Pendable<
10 | Result<
11 | Success,
12 | Failure
13 | >
14 | >
15 | >
16 |
17 | extension Spy {
18 | /// Create a throwing pendable Spy that is pre-stubbed to return a pending that will block for a bit before returning success.
19 | public convenience init(pendingSuccess: Success) where Returning == ThrowingPendable {
20 | self.init(.pending(fallback: .success(pendingSuccess)))
21 | }
22 |
23 | /// Create a throwing pendable Spy that is pre-stubbed to return a pending that will block for a bit before returning Void.
24 | public convenience init() where Returning == ThrowingPendable<(), Failure> {
25 | self.init(.pending(fallback: .success(())))
26 | }
27 |
28 | /// Create a throwing pendable Spy that is pre-stubbed to return a pending that will block for a bit before throwing an error.
29 | public convenience init(pendingFailure: Failure) where Returning == ThrowingPendable {
30 | self.init(.pending(fallback: .failure(pendingFailure)))
31 | }
32 |
33 | /// Create a throwing pendable Spy that is pre-stubbed to return a pending that will block for a bit before throwing an error
34 | public convenience init() where Returning == ThrowingPendable {
35 | self.init(.pending(fallback: .failure(EmptyError())))
36 | }
37 |
38 | /// Create a throwing pendable Spy that is pre-stubbed to return a finished & successful value.
39 | public convenience init(success: Success) where Returning == ThrowingPendable {
40 | self.init(.finished(.success(success)))
41 | }
42 |
43 | /// Create a throwing pendable Spy that is pre-stubbed to throw the given error.
44 | public convenience init(failure: Failure) where Returning == ThrowingPendable {
45 | self.init(.finished(.failure(failure)))
46 | }
47 | }
48 |
49 | extension Spy {
50 | /// Resolve the pendable Spy's stub with the success value.
51 | public func resolveStub(success: Success) where Returning == ThrowingPendable {
52 | self.resolveStub(with: .success(success))
53 | }
54 |
55 | /// Resolve the pendable spy's stub with the given error
56 | public func resolveStub(failure: Failure) where Returning == ThrowingPendable {
57 | self.resolveStub(with: .failure(failure))
58 | }
59 | }
60 |
61 | extension Spy {
62 | /// Update the pendable Spy's stub to be in a pending state.
63 | public func stub(pendingSuccess: Success) where Returning == ThrowingPendable {
64 | self.stub(.pending(fallback: .success(pendingSuccess)))
65 | }
66 |
67 | /// Update the pendable Spy's stub to be in a pending state.
68 | public func stub(pendingFailure: Failure) where Returning == ThrowingPendable {
69 | self.stub(.pending(fallback: .failure(pendingFailure)))
70 | }
71 |
72 | /// Update the pendable Spy's stub to be in a pending state with a default failure value.
73 | public func stubPendingFailure() where Returning == ThrowingPendable {
74 | self.stub(pendingFailure: EmptyError())
75 | }
76 |
77 | /// Update the throwing pendable Spy's stub to be successful, with the given value.
78 | ///
79 | /// - parameter success: The value to return when `callAsFunction` is called.
80 | public func stub(success: Success) where Returning == ThrowingPendable {
81 | self.stub(.finished(.success(success)))
82 | }
83 |
84 | /// Update the throwing pendable Spy's stub to throw the given error.
85 | ///
86 | /// - parameter failure: The error to throw when `callAsFunction` is called.
87 | public func stub(failure: Failure) where Returning == ThrowingPendable {
88 | self.stub(.finished(.failure(failure)))
89 | }
90 |
91 | /// Update the throwing pendable Spy's stub to throw an error.
92 | public func stubFailure() where Returning == ThrowingPendable {
93 | self.stub(failure: EmptyError())
94 | }
95 | }
96 |
97 | extension Spy {
98 | // Returning == ThrowingPendable
99 | /// Records the arguments and handles the result according to ``Pendable/call(fallbackDelay:)``.
100 | /// This call then throws or returns the success, according to `Result.get`.
101 | ///
102 | /// - parameter arguments: The arguments to record.
103 | /// - parameter pendingDelay: The amount of seconds to delay if the `Pendable` is .pending before
104 | /// throwing a `PendableInProgressError`. If the `Pendable` is .finished, then this value is ignored.
105 | public func callAsFunction(
106 | _ arguments: Arguments,
107 | fallbackDelay: TimeInterval = PendableDefaults.delay
108 | ) async throws -> Success where Returning == ThrowingPendable {
109 | return try await call(arguments).call(fallbackDelay: fallbackDelay).get()
110 | }
111 |
112 | /// Records that a call was made and handles the result according to ``Pendable/call(fallbackDelay:)``.
113 | /// This call then throws or returns the success, according to `Result.get`.
114 | ///
115 | /// - parameter pendingDelay: The amount of seconds to delay if the `Pendable` is .pending before
116 | /// throwing a `PendableInProgressError`. If the `Pendable` is .finished, then this value is ignored.
117 | public func callAsFunction(
118 | fallbackDelay: TimeInterval = PendableDefaults.delay
119 | ) async throws -> Success where Arguments == Void, Returning == ThrowingPendable {
120 | return try await call(()).call(fallbackDelay: fallbackDelay).get()
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/Sources/Fakes/Pendable/Pendable.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// A way to type-erase Pendable, specifically just for resolving with the fallback.
4 | ///
5 | /// This is meant to be used by ```Spy``` as part of changing the stub in order to quickly resolve pending calls.
6 | public protocol ResolvableWithFallback {
7 | func resolveWithFallback()
8 | }
9 |
10 | /// Pendable is a safe way to represent the 2 states that an asynchronous call can be in
11 | ///
12 | /// - `pending`, the state while waiting for the call to finish.
13 | /// - `finished`, the state once the call has finished.
14 | ///
15 | /// Pendable allows you to finish a pending call after it's been made. This makes Pendable behave very
16 | /// similarly to something like Combine's `Future`.
17 | ///
18 | /// - Note: The reason you must provide a fallback value is to prevent deadlock when used in test.
19 | /// Unlike something like Combine's `Future`, it is very often the case that you will write
20 | /// tests which end while the call is in the pending state. If you do this too much, then your
21 | /// entire test suite will deadlock, as Swift Concurrency works under the assumption that
22 | /// blocked tasks of work will always eventually be unblocked. To help prevent this, pending calls
23 | /// are always resolved with the fallback after a given delay. You can also manually force this
24 | /// by calling the ``Pendable\resolveWithFallback()`` method.
25 | public final class Pendable: @unchecked Sendable, ResolvableWithFallback {
26 | private enum State: Sendable {
27 | case pending
28 | case finished(Value)
29 | }
30 |
31 | private let lock = NSRecursiveLock()
32 | private var state = State.pending
33 |
34 | private var inProgressCalls = [UnsafeContinuation]()
35 |
36 | private let fallbackValue: Value
37 |
38 | private var currentValue: Value {
39 | switch state {
40 | case .pending:
41 | return fallbackValue
42 | case .finished(let value):
43 | return value
44 | }
45 | }
46 |
47 | deinit {
48 | resolveWithFallback()
49 | }
50 |
51 | /// Initializes a new `Pendable`, in a pending state, with the given fallback value.
52 | public init(fallbackValue: Value) {
53 | self.fallbackValue = fallbackValue
54 | }
55 |
56 | /// Gets the value for the `Pendable`, possibly waiting until it's resolved.
57 | ///
58 | /// - parameter fallbackDelay: The amount of time (in seconds) to wait until the call returns
59 | /// the fallback value. This is only used when the `Pendable` is in a pending state.
60 | public func call(fallbackDelay: TimeInterval = PendableDefaults.delay) async -> Value {
61 | return await withTaskGroup(of: Value.self) { taskGroup in
62 | taskGroup.addTask { await self.handleCall() }
63 | taskGroup.addTask { await self.resolveAfterDelay(fallbackDelay) }
64 |
65 | guard let value = await taskGroup.next() else {
66 | fatalError("There were no tasks in the task group. This should not ever happen.")
67 | }
68 | taskGroup.cancelAll()
69 | return value
70 |
71 | }
72 | }
73 |
74 | /// Resolves the `Pendable` with the fallback value.
75 | ///
76 | /// - Note: This no-ops if the pendable is already in a resolved state.
77 | /// - Note: This is called for when you re-stub a `Spy` in ``Spy/stub(_:)``
78 | public func resolveWithFallback() {
79 | lock.lock()
80 | defer { lock.unlock() }
81 |
82 | if case .pending = state {
83 | resolve(with: fallbackValue)
84 | }
85 | }
86 |
87 | /// Resolves the `Pendable` with the given value.
88 | ///
89 | /// Even if the pendable is already resolves, this resets the resolved value to the given value.
90 | public func resolve(with value: Value) {
91 | lock.lock()
92 | defer { lock.unlock() }
93 | state = .finished(value)
94 | inProgressCalls.forEach {
95 | $0.resume(returning: value)
96 | }
97 | inProgressCalls = []
98 |
99 | }
100 |
101 | /// Resolves any outstanding calls to the `Pendable` with the current value,
102 | /// and resets it back into the pending state.
103 | public func reset() {
104 | lock.lock()
105 | defer { lock.unlock() }
106 |
107 | inProgressCalls.forEach {
108 | $0.resume(returning: currentValue)
109 | }
110 | inProgressCalls = []
111 | state = .pending
112 | }
113 |
114 | // MARK: - Private
115 | private func handleCall() async -> Value {
116 | return await withUnsafeContinuation { continuation in
117 | lock.lock()
118 | defer { lock.unlock() }
119 | switch state {
120 | case .pending:
121 | inProgressCalls.append(continuation)
122 | case .finished(let value):
123 | continuation.resume(returning: value)
124 | }
125 | }
126 | }
127 |
128 | private func resolveAfterDelay(_ delay: TimeInterval) async -> Value {
129 | do {
130 | try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
131 | } catch {}
132 | resolveWithFallback()
133 | return fallbackValue
134 | }
135 | }
136 |
137 | @available(*, deprecated, renamed: "ThrowingPendable")
138 | public typealias ThrowingDynamicPendable = Pendable>
139 |
140 | public typealias ThrowingPendable = Pendable>
141 |
142 | extension Pendable {
143 | /// Gets or throws value for the `Pendable`, possibly waiting until it's resolved.
144 | ///
145 | /// - parameter resolveDelay: The amount of time (in seconds) to wait until the call returns
146 | /// the fallback value. This is only used when the `Pendable` is in a pending state.
147 | public func call(
148 | resolveDelay: TimeInterval = PendableDefaults.delay
149 | ) async throws -> Success where Value == Result {
150 | try await call(fallbackDelay: resolveDelay).get()
151 | }
152 | }
153 |
154 | extension Pendable {
155 | /// Creates a new finished `Pendable` pre-resolved with the given value.
156 | public static func finished(_ value: Value) -> Pendable {
157 | let pendable = Pendable(fallbackValue: value)
158 | pendable.resolve(with: value)
159 | return pendable
160 | }
161 |
162 | /// Creates a new finished `Pendable` pre-resolved with Void.
163 | public static func finished() -> Pendable where Value == Void {
164 | return Pendable.finished(())
165 | }
166 | }
167 |
168 | extension Pendable {
169 | /// Creates a new pending `Pendable` with the given fallback value.
170 | public static func pending(fallback: Value) -> Pendable {
171 | Pendable(fallbackValue: fallback)
172 | }
173 |
174 | /// Creates a new pending `Pendable` with a fallback value of Void.
175 | public static func pending() -> Pendable where Value == Void {
176 | Pendable(fallbackValue: ())
177 | }
178 |
179 | /// Creates a new pending `Pendable` with a fallback value of nil.
180 | public static func pending() -> Pendable where Value == Optional {
181 | // swiftlint:disable:previous syntactic_sugar
182 | Pendable(fallbackValue: nil)
183 | }
184 |
185 | /// Creatse a new pending `Pendable` with a fallback value of an error.
186 | public static func pending() -> Pendable where Value == Result {
187 | Pendable(fallbackValue: Result.failure(EmptyError()))
188 | }
189 | }
190 |
--------------------------------------------------------------------------------
/Sources/Fakes/Fakes.docc/Tutorials/WritingFakes/RecipeService.tutorial:
--------------------------------------------------------------------------------
1 | @Tutorial(time: 20) {
2 | @Intro(title: "The RecipeService object") {
3 | Testing an object to download recipes and upload new ones.
4 | }
5 |
6 | @Section(title: "Writing the synchronous Fake") {
7 | @ContentAndMedia {
8 | Suppose you are working on an application to view and write recipes.
9 |
10 | As part of this, there is a server component you need to interface with
11 | in order to download recipes and upload new ones.
12 | }
13 |
14 | @Steps {
15 | @Step {
16 | Let's first start with the `NetworkInterface` protocol.
17 |
18 | This is a protocol for a `URLSession`-like object that can make
19 | various network requests.
20 |
21 | @Code(name: "NetworkInterface.swift", file: "NetworkInterface-01.swift")
22 | }
23 |
24 | @Step {
25 | Given this `NetworkInterface`, you might write `RecipeService`
26 | like so:
27 |
28 | @Code(name: "RecipeService.swift", file: "RecipeService-01.swift")
29 | }
30 |
31 | @Step {
32 | In writing the tests for your RecipeService class,
33 | you could just use the real implementation of `NetworkInterface`. But now your
34 | tests require you to have a reliable connection not just to the internet at
35 | large, but also to the particular target server in order to work. If that server
36 | ever goes down for maintenance, your tests will just fail. Additionally, using
37 | a real version of `NetworkInterface` means you can only semi-reliably test the
38 | happy paths. Good luck writing tests of any error cases.
39 |
40 | Given that, you decide to create a Fake and inject it (using
41 | ) to your Subject. You decide to come up with this
42 | first attempt at `FakeNetworkInterface`.
43 |
44 | @Code(name: "FakeNetworkInterface.swift", file: "FakeNetworkInterface-01.swift")
45 | }
46 |
47 | @Step {
48 | But this isn't very useful. For one, there's no way to control the result of
49 | `get(from:)`. For another, there's no way to even check that your fake is even
50 | used. This is where ``Spy`` comes in. Let's rewrite `FakeNetworkInterface` using
51 | ``Spy``.
52 |
53 | @Code(name: "FakeNetworkInterface.swift", file: "FakeNetworkInterface-02.swift")
54 | }
55 |
56 | @Step {
57 | This is the start of everything we need to write tests for
58 | `RecipeService`, so let's do just that.
59 |
60 | @Code(name: "RecipeServiceTests.swift", file: "RecipeServiceTests-01.swift")
61 | }
62 | }
63 | }
64 |
65 | @Section(title: "Updating for Throwing") {
66 | @ContentAndMedia {
67 | Actual network calls can fail, and just crashing the app
68 | just because the network fails is a bad experience at best. Luckily,
69 | `Spy` can handle throwing calls. So let's update our code to handle
70 | network errors.
71 | }
72 |
73 | @Steps {
74 | @Step {
75 | First, let's start with updating `NetworkInterface`.
76 |
77 | @Code(name: "NetworkInterface.swift", file: "NetworkInterface-02.swift")
78 | }
79 |
80 | @Step {
81 | With the updated `NetworkInterface`, we also have to update
82 | `RecipeService` to handle these new errors. For our purposes,
83 | let's just rethrow the errors and not do anything.
84 |
85 | @Code(name: "RecipeService.swift", file: "RecipeService-02.swift")
86 | }
87 |
88 | @Step {
89 | Updating `NetworkInterface` also requires us to update
90 | `FakeNetworkInterface`.
91 | There are two ways we could do this: Use
92 | `Spy>`, or use the typealias
93 | `ThrowingSpy`.
94 | `ThrowingSpy` is less boilerplate, and therefore easier to
95 | understand, so we'll go with that.
96 |
97 | @Code(name: "FakeNetworkInterface.swift", file: "FakeNetworkInterface-03.swift")
98 | }
99 |
100 | @Step {
101 | Now that we can throw errors in test, we really should add tests
102 | of what happens when `NetworkInterface` throws an error.
103 | This particular case is rather boring, but it is good to verify
104 | that we're not crashing on errors. A downstream object might
105 | take the error and present an alert with a retry option.
106 |
107 | @Code(name: "RecipeServiceTests.swift", file: "RecipeServiceTests-02.swift")
108 | }
109 | }
110 | }
111 |
112 | @Section(title: "Handling Asynchronous Calls") {
113 | @ContentAndMedia {
114 | In practice, it's also really bad practice to make synchronous
115 | network calls. Doing so requires us to remember to call the
116 | `NetworkInterface` from a background thread, or else risk blocking
117 | the main thread. Which is, again, a poor experience at best, and
118 | very likely to get you an `8BADF00D` crash. Better to make
119 | network calls asynchronous at the source - meaning at
120 | `NetworkInterface`.
121 | }
122 |
123 | @Steps {
124 | @Step {
125 | As with before, let's start with `NetworkInterface`, making use
126 | of Swift Concurrency for this.
127 |
128 | @Code(name: "NetworkInterface.swift", file: "NetworkInterface-03.swift")
129 | }
130 |
131 | @Step {
132 | With the again-updated `NetworkInterface`, we once again have
133 | to update `RecipeService` to work with the asynchronous
134 | `NetworkInterface`.
135 |
136 | @Code(name: "RecipeService.swift", file: "RecipeService-03.swift")
137 | }
138 |
139 | @Step {
140 | And again, we turn our attention to `FakeNetworkInterface`.
141 | We could update the Spies to be any of
142 | `Spy<..., Pendable>`,
143 | `Spy<..., ThrowingPendable>`,
144 | `PendableSpy<..., Result>`, or
145 | `ThrowingPendableSpy<..., Success, Error>`. As with before,
146 | let's use the one that flattens the generics as much as
147 | possible: `ThrowingPendableSpy`.
148 |
149 | @Code(name: "FakeNetworkInterface.swift", file: "FakeNetworkInterface-04.swift")
150 | }
151 |
152 | @Step {
153 | Finally, we need to update the tests to work with all these
154 | async calls.
155 |
156 | @Code(name: "RecipeServiceTests.swift", file: "RecipeServiceTests-03.swift")
157 | }
158 | }
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/Tests/FakesTests/SpyTests.swift:
--------------------------------------------------------------------------------
1 | import Nimble
2 | import XCTest
3 | @testable import Fakes
4 |
5 | final class SpyTests: XCTestCase {
6 | func testVoid() {
7 | let subject = Spy()
8 |
9 | // it returns the returning argument as the result, without stubbing.
10 | expect {
11 | subject(())
12 | }.to(beVoid())
13 |
14 | // it records the call
15 | expect(subject.calls).to(haveCount(1))
16 | expect(subject.calls.first).to(beVoid())
17 |
18 | // allows Void arguments to be called without passing in a void
19 | expect {
20 | subject()
21 | }.to(beVoid())
22 |
23 | // it records the call.
24 | expect(subject.calls).to(haveCount(2))
25 | expect(subject.calls.last).to(beVoid())
26 | }
27 |
28 | func testStubbing() {
29 | let subject = Spy(123)
30 |
31 | expect {
32 | subject()
33 | }.to(equal(123))
34 |
35 | subject.stub(456)
36 |
37 | expect {
38 | subject()
39 | }.to(equal(456))
40 | }
41 |
42 | func testMultipleStubs() {
43 | let subject = Spy(1, 2, 3)
44 |
45 | expect(subject()).to(equal(1))
46 | expect(subject()).to(equal(2))
47 | expect(subject()).to(equal(3))
48 | expect(subject()).to(equal(3))
49 | }
50 |
51 | func testClosureStubs() {
52 | let subject = Spy { $0 }
53 |
54 | expect(subject(1)).to(equal(1))
55 | expect(subject(2)).to(equal(2))
56 | expect(subject(3)).to(equal(3))
57 | expect(subject(10)).to(equal(10))
58 | }
59 |
60 | func testReplacingStubs() {
61 | let subject = Spy(5)
62 | subject.stub(1, 2, 3)
63 |
64 | expect(subject()).to(equal(1))
65 | expect(subject()).to(equal(2))
66 | expect(subject()).to(equal(3))
67 | expect(subject()).to(equal(3))
68 | }
69 |
70 | func testReplacingClosures() {
71 | let subject = Spy(5)
72 | subject.stub { $0 }
73 |
74 | expect(subject(1)).to(equal(1))
75 | expect(subject(2)).to(equal(2))
76 | expect(subject(3)).to(equal(3))
77 | expect(subject(10)).to(equal(10))
78 | }
79 |
80 | func testResult() {
81 | let subject = Spy>(.failure(TestError.uhOh))
82 |
83 | expect {
84 | try subject() as Int
85 | }.to(throwError(TestError.uhOh))
86 |
87 | expect {
88 | try subject(()) as Int
89 | }.to(throwError(TestError.uhOh))
90 |
91 | // stub(success:)
92 |
93 | subject.stub(success: 2)
94 |
95 | expect {
96 | try subject()
97 | }.to(equal(2))
98 |
99 | // stub(failure:)
100 |
101 | subject.stub(failure: .ohNo)
102 |
103 | expect {
104 | try subject() as Int
105 | }.to(throwError(TestError.ohNo))
106 | }
107 |
108 | func testResultInitializers() {
109 | let subject = ThrowingSpy(failure: .ohNo)
110 |
111 | expect {
112 | try subject() as Int
113 | }.to(throwError(TestError.ohNo))
114 |
115 | let subject2 = ThrowingSpy(success: 3)
116 | expect {
117 | try subject2()
118 | }.to(equal(3))
119 | }
120 |
121 | func testResultTakesNonVoidArguments() {
122 | let intSpy = Spy()
123 |
124 | intSpy(1)
125 |
126 | expect(intSpy.calls).to(equal([1]))
127 |
128 | intSpy(1)
129 | }
130 |
131 | func testPendable() async {
132 | let subject = PendableSpy(pendingFallback: 1)
133 |
134 | await expect {
135 | await subject(fallbackDelay: 0)
136 | }.toEventually(equal(1))
137 |
138 | subject.stub(finished: 4)
139 |
140 | await expect {
141 | await subject(fallbackDelay: 0)
142 | }.toEventually(equal(4))
143 | }
144 |
145 | func testPendableTakesNonVoidArguments() async throws {
146 | let subject = PendableSpy(finished: ())
147 |
148 | await subject(3, fallbackDelay: 0)
149 |
150 | expect(subject.calls).to(equal([3]))
151 | }
152 |
153 | func testThrowingPendable() async {
154 | let subject = ThrowingPendableSpy(pendingSuccess: 0)
155 |
156 | await expect {
157 | try await subject(fallbackDelay: 0)
158 | }.toEventually(equal(0))
159 |
160 | subject.stub(success: 5)
161 |
162 | await expect {
163 | try await subject(fallbackDelay: 0)
164 | }.toEventually(equal(5))
165 |
166 | subject.stub(failure: TestError.uhOh)
167 | await expect {
168 | try await subject(fallbackDelay: 0)
169 | }.toEventually(throwError(TestError.uhOh))
170 | }
171 |
172 | func testThrowingPendableTakesNonVoidArguments() async throws {
173 | let subject = ThrowingPendableSpy(success: ())
174 |
175 | try await subject(8, fallbackDelay: 0)
176 |
177 | expect(subject.calls).to(equal([8]))
178 | }
179 |
180 | func testClearCalls() {
181 | let subject = Spy()
182 |
183 | subject(1)
184 | subject(2)
185 |
186 | subject.clearCalls()
187 | expect(subject.calls).to(beEmpty())
188 | }
189 |
190 | func testDynamicPendable() async {
191 | let subject = Spy>()
192 |
193 | let managedTask = await ManagedTask.running {
194 | await subject()
195 | }
196 |
197 | await expect { await managedTask.isFinished }.toNever(beTrue())
198 |
199 | subject.resolveStub(with: ())
200 |
201 | await expect { await managedTask.isFinished }.toEventually(beTrue())
202 | }
203 |
204 | func testDynamicPendableDeinit() async {
205 | let subject = Spy>()
206 |
207 | let managedTask = await ManagedTask.running {
208 | await subject()
209 | }
210 |
211 | await expect { await managedTask.hasStarted }.toEventually(beTrue())
212 |
213 | subject.stub(Pendable.pending())
214 | subject.resolveStub(with: ())
215 |
216 | await expect { await managedTask.isFinished }.toEventually(beTrue())
217 | }
218 | }
219 |
220 | actor ManagedTask {
221 | var hasStarted = false
222 | var isFinished = false
223 |
224 | var task: Task!
225 |
226 | static func running(closure: @escaping @Sendable () async throws -> Success) async -> ManagedTask where Failure == Error {
227 | let task = ManagedTask()
228 |
229 | await task.run(closure: closure)
230 |
231 | return task
232 | }
233 |
234 | static func running(closure: @escaping @Sendable () async -> Success) async -> ManagedTask where Failure == Never {
235 | let task = ManagedTask()
236 |
237 | await task.run(closure: closure)
238 |
239 | return task
240 | }
241 |
242 | private init() {}
243 |
244 | private func run(closure: @escaping @Sendable () async throws -> Success) where Failure == Error {
245 | task = Task {
246 | self.recordStarted()
247 | let result = try await closure()
248 | self.recordFinished()
249 | return result
250 | }
251 | }
252 |
253 | private func run(closure: @escaping @Sendable () async -> Success) where Failure == Never {
254 | task = Task {
255 | self.recordStarted()
256 | let result = await closure()
257 | self.recordFinished()
258 | return result
259 | }
260 | }
261 |
262 | private func recordStarted() {
263 | self.hasStarted = true
264 | }
265 |
266 | private func recordFinished() {
267 | self.isFinished = true
268 | }
269 |
270 | var result: Result {
271 | get async {
272 | await task.result
273 | }
274 | }
275 |
276 | var value: Success {
277 | get async throws {
278 | try await task.value
279 | }
280 | }
281 | }
282 |
283 | enum TestError: Error {
284 | case uhOh
285 | case ohNo
286 | }
287 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "{}"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright 2024, Quick Team
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
203 |
--------------------------------------------------------------------------------
/Sources/Fakes/Fakes.docc/Guides/VerifyingCallbacks.md:
--------------------------------------------------------------------------------
1 | # Verifying Callbacks and Faking DispatchQueue
2 |
3 | When testing a method that calls a callback, how do you verify that the
4 | callback actually works?
5 |
6 | ## Contents
7 |
8 | Let's say we're testing a method that uses a callback as part of its behavior.
9 | How would we verify that the callback actually gets called?
10 |
11 | To examine that, let's look at a function that does some expensive computation.
12 | This function uses `DispatchQueue` to do that computation on a background
13 | thread. To let the caller know that the work has finished, this function takes
14 | in a completion handler in the form of a callback.
15 |
16 | Let's say that method looks something like this:
17 |
18 | ```swift
19 | func expensiveComputation(completionHandler: @escaping (Int) -> Void) {
20 | DispatchQueue.global().async {
21 | // ... do some work, to compute a value
22 | let value: Int = 1337 // for the sake of example.
23 | completionHandler(value)
24 | }
25 | }
26 | ```
27 |
28 | In that case, we could pass a closure that calls a ``Spy`` with the
29 | completion handler's arguments:
30 |
31 | ```swift
32 | final class ExpensiveComputationTests: XCTestCase {
33 | func testCallsTheCompletionHandler() {
34 | let completionHandler = Spy()
35 |
36 | let expectation = self.expectation(
37 | description: "expensiveComputation completion handler"
38 | )
39 |
40 | expensiveComputation {
41 | completionHandler.callAsFunction($0)
42 | expectation.fulfill()
43 | }
44 |
45 | self.waitForExpectations(timeout: 1)
46 |
47 | XCTAssertEqual(completionHandler.calls, [1])
48 | }
49 | }
50 | ```
51 |
52 | This has a lot of boilerplate. Setting up an `Expectation`, fulfilling it,
53 | and waiting for it to be called is exactly the kind of situation that
54 | [Nimble's Polling Expectations](https://quick.github.io/Nimble/documentation/nimble/pollingexpectations)
55 | were created to handle.
56 |
57 | Let's refactor the test using Polling Expectations:
58 |
59 | ```swift
60 | final class ExpensiveComputationTests: XCTestCase {
61 | func testCallsTheCallback() {
62 | let completionHandler = Spy()
63 |
64 | expensiveComputation(completionHandler: completionHandler.callAsFunction)
65 |
66 | expect(completionHandler).toEventually(beCalled(1337))
67 | }
68 | }
69 | ```
70 |
71 | ### Writing Fakes that use Callbacks
72 |
73 | Of course, even better than waiting for the completion handler to eventually be
74 | called, is being able to control when it's called.
75 |
76 | > Note: This section assumes you're familiar with writing fakes and injecting
77 | them to the subject using Dependency Injection. See
78 | for a refresher.
79 |
80 | Because `DispatchQueue` is an open class, we could write a subclass that
81 | overrides its public API and does the right thing. That's doable, but
82 | [`DispatchQueue`](https://developer.apple.com/documentation/dispatch/dispatchqueue)
83 | has a very large public API, and our subclass would have to implement all of that
84 | plus keep up with any changes Apple makes in the future to avoid accidentally
85 | calling into the base DispatchQueue class.
86 |
87 | Instead, whenever possible, you should favor composition over inheritance.
88 | Which, for this case, means we'll write a protocol to wrap `DispatchQueue`.
89 |
90 | We're only calling `async` with the default arguments, so our protocol can
91 | be a single method that takes the `execute` argument:
92 |
93 | ```swift
94 | protocol DispatchQueueProtocol {
95 | func async(execute work: @escaping @Sendable () -> Void)
96 | }
97 |
98 | extension DispatchQueue: DispatchQueueProtocol {
99 | func async(execute work: @escaping @Sendable () -> Void) {
100 | self.async(group: nil, execute: work)
101 | }
102 | }
103 | ```
104 |
105 | Our `DispatchQueueProtocol` is only using one of the arguments to DispatchQueue's [`async(group:qos:flags:execute:)`](https://developer.apple.com/documentation/dispatch/dispatchqueue/2016098-async),
106 | so the protocol wrapping `DispatchQueue` only really needs the `execute`
107 | argument. Because default arguments don't really play well with protocols, we
108 | need to manually implement `async(execute:)`, and also specify one of the
109 | default arguments to prevent recursively calling `async(execute:)`.
110 |
111 | Now, we can inject our `DispatchQueueProtocol`:
112 |
113 | ```swift
114 | func expensiveComputation(dispatchQueue: DispatchQueueProtocol, completionHandler: @escaping (Int) -> Void) {
115 | dispatchQueue.async {
116 | // ... do some work, to compute a value
117 | let value: Int = 1337 // for the sake of example.
118 | completionHandler(value)
119 | }
120 | }
121 | ```
122 |
123 | and then turn our attention to to implementing `FakeDispatchQueue`.
124 |
125 | Following the example from , we will implement
126 | `FakeDispatchQueue` using only `Spy`:
127 |
128 | ```swift
129 | final class FakeDispatchQueue: DispatchQueueProtocol {
130 | let asyncSpy = Spy<@Sendable () -> Void, Void>()
131 | func async(execute work: @escaping @Sendable () -> Void) {
132 | asyncSpy(work)
133 | }
134 | }
135 | ```
136 |
137 | This will work similarly to when we used a Spy as the completion handler to
138 | `expensiveComputation(completionHandler:)` earlier.
139 |
140 | Now we use `FakeDispatchQueue` in our tests for `expensiveComputation`:
141 |
142 | ```swift
143 | final class ExpensiveComputationTests: XCTestCase {
144 | func testCallsTheCallback() throws {
145 | let dispatchQueue = FakeDispatchQueue()
146 | let completionHandler = Spy()
147 |
148 | expensiveComputation(
149 | dispatchQueue: dispatchQueue,
150 | completionHandler: completionHandler.callAsFunction
151 | )
152 |
153 | try require(dispatchQueue.asyncSpy).to(beCalled())
154 |
155 | dispatchQueue.asyncSpy.calls.last?() // call the recorded completion handler.
156 |
157 | expect(completionHandler).to(beCalled(1337))
158 | }
159 | }
160 | ```
161 |
162 | This has quite a bit more boilerplate than simply using
163 | `expect(completionHandler).toEventually(beCalled(...))`. However, the benefit of
164 | this boilerplate is that this test runs significantly faster. We no longer have
165 | to wait for the system to decide to run the closure that calls the completion
166 | handler, which results in a small reduction in test runtime.
167 |
168 | Additionally, we can also control when this closure is run, which allows us
169 | to verify any other behavior that might be happening before the code is
170 | run in the background, or while code is run in the background.
171 |
172 | ### Improving FakeDispatchQueue
173 |
174 | The current implementation of `FakeDispatchQueue` still leaves a lot to be
175 | desired. Yes, it works, but it's clunky. Having to first require that `async`
176 | was called, then call the last call recorded is a lot of typing. Being able to
177 | control when closures run is great, but having to go through ``Spy`` makes it
178 | really easy to either run the same closure twice, or not run one that should
179 | have run.
180 |
181 | To address this, we'll flesh out `FakeDispatchQueue`
182 | some more, adding some logic to record closures, call them at a later time, or
183 | run the closure when `async(execute:)` is called.
184 |
185 | ```swift
186 | import Foundation
187 |
188 | final class FakeDispatchQueue: DispatchQueueProtocol, @unchecked Sendable {
189 | private let lock = NSRecursiveLock()
190 | private var calls = [@Sendable () -> Void]()
191 |
192 | func pendingTaskCount() -> Int {
193 | lock.withLock { calls.count }
194 | }
195 |
196 | func runNextTask() {
197 | lock.withLock {
198 | guard !calls.isEmpty else { return }
199 | calls.removeFirst()()
200 | }
201 | }
202 |
203 | func async(execute work: @escaping @Sendable () -> Void) {
204 | lock.withLock {
205 | guard _runSynchronously == false else {
206 | return work()
207 | }
208 | calls.append(work)
209 | }
210 | }
211 | }
212 | ```
213 |
214 | Before we can use this new `FakeDispatchQueue`, it's very important to realize
215 | that, because it now has logic in it, we have to [write tests for that logic](https://blog.rachelbrindle.com/2023/06/25/testing-test-helpers/).
216 | Otherwise, we have no idea if a test failure is caused by something
217 | in the production code, or something in `FakeDispatchQueue`.
218 |
219 | Some tests that verify the entire API for `FakeDispatchQueue` look like this:
220 |
221 | ```swift
222 | final class FakeDispatchQueueTests: XCTestCase {
223 | func testRunNextTaskWithoutPendingTasksDoesntBlowUp() {
224 | let subject = FakeDispatchQueue()
225 |
226 | expect { subject.runNextTask() }.toNot(throwAssertion())
227 | }
228 |
229 | func testRunNextTaskWithPendingTasksCallsTheCallback() {
230 | let subject = FakeDispatchQueue()
231 | let spy = Spy()
232 |
233 | subject.async { spy.call() }
234 |
235 | expect(spy).toNot(beCalled())
236 |
237 | subject.runNextTask()
238 |
239 | expect(spy).to(beCalled())
240 | }
241 |
242 | func testPendingTaskCountReturnsNumberOfUnRunTasks() {
243 | let subject = FakeDispatchQueue()
244 | let spy = Spy()
245 |
246 | expect(subject.pendingTaskCount).to(equal(0))
247 |
248 | subject.async { spy.call() }
249 |
250 | expect(subject.pendingTaskCount).to(equal(1))
251 |
252 | subject.async { spy.call() }
253 |
254 | expect(subject.pendingTaskCount).to(equal(2))
255 |
256 | subject.runNextTask()
257 |
258 | expect(subject.pendingTaskCount).to(equal(1))
259 |
260 | subject.runNextTask()
261 |
262 | expect(subject.pendingTaskCount).to(equal(0))
263 | }
264 |
265 | func testPendingTasksAreRunInTheOrderReceived() {
266 | let subject = FakeDispatchQueue()
267 | let spy = Spy()
268 |
269 | subject.async { spy.call(1) }
270 | subject.async { spy.call(2) }
271 |
272 | subject.runNextTask()
273 | subject.runNextTask()
274 |
275 | expect(subject.calls).to(equal([1, 2]))
276 | }
277 | }
278 | ```
279 |
280 | > Important: Any code that has logic in it must have tests driving out that logic.
281 |
282 | These tests do the basic of ensuring that tasks are queued up properly, and
283 | are actually ran in a queue. They make sure that we can correctly assert on
284 | the number of queued up tasks. And, most importantly, they ensure that if
285 | we try to run a task when there aren't any to run, the tests don't crash.
286 |
287 | Now, with `FakeDispatchQueue` implemented and tested, we can use it in
288 | `ExpensiveComputationTests`:
289 |
290 | ```swift
291 | final class ExpensiveComputationTests: XCTestCase {
292 | func testCallsTheCallback() {
293 | let dispatchQueue = FakeDispatchQueue()
294 | let completionHandler = Spy()
295 |
296 | expensiveComputation(
297 | dispatchQueue: dispatchQueue,
298 | completionHandler: completionHandler.callAsFunction
299 | )
300 |
301 | expect(dispatchQueue.pendingTaskCount).to(equal(1))
302 |
303 | dispatchQueue.runNextTask()
304 |
305 | expect(completionHandler).to(beCalled(1337))
306 | }
307 | }
308 | ```
309 |
310 | Which isn't that much different, but it's now significantly easier to understand
311 | what's actually going on. `runNextTask()` is so much easier to read than
312 | `asyncSpy.calls.last?()`, and it has way fewer sharp edges to cut yourself on.
313 |
314 | ### Conclusion
315 |
316 | In this article, we discussed verifying callbacks as well as designing and
317 | testing code using `DispatchQueue` to run code asynchronously. Verify callbacks
318 | by injecting a ``Spy``, and do whatever you can to control when dispatched code
319 | actually runs.
320 |
321 | Swift Concurrency (async/await) brings its own challenges, which will be
322 | addressed in a separate article.
323 |
--------------------------------------------------------------------------------
/Sources/Fakes/Spy/Spy+Nimble.swift:
--------------------------------------------------------------------------------
1 | import Nimble
2 |
3 | // MARK: - Verifying any calls to the Spy.
4 |
5 | /// A Nimble matcher for ``Spy`` that succeeds when any of the calls to the spy matches the given matchers.
6 | ///
7 | /// If no matchers are specified, then this matcher will succeed if the spy has been called at all.
8 | ///
9 | /// - parameter matchers: The matchers to run against the calls to spy to verify it has been called correctly
10 | /// - Note: If the Spy's `Arguments` is Equatable, you can use `BeCalled` with
11 | /// the expected value. This is the same as `beCalled(equal(expectedValue))`.
12 | /// - Note: All matchers for a single call must pass in order for this matcher
13 | /// to pass. Specifying multiple matchers DOES NOT verify multiple calls.
14 | /// Passing in multiple matchers is a shorthand for `beCalled(satisfyAllOf(...))`.
15 | /// - SeeAlso: ``mostRecentlyBeCalled(_:)-9i9t9`` for when you want to check that
16 | /// only the most recent call to the spy matches the matcher.
17 | public func beCalled(
18 | _ matchers: Matcher...
19 | ) -> Matcher> {
20 | let rawMessage: String
21 | if matchers.isEmpty {
22 | rawMessage = "be called"
23 | } else {
24 | rawMessage = "be called with \(matchers.count) matchers"
25 | }
26 | return _beCalled(rawMessage: rawMessage) { validatorArgs in
27 | if matchers.isEmpty {
28 | return MatcherResult(
29 | bool: validatorArgs.spy.calls.isEmpty == false,
30 | message: validatorArgs.message)
31 | }
32 | for call in validatorArgs.spy.calls {
33 | let matcherExpression = Expression(
34 | expression: { call },
35 | location: validatorArgs.expression.location
36 | )
37 | let results = try matchers.map {
38 | try $0.satisfies(matcherExpression)
39 | }
40 | if results.allSatisfy({ $0.toBoolean(expectation: .toMatch) }) {
41 | return MatcherResult(
42 | bool: true,
43 | message: validatorArgs.message.appended(
44 | details: results.map {
45 | $0.message.toString(
46 | actual: stringify(validatorArgs.spy)
47 | )
48 | }.joined(separator: "\n")
49 | )
50 | )
51 | }
52 | }
53 | return MatcherResult(bool: false, message: validatorArgs.message)
54 | }
55 | }
56 |
57 | /// A nimble matcher for ``Spy`` that succeeds when the spy has been called
58 | /// `times` times, and it has been called at least once with arguments that match
59 | /// the matcher.
60 | ///
61 | /// If no matchers are given, then this matcher will succeed if the spy has
62 | /// been called exactly the number of times specified.
63 | ///
64 | /// For example, if your spy has been called a total of 4 times, and at least one of those times matching
65 | /// whatever your matcher is, then `beCalled(..., times: 4)` will match.
66 | /// However, `beCalled(..., times: 3)` will not match, because the matcher has been called 4 times.
67 | ///
68 | /// Alternatively, if your spy has been called a total of 4 times, and you pass in 0 matchers, then
69 | /// `beCalled(times: 4)` will match, regardless of what those calls are.
70 | ///
71 | /// - parameter matchers: The matchers used to search for matching calls.
72 | /// - parameter times: The expected amount of calls the Spy should have been called.
73 | ///
74 | /// - Note: All matchers for a single call must pass in order for this matcher
75 | /// to pass. Specifying multiple matchers DOES NOT verify multiple calls.
76 | /// Passing in multiple matchers is a shorthand for `beCalled(satisfyAllOf(...))`.
77 | public func beCalled(
78 | _ matchers: Matcher...,
79 | times: Int
80 | ) -> Matcher> {
81 | let rawMessage: String
82 | if matchers.isEmpty {
83 | rawMessage = "be called \(times) times"
84 | } else {
85 | rawMessage = "be called \(times) times, at least one of them matches \(matchers.count) matchers"
86 | }
87 | return _beCalled(rawMessage: rawMessage) { validatorArgs in
88 | let calls = validatorArgs.spy.calls
89 |
90 | if calls.count != times {
91 | return MatcherResult(
92 | bool: false,
93 | message: validatorArgs.message.appended(
94 | details: "but was called \(calls.count) times"
95 | )
96 | )
97 | }
98 |
99 | if matchers.isEmpty {
100 | return MatcherResult(bool: true, message: validatorArgs.message)
101 | }
102 | for call in calls {
103 | let matcherExpression = Expression(
104 | expression: { call },
105 | location: validatorArgs.expression.location
106 | )
107 | let results = try matchers.map {
108 | try $0.satisfies(matcherExpression)
109 | }
110 | if results.allSatisfy({ $0.toBoolean(expectation: .toMatch) }) {
111 | return MatcherResult(
112 | bool: true,
113 | message: validatorArgs.message.appended(
114 | details: results.map {
115 | $0.message.toString(
116 | actual: stringify(validatorArgs.spy)
117 | )
118 | }.joined(separator: "\n")
119 | )
120 | )
121 | }
122 | }
123 | return MatcherResult(bool: false, message: validatorArgs.message)
124 | }
125 | }
126 |
127 | /// A Nimble matcher for ``Spy`` that succeeds when any of the calls to the spy are equal to the given value.
128 | ///
129 | /// - parameter value: The expected value of any of the calls to the `Spy`.
130 | /// - SeeAlso: ``beCalled(_:)-82qlg``
131 | public func beCalled(
132 | _ value: Arguments
133 | ) -> Matcher> where Arguments: Equatable {
134 | let rawMessage = "be called with \(stringify(value))"
135 | return _beCalled(rawMessage: rawMessage) { validatorArgs in
136 | return MatcherResult(
137 | bool: validatorArgs.spy.calls.contains(value),
138 | message: validatorArgs.message
139 | )
140 | }
141 | }
142 |
143 | /// A Nimble matcher for ``Spy`` that succeeds when the spy has been called `times` times,
144 | /// and at least one of those calls is equal to the given value.
145 | ///
146 | /// This is a shorthand for `satisfyAllOf(beCalled(times: times), beCalled(value))`
147 | ///
148 | /// - parameter value: The expected value of any of the calls to the `Spy`.
149 | /// - parameter times: The expected amount of calls the Spy should have been called.
150 | /// - SeeAlso: ``beCalled(_:times:)-6125c``
151 | public func beCalled(_ value: Arguments, times: Int) -> Matcher> where Arguments: Equatable {
152 | let rawMessage = "be called \(times) times, at least one of them is \(stringify(value))"
153 | return _beCalled(rawMessage: rawMessage) { validatorArgs in
154 | let calls = validatorArgs.spy.calls
155 | if calls.count != times {
156 | return MatcherResult(
157 | bool: false,
158 | message: validatorArgs.message.appended(
159 | details: "but was called \(calls.count) times"
160 | )
161 | )
162 | }
163 | return MatcherResult(
164 | bool: calls.contains(value),
165 | message: validatorArgs.message
166 | )
167 | }
168 | }
169 |
170 | // MARK: - Verifying the last call to the Spy
171 |
172 | /// A Nimble matcher for ``Spy`` that succeeds when the most recent call to the spy matches the given matchers.
173 | ///
174 | /// - Note: This matcher will fail if no matchers have been passed.
175 | /// - SeeAlso: ``beCalled(_:)-82qlg`` for when you want to check if any of the calls to the spy match the matcher.
176 | /// - SeeAlso: ``mostRecentlyBeCalled(_:)-91ves`` as a shorthand when Arguments is equatable, and you want to check if it's equal to some value.
177 | public func mostRecentlyBeCalled(_ matchers: Matcher...) -> Matcher> {
178 | let rawMessage = "most recently be called with \(matchers.count) matchers"
179 | return _mostRecentlyBeCalled(rawMessage: rawMessage) { validatorArgs in
180 | guard matchers.isEmpty == false else {
181 | return MatcherResult(status: .fail, message: validatorArgs.message.appended(
182 | message: "Error: No matchers were specified. " +
183 | "Use `beCalled()` to check if the spy has been called at all."
184 | ))
185 | }
186 |
187 | let matcherExpression = Expression(
188 | expression: { validatorArgs.lastCall },
189 | location: validatorArgs.expression.location
190 | )
191 | let results = try matchers.map {
192 | try $0.satisfies(matcherExpression)
193 | }
194 | if results.allSatisfy({ $0.toBoolean(expectation: .toMatch) }) {
195 | }
196 | return MatcherResult(
197 | bool: results.allSatisfy { $0.toBoolean(expectation: .toMatch) },
198 | message: validatorArgs.message.appended(details: results.map {
199 | $0.message.toString(
200 | actual: stringify(validatorArgs.spy)
201 | )
202 | }.joined(separator: "\n"))
203 | )
204 | }
205 | }
206 |
207 | /// A Nimble matcher for ``Spy`` that succeeds when the most recent call to the spy is equal to the expected value.
208 | ///
209 | /// - SeeAlso: ``beCalled(_:)-7sn1o`` for when you want to check if any of the calls to the spy are equal to the expected value
210 | /// - SeeAlso: ``mostRecentlyBeCalled(_:)-9i9t9`` when you want to use a Matcher to check if the most recent call matches.
211 | public func mostRecentlyBeCalled(
212 | _ value: Arguments
213 | ) -> Matcher> where Arguments: Equatable {
214 | let rawMessage = "most recently be called with \(stringify(value))"
215 | return _mostRecentlyBeCalled(rawMessage: rawMessage) { validatorArgs in
216 | return MatcherResult(
217 | bool: validatorArgs.lastCall == value,
218 | message: validatorArgs.message.appended(
219 | message: "but got \(stringify(validatorArgs.lastCall))"
220 | )
221 | )
222 | }
223 | }
224 |
225 | extension Spy: CustomDebugStringConvertible {
226 | public var debugDescription: String {
227 | return "Spy<\(stringify(Arguments.self)), \(stringify(Returning.self))>, calls: \(stringify(calls))"
228 | }
229 | }
230 |
231 | // MARK: - Private
232 |
233 | private struct BeCalledValidatorArgs {
234 | let spy: Spy
235 | let expression: Expression>
236 | let message: ExpectationMessage
237 |
238 | init(
239 | _ spy: Spy,
240 | _ expression: Expression>,
241 | _ message: ExpectationMessage
242 | ) {
243 | self.spy = spy
244 | self.expression = expression
245 | self.message = message
246 | }
247 | }
248 |
249 | private func _beCalled(
250 | rawMessage: String,
251 | validator: @escaping (BeCalledValidatorArgs) throws -> MatcherResult
252 | ) -> Matcher> {
253 | return Matcher.define(rawMessage) { expression, message in
254 | guard let spy = try expression.evaluate() else {
255 | return MatcherResult(status: .fail, message: message.appendedBeNilHint())
256 | }
257 |
258 | return try validator(BeCalledValidatorArgs(spy, expression, message))
259 | }
260 | }
261 |
262 | private struct MostRecentlyBeCalledValidatorArgs {
263 | let lastCall: Arguments
264 | let spy: Spy
265 | let expression: Expression>
266 | let message: ExpectationMessage
267 |
268 | init(
269 | _ lastCall: Arguments,
270 | _ spy: Spy,
271 | _ expression: Expression>,
272 | _ message: ExpectationMessage
273 | ) {
274 | self.lastCall = lastCall
275 | self.spy = spy
276 | self.expression = expression
277 | self.message = message
278 | }
279 | }
280 |
281 | private func _mostRecentlyBeCalled(
282 | rawMessage: String,
283 | validator: @escaping (
284 | MostRecentlyBeCalledValidatorArgs
285 | ) throws -> MatcherResult
286 | ) -> Matcher> {
287 | return _beCalled(rawMessage: rawMessage) { beCalledValidatorArgs in
288 | guard let lastCall = beCalledValidatorArgs.spy.calls.last else {
289 | return MatcherResult(
290 | status: .fail,
291 | message: beCalledValidatorArgs.message.appended(
292 | message: "but spy was never called."
293 | )
294 | )
295 | }
296 | return try validator(
297 | MostRecentlyBeCalledValidatorArgs(
298 | lastCall,
299 | beCalledValidatorArgs.spy,
300 | beCalledValidatorArgs.expression,
301 | beCalledValidatorArgs.message
302 | )
303 | )
304 | }
305 | }
306 |
--------------------------------------------------------------------------------
/Sources/Fakes/Fakes.docc/Guides/DependencyInjection.md:
--------------------------------------------------------------------------------
1 | # Dependency Injection
2 |
3 | Providing dependencies instead of reaching out to them.
4 |
5 | ## Contents
6 |
7 | **Dependency Injection** means to provide the dependencies for an object, instead
8 | of the object _a priori_ knowing how to either reach them, or how to make them
9 | itself. Dependency injection is a fundamental design pattern for enabling and
10 | improving not just testability of an object, but also the reliability of the
11 | entire system.
12 |
13 | An object can have 2 types of dependencies: Implicit and Explicit. Explicit
14 | dependencies are anything passed in or otherwise given to the object. Implicit
15 | dependencies, thus, are anything the object itself knows how to call or make.
16 | Even putting aside testability, implicit dependencies inherently make your
17 | system's dependency graph harder to read. That said, not all implicit
18 | dependencies are bad; it would be absurd to inject the `+` operator just for
19 | testability.
20 |
21 | ## Testing greetingForTimeOfDay()
22 |
23 | For example, consider a function that uses the current time in order to greet
24 | the user with one of either "Good morning", "Good afternoon", or "Good evening".
25 | Without using dependency injection, you might write the code as:
26 |
27 | ```swift
28 | func greetingForTimeOfDay() -> String {
29 | let hour = Calendar.current.component(.hour, from: Date())
30 | switch hour {
31 | case 0..<12: return "Good morning"
32 | case 12..<18: return "Good afternoon"
33 | default: return "Good evening"
34 | }
35 | }
36 | ```
37 |
38 | Which, sure, works, but is impossible to reliably test. The result of
39 | `greetingForTimeOfDay`, by definition, depends on the current time of
40 | day. Which means that you have three choices for testing this as-written:
41 |
42 | 1. Don't test it.
43 | 2. Write a weaker test, just checking that the output is one of the three choices:
44 | That is: `XCTAssert(["Good morning", "Good afternoon", "Good evening"].contains(greetingForTimeOfDay()))`.
45 | This is not only harder to read, but you're bundling up the three behaviors of
46 | `greetingForTimeOfDay` and making the tests less clear than asserting on the
47 | exact output.
48 | 3. Add logic to your tests, wherein you basically end up re-implementing
49 | `greetingForTimeOfDay` in your tests.
50 | That is, writing your test like:
51 |
52 | ```swift
53 | func testGreetingForTimeOfDay() {
54 | let greeting = greetingForCurrentTimeOfDay()
55 |
56 | let hour = Calendar.current.component(.hour, from: Date())
57 | switch hour {
58 | case 0..<12: XCTAssertEqual(greeting, "Good morning")
59 | case 12..<18: XCTAssertEqual(greeting, "Good afternoon")
60 | default: XCTAssertEqual(greeting, "Good evening")
61 | }
62 | }
63 | ```
64 |
65 | This option increases coupling, which is not what we want. The more the test
66 | knows about the internals of its subject, the more tightly coupled, the less
67 | valuable, and the harder to maintain that test will be.
68 |
69 | Additionally, in this option, we end up creating a tautology, which is a
70 | particularly useless test, as it basically ends up boiling down to "the code
71 | works because it works". The most you can say about a tautology is that at least
72 | it doesn't blow up. There are better ways to express that desire, such as using
73 | one of [Nimble's](https://github.com/quick/nimble/)
74 | [assertion-checking](https://quick.github.io/Nimble/documentation/nimble/swiftassertions),
75 | [error-checking](https://quick.github.io/Nimble/documentation/nimble/swifterrors/),
76 | or [exception-checking](https://quick.github.io/Nimble/documentation/nimble/exceptions)
77 | matchers.
78 |
79 | > Note: A Tautological Test is a test that is a mirror of the production code.
80 | In this example, this is fairly easy to identify, but they can get quite tricky
81 | as we introduce fakes. See [this post from Fabio Pereira](https://www.fabiopereira.me/blog/2010/05/27/ttdd-tautological-test-driven-development-anti-pattern/).
82 |
83 | Neither of these three options are examples of strong, reliable, easy-to-read
84 | tests. Instead, to be able to write better tests, we must improve the testability
85 | of `greetingForTimeOfDay` by injecting the dependencies.
86 |
87 | `greetingForTimeOfDay` has 2 implicit dependencies: The `Calendar` object,
88 | and the current `Date`. In this particular case, we could inject either one and
89 | be able to massively improve the testability of the function. For the sake of
90 | teaching, let's cover both.
91 |
92 | ### Injecting a DateProvider
93 |
94 | First, let's look at injecting a way to get the current `Date`, instead of
95 | making a new `Date` object that Foundation helpfully assigns to the current
96 | `Date`. We could do this in one of three ways:
97 |
98 | 1. Directly pass in a `Date` object.
99 | 2. Inject a protocol that has a single method which returns a `Date` object.
100 | 3. Inject a closure that returns a `Date` object.
101 |
102 | The first is just kicking the issue of getting the date up the stack, and thus
103 | not something we should do.
104 | The second option is a case of the
105 | [Humble Object](https://martinfowler.com/bliki/HumbleObject.html) pattern, and
106 | is an excellent way to handle this.
107 | Option 3 is only really suitable for very simple cases, and arguably not even
108 | in that case. You should know it's an option, but for the sake of consistency,
109 | you should prefer creating a protocol.
110 |
111 | With that in mind, let's introduce `DateProvider`, a single-method protocol,
112 | which is only responsible for providing a date. Alongside it, for production
113 | use, is the `CurrentDateProvider`.
114 |
115 | ```swift
116 | protocol DateProvider {
117 | func provide() -> Date
118 | }
119 |
120 | struct CurrentDateProvider: DateProvider {
121 | func provide() -> Date { Date() }
122 | }
123 | ```
124 |
125 | > Tip: If you haven't watched the amazing WWDC 2015 talk, [Protocol Oriented
126 | Programming in Swift](https://www.youtube.com/watch?v=p3zo4ptMBiQ), be sure to
127 | take the time to do so!
128 |
129 | Now, we can refactor our `greetingForTimeOfDay` function to take in the
130 | `DateProvider`:
131 |
132 | ```swift
133 | func greetingForTimeOfDay(dateProvider: DateProvider) -> String {
134 | let hour = Calendar.current.component(.hour, from: dateProvider.provide())
135 | switch hour {
136 | case 0..<12: return "Good morning"
137 | case 12..<18: return "Good afternoon"
138 | default: return "Good evening"
139 | }
140 | }
141 | ```
142 |
143 | Next, we have to create a `FakeDateProvider`, which uses a `Spy` to
144 | record arguments to `provide()`, and return a pre-stubbed (that is, pre-set),
145 | date:
146 |
147 | ```swift
148 | final class FakeDateProvider: DateProvider {
149 | let provideSpy = Spy(Date(timeIntervalSince1970: 0))
150 | func provide() {
151 | provideSpy()
152 | }
153 | }
154 | ```
155 |
156 | Now we have all the components necessary to write our our tests for the 3
157 | potential states. Like so:
158 |
159 | ```swift
160 | final class GreetingForTimeOfDayTests: XCTestCase {
161 | var dateProvider: FakeDateProvider!
162 | override func setUp() {
163 | super.setUp()
164 | dateProvider = FakeDateProvider()
165 | }
166 |
167 | func testMorning() {
168 | // Arrange
169 | dateProvider.provideSpy.stub(Date(timeIntervalSince1970: 3600)) // Jan 1st, 1970 at ~1 am.
170 |
171 | // Act
172 | let greeting = greetingForTimeOfDay(dateProvider: dateProvider)
173 |
174 | // Assert
175 | XCTAssertEqual(greeting, "Good morning")
176 | }
177 |
178 | func testAfternoon() {
179 | // Arrange
180 | dateProvider.provideSpy.stub(Date(timeIntervalSince1970: 3600 * 12)) // Jan 1st, 1970 at ~noon am.
181 |
182 | // Act
183 | let greeting = greetingForTimeOfDay(dateProvider: dateProvider)
184 |
185 | // Assert
186 | XCTAssertEqual(greeting, "Good afternoon")
187 | }
188 |
189 | func testEvening() {
190 | // Arrange
191 | dateProvider.provideSpy.stub(Date(timeIntervalSince1970: 3600 * 18)) // Jan 1st, 1970 at ~6 pm.
192 |
193 | // Act
194 | let greeting = greetingForTimeOfDay(dateProvider: dateProvider)
195 |
196 | // Assert
197 | XCTAssertEqual(greeting, "Good evening")
198 | }
199 | }
200 | ```
201 |
202 | > Note: Annoyed at the setup and act repetition? Check out
203 | [Quick](https://github.com/Quick/Quick/)! It provides a simple DSL enabling
204 | powerful test simplifications.
205 |
206 | This is significantly easier to understand the three behaviors that
207 | `greetingForTimeOfDay` has. However, it still requires us to carefully construct
208 | the Date objects that are then passed into the `Calendar`. So, let's look at
209 | injecting the `Calendar`.
210 |
211 | ### Injecting `Calendar`
212 |
213 | Like with providing `Date`, we have 3 options for injecting the
214 | `component(_:from:)` method. However, this case is definitely complex enough
215 | that there's really only one approach: wrapping `component(_:from:)` in a
216 | protocol.
217 |
218 | Let's do this by creating a protocol to wrap `Calendar`. For this case, we only
219 | need a single method in that protocol, but you can imagine this protocol might
220 | grow with time (or not, as per the [Interface Segregation Principle](https://en.wikipedia.org/wiki/Interface_segregation_principle)).
221 |
222 | ```swift
223 | protocol CalendarProtocol {
224 | func component(
225 | _ component: Calendar.Component,
226 | from date: Date
227 | ) -> Int
228 | }
229 |
230 | extension Calendar: CalendarProtocol {}
231 | ```
232 |
233 | Because `component(_:from:)` is already an existing method on `Calendar`,
234 | conforming `Calendar` to `CalendarProtocol` requires no additional methods.
235 |
236 | Next, we have to inject our `CalendarProtocol` into `greetingForTimeOfDay`:
237 |
238 | ```swift
239 | func greetingForTimeOfDay(calendar: CalendarProtocol, dateProvider: DateProvider) -> String {
240 | let hour = calendar.component(.hour, from: dateProvider())
241 | switch hour {
242 | case 0..<12: return "Good morning"
243 | case 12..<18: return "Good afternoon"
244 | default: return "Good evening"
245 | }
246 | }
247 | ```
248 |
249 | Which, again, is a fairly straightforward thing to do.
250 |
251 | Now, let's create a Fake implementation of `CalendarProtocol`. For more details
252 | on creating fakes, please check out the tutorial .
253 |
254 | ```swift
255 | final class FakeCalendar: CalendarProtocol {
256 | let componentSpy = Spy<(component: Calendar.Component, date: Date), Int>(0)
257 | func component(
258 | _ component: Calendar.Component,
259 | from date: Date
260 | ) -> Int {
261 | componentSpy((component, date))
262 | }
263 | }
264 | ```
265 |
266 | Finally, we can update our tests to use `FakeCalendar`.
267 |
268 | ```swift
269 | final class GreetingForTimeOfDayTests: XCTestCase {
270 | var dateProvider: FakeDateProvider!
271 | var calendar: FakeCalendar!
272 | override func setUp() {
273 | super.setUp()
274 | dateProvider = FakeDateProvider()
275 | calendar = FakeCalendar()
276 | }
277 |
278 | func testMorning() {
279 | // Arrange
280 | calendar.componentsSpy.stub(0)
281 |
282 | // Act
283 | let greeting = greetingForTimeOfDay(
284 | calendar: calendar,
285 | dateProvider: dateProvider
286 | )
287 |
288 | // Assert
289 | XCTAssertEqual(greeting, "Good morning")
290 | }
291 |
292 | func testAfternoon() {
293 | // Arrange
294 | calendar.componentsSpy.stub(12)
295 |
296 | // Act
297 | let greeting = greetingForTimeOfDay(
298 | calendar: calendar,
299 | dateProvider: dateProvider
300 | )
301 |
302 | // Assert
303 | XCTAssertEqual(greeting, "Good afternoon")
304 | }
305 |
306 | func testEvening() {
307 | // Arrange
308 | calendar.componentsSpy.stub(18)
309 |
310 | // Act
311 | let greeting = greetingForTimeOfDay(
312 | calendar: calendar,
313 | dateProvider: dateProvider
314 | )
315 |
316 | // Assert
317 | XCTAssertEqual(greeting, "Good evening")
318 | }
319 | }
320 | ```
321 |
322 | This is significantly easier to read. By injecting our `FakeCalendar`, we were
323 | able to make `greetingForTimeOfDay` significantly easier to test, as well as
324 | drastically improving the readability and reliability of the tests.
325 |
326 | You might notice that these tests never verified that `FakeCalendar` or
327 | `dateProvider` were called. That's because it's entirely unnecessary to do so in
328 | this case. Generally, you only need to verify those as either part of
329 | scaffolding tests (tests which are only necessary while writing out the
330 | component), or when they have some kind of side effect in addition to their
331 | return value. For example, making a network call by definition has side
332 | effects (in addition to returning the data/throwing an error). So you would want
333 | to verify that the ``Spy`` for the network call has recorded a call.
334 |
335 | ### Dependency Injection for Types
336 |
337 | If `greetingForTimeOfDay` were wrapped in a type, you should inject
338 | `CalendarProtocol` and `DateProvider` as arguments to the types initializer.
339 | That way, callers of `greetingForTimeOfDay` only need the wrapping type, and not
340 | the `CalendarProtocol` and `DateProvider`. Like so:
341 |
342 | ```swift
343 | struct Greeter {
344 | let calendar: CalendarProtocol
345 | let dateProvider: DateProvider
346 |
347 | // This would normally be auto-generated, but is included here for
348 | // illustrative purposes.
349 | init(calendar: CalendarProtocol, dateProvider: DateProvider) {
350 | self.calendar = calendar
351 | self.dateProvider = dateProvider
352 | }
353 |
354 | func greetingForTimeOfDay() -> String {
355 | let hour = calendar.component(.hour, from: dateProvider())
356 | switch hour {
357 | case 0..<12: return "Good morning"
358 | case 12..<18: return "Good afternoon"
359 | default: return "Good evening"
360 | }
361 | }
362 | }
363 | ```
364 |
365 | > Note: By the way, did you notice the other three potential issues here? That's
366 | right, different people and cultures might define morning, afternoon, and
367 | evening differently.
368 |
--------------------------------------------------------------------------------