diff --git a/RxExample/RxExample-iOSTests/Info.plist b/RxExample/RxExample-iOSTests/Info.plist new file mode 100644 index 00000000..ba72822e --- /dev/null +++ b/RxExample/RxExample-iOSTests/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1 + + diff --git a/RxExample/RxExample-iOSTests/Mocks/MockGitHubAPI.swift b/RxExample/RxExample-iOSTests/Mocks/MockGitHubAPI.swift new file mode 100644 index 00000000..de6d18a2 --- /dev/null +++ b/RxExample/RxExample-iOSTests/Mocks/MockGitHubAPI.swift @@ -0,0 +1,31 @@ +// +// MockGitHubAPI.swift +// RxExample +// +// Created by Krunoslav Zaher on 12/29/15. +// Copyright © 2015 Krunoslav Zaher. All rights reserved. +// + +import Foundation +import RxSwift + +class MockGitHubAPI : GitHubAPI { + let _usernameAvailable: String -> Observable + let _signup: (String, String) -> Observable + + init( + usernameAvailable: (String) -> Observable = notImplemented(), + signup: (String, String) -> Observable = notImplemented() + ) { + _usernameAvailable = usernameAvailable + _signup = signup + } + + func usernameAvailable(username: String) -> Observable { + return _usernameAvailable(username) + } + + func signup(username: String, password: String) -> Observable { + return _signup(username, password) + } +} diff --git a/RxExample/RxExample-iOSTests/Mocks/MockWireframe.swift b/RxExample/RxExample-iOSTests/Mocks/MockWireframe.swift new file mode 100644 index 00000000..7a1b4081 --- /dev/null +++ b/RxExample/RxExample-iOSTests/Mocks/MockWireframe.swift @@ -0,0 +1,29 @@ +// +// MockWireframe.swift +// RxExample +// +// Created by Krunoslav Zaher on 12/29/15. +// Copyright © 2015 Krunoslav Zaher. All rights reserved. +// + +import Foundation +import RxSwift + +class MockWireframe : Wireframe { + let _openURL: (NSURL) -> () + let _promptFor: (String, Any, [Any]) -> Observable + + init(openURL: (NSURL) -> () = notImplementedSync(), + promptFor: (String, Any, [Any]) -> Observable = notImplemented()) { + _openURL = openURL + _promptFor = promptFor + } + + func openURL(URL: NSURL) { + _openURL(URL) + } + + func promptFor(message: String, cancelAction: Action, actions: [Action]) -> Observable { + return _promptFor(message, cancelAction, actions.map { $0 as Any }).map { $0 as! Action } + } +} diff --git a/RxExample/RxExample-iOSTests/Mocks/NotImplementedStubs.swift b/RxExample/RxExample-iOSTests/Mocks/NotImplementedStubs.swift new file mode 100644 index 00000000..97463805 --- /dev/null +++ b/RxExample/RxExample-iOSTests/Mocks/NotImplementedStubs.swift @@ -0,0 +1,29 @@ +// +// Mocks.swift +// RxExample +// +// Created by Krunoslav Zaher on 12/29/15. +// Copyright © 2015 Krunoslav Zaher. All rights reserved. +// + +import Foundation +import RxSwift +import RxTests + +// MARK: Generic support code + + +// MARK: Not implemented stubs + +func notImplemented() -> (T1) -> Observable { + return { _ in + fatalError() + return Observable.empty() + } +} + +func notImplementedSync() -> (T1) -> Void { + return { _ in + fatalError() + } +} diff --git a/RxExample/RxExample-iOSTests/Mocks/ValidationResult+Equatable.swift b/RxExample/RxExample-iOSTests/Mocks/ValidationResult+Equatable.swift new file mode 100644 index 00000000..904fd199 --- /dev/null +++ b/RxExample/RxExample-iOSTests/Mocks/ValidationResult+Equatable.swift @@ -0,0 +1,30 @@ +// +// ValidationResult+Equatable.swift +// RxExample +// +// Created by Krunoslav Zaher on 12/29/15. +// Copyright © 2015 Krunoslav Zaher. All rights reserved. +// + +import Foundation + +// MARK: Equatable + +extension ValidationResult : Equatable { + +} + +func == (lhs: ValidationResult, rhs: ValidationResult) -> Bool { + switch (lhs, rhs) { + case (.OK, .OK): + return true + case (.Empty, .Empty): + return true + case (.Validating, .Validating): + return true + case (.Failed, .Failed): + return true + default: + return false + } +} \ No newline at end of file diff --git a/RxExample/RxExample-iOSTests/RxExample_iOSTests.swift b/RxExample/RxExample-iOSTests/RxExample_iOSTests.swift new file mode 100644 index 00000000..b8a302ec --- /dev/null +++ b/RxExample/RxExample-iOSTests/RxExample_iOSTests.swift @@ -0,0 +1,200 @@ +// +// RxExample_iOSTests.swift +// RxExample-iOSTests +// +// Created by Krunoslav Zaher on 12/28/15. +// Copyright © 2015 Krunoslav Zaher. All rights reserved. +// + +import XCTest + +import RxSwift +import RxTests +@testable import RxCocoa + +let resolution: NSTimeInterval = 0.2 // seconds + +// MARK: Concrete tests + +/** +This is just an example of one way how this can be done. +*/ +class RxExample_iOSTests + : XCTestCase { + + let booleans = ["t" : true, "f" : false] + let events = ["x" : ()] + let errors = [ + "#1" : NSError(domain: "Some unknown error maybe", code: -1, userInfo: nil), + "#u" : NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut, userInfo: nil) + ] + let validations = [ + "e" : ValidationResult.Empty, + "f" : ValidationResult.Failed(message: ""), + "o" : ValidationResult.OK(message: "Validated"), + "v" : ValidationResult.Validating + ] + + let stringValues = [ + "u1" : "verysecret", + "u2" : "secretuser", + "u3" : "secretusername", + "p1" : "huge secret", + "p2" : "secret", + "e" : "" + ] + + //////////////////////////////////////////////////////////////////////////////// + // This is test of enabled user interface elements. + // I guess you could do this for view models, but this is probably overkill to + // do. + // + // It's probably more suitable for some vital components of your system, but + // the pricinciple is the same. + //////////////////////////////////////////////////////////////////////////////// + func testGitHubSignup_vanillaObservables_1_testEnabledUserInterfaceElements() { + let scheduler = TestScheduler(initialClock: 0, resolution: resolution, simulateProcessingDelay: false) + + // mock the universe + let mockAPI = mockGithubAPI(scheduler) + + // expected events and test data + let ( + usernameEvents, + passwordEvents, + repeatedPasswordEvents, + loginTapEvents, + + expectedValidatedUsernameEvents, + expectedSignupEnabledEvents + ) = ( + scheduler.parseEventsAndTimes("e---u1----u2-----u3-----------------", values: stringValues).first!, + scheduler.parseEventsAndTimes("e----------------------p1-----------", values: stringValues).first!, + scheduler.parseEventsAndTimes("e---------------------------p2---p1-", values: stringValues).first!, + scheduler.parseEventsAndTimes("------------------------------------", values: events).first!, + + scheduler.parseEventsAndTimes("e---v--f--v--f---v--o----------------", values: validations).first!, + scheduler.parseEventsAndTimes("f--------------------------------t---", values: booleans).first! + ) + + let wireframe = MockWireframe() + let validationService = GitHubDefaultValidationService(API: mockAPI) + + let viewModel = GithubSignupViewModel1( + input: ( + username: scheduler.createHotObservable(usernameEvents).asObservable(), + password: scheduler.createHotObservable(passwordEvents).asObservable(), + repeatedPassword: scheduler.createHotObservable(repeatedPasswordEvents).asObservable(), + loginTaps: scheduler.createHotObservable(loginTapEvents).asObservable() + ), + dependency: ( + API: mockAPI, + validationService: validationService, + wireframe: wireframe + ) + ) + + // run experiment + let recordedSignupEnabled = scheduler.record(viewModel.signupEnabled) + let recordedValidatedUsername = scheduler.record(viewModel.validatedUsername) + + scheduler.start() + + // validate + XCTAssertEqual(recordedValidatedUsername.events, expectedValidatedUsernameEvents) + XCTAssertEqual(recordedSignupEnabled.events, expectedSignupEnabledEvents) + } + + func testGitHubSignup_drivers_2_testEnabledUserInterfaceElements() { + let scheduler = TestScheduler(initialClock: 0, resolution: resolution, simulateProcessingDelay: false) + + // mock the universe + let mockAPI = mockGithubAPI(scheduler) + + // expected events and test data + let ( + usernameEvents, + passwordEvents, + repeatedPasswordEvents, + loginTapEvents, + + expectedValidatedUsernameEvents, + expectedSignupEnabledEvents + ) = ( + scheduler.parseEventsAndTimes("e---u1----u2-----u3-----------------", values: stringValues).first!, + scheduler.parseEventsAndTimes("e----------------------p1-----------", values: stringValues).first!, + scheduler.parseEventsAndTimes("e---------------------------p2---p1-", values: stringValues).first!, + scheduler.parseEventsAndTimes("------------------------------------", values: events).first!, + + scheduler.parseEventsAndTimes("e---v--f--v--f---v--o----------------", values: validations).first!, + scheduler.parseEventsAndTimes("f--------------------------------t---", values: booleans).first! + ) + + let wireframe = MockWireframe() + let validationService = GitHubDefaultValidationService(API: mockAPI) + + let viewModel = GithubSignupViewModel2( + input: ( + username: scheduler.createHotObservable(usernameEvents).asDriver(onErrorJustReturn: ""), + password: scheduler.createHotObservable(passwordEvents).asDriver(onErrorJustReturn: ""), + repeatedPassword: scheduler.createHotObservable(repeatedPasswordEvents).asDriver(onErrorJustReturn: ""), + loginTaps: scheduler.createHotObservable(loginTapEvents).asDriver(onErrorJustReturn: ()) + ), + dependency: ( + API: mockAPI, + validationService: validationService, + wireframe: wireframe + ) + ) + + /** + This is important because driver will try to ensure that elements are being pumped on main scheduler, + and that sometimes means that it will get queued using `dispatch_async` to main dispatch queue and + not get flushed until end of the test. + + This method enables using mock schedulers for while testing drivers. + */ + driveOnScheduler(scheduler) { + // run experiment + let recordedSignupEnabled = scheduler.record(viewModel.signupEnabled) + let recordedValidatedUsername = scheduler.record(viewModel.validatedUsername) + + scheduler.start() + + // validate + XCTAssertEqual(recordedValidatedUsername.events, expectedValidatedUsernameEvents) + XCTAssertEqual(recordedSignupEnabled.events, expectedSignupEnabledEvents) + } + } +} + +// MARK: Mocks + +extension RxExample_iOSTests { + func mockGithubAPI(scheduler: TestScheduler) -> GitHubAPI { + return MockGitHubAPI( + usernameAvailable: scheduler.mock(booleans, errors: errors) { (username) -> String in + if username == "secretusername" { + return "---t" + } + else if username == "secretuser" { + return "---#1" + } + else { + return "---f" + } + }, + signup: scheduler.mock(booleans, errors: errors) { (username, password) -> String in + if username == "secretusername" && password == "secret" { + return "--t" + } + else { + return "--f" + } + } + ) + } +} + +// MARK: Mocks + diff --git a/RxExample/RxExample-iOSTests/TestScheduler+MarbleTests.swift b/RxExample/RxExample-iOSTests/TestScheduler+MarbleTests.swift new file mode 100644 index 00000000..a758e3e4 --- /dev/null +++ b/RxExample/RxExample-iOSTests/TestScheduler+MarbleTests.swift @@ -0,0 +1,198 @@ +// +// TestScheduler+MarbleTests.swift +// RxExample +// +// Created by Krunoslav Zaher on 12/29/15. +// Copyright © 2015 Krunoslav Zaher. All rights reserved. +// + +import Foundation +import RxSwift +import RxTests +import RxCocoa + +/** +There are examples like this all over the web, but I think that I've first something like this here + https://github.com/ReactiveX/RxJS/blob/master/doc/writing-marble-tests.md + +These tests are called marble tests. + +*/ +extension TestScheduler { + /** + Transformation from this format: + + ---a---b------c----- + + to this format + + schedule onNext(1) @ 0.6s + schedule onNext(2) @ 1.4s + schedule onNext(3) @ 7.0s + .... + ] + + You can also specify retry data in this format: + + ---a---b------c----#|----a--#|----b + + - letters and digits mark values + - `#` marks unknown error + - `|` marks sequence completed + + */ + func parseEventsAndTimes(timeline: String, values: [String: T], errors: [String: ErrorType] = [:]) -> [[Recorded>]] { + print("parsing: \(timeline)") + typealias RecordedEvent = Recorded> + let timelines = timeline.componentsSeparatedByString("|") + + let allExceptLast = timelines[0 ..< timelines.count - 1] + + return (allExceptLast.map { $0 + "|" } + [timelines.last!]) + .filter { $0.characters.count > 0 } + .map { timeline -> [Recorded>] in + let segments = timeline.componentsSeparatedByString("-") + let (time: _, events: events) = segments.reduce((time: 0, events: [RecordedEvent]())) { state, event in + let tickIncrement = event.characters.count + 1 + + if event.characters.count == 0 { + return (state.time + tickIncrement, state.events) + } + + if event == "#" { + let errorEvent = RecordedEvent(time: state.time, event: Event.Error(NSError(domain: "Any error domain", code: -1, userInfo: nil))) + return (state.time + tickIncrement, state.events + [errorEvent]) + } + + if event == "|" { + let completed = RecordedEvent(time: state.time, event: Event.Completed) + return (state.time + tickIncrement, state.events + [completed]) + } + + guard let next = values[event] else { + guard let error = errors[event] else { + fatalError("Value with key \(event) not registered as value:\n\(values)\nor error:\n\(errors)") + } + + let nextEvent = RecordedEvent(time: state.time, event: Event.Error(error)) + return (state.time + tickIncrement, state.events + [nextEvent]) + } + + let nextEvent = RecordedEvent(time: state.time, event: Event.Next(next)) + return (state.time + tickIncrement, state.events + [nextEvent]) + } + + print("parsed: \(events)") + return events + } + } + + /** + Creates driver for marble test. + + - parameter timeline: Timeline in the form `---a---b------c--|` + - parameter values: Dictionary of values in timeline. `[a:1, b:2]` + + - returns: Driver specified by timeline and values. + */ + func createDriver(timeline: String, values: [String: T]) -> Driver { + return createObservable(timeline, values: values, errors: [:]).asDriver(onErrorRecover: { (error) -> Driver in + fatalError("This can't error out") + return Driver.never() + }) + } + + /** + Creates observable for marble tests. + + - parameter timeline: Timeline in the form `---a---b------c--|` + - parameter values: Dictionary of values in timeline. `[a:1, b:2]` + - parameter errors: Dictionary of errors in timeline. + + - returns: Observable sequence specified by timeline and values. + */ + func createObservable(timeline: String, values: [String: T], errors: [String: ErrorType] = [:]) -> Observable { + let events = self.parseEventsAndTimes(timeline, values: values, errors: errors) + return createObservable(events) + } + + /** + Creates observable for marble tests. + + - parameter events: Recorded events to replay. + + - returns: Observable sequence specified by timeline and values. + */ + func createObservable(events: [Recorded>]) -> Observable { + return createObservable([events]) + } + + /** + Creates observable for marble tests. + + - parameter events: Recorded events to replay. This overloads enables modeling of retries. + `---a---b------c----#|----a--#|----b` + When next observer is subscribed, next sequence will be replayed. If all sequences have + been replayed and new observer is subscribed, `fatalError` will be raised. + + - returns: Observable sequence specified by timeline and values. + */ + func createObservable(events: [[Recorded>]]) -> Observable { + var attemptCount = 0 + print("created for \(events)") + + return Observable.create { observer in + if attemptCount >= events.count { + fatalError("This is attempt # \(attemptCount + 1), but timeline only allows \(events.count).\n\(events)") + } + + let scheduledEvents = events[attemptCount].map { event in + return self.scheduleRelative((), dueTime: resolution * NSTimeInterval(event.time)) { _ in + observer.on(event.value) + return NopDisposable.instance + } + } + + attemptCount += 1 + + return CompositeDisposable(disposables: scheduledEvents) + } + } + + /** + Enables simple construction of mock implementations from marble timelines. + + - parameter Arg: Type of arguments of mocked method. + - parameter Ret: Return type of mocked method. `Observable` + + - parameter values: Dictionary of values in timeline. `[a:1, b:2]` + - parameter errors: Dictionary of errors in timeline. + - parameter timelineSelector: Method implementation. The returned string value represents timeline of + returned observable sequence. `---a---b------c----#|----a--#|----b` + + - returns: Implementation of method that accepts arguments with parameter `Arg` and returns observable sequence + with parameter `Ret`. + */ + func mock(values: [String: Ret], errors: [String: ErrorType] = [:], timelineSelector: Arg -> String) -> Arg -> Observable { + return { (parameters: Arg) -> Observable in + let timeline = timelineSelector(parameters) + + return self.createObservable(timeline, values: values, errors: errors) + } + } + + /** + Builds testable observer for s specific observable sequence, binds it's results and sets up disposal. + + - parameter source: Observable sequence to observe. + - returns: Observer that records all events for observable sequence. + */ + func record(source: O) -> TestableObserver { + let observer = self.createObserver(O.E.self) + let disposable = source.asObservable().bindTo(observer) + self.scheduleAt(100000) { + disposable.dispose() + } + return observer + } +}