Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Swift Testing Implementation #6

Merged
merged 12 commits into from
Nov 22, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@ let package = Package(
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "TestableCombinePublishers",
targets: ["TestableCombinePublishers"]),
targets: ["TestableCombinePublishers"]
),
.library(
name: "SwiftTestingTestableCombinePublishers",
targets: ["SwiftTestingTestableCombinePublishers"]
),
.library(
name: "TestableCombinePublishersUtility",
targets: ["TestableCombinePublishersUtility"]
),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
Expand All @@ -21,12 +30,38 @@ let package = Package(
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "TestableCombinePublishers",
dependencies: [],
dependencies: ["TestableCombinePublishersUtility"],
path: "Sources/TestableCombinePublishers",
linkerSettings: [
.linkedFramework("XCTest")
]),
.target(
name: "SwiftTestingTestableCombinePublishers",
dependencies: ["TestableCombinePublishersUtility"],
path: "Sources/SwiftTestingTestableCombinePublishers",
linkerSettings: [
.linkedFramework("Testing")
]
),
.target(
name: "TestableCombinePublishersUtility",
dependencies: [],
path: "Sources/TestableCombinePublishersUtility"
),
.testTarget(
name: "TestableCombinePublishersTests",
dependencies: ["TestableCombinePublishers"]),
dependencies: ["TestableCombinePublishers"],
path: "Tests/TestableCombinePublishersTests"
),
.testTarget(
name: "SwiftTestingTestableCombinePublishersTests",
dependencies: ["SwiftTestingTestableCombinePublishers"],
path: "Tests/SwiftTestingTestableCombinePublishersTests"
),
.testTarget(
name: "TestableCombinePublishersUtilityTests",
dependencies: ["TestableCombinePublishersUtility"],
path: "Tests/TestableCombinePublishersUtilityTests"
),
]
)
108 changes: 97 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@
# Testable Combine Publishers

An easy, declarative way to unit test Combine Publishers in Swift
An easy, declarative way to unit test Combine Publishers in Swift. Available for both XCTest and Swift Testing.

![Example Combine Unit Test](example.png)
**XCTest**:
![Example Combine Unit Test - XCTest](example_xctest.png)
ethan-vanheerden marked this conversation as resolved.
Show resolved Hide resolved

**Swift Testing**:
![Example Combine Unit Test - Swift Testing](example_swift_testing.png)
ethan-vanheerden marked this conversation as resolved.
Show resolved Hide resolved

## About

