Mastering Unit Testing with RxSwift: A Practical Guide

|

RxSwift simplifies working with asynchronous streams of data in iOS development. While its reactive nature offers immense power, it also introduces complexity in testing. This article dives into unit testing in RxSwift, beginning with an example SimpleViewController that demonstrates combining streams and binding to the UI.

We’ll dissect the SimpleViewController, explain its functionality, and explore how to write robust unit tests for the reactive components.

Understanding the SimpleViewController

The SimpleViewController is a basic example of how RxSwift can simplify UI updates by reacting to changes in user input.

Key Features

  1. Dynamic Binding of TextFields to a Label:
    • The SimpleViewController combines the input from two text fields and displays their concatenated values in a label using RxSwift‘s Observable.combineLatest.
  2. Reactive Programming with RxSwift:
    • The RxCocoa extensions for UITextField and UILabel allow seamless binding between UI elements.
  3. Memory Management with DisposeBag:
    • RxSwift uses DisposeBag to ensure subscriptions are disposed of when the view controller is deallocated, preventing memory leaks.
import UIKit
import RxSwift
import RxCocoa

class SimpleViewController: UIViewController {
    
    private let disposeBag = DisposeBag()
    
    let firstTextField: UITextField = {
        let textField = UITextField()
        textField.placeholder = "Enter first value"
        textField.borderStyle = .roundedRect
        textField.translatesAutoresizingMaskIntoConstraints = false
        
        return textField
    }()
    
    let secondTextField: UITextField = {
        let textField = UITextField()
        textField.placeholder = "Enter second value"
        textField.borderStyle = .roundedRect
        textField.translatesAutoresizingMaskIntoConstraints = false
        
        return textField
    }()
    
    let resultLabel: UILabel = {
        let label = UILabel()
        label.text = ""
        label.textAlignment = .center
        label.numberOfLines = 0
        label.translatesAutoresizingMaskIntoConstraints = false
        
        return label
    }()
    
    let stackView: UIStackView = {
        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.alignment = .fill
        stackView.distribution = .equalSpacing
        stackView.spacing = 16
        
        return stackView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupUI()
        setupBindings()
    }
    
    private func setupUI() {
        stackView.addArrangedSubview(firstTextField)
        stackView.addArrangedSubview(secondTextField)
        stackView.addArrangedSubview(resultLabel)
        
        stackView.translatesAutoresizingMaskIntoConstraints = false
        
        view.addSubview(stackView)
        
        NSLayoutConstraint.activate([
            stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
            stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16)
        ])
    }
    
    private func setupBindings() {
        Observable
            .combineLatest(firstTextField.rx.text.orEmpty,
                           secondTextField.rx.text.orEmpty) { firstText, secondText in
                return "First: \(firstText)\nSecond: \(secondText)"
            }
            .bind(to: resultLabel.rx.text)
            .disposed(by: disposeBag)
    }
}

Code Walkthrough

UI Setup

private func setupUI() {
    stackView.addArrangedSubview(firstTextField)
    stackView.addArrangedSubview(secondTextField)
    stackView.addArrangedSubview(resultLabel)
    
    stackView.translatesAutoresizingMaskIntoConstraints = false
    
    view.addSubview(stackView)
    
    NSLayoutConstraint.activate([
        stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
        stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
        stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16)
    ])
}

The setupUI function organizes the UI elements using a vertical UIStackView. Each element—two UITextFields and a UILabel—is added to the stack view. Constraints center the stack view and ensure it adjusts to the screen size.


Reactive Binding with RxSwift

The setupBindings function showcases the core reactive programming approach:

private func setupBindings() {
    Observable
        .combineLatest(firstTextField.rx.text.orEmpty,
                       secondTextField.rx.text.orEmpty) { firstText, secondText in
            return "First: \(firstText)\nSecond: \(secondText)"
        }
        .bind(to: resultLabel.rx.text)
        .disposed(by: disposeBag)
}

Explanation:

  1. combineLatest:
    • Merges two observables (firstTextField.rx.text.orEmpty and secondTextField.rx.text.orEmpty).
    • Emits a combined value every time either text field’s text changes.
  2. bind(to:):
    • Directly binds the merged output string to the text property of the resultLabel.
  3. disposed(by:):
    • Adds the subscription to the disposeBag, ensuring cleanup when the SimpleViewController is deallocated.

