Adds marble tests for GitHub sign-in example.

This commit is contained in:
Krunoslav Zaher 2015-12-29 17:37:13 +01:00
parent 004e637774
commit f1482d2126
7 changed files with 541 additions and 0 deletions

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>

View File

@ -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<Bool>
let _signup: (String, String) -> Observable<Bool>
init(
usernameAvailable: (String) -> Observable<Bool> = notImplemented(),
signup: (String, String) -> Observable<Bool> = notImplemented()
) {
_usernameAvailable = usernameAvailable
_signup = signup
}
func usernameAvailable(username: String) -> Observable<Bool> {
return _usernameAvailable(username)
}
func signup(username: String, password: String) -> Observable<Bool> {
return _signup(username, password)
}
}

View File

@ -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<Any>
init(openURL: (NSURL) -> () = notImplementedSync(),
promptFor: (String, Any, [Any]) -> Observable<Any> = notImplemented()) {
_openURL = openURL
_promptFor = promptFor
}
func openURL(URL: NSURL) {
_openURL(URL)
}
func promptFor<Action: CustomStringConvertible>(message: String, cancelAction: Action, actions: [Action]) -> Observable<Action> {
return _promptFor(message, cancelAction, actions.map { $0 as Any }).map { $0 as! Action }
}
}

View File

@ -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, T2>() -> (T1) -> Observable<T2> {
return { _ in
fatalError()
return Observable.empty()
}
}
func notImplementedSync<T1>() -> (T1) -> Void {
return { _ in
fatalError()
}
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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<T>(timeline: String, values: [String: T], errors: [String: ErrorType] = [:]) -> [[Recorded<Event<T>>]] {
print("parsing: \(timeline)")
typealias RecordedEvent = Recorded<Event<T>>
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<Event<T>>] 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<T>.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<T>.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<T>.Error(error))
return (state.time + tickIncrement, state.events + [nextEvent])
}
let nextEvent = RecordedEvent(time: state.time, event: Event<T>.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<T>(timeline: String, values: [String: T]) -> Driver<T> {
return createObservable(timeline, values: values, errors: [:]).asDriver(onErrorRecover: { (error) -> Driver<T> 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<T>(timeline: String, values: [String: T], errors: [String: ErrorType] = [:]) -> Observable<T> {
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<T>(events: [Recorded<Event<T>>]) -> Observable<T> {
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<T>(events: [[Recorded<Event<T>>]]) -> Observable<T> {
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<Ret>`
- 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<Arg, Ret>(values: [String: Ret], errors: [String: ErrorType] = [:], timelineSelector: Arg -> String) -> Arg -> Observable<Ret> {
return { (parameters: Arg) -> Observable<Ret> 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<O: ObservableConvertibleType>(source: O) -> TestableObserver<O.E> {
let observer = self.createObserver(O.E.self)
let disposable = source.asObservable().bindTo(observer)
self.scheduleAt(100000) {
disposable.dispose()
}
return observer
}
}