Adds marble tests for GitHub sign-in example.
This commit is contained in:
parent
004e637774
commit
f1482d2126
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue