diff --git a/Changelog.md b/Changelog.md index 55e4041..703d9f3 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,7 +2,7 @@ ## Current Master -- Nothing yet. +- Added support to RxTest. Users may now choose between `RxTest` and `RxBlocking` (or both) ## 4.3.0 - Swift 4.2 support diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index 78d4d75..9e39b24 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -7,12 +7,14 @@ objects = { /* Begin PBXBuildFile section */ + 3A4288F2217D7B0000D3651D /* RxNimbleRxTestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A4288F1217D7B0000D3651D /* RxNimbleRxTestTests.swift */; }; + 3A4288F4217D7B6200D3651D /* AnyError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A4288F3217D7B6200D3651D /* AnyError.swift */; }; 5E47F32C1C3EAE9B00EC0751 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E47F32B1C3EAE9B00EC0751 /* AppDelegate.swift */; }; 5E47F32E1C3EAE9B00EC0751 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E47F32D1C3EAE9B00EC0751 /* ViewController.swift */; }; 5E47F3311C3EAE9B00EC0751 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5E47F32F1C3EAE9B00EC0751 /* Main.storyboard */; }; 5E47F3331C3EAE9B00EC0751 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5E47F3321C3EAE9B00EC0751 /* Assets.xcassets */; }; 5E47F3361C3EAE9B00EC0751 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5E47F3341C3EAE9B00EC0751 /* LaunchScreen.storyboard */; }; - 5E47F3411C3EAE9B00EC0751 /* DemoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E47F3401C3EAE9B00EC0751 /* DemoTests.swift */; }; + 5E47F3411C3EAE9B00EC0751 /* RxNimbleRxBlockingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E47F3401C3EAE9B00EC0751 /* RxNimbleRxBlockingTests.swift */; }; D04F8C922A4EA6727B35872D /* Pods_Demo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9265FBADB809B4B52B402D23 /* Pods_Demo.framework */; }; F189612069FC08D6409FD015 /* Pods_DemoTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 305E5AFA494DF2A10E0B8767 /* Pods_DemoTests.framework */; }; /* End PBXBuildFile section */ @@ -30,6 +32,8 @@ /* Begin PBXFileReference section */ 305E5AFA494DF2A10E0B8767 /* Pods_DemoTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_DemoTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3388B98AFD3E9302DB045597 /* Pods-DemoTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DemoTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-DemoTests/Pods-DemoTests.debug.xcconfig"; sourceTree = ""; }; + 3A4288F1217D7B0000D3651D /* RxNimbleRxTestTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RxNimbleRxTestTests.swift; sourceTree = ""; }; + 3A4288F3217D7B6200D3651D /* AnyError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyError.swift; sourceTree = ""; }; 5E47F3281C3EAE9B00EC0751 /* Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Demo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5E47F32B1C3EAE9B00EC0751 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 5E47F32D1C3EAE9B00EC0751 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; @@ -38,7 +42,7 @@ 5E47F3351C3EAE9B00EC0751 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 5E47F3371C3EAE9B00EC0751 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 5E47F33C1C3EAE9B00EC0751 /* DemoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DemoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 5E47F3401C3EAE9B00EC0751 /* DemoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoTests.swift; sourceTree = ""; }; + 5E47F3401C3EAE9B00EC0751 /* RxNimbleRxBlockingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RxNimbleRxBlockingTests.swift; sourceTree = ""; }; 5E47F3421C3EAE9B00EC0751 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 9265FBADB809B4B52B402D23 /* Pods_Demo.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Demo.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 94276AA888ACDCBD57B00C60 /* Pods-Demo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Demo.release.xcconfig"; path = "Pods/Target Support Files/Pods-Demo/Pods-Demo.release.xcconfig"; sourceTree = ""; }; @@ -113,7 +117,9 @@ 5E47F33F1C3EAE9B00EC0751 /* DemoTests */ = { isa = PBXGroup; children = ( - 5E47F3401C3EAE9B00EC0751 /* DemoTests.swift */, + 3A4288F3217D7B6200D3651D /* AnyError.swift */, + 5E47F3401C3EAE9B00EC0751 /* RxNimbleRxBlockingTests.swift */, + 3A4288F1217D7B0000D3651D /* RxNimbleRxTestTests.swift */, 5E47F3421C3EAE9B00EC0751 /* Info.plist */, ); path = DemoTests; @@ -259,6 +265,7 @@ "${BUILT_PRODUCTS_DIR}/RxBlocking/RxBlocking.framework", "${BUILT_PRODUCTS_DIR}/RxNimble/RxNimble.framework", "${BUILT_PRODUCTS_DIR}/RxSwift/RxSwift.framework", + "${BUILT_PRODUCTS_DIR}/RxTest/RxTest.framework", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( @@ -267,6 +274,7 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RxBlocking.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RxNimble.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RxSwift.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RxTest.framework", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -307,7 +315,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5E47F3411C3EAE9B00EC0751 /* DemoTests.swift in Sources */, + 3A4288F4217D7B6200D3651D /* AnyError.swift in Sources */, + 3A4288F2217D7B0000D3651D /* RxNimbleRxTestTests.swift in Sources */, + 5E47F3411C3EAE9B00EC0751 /* RxNimbleRxBlockingTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Demo/DemoTests/AnyError.swift b/Demo/DemoTests/AnyError.swift new file mode 100644 index 0000000..7b3c347 --- /dev/null +++ b/Demo/DemoTests/AnyError.swift @@ -0,0 +1,5 @@ +import Foundation + +enum AnyError: Error { + case any +} diff --git a/Demo/DemoTests/DemoTests.swift b/Demo/DemoTests/RxNimbleRxBlockingTests.swift similarity index 85% rename from Demo/DemoTests/DemoTests.swift rename to Demo/DemoTests/RxNimbleRxBlockingTests.swift index 6c706d2..a911f50 100644 --- a/Demo/DemoTests/DemoTests.swift +++ b/Demo/DemoTests/RxNimbleRxBlockingTests.swift @@ -3,16 +3,8 @@ import Nimble import RxSwift import RxNimble -class RxNimbleTest: QuickSpec { +class RxNimbleRxBlockingTests: QuickSpec { override func spec() { - /// A type-erased `Swift.Error` for testing purposes - struct AnyError: Swift.Error { - let message: String - init(_ message: String = "") { - self.message = message - } - } - //MARK: First describe("First") { it("works with plain observables") { @@ -39,7 +31,7 @@ class RxNimbleTest: QuickSpec { it("get first error") { let subject = ReplaySubject.createUnbounded() - subject.onError(AnyError()) + subject.onError(AnyError.any) expect(subject).first.to(throwError()) } @@ -64,7 +56,7 @@ class RxNimbleTest: QuickSpec { it("error, if terminated with error") { let subject = ReplaySubject.createUnbounded() subject.onNext("Hello, world!") - subject.onError(AnyError()) + subject.onError(AnyError.any) expect(subject).last.to(throwError()) } diff --git a/Demo/DemoTests/RxNimbleRxTestTests.swift b/Demo/DemoTests/RxNimbleRxTestTests.swift new file mode 100644 index 0000000..c205c8c --- /dev/null +++ b/Demo/DemoTests/RxNimbleRxTestTests.swift @@ -0,0 +1,106 @@ +import Quick +import Nimble +import RxSwift +import RxTest +import RxNimble + +class RxNimbleRxTestTests: QuickSpec { + override func spec() { + describe("Events") { + let initialClock = 0 + var scheduler: TestScheduler! + var disposeBag: DisposeBag! + + beforeEach { + disposeBag = DisposeBag() + scheduler = TestScheduler(initialClock: initialClock, simulateProcessingDelay: false) + } + + it("works with uncompleted streams") { + let subject = scheduler.createHotObservable([ + next(5, "Hello"), + next(10, "World"), + ]) + + expect(subject).events(scheduler: scheduler, disposeBag: disposeBag) + .to(equal([ + Recorded.next(5, "Hello"), + Recorded.next(10, "World") + ])) + } + + it("works with completed streams") { + let subject = scheduler.createHotObservable([ + next(5, "Hello"), + next(10, "World"), + completed(100) + ]) + + expect(subject).events(scheduler: scheduler, disposeBag: disposeBag) + .to(equal([ + Recorded.next(5, "Hello"), + Recorded.next(10, "World"), + Recorded.completed(100) + ])) + } + + it("works with errored streams") { + let subject: TestableObservable = scheduler.createHotObservable([ + error(5, AnyError.any) + ]) + + expect(subject).events(scheduler: scheduler, disposeBag: disposeBag) + .to(equal([ + Recorded.error(5, AnyError.any) + ])) + } + + it("throws error if any event is error") { + let subject = scheduler.createHotObservable([ + Recorded.next(5, "Hello"), + Recorded.next(10, "World"), + error(15, AnyError.any) + ]) + + expect(subject).events(scheduler: scheduler, disposeBag: disposeBag) + .to(throwError()) + } + + it("does not throw error if no errors") { + let subject = scheduler.createHotObservable([ + Recorded.next(5, "Hello"), + Recorded.next(10, "World") + ]) + + expect(subject).events(scheduler: scheduler, disposeBag: disposeBag) + .toNot(throwError()) + } + + it("subscribes at specified initial time") { + let initialTime = 50 + let eventTime = 100 + let subject = scheduler.createColdObservable([ + next(eventTime, "Hi") + ]) + + expect(subject).events(scheduler: scheduler, disposeBag: disposeBag, startAt: initialTime) + .to(equal([ + Recorded.next(initialTime + eventTime, "Hi") + ])) + } + + it("ignores hot stream events before initial time") { + let subject = scheduler.createHotObservable([ + next(5, "Hello"), + next(10, "World"), + completed(100) + ]) + + expect(subject).events(scheduler: scheduler, disposeBag: disposeBag, startAt: 15) + .to(equal([ + Recorded.completed(100) + ])) + } + } + } +} diff --git a/Demo/Podfile b/Demo/Podfile index 9537adb..b476642 100644 --- a/Demo/Podfile +++ b/Demo/Podfile @@ -12,7 +12,7 @@ target 'DemoTests' do pod 'Quick' pod 'Nimble' -pod 'RxNimble', path: '../' +pod 'RxNimble', subspecs: ['RxBlocking', 'RxTest'], path: '../' end diff --git a/Demo/Podfile.lock b/Demo/Podfile.lock index 30827b9..8a598d0 100644 --- a/Demo/Podfile.lock +++ b/Demo/Podfile.lock @@ -3,16 +3,24 @@ PODS: - Quick (1.3.2) - RxBlocking (4.3.1): - RxSwift (~> 4.0) - - RxNimble (4.2.0): + - RxNimble/Core (4.4.0): - Nimble (~> 7.0) - - RxBlocking (~> 4.0) - RxSwift (~> 4.2) + - RxNimble/RxBlocking (4.4.0): + - RxBlocking + - RxNimble/Core + - RxNimble/RxTest (4.4.0): + - RxNimble/Core + - RxTest - RxSwift (4.3.1) + - RxTest (4.3.1): + - RxSwift (~> 4.0) DEPENDENCIES: - Nimble - Quick - - RxNimble (from `../`) + - RxNimble/RxBlocking (from `../`) + - RxNimble/RxTest (from `../`) SPEC REPOS: https://github.com/cocoapods/specs.git: @@ -20,6 +28,7 @@ SPEC REPOS: - Quick - RxBlocking - RxSwift + - RxTest EXTERNAL SOURCES: RxNimble: @@ -29,9 +38,10 @@ SPEC CHECKSUMS: Nimble: 04f732da099ea4d153122aec8c2a88fd0c7219ae Quick: 2623cb30d7a7f41ca62f684f679586558f483d46 RxBlocking: 64c051285261ca2339481e91b5f70eb06b03660a - RxNimble: cb8c3dd1ca79b83c60744f1c327130c1aef202a1 + RxNimble: 170cdfa19fb020c25608760961fd35053f2a2646 RxSwift: fe0fd770a43acdb7d0a53da411c9b892e69bb6e4 + RxTest: ea97a208826906f3904c0debdd09575ebcbfe216 -PODFILE CHECKSUM: 7405369509db0cbd242146add66181ab5ab0efb0 +PODFILE CHECKSUM: 41dbff57d3169ba9e4e1dd63856bcace5b3f96c1 COCOAPODS: 1.5.3 diff --git a/README.md b/README.md index d2a0aaa..2318660 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,48 @@ expect(observable).first == 42 Nice. +--- + +If on the other hand you'd rather use [RxTest](http://cocoapods.org/pods/RxTest) instead of `RxBlocking`, you can do it by specifying RxNimble's `RxTest` subspec. With _RxTest_ you can have more powerful tests, checking a stream as a whole instead of being limited to `first`, `last` and `array` (while the last 2 implicitly require the stream to have completed). + +That means _RxTest_ allows you to verify the occurrence of multiple `next`, `error` and `completed` events at specific virtual times: + +``` +expect(subject).events(scheduler: scheduler, disposeBag: disposeBag) + .to(equal([ + Recorded.next(5, "Hello"), + Recorded.next(10, "World"), + Recorded.completed(100) + ])) +``` + +You may also verify specific error types: + +``` +expect(imageSubject).events(scheduler: scheduler, disposeBag: disposeBag) + .to(equal([ + Recorded.error(5, ImageError.invalidImage) + ])) +``` + ## Installation -Add to your podfile: +Add to the tests target in your Podfile: + +```rb +pod 'RxNimble' # same as RxNimble/RxBlocking +``` + +or + +```rb +pod 'RxNimble/RxTest' # installs RxTest instead of RxBlocking +``` + +or even ```rb -pod 'RxNimble' +pod 'RxNimble', subspecs: ['RxBlocking', 'RxTest'] # installs both dependencies ``` And `pod install` and that's it! diff --git a/RxNimble.podspec b/RxNimble.podspec index 340b4fb..31f35aa 100644 --- a/RxNimble.podspec +++ b/RxNimble.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "RxNimble" - s.version = "4.3.0" + s.version = "4.4.0" s.summary = "Nimble extensions that making unit testing with RxSwift easier 🎉" s.description = <<-DESC This library includes functions that make testing RxSwift projects easier with Nimble. @@ -13,10 +13,25 @@ Pod::Spec.new do |s| s.osx.deployment_target = "10.10" s.tvos.deployment_target = "9.0" s.source = { :git => "https://github.com/RxSwiftCommunity/RxNimble.git", :tag => s.version } - s.source_files = "Source/**/*.swift" - s.dependency "Nimble", "~> 7.0" - s.dependency "RxSwift", "~> 4.2" - s.dependency "RxBlocking", "~> 4.0" + s.default_subspec = "RxBlocking" s.pod_target_xcconfig = { 'ENABLE_BITCODE' => 'NO', 'FRAMEWORK_SEARCH_PATHS' => '$(inherited) "$(PLATFORM_DIR)/Developer/Library/Frameworks"' } + + s.subspec "Core" do |ss| + ss.source_files = "Source/Core/" + ss.dependency "Nimble", "~> 7.0" + ss.dependency "RxSwift", "~> 4.2" + end + + s.subspec "RxBlocking" do |ss| + ss.source_files = "Source/RxBlocking/" + ss.dependency "RxNimble/Core" + ss.dependency "RxBlocking" + end + + s.subspec "RxTest" do |ss| + ss.source_files = "Source/RxTest/" + ss.dependency "RxNimble/Core" + ss.dependency "RxTest" + end end diff --git a/Source/Expectation+Ext.swift b/Source/Core/Expectation+Ext.swift similarity index 100% rename from Source/Expectation+Ext.swift rename to Source/Core/Expectation+Ext.swift diff --git a/Source/Expectation+Blocking.swift b/Source/RxBlocking/Expectation+Blocking.swift similarity index 100% rename from Source/Expectation+Blocking.swift rename to Source/RxBlocking/Expectation+Blocking.swift diff --git a/Source/RxNimble.swift b/Source/RxBlocking/RxNimble.swift similarity index 100% rename from Source/RxNimble.swift rename to Source/RxBlocking/RxNimble.swift diff --git a/Source/RxTest/Equal+RxTest.swift b/Source/RxTest/Equal+RxTest.swift new file mode 100644 index 0000000..3349538 --- /dev/null +++ b/Source/RxTest/Equal+RxTest.swift @@ -0,0 +1,17 @@ +import Nimble +import RxSwift +@testable import RxTest + +/// A Nimble matcher that succeeds when the actual events are equal to the expected events. +public func equal(_ expectedEvents: RecordedEvents) -> Predicate> { + return Predicate.define { actualEvents in + let actualEquatableEvents = try actualEvents.evaluate()?.map { AnyEquatable(target: $0, comparer: ==) } + let expectedEquatableEvents = expectedEvents.map { AnyEquatable(target: $0, comparer: ==) } + + let matches = (actualEquatableEvents == expectedEquatableEvents) + return PredicateResult(bool: matches, + message: .expectedActualValueTo( + "emit <\(stringify(expectedEquatableEvents))>") + ) + } +} diff --git a/Source/RxTest/Expectation+RxTest.swift b/Source/RxTest/Expectation+RxTest.swift new file mode 100644 index 0000000..bfe8e7b --- /dev/null +++ b/Source/RxTest/Expectation+RxTest.swift @@ -0,0 +1,29 @@ +import Nimble +import RxSwift +import RxTest + +public typealias RecordedEvents = [Recorded>] + +public extension Expectation where T: ObservableType { + /// Make an expectation on the events emitted by an observable. + /// + /// - Parameters: + /// - scheduler: the scheduler used to record events in virtual time units. + /// - disposeBag: the dispose bag that will dispose all of its resources between tests. + /// - initialTime: the time at which subscription/recording should begin. + /// - Returns: an expectation of the actual events emitted by the observable. + func events(scheduler: TestScheduler, + disposeBag: DisposeBag, + startAt initialTime: Int = 0) -> Expectation> { + return transform { source in + let results = scheduler.createObserver(T.E.self) + + scheduler.scheduleAt(initialTime) { + source?.subscribe(results).disposed(by: disposeBag) + } + scheduler.start() + + return results.events + } + } +} diff --git a/Source/RxTest/ThrowError+RxTest.swift b/Source/RxTest/ThrowError+RxTest.swift new file mode 100644 index 0000000..25c8a1c --- /dev/null +++ b/Source/RxTest/ThrowError+RxTest.swift @@ -0,0 +1,38 @@ +import Nimble +import RxSwift +import RxTest + +/// A Nimble matcher that succeeds when the actual events emit an error +/// of any type. +public func throwError() -> Predicate> { + func extractError(_ recorded: RecordedEvents?) -> [Error]? { + func extractError(_ recorded: Recorded>) -> Error? { + return recorded.value.error + } + + #if swift(>=4.1) + return recorded?.compactMap(extractError) + #else + return recorded?.flatMap(extractError) + #endif + } + + + return Predicate { actualEvents in + var actualError: Error? + do { + let recordedEvents = try actualEvents.evaluate() + if let error = extractError(recordedEvents)?.first { + throw error + } + } catch { + actualError = error + } + + if let actualError = actualError { + return PredicateResult(bool: true, message: .expectedCustomValueTo("throw any error", "<\(actualError)>")) + } else { + return PredicateResult(bool: false, message: .expectedCustomValueTo("throw any error", "no error")) + } + } +}