16.
Testing with RxTest
Written by Scott Gardner
👏🏼 💯🚀
☝🏼 That’s for you, for not skipping this chapter. Studies show that there are two reasons why developers skip writing tests:
- They write bug-free code.
- Writing tests isn’t fun.
If the first reason is all you, you’re hired! And if you agree with the second reason, well, let me introduce you to my little friend: RxTest.
For all the reasons why you started reading this book and are excited to begin using RxSwift in your app projects, RxTest and RxBlocking should get you excited to write tests against your RxSwift code, too. They provide an elegant API that makes writing tests easy and fun.
This chapter will introduce you to RxTest and RxBlocking. You’ll write tests against several RxSwift operators and production RxSwift code in an iOS app project.
Getting started
The starter project for this chapter is named Testing, and it contains a handy app to give you the red, green, and blue values and color name if available for the hex color code you enter. After running pod install, open up the project workspace and run it. You will see the app starts off with rayWenderlichGreen, but you can enter any hex color code and get the RGB and name values.
This app is organized using the MVVM design pattern, which you’ll learn about in Chapter 24, “MVVM with RxSwift.” The view model contains the following logic that the view controller will use to control the view, and you’ll write tests against this logic later in the chapter:
// Convert hex text to color
color = hexString
.map { hex in
guard hex.count == 7 else { return .clear }
let color = UIColor(hex: hex)
return color
}
.asDriver(onErrorJustReturn: .clear)
// Convert the color to an rgb tuple
rgb = color
.map { color in
var red: CGFloat = 0.0
var green: CGFloat = 0.0
var blue: CGFloat = 0.0
color.getRed(&red, green: &green, blue: &blue, alpha: nil)
let rgb = (Int(red * 255.0), Int(green * 255.0), Int(blue * 255.0))
return rgb
}
.asDriver(onErrorJustReturn: (0, 0, 0))
// Convert the hex text to a matching name
colorName = hexString
.map { hexString in
let hex = String(hexString.dropFirst())
if let color = ColorName(rawValue: hex) {
return "\(color)"
} else {
return "--"
}
}
.asDriver(onErrorJustReturn: "")
Before diving into testing this code, you’ll learn about RxTest by writing a few tests against RxSwift operators.
Note: This chapter presumes you are familiar with writing unit tests in iOS using
XCTest. If you’re new to unit testing in iOS, check out our video course, Beginning iOS Unit and UI Testing at https://videos.raywenderlich.com/courses/57-beginning-ios-unit-and-ui-testing/lessons/1.
Testing operators with RxTest
RxTest is a separate library from RxSwift. It’s hosted within the RxSwift repo but requires a separate pod install and import. RxTest provides many useful additions for testing RxSwift code, including:
-
TestScheduler- a virtual time scheduler that gives you granular control over testing time-linear operations. -
Recorded.next(_:_:),Recorded.completed(_:_:), andRecorded.error(_:_:_:)- factory methods that enable adding these events onto observables at specified times in your tests.
RxTest also adds hot and cold observables. You can think of these like hot and cold sandwiches. No, not really.
What are hot and cold observables?
RxSwift goes to great lengths to streamline and simplify your Rx code. There are circles of thought in the RxSwift community that feel hot and cold should be thought of as traits of observables instead of concrete types.
This is an implementation detail, but it’s worth being aware of because you won’t see much talk about hot and cold observables in RxSwift outside of testing.
Hot observables:
- Use resources whether or not there are subscribers.
- Produce elements whether or not there are subscribers.
- Are primarily used with stateful types such as
BehaviorRelay.
Cold observables:
- Only consume resources upon subscription.
- Only produce elements if there are subscribers.
- Are primarily used for async operations such as for networking.
You’ll use hot observables in the unit tests you’ll write shortly, but it’s good to know these differences in case your needs call for using one over the other.
Open TestingOperators.swift in the TestingTests group. At the top of the class TestingOperators definition, there are a couple of properties defined:
var scheduler: TestScheduler!
var subscription: Disposable!
scheduler is an instance of TestScheduler that you’ll use in each test, and subscription will hold your subscription in each test. Change the definition of setUp() to match the following:
override func setUp() {
super.setUp()
scheduler = TestScheduler(initialClock: 0)
}
In the setUp() method, which is called before each test case begins, you initialize a new scheduler with an initialClock value of 0. This means you want to start the test scheduler at the beginning time of the test. This will make more sense shortly.
Now change the tearDown() definition to match this code:
override func tearDown() {
scheduler.scheduleAt(1000) {
self.subscription.dispose()
}
scheduler = nil
super.tearDown()
}
tearDown() is called at the completion of each test. In it, you schedule the disposal of the test’s subscription at 1000 virtual time units, and set scheduler to nil to release its memory. The time values you’re using don’t correspond with actual seconds; they are virtual time units computed internally by RxTest.
And now it’s time to write a test! Add this new test to TestingOperators after the definition of tearDown():
// 1
func testAmb() {
// 2
let observer = scheduler.createObserver(String.self)
}
Here’s what you did:
- As with all tests using XCTest, the method name must begin with
test. You stub out a new test case here to test theamboperator. - You create an
observerusing thescheduler’screateObserver(_:)method, with a type hint ofString.
This observer is a special kind of observer called a TestableObserver, which will record and timestamp every event it receives. This is kind of like the debug operator in RxSwift, except it doesn’t print anything out.
You learned about the amb operator in Chapter 9, “Combining Operators” — use it between two observables to propagate events emitted by whichever observable emits first. So, in order to test amb, you need to create two observables. Add this code to the test:
// 1
let observableA = scheduler.createHotObservable([
// 2
.next(100, "a"),
.next(200, "b"),
.next(300, "c")
])
// 3
let observableB = scheduler.createHotObservable([
// 4
.next(90, "1"),
.next(200, "2"),
.next(300, "3")
])
With this code, you:
- Create an
observableAusing thescheduler’screateHotObservable(_:)method. This is a special kind of Observable calledTestableObservable, made specifcally for RxTest tests. - Use
.next(_:_:)to addnextevents ontoobservableAat the designated virtual times with the value passed as the second parameter. - Create an
observableBhot observable. - Add
nextevents toobservableBat the designated times and with the specified values.
Your test will confirm that using amb between these two observables should result in receiving observableB’s elements, because it emitted first.
To test this, add the following code to use the amb operator and assign the result to a local constant:
let ambObservable = observableA.amb(observableB)
Option-click on ambObservable and you’ll see it’s type is Observable<String>.
Note: If Xcode is on the fritz, you might see
<<error type>>instead. Don’t worry. Xcode will figure things out when you run the test.
Next, add the following code:
self.subscription = ambObservable.subscribe(observer)
You subscribe ambObservable to the observer and assign the subscription to the subscription property so that tearDown() can dispose of the subscription when the test is done.
In order to actually kick off the test and then verify the results, add the following code:
scheduler.start()
This starts the virtual time scheduler, and observer will receive next events via the amb operation.
Now you can now collect and analyze the results. Add this code:
let results = observer.events.compactMap {
$0.value.element
}
You use compactMap on the observer’s events property to access each event’s element. Now add the following code to assert these actual results match your expected results:
XCTAssertEqual(results, ["1", "2", "3"])
Click the diamond button in the gutter to the left of func testAmb() to execute this test.
After Xcode builds and runs this test, you should see that it succeeded, or passed.
You would normally create a negative test to complement this one, to test that the results received do not match what you know they should not be. You have many more tests to write before this chapter is done though, so to quickly check that your test is working, change the assertion to match the following:
XCTAssertEqual(results, ["1", "2", "No you didn't!"])
Run the test again to verify that it failed with this error message:
XCTAssertEqual failed: ("["1", "2", "3"]") is not equal to ("["1", "2", "No you didn't!"]")
Undo that change and run the test again, and confirm it passes again.
You spent a whole chapter learning about filtering operators, so why not test one out? Add this test to TestingOperators, which follows the same format as testAmb():
func testFilter() {
// 1
let observer = scheduler.createObserver(Int.self)
// 2
let observable = scheduler.createHotObservable([
.next(100, 1),
.next(200, 2),
.next(300, 3),
.next(400, 2),
.next(500, 1)
])
// 3
let filterObservable = observable.filter {
$0 < 3
}
// 4
scheduler.scheduleAt(0) {
self.subscription = filterObservable.subscribe(observer)
}
// 5
scheduler.start()
// 6
let results = observer.events.compactMap {
$0.value.element
}
// 7
XCTAssertEqual(results, [1, 2, 2, 1])
}
From the top, you:
- Create an
observer, this time with a generic type ofInt. - Create a hot observable and schedule a
nextevent every virtual second for a total 5 virtual seconds. - Create the
filterObservableto hold the result of usingfilteronobservablewith a predicate that requires the element value to be less than3. - Schedule the subscription to start at time
0and assign it to thesubscriptionproperty so it will be disposed of intearDown(). - Start the scheduler.
- Collect the results.
- Assert that the results are what you expected.
Click the diamond in the gutter for this test to run it, and you should get a green checkmark indicating that the test succeeded.
These tests have been synchronous. When you want to test asynchronous operations, you have a couple of choices. You’ll learn the easiest way first, using RxBlocking.
Using RxBlocking
RxBlocking is another library housed within the RxSwift repo that has its own pod and must be separately imported. Its primary purpose is to convert an observable to a BlockingObservable via its toBlocking(timeout:) method. What this does is block the current thread until the observable terminates, either normally or by reaching the timeout. The timeout argument is an optional TimeInterval which is nil by default. If you set a value for timeout and that time interval elapses before the observable terminates normally, toBlocking will throw an RxError.timeout error. This essentially turns an asynchronous operation into a synchronous one, which makes testing much easier.
Add this test to TestingOperators to test the toArray operator in three lines of code using RxBlocking:
func testToArray() throws {
// 1
let scheduler = ConcurrentDispatchQueueScheduler(qos: .default)
// 2
let toArrayObservable = Observable.of(1, 2).subscribeOn(scheduler)
// 3
XCTAssertEqual(try toArrayObservable.toBlocking().toArray(), [1, 2])
}
What you just did:
- Create a concurrent scheduler to run this asynchronous test, with the default quality of service.
- Create an observable to hold the result of subscribing to an observable of two integers on the
scheduler. - Use
toArrayon the result of callingtoBlocking()ontoArrayObservable, and assert that the return value fromtoArrayequals the expected result.
The toBlocking() operator converts toArrayObservable to a blocking observable, blocking the thread spawned by the scheduler until it terminates. Run the test and you should see it succeed. Three lines of code to test an asynchronous operation — woot!
RxBlocking also has a materialize operator that can be used to examine the result of a blocking operation. It will return a MaterializedSequenceResult, which is an enum with two cases with associated values. From the documentation:
public enum MaterializedSequenceResult<T> {
case completed(elements: [T])
case failed(elements: [T], error: Error)
}
If the observable terminates successfully, the completed case will associate an array of elements emitted from the underlying observable. And if it fails, the failed case will associate both the elements array and the error. Add this new example to the playground, which reimplements the previous test of toArray using materialize:
func testToArrayMaterialized() {
// 1
let scheduler = ConcurrentDispatchQueueScheduler(qos: .default)
let toArrayObservable = Observable.of(1, 2).subscribeOn(scheduler)
// 2
let result = toArrayObservable
.toBlocking()
.materialize()
// 3
switch result {
case .completed(let elements):
XCTAssertEqual(elements, [1, 2])
case .failed(_, let error):
XCTFail(error.localizedDescription)
}
}
Step by step, you:
- Create a scheduler and observable to test, same as in the previous test.
- Call
toBlockingandmaterializeon the observable, and assign the result to a local constantresult. - Switch on
resultand handle each case.
Run the tests again and confirm all tests succeed. As you can see, the usage of materialize in RxBlocking differs from RxSwift, but they are conceptually similar. The RxBlocking version goes the extra step of modeling the result as an enum to make examining it more robust and explicit.
You’ll work more with RxBlocking shortly, but now it’s time to move away from testing operators and write some tests against the app’s production code.
Testing RxSwift production code
Start by opening ViewModel.swift in the Testing group. At the top, you’ll see these property definitions:
let hexString = BehaviorRelay(value: "")
let color: Driver<UIColor>
let rgb: Driver<(Int, Int, Int)>
let colorName: Driver<String>
hexString receives input from the view controller. color, rgb, and colorName are outputs that the view controller will bind to views.
In the initializer for this view model, each output observable is initialized by transforming another observable and returning the result as a Driver. This is the code displayed at the beginning of the chapter.
Below the initializer is an enumeration used to model common color names:
enum ColorName: String {
case aliceBlue = "F0F8FF"
case antiqueWhite = "FAEBD7"
case aqua = "0080FF"
// And many more...
Now open ViewController.swift and focus on the viewDidLoad() implementation:
override func viewDidLoad() {
super.viewDidLoad()
configureUI()
guard let textField = self.hexTextField else { return }
textField.rx.text.orEmpty
.bind(to: viewModel.hexString)
.disposed(by: disposeBag)
for button in buttons {
button.rx.tap
.bind {
var shouldUpdate = false
switch button.titleLabel!.text! {
case "⊗":
textField.text = "#"
shouldUpdate = true
case "←" where textField.text!.count > 1:
textField.text = String(textField.text!.dropLast())
shouldUpdate = true
case "←":
break
case _ where textField.text!.count < 7:
textField.text!.append(button.titleLabel!.text!)
shouldUpdate = true
default:
break
}
if shouldUpdate {
textField.sendActions(for: .valueChanged)
}
}
.disposed(by: disposeBag)
}
viewModel.color
.drive(onNext: { [unowned self] color in
UIView.animate(withDuration: 0.2) {
self.view.backgroundColor = color
}
})
.disposed(by: disposeBag)
viewModel.rgb
.map { "\($0.0), \($0.1), \($0.2)" }
.drive(rgbTextField.rx.text)
.disposed(by: disposeBag)
viewModel.colorName
.drive(colorNameTextField.rx.text)
.disposed(by: disposeBag)
}
From the top, you:
- Bind the text field’s text (or an empty string) to the view model’s
hexStringinput observable. - Loop over the buttons outlet collection, binding taps and switching on the button’s title to determine how to update the text field’s text, and if the text field should send the
valueChangedcontrol event. - Use the view model’s
colordriver to update theview’s background color. - Use the view model’s
rgbdriver to update thergbTextField’s text. - Use the view model’s
colorNamedriver to update thecolorNameTextField’s text.
Open TestingViewModel.swift in the TestingTests group, and change the implementation of setUp() to match the following:
override func setUp() {
super.setUp()
viewModel = ViewModel()
scheduler = ConcurrentDispatchQueueScheduler(qos: .default)
}
Here, you assign viewModel an instance of the app’s ViewModel class, and assign scheduler an instance of a concurrent scheduler with a default quality of service. You’re now ready to write tests against the app’s view model. To begin, you’ll write an asynchronous test using the traditional XCTest API with expectations. Add this test of the view model’s color driver to TestingViewModel:
func testColorIsRedWhenHexStringIsFF0000_async() {
let disposeBag = DisposeBag()
// 1
let expect = expectation(description: #function)
// 2
let expectedColor = UIColor(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)
// 3
var result: UIColor!
}
Here, you:
- Create an expectation to be fulfilled later.
- Create the expected test result
expectedColorequal to a red color. - Define the result to be later assigned.
This is just setup code. Now add the following code to the test to subscribe to the view model’s color driver:
// 1
viewModel.color.asObservable()
.skip(1)
.subscribe(onNext: {
// 2
result = $0
expect.fulfill()
})
.disposed(by: disposeBag)
// 3
viewModel.hexString.accept("#ff0000")
// 4
waitForExpectations(timeout: 1.0) { error in
guard error == nil else {
XCTFail(error!.localizedDescription)
return
}
// 5
XCTAssertEqual(expectedColor, result)
}
With this code, you:
- Create a subscription to the view model’s
colordriver. Notice that you skip the first one because Driver will replay the initial element upon subscription. - Assign the
nextevent element toresultand callfulfill()on the expectation. - Add a new value onto the view model’s
hexStringinput observable, which is aBehaviorRelay. - Wait for the expectation to fulfill with a
1second timeout. In the closure, you guard for an error and then assert that the expected color equals the actual result.
Easy peasy, but a bit verbose. Run that test just to make sure it passes.
Next, add the following test, which accomplishes the same thing by using RxBlocking:
func testColorIsRedWhenHexStringIsFF0000() throws {
// 1
let colorObservable = viewModel.color.asObservable().subscribeOn(scheduler)
// 2
viewModel.hexString.accept("#ff0000")
// 3
XCTAssertEqual(try colorObservable.toBlocking(timeout: 1.0).first(),
.red)
}
In the above code, you:
- Create the
colorObservableto hold on to the observable result of subscribing on the concurrent scheduler. - Add a new value onto the view model’s
hexStringinput observable. - Block the observable and wait for the first element to be emitted, asserting that it emits the expected color.
Run the test to confirm it succeeds. This is essentially the same test as the previous one. You just didn’t have to work as hard.
Next, add this code to test that the view model’s rgb driver emits the expected red, green, and blue values for the given hexString input:
func testRgbIs010WhenHexStringIs00FF00() throws {
// 1
let rgbObservable = viewModel.rgb.asObservable().subscribeOn(scheduler)
// 2
viewModel.hexString.accept("#00ff00")
// 3
let result = try rgbObservable.toBlocking().first()!
XCTAssertEqual(0 * 255, result.0)
XCTAssertEqual(1 * 255, result.1)
XCTAssertEqual(0 * 255, result.2)
}
Step-by-step, you:
- Create
rgbObservableto hold the subscription on the scheduler. - Add a new value onto the view model’s
hexStringinput observable. - Retrieve the first result of calling
toBlockingonrgbObservable, and then assert that each value matches expectations.
The conversion from 0-to-1 to 0-to-255 was just to match the test name and make things easier to follow. Run this test and it should succeed.
One more driver to test. Add this test to TestingViewModel, which tests that the view model’s colorName driver emits the correct element for the given hexString input:
func testColorNameIsRayWenderlichGreenWhenHexStringIs006636() throws {
// 1
let colorNameObservable = viewModel.colorName.asObservable().subscribeOn(scheduler)
// 2
viewModel.hexString.accept("#006636")
// 3
XCTAssertEqual("rayWenderlichGreen", try colorNameObservable.toBlocking().first()!)
}
In this above test, you:
- Create the observable.
- Add the test value.
- Assert that the actual result matches the expected result.
The phrase “rinse and repeat” comes to mind, but in a good way. Writing tests should always be this easy. Press Command-U to run all the tests in this project, and everything should pass with flying colors — actually, with the only color you want to see here: green.
Where to go from here?
Writing tests using RxText and RxBlocking is similar to writing data and UI binding code using RxSwift and RxCocoa. There are no challenges for this chapter, because you will be doing more view model testing in Chapter 24, “MVVM with RxSwift.” Happy testing!