Understanding the Unit Tests for SimpleViewController

Unit testing is crucial for ensuring that the components of a view controller behave as expected. In the provided test class, several test cases validate the behavior of SimpleViewController, focusing on its UI setup and the reactive bindings. Let’s break down each test and its purpose.

import XCTest
import RxSwift
import RxCocoa
import RxTest
import RxBlocking

@testable import RXSwift_Exercise

class SimpleViewControllerTests: XCTestCase {
    
    var viewController: SimpleViewController!
    
    override func setUp() {
        super.setUp()
        
        // Initialize the ViewController
        viewController = SimpleViewController()
        
        // Loading the View in the ViewController
        _ = viewController.view
    }
    
    override func tearDown() {
        viewController = nil
        super.tearDown()
    }
    
    func testUISetup() {
        // Checking if the elements are in the interface
        XCTAssertNotNil(viewController.firstTextField)
        XCTAssertNotNil(viewController.secondTextField)
        XCTAssertNotNil(viewController.resultLabel)
        XCTAssertNotNil(viewController.stackView)
        
        // Check ifthe fields and the label were added to the stackView
        XCTAssertTrue(viewController.stackView.arrangedSubviews.contains(viewController.firstTextField))
        XCTAssertTrue(viewController.stackView.arrangedSubviews.contains(viewController.secondTextField))
        XCTAssertTrue(viewController.stackView.arrangedSubviews.contains(viewController.resultLabel))
    }
    
    func testResultLabelUpdatesWithTextFields() {
        // Simulate an entry to the the first field
        viewController.firstTextField.text = "Hello"
        viewController.firstTextField.sendActions(for: .editingChanged)
        
        // Simulate an entry to the the second field
        viewController.secondTextField.text = "World"
        viewController.secondTextField.sendActions(for: .editingChanged)
        
        // Checking the result in the label
        XCTAssertEqual(viewController.resultLabel.text, "First: Hello\nSecond: World")
        
        // Simulate a new entry in the first field
        viewController.firstTextField.text = "Rx"
        viewController.firstTextField.sendActions(for: .editingChanged)
        
        // Simulate a new entry in the second field
        viewController.secondTextField.text = "Swift"
        viewController.secondTextField.sendActions(for: .editingChanged)
        
        // Checking the result in the label
        XCTAssertEqual(viewController.resultLabel.text, "First: Rx\nSecond: Swift")
    }
    
    func testEmptyTextFieldsResultLabel() {
        // Simulating empty fields
        viewController.firstTextField.text = ""
        viewController.firstTextField.sendActions(for: .editingChanged)
        
        viewController.secondTextField.text = ""
        viewController.secondTextField.sendActions(for: .editingChanged)
        
        // Checking the result in the label
        XCTAssertEqual(viewController.resultLabel.text, "First: \nSecond: ")
    }
    
    func testPartialInputInTextFields() {
        // Simulates partial input in text fields
        viewController.firstTextField.text = "Partial"
        viewController.firstTextField.sendActions(for: .editingChanged)
        
        viewController.secondTextField.text = ""
        viewController.secondTextField.sendActions(for: .editingChanged)
        
        // Checking the result in the label
        XCTAssertEqual(viewController.resultLabel.text, "First: Partial\nSecond: ")
    }
}

Code Walkthrough

testUISetup

func testUISetup() {
    // Checking if the elements are in the interface
    XCTAssertNotNil(viewController.firstTextField)
    XCTAssertNotNil(viewController.secondTextField)
    XCTAssertNotNil(viewController.resultLabel)
    XCTAssertNotNil(viewController.stackView)
    
    // Check if the fields and the label were added to the stackView
    XCTAssertTrue(viewController.stackView.arrangedSubviews.contains(viewController.firstTextField))
    XCTAssertTrue(viewController.stackView.arrangedSubviews.contains(viewController.secondTextField))
    XCTAssertTrue(viewController.stackView.arrangedSubviews.contains(viewController.resultLabel))
}

Purpose:

  • Validates the UI setup to ensure that all the key elements (firstTextField, secondTextField, resultLabel, and stackView) are properly instantiated and not nil.
  • Confirms that the UI components (firstTextField, secondTextField, resultLabel) are added to the stackView as arranged subviews.

Why it matters: This ensures the setupUI method has correctly constructed the interface and that the required UI elements are accessible during runtime.

