Skip to content

Commit

Permalink
Rename FailingScheduler to UnimplementedScheduler (pointfreeco#54)
Browse files Browse the repository at this point in the history
* Rename `FailingScheduler` to `UnimplementedScheduler`

* wip

* wip

* wip

* wip

* wip
  • Loading branch information
stephencelis authored Jun 28, 2022
1 parent 28e5d15 commit 0ba3a56
Show file tree
Hide file tree
Showing 13 changed files with 588 additions and 435 deletions.
7 changes: 2 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,11 @@ on:

jobs:
library:
runs-on: macOS-11
runs-on: macOS-12
strategy:
matrix:
xcode:
- '11.7'
- '12.4'
- '12.5.1'
- '13.0'
- '13.4.1'
steps:
- uses: actions/checkout@v2
- name: Select Xcode ${{ matrix.xcode }}
Expand Down
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
PLATFORM_IOS = iOS Simulator,name=iPhone 11 Pro Max
PLATFORM_MACOS = macOS
PLATFORM_TVOS = tvOS Simulator,name=Apple TV 4K (at 1080p)
PLATFORM_WATCHOS = watchOS Simulator,name=Apple Watch Series 4 - 44mm
PLATFORM_TVOS = tvOS Simulator,name=Apple TV
PLATFORM_WATCHOS = watchOS Simulator,name=Apple Watch Series 5 - 44mm

test:
swift test --enable-test-discovery
swift test
xcodebuild test \
-scheme combine-schedulers \
-destination platform="$(PLATFORM_IOS)"
Expand Down
4 changes: 2 additions & 2 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
"repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state": {
"branch": null,
"revision": "50a70a9d3583fe228ce672e8923010c8df2deddd",
"version": "0.2.1"
"revision": "f821dcbac7cb6913f8e0d1a80496d0ba0199fa81",
"version": "0.3.0"
}
}
]
Expand Down
8 changes: 4 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version:5.1
// swift-tools-version:5.5

import PackageDescription