Combine Publishers are [notoriously verbose to unit test](https://mokacoding.com/blog/testing-combine-publisher-cheatsheet/#how-to-test-publisher-publishes-one-value-then-finishes). They require you to write complex Combine chains in Swift for each test, keeping track of `AnyCancellable`s, and interweaving `XCTestExpectation`s, fulfillment requirements, and timeouts.

This Swift Package aims to simplify writing unit tests for Combine `Publisher`s by providing a natural spelling of `.expect(...)` for chaining expectations on the `Publisher` subject. The resulting `PublisherExpectation` type collects the various expectations and then provides a way to assert that the expectations are fulfilled by calling `.waitForExpectations(timeout: 1)`
This Swift Package aims to simplify writing unit tests for Combine `Publisher`s by providing a natural spelling of `.expect(...)` for chaining expectations on the `Publisher` subject. The resulting `PublisherExpectation` or `SwiftTestingPublisherExpectation` type collects the various expectations and then provides a way to assert that the expectations are fulfilled by calling `.waitForExpectations(timeout: 1)`

Under the hood, `PublisherExpectation` is utilizing standard `XCTest` framework APIs and forwarding those assertion results to the corresponding lines of code that declared the expectation. This allows you to quickly see which specific expectation, in a chain of expectations, is failing in your unit tests, both in Xcode and in the console output.
Under the hood, `PublisherExpectation` is utilizing standard XCTest/Swift Testing framework APIs and forwarding those assertion results to the corresponding lines of code that declared the expectation. This allows you to quickly see which specific expectation, in a chain of expectations, is failing in your unit tests, both in Xcode and in the console output.

## Usage

In an `XCTestCase`, add a new unit test function, as normal, preparing the `Publisher` test subject to be tested. Utilize any combination of the examples below to validate the behavior of any `Publisher` in your unit tests.
In an `XCTestCase` or a Swift Testing suite, add a new unit test function, as normal, preparing the `Publisher` test subject to be tested. Utilize any combination of the examples below to validate the behavior of any `Publisher` in your unit tests.

### Examples

For a `Publisher` that is expected to emit a single value and complete with `.finished`

**XCTest**:
```swift
func testSingleValueCompletingPublisher() {
somePublisher
Expand All @@ -28,7 +34,19 @@ func testSingleValueCompletingPublisher() {
}
```

**Swift Testing**:
```swift
@Test func singleValueCompletingPublisher() async {
await somePublisher
.expect(someEquatableValue)
.expectSuccess()
.waitForExpectations(timeout: 1)
}
```

For a `Publisher` that is expected to emit multiple values, but is expected to not complete

**XCTest**:
```swift
func testMultipleValuePersistentPublisher() {
somePublisher
Expand All @@ -39,7 +57,20 @@ func testMultipleValuePersistentPublisher() {
}
```

**Swift Testing**:
```swift
@Test func multipleValuePersistentPublisher() async {
await somePublisher
.collect(someCount)
.expect(someEquatableValueArray)
.expectNoCompletion()
.waitForExpectations(timeout: 1)
}
```

For a `Publisher` that is expected to fail

**XCTest**:
```swift
func testPublisherFailure() {
somePublisher
Expand All @@ -48,7 +79,18 @@ func testPublisherFailure() {
}
```

**Swift Testing**:
```swift
@Test func publisherFailure() async {
await somePublisher
.expectFailure()
.waitForExpectations(timeout: 1)
}
```

For a `Publisher` that is expected to emit a value after being acted upon externally

**XCTest**:
```swift
func testLoadablePublisher() {
let test = someDataSource.publisher
Expand All @@ -58,7 +100,19 @@ func testLoadablePublisher() {
}
```

**Swift Testing**:
```swift
@Test func loadablePublisher() async {
let test = someDataSource.publisher
.expect(someEquatableValue)
someDataSource.load()
await test.waitForExpectations(timeout: 1)
}
```

For a `Publisher` expected to emit a single value whose `Output` is not `Equatable`

**XCTest**:
```swift
func testNonEquatableSingleValue() {
somePublisher
Expand All @@ -71,7 +125,22 @@ func testNonEquatableSingleValue() {
}
```

**Swift Testing**:
```swift
@Test func nonEquatableSingleValue() async {
await somePublisher
.expect({ value in
if case .loaded(let model) = value, !model.rows.isEmpty { } else {
Issue.record("Expected loaded and populated model")
}
})
.waitForExpectations(timeout: 1)
}
```

For a `Publisher` that should emit a specific non-`Equatable` `Error`

**XCTest**:
```swift
func testNonEquatableFailure() {
somePublisher
Expand All @@ -87,13 +156,29 @@ func testNonEquatableFailure() {
}
```

**Swift Testing**:
```swift
@Test func nonEquatableFailure() async {
await somePublisher
.expectFailure({ failure in
switch failure {
case .noInternet, .airPlaneMode:
break
default:
Issue.record("Expected connectivity error")
}
})
.waitForExpectations(timeout: 1)
}
```

## Available Expectations

### Value Expectations

- `expect(_ expected: Output)` - Asserts that the provided `Equatable` value will be emitted by the `Publisher`
- `expectNot(_ expected: Output)` - Asserts that a value will be emitted by the `Publisher` and that it does NOT match the provided `Equatable`
- `expect(_ assertion: (Output) -> Void)` - Invokes the provided assertion closure on every value emitted by the `Publisher`. Useful for calling `XCTAssert` variants where custom evaluation is required
- `expect(_ assertion: (Output) -> Void)` - Invokes the provided assertion closure on every value emitted by the `Publisher`. Useful for calling `XCTAssert`/`#expect` variants where custom evaluation is required

### Success Expectations

Expand All @@ -104,13 +189,13 @@ func testNonEquatableFailure() {
- `expectFailure()` - Asserts that the `Publisher` data stream completes with a failure status (`.failure(Failure)`)
- `expectFailure(_ failure: Failure)` - Asserts that the provided `Equatable` `Failure` type is returned when the `Publisher` completes
- `expectNotFailure(_ failure: Failure)` - Asserts that the `Publisher` completes with a `Failure` type which does NOT match the provided `Equatable` `Failure`
- `expectFailure(_ assertion: (Failure) -> Void)` - Invokes the provided assertion closure on the `Failure` result's associated `Error` value of the `Publisher`. Useful for calling `XCTAssert` variants where custom evaluation is required
- `expectFailure(_ assertion: (Failure) -> Void)` - Invokes the provided assertion closure on the `Failure` result's associated `Error` value of the `Publisher`. Useful for calling `XCTAssert`/`#expect` variants where custom evaluation is required

### Completion Expectations

- `expectCompletion()` - Asserts that the `Publisher` data stream completes, indifferent of the returned success/failure status
- `expectNoCompletion()` - Asserts that the `Publisher` data stream does NOT complete. ⚠️ This will wait for the full timeout in `waitForExpectations(timeout:)`
- `expectCompletion(_ assertion: (Completion<Failure>) -> Void)` - Invokes the provided assertion closure on the `recieveCompletion` handler of the `Publisher`. Useful for calling `XCTAssert` variants where custom evaluation is required
- `expectCompletion(_ assertion: (Completion<Failure>) -> Void)` - Invokes the provided assertion closure on the `recieveCompletion` handler of the `Publisher`. Useful for calling `XCTAssert`/`#expect` variants where custom evaluation is required

## Upcoming Features

Expand Down Expand Up @@ -153,16 +238,17 @@ enum MyCustomType {
extension MyCustomType: AutomaticallyEquatable { /*no-op*/ }
```

Then, you can compare two of `MyCustomType` using `expect(...), `==`, or an XCTest framework equality assertion.
Then, you can compare two of `MyCustomType` using `expect(...)`, `==`, or an XCTest/Swift Testing framework equality assertion.

```swift
somePublisher
.expect(MyCustomType.bar(Baz(answer: 42))
.expect(MyCustomType.bar(Baz(answer: 42)))
.waitForExpectations(timeout: 1)

// or

XCTAssertEqual(output, MyCustomType.bar(Baz(answer: 42))
XCTAssertEqual(output, MyCustomType.bar(Baz(answer: 42)))
ethan-vanheerden marked this conversation as resolved.
Show resolved Hide resolved
#expect(output == MyCustomType.bar(Baz(answer: 42)))
ethan-vanheerden marked this conversation as resolved.
Show resolved Hide resolved

// or

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// SwiftTestingExpectation.swift
// TestableCombinePublishers
//
// Created by Ethan van Heerden on 11/15/24.
//

import Foundation
import Testing

/// The Swift Testing version of an `XCTestExpectation`.
final class SwiftTestingExpectation {
let id: UUID
let description: String
private let expectedFulfillmentCount: Int
let isInverted: Bool
let sourceLocation: SourceLocation
private var actualFulfillmentCount: Int = 0
private let actualFulfillmentCountLock = NSLock()

init(id: UUID = UUID(),
description: String,
expectedFulfillmentCount: Int = 1,
isInverted: Bool = false,
sourceLocation: SourceLocation) {
self.id = id
self.description = description
self.expectedFulfillmentCount = expectedFulfillmentCount
self.isInverted = isInverted
self.sourceLocation = sourceLocation
}

/// Fulfills this expectation by increasing the `actualFulfillmentCount`.
func fulfill() {
actualFulfillmentCountLock.withLock {
actualFulfillmentCount += 1
}
}

/// Determines if this expectation is considered fully fulfilled.
/// An expectation is considered fulfilled when the `actualFulfillmentCount` is greater than or equal to
/// the `expectedFulfillmentCount`
var isFulfilled: Bool {
actualFulfillmentCountLock.withLock {
return actualFulfillmentCount >= expectedFulfillmentCount
}
}
}
Loading