testResultLabelUpdatesWithTextFields

func testResultLabelUpdatesWithTextFields() {
    // Simulate an entry to the the first field
    viewController.firstTextField.text = "Hello"
    viewController.firstTextField.sendActions(for: .editingChanged)
    
    // Simulate an entry to the the second field
    viewController.secondTextField.text = "World"
    viewController.secondTextField.sendActions(for: .editingChanged)
    
    // Checking the result in the label
    XCTAssertEqual(viewController.resultLabel.text, "First: Hello\nSecond: World")
    
    // Simulate a new entry in the first field
    viewController.firstTextField.text = "Rx"
    viewController.firstTextField.sendActions(for: .editingChanged)
    
    // Simulate a new entry in the second field
    viewController.secondTextField.text = "Swift"
    viewController.secondTextField.sendActions(for: .editingChanged)
    
    // Checking the result in the label
    XCTAssertEqual(viewController.resultLabel.text, "First: Rx\nSecond: Swift")
}

Purpose:

  • Ensures that the resultLabel text is updated dynamically when text changes occur in the two text fields (firstTextField and secondTextField).

How it works:

  1. Mock user input by assigning text to the text fields and simulating the .editingChanged event with sendActions(for:).
  2. Verify that the resultLabel reflects the combined values of the text fields, as expected by the reactive binding.

Why it matters: This test guarantees that the setupBindings method properly connects the UITextField inputs to the UILabel output.


3. testEmptyTextFieldsResultLabel

func testEmptyTextFieldsResultLabel() {
    // Simulating empty fields
    viewController.firstTextField.text = ""
    viewController.firstTextField.sendActions(for: .editingChanged)
    
    viewController.secondTextField.text = ""
    viewController.secondTextField.sendActions(for: .editingChanged)
    
    // Checking the result in the label
    XCTAssertEqual(viewController.resultLabel.text, "First: \nSecond: ")
}

Purpose:

  • Validates that the resultLabel displays a proper result when both text fields are empty.

How it works:

  1. Simulate clearing the text in both text fields.
  2. Verify that the resultLabel text matches the expected empty state ("First: \nSecond: ").

Why it matters: Ensures the application handles edge cases gracefully, such as when no input is provided.


4. testPartialInputInTextFields

func testPartialInputInTextFields() {
    // Simulates partial input in text fields
    viewController.firstTextField.text = "Partial"
    viewController.firstTextField.sendActions(for: .editingChanged)
    
    viewController.secondTextField.text = ""
    viewController.secondTextField.sendActions(for: .editingChanged)
    
    // Checking the result in the label
    XCTAssertEqual(viewController.resultLabel.text, "First: Partial\nSecond: ")
}

Purpose:

  • Tests the behavior when only one text field contains input, and the other is empty.

How it works:

  1. Assign text to one field while leaving the other empty.
  2. Verify the resultLabel displays the partial input correctly.

Why it matters: This ensures the app doesn’t break or behave unexpectedly when one text field is left blank.


General Notes on the Test Class

  1. Lifecycle Methods:
    • setUp initializes the SimpleViewController and ensures its view is loaded before each test.
    • tearDown cleans up the instance after each test, avoiding memory leaks or shared state between tests.
  2. Reactive Testing with UI Components:
    • The tests simulate user interaction by modifying UITextField values and triggering their actions (.editingChanged).
    • This mimics real user behavior, ensuring the SimpleViewController reacts correctly in practice.
  3. Validation Strategy:
    • The tests systematically validate different scenarios:
      • Full input in both fields (testResultLabelUpdatesWithTextFields).
      • Empty input (testEmptyTextFieldsResultLabel).
      • Partial input (testPartialInputInTextFields).
    • This ensures the SimpleViewController behaves predictably regardless of user input.

Conclusion

These unit tests validate that SimpleViewController:

  • Sets up its UI elements correctly.
  • Properly binds text fields to the result label using reactive programming.
  • Handles edge cases like empty or partial input gracefully.

By using techniques like simulating user interaction and verifying output, these tests provide confidence that the reactive logic works as expected in real-world scenarios. Combined with tools like RxTest or RxBlocking, these approaches can be extended to test more complex reactive applications.

Check out the repository source code

https://github.com/san0suke/rxswift-tests-example

Check my other article about RXSwift Testing using MVVM