Expand All @@ -17,19 +17,19 @@ let package = Package(
)
],
dependencies: [
.package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "0.2.1")
.package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "0.3.0")
],
targets: [
.target(
name: "CombineSchedulers",
dependencies: [
"XCTestDynamicOverlay"
.product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"),
]
),
.testTarget(
name: "CombineSchedulersTests",
dependencies: [
"CombineSchedulers"
"CombineSchedulers",
]
),
]
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ A few schedulers that make working with Combine more testable and more versatile
* [`TestScheduler`](#testscheduler)
* [`ImmediateScheduler`](#immediatescheduler)
* [Animated schedulers](#animated-schedulers)
* [`FailingScheduler`](#failingscheduler)
* [`UnimplementedScheduler`](#unimplementedscheduler)
* [`UIScheduler`](#uischeduler)
* [`Publishers.Timer`](#publisherstimer)
* [Installation](#installation)
Expand Down Expand Up @@ -307,13 +307,13 @@ self.apiClient.fetchEpisode()
.assign(to: &self.$episode)
```

### `FailingScheduler`
### `UnimplementedScheduler`

A scheduler that causes a test to fail if it is used.

This scheduler can provide an additional layer of certainty that a tested code path does not require the use of a scheduler.

As a view model becomes more complex, only some of its logic may require a scheduler. When writing unit tests for any logic that does _not_ require a scheduler, one should provide a failing scheduler, instead. This documents, directly in the test, that the feature does not use a scheduler. If it did, or ever does in the future, the test will fail.
As a view model becomes more complex, only some of its logic may require a scheduler. When writing unit tests for any logic that does _not_ require a scheduler, one should provide an unimplemented scheduler, instead. This documents, directly in the test, that the feature does not use a scheduler. If it did, or ever does in the future, the test will fail.

For example, the following view model has a couple responsibilities:

Expand Down Expand Up @@ -346,13 +346,13 @@ class EpisodeViewModel: ObservableObject {

The API client delivers the episode on a background queue, so the view model must receive it on its main queue before mutating its state.

Tapping the favorite button, however, involves no scheduling. This means that a test can be written with a failing scheduler:
Tapping the favorite button, however, involves no scheduling. This means that a test can be written with an unimplemented scheduler:

```swift
func testFavoriteButton() {
let viewModel = EpisodeViewModel(
apiClient: .mock,
mainQueue: .failing
mainQueue: .unimplemented
)
viewModel.episode = .mock

Expand All @@ -364,7 +364,7 @@ func testFavoriteButton() {
}
```

With `.failing`, this test strongly declares that favoriting an episode does not need a scheduler to do the job, which means it is reasonable to assume that the feature is simple and does not involve any asynchrony.
With `.unimplemented`, this test strongly declares that favoriting an episode does not need a scheduler to do the job, which means it is reasonable to assume that the feature is simple and does not involve any asynchrony.

In the future, should favoriting an episode fire off an API request that involves a scheduler, this test will begin to fail, which is a good thing! This will force us to address the complexity that was introduced. Had we used any other scheduler, it would quietly receive this additional work and the test would continue to pass.

Expand Down
116 changes: 64 additions & 52 deletions Sources/CombineSchedulers/AnyScheduler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,23 @@ import Foundation
/// example, suppose you have a view model `ObservableObject` that performs an API request when a
/// method is called:
///
/// class EpisodeViewModel: ObservableObject {
/// @Published var episode: Episode?
/// ```swift
/// class EpisodeViewModel: ObservableObject {
/// @Published var episode: Episode?
///
/// let apiClient: ApiClient
/// let apiClient: ApiClient
///
/// init(apiClient: ApiClient) {
/// self.apiClient = apiClient
/// }
/// init(apiClient: ApiClient) {
/// self.apiClient = apiClient
/// }
///
/// func reloadButtonTapped() {
/// self.apiClient.fetchEpisode()
/// .receive(on: DispatchQueue.main)
/// .assign(to: &self.$episode)
/// }
/// }
/// func reloadButtonTapped() {
/// self.apiClient.fetchEpisode()
/// .receive(on: DispatchQueue.main)
/// .assign(to: &self.$episode)
/// }
/// }
/// ```
///
/// Notice that we are using `DispatchQueue.main` in the `reloadButtonTapped` method because the
/// `fetchEpisode` endpoint most likely delivers its output on a background thread (as is the case
Expand All @@ -39,23 +41,25 @@ import Foundation
/// with no thread hops. In order to allow for this we would need to inject a scheduler into our
/// view model so that we can control it from the outside:
///
/// class EpisodeViewModel<S: Scheduler>: ObservableObject {
/// @Published var episode: Episode?
/// ```swift
/// class EpisodeViewModel<S: Scheduler>: ObservableObject {
/// @Published var episode: Episode?
///
/// let apiClient: ApiClient
/// let scheduler: S
/// let apiClient: ApiClient
/// let scheduler: S
///
/// init(apiClient: ApiClient, scheduler: S) {
/// self.apiClient = apiClient
/// self.scheduler = scheduler
/// }
/// init(apiClient: ApiClient, scheduler: S) {
/// self.apiClient = apiClient
/// self.scheduler = scheduler
/// }
///
/// func reloadButtonTapped() {
/// self.apiClient.fetchEpisode()
/// .receive(on: self.scheduler)
/// .assign(to: &self.$episode)
/// }
/// }
/// func reloadButtonTapped() {
/// self.apiClient.fetchEpisode()
/// .receive(on: self.scheduler)
/// .assign(to: &self.$episode)
/// }
/// }
/// ```
///
/// Now we can initialize this view model in production by using `DispatchQueue.main` and we can
/// initialize it in tests using `DispatchQueue.immediate`. Sounds like a win!
Expand All @@ -73,46 +77,54 @@ import Foundation
/// Instead of holding a generic scheduler in our view model we can say that we only want a
/// scheduler whose associated types match that of `DispatchQueue`:
///
/// class EpisodeViewModel: ObservableObject {
/// @Published var episode: Episode?
/// ```swift
/// class EpisodeViewModel: ObservableObject {
/// @Published var episode: Episode?
///
/// let apiClient: ApiClient
/// let scheduler: AnySchedulerOf<DispatchQueue>
/// let apiClient: ApiClient
/// let scheduler: AnySchedulerOf<DispatchQueue>
///
/// init(apiClient: ApiClient, scheduler: AnySchedulerOf<DispatchQueue>) {
/// self.apiClient = apiClient
/// self.scheduler = scheduler
/// }
/// init(apiClient: ApiClient, scheduler: AnySchedulerOf<DispatchQueue>) {
/// self.apiClient = apiClient
/// self.scheduler = scheduler
/// }
///
/// func reloadButtonTapped() {
/// self.apiClient.fetchEpisode()
/// .receive(on: self.scheduler)
/// .assign(to: &self.$episode)
/// }
/// }
/// func reloadButtonTapped() {
/// self.apiClient.fetchEpisode()
/// .receive(on: self.scheduler)
/// .assign(to: &self.$episode)
/// }
/// }
/// ```
///
/// Then, in production we can create a view model that uses a live `DispatchQueue`, but we just
/// have to first erase its type:
///
/// let viewModel = EpisodeViewModel(
/// apiClient: ...,
/// scheduler: DispatchQueue.main.eraseToAnyScheduler()
/// )
/// ```swift
/// let viewModel = EpisodeViewModel(
/// apiClient: ...,
/// scheduler: DispatchQueue.main.eraseToAnyScheduler()
/// )
/// ```
///
/// For common schedulers, like `DispatchQueue`, `OperationQueue`, and `RunLoop`, there is even a
/// static helper on `AnyScheduler` that further simplifies this:
///
/// let viewModel = EpisodeViewModel(
/// apiClient: ...,
/// scheduler: .main
/// )
/// ```swift
/// let viewModel = EpisodeViewModel(
/// apiClient: ...,
/// scheduler: .main
/// )
/// ```
///
/// And in tests we can use an immediate scheduler:
///
/// let viewModel = EpisodeViewModel(
/// apiClient: ...,
/// scheduler: .immediate
/// )
/// ```swift
/// let viewModel = EpisodeViewModel(
/// apiClient: ...,
/// scheduler: .immediate
/// )
/// ```
///
/// So, in general, `AnyScheduler` is great for allowing one to control what scheduler is used
/// in classes, functions, etc. without needing to introduce a generic, which can help simplify
Expand Down
Loading

0 comments on commit 0ba3a56

Please sign in to comment.