Mastering Unit Testing with RxSwift: Reactive MVVM Architecture

|

In this section, we enhance our understanding of RxSwift by implementing the Model-View-ViewModel (MVVM) pattern in a simple form app. The SimpleFormMVVMViewController demonstrates how to bind UI elements to a ViewModel, making it reactive and decoupled.

import UIKit
import RxSwift
import RxCocoa

class SimpleFormMVVMViewController: UIViewController {
    
    let disposeBag = DisposeBag()
    let viewModel = SimpleFormViewModel()
    
    let nameTextField: UITextField = {
        let textField = UITextField()
        textField.translatesAutoresizingMaskIntoConstraints = false
        textField.borderStyle = .roundedRect
        textField.placeholder = "Enter your name"
        
        return textField
    }()
    
    let lastNameTextField: UITextField = {
        let textField = UITextField()
        textField.translatesAutoresizingMaskIntoConstraints = false
        textField.borderStyle = .roundedRect
        textField.placeholder = "Enter your last name"
        
        return textField
    }()
    
    let submitButton: UIButton = {
        let button = UIButton(type: .roundedRect)
        button.setTitle("Submit", for: .normal)
        button.translatesAutoresizingMaskIntoConstraints = false
        button.backgroundColor = .systemBlue
        button.tintColor = .white
        button.layer.cornerRadius = 8
        
        return button
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupUI()
        setupBindings()
    }
    
    private func setupUI() {
        view.addSubview(nameTextField)
        view.addSubview(lastNameTextField)
        view.addSubview(submitButton)
        
        NSLayoutConstraint.activate([
            nameTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
            nameTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
            nameTextField.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
            
            lastNameTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
            lastNameTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
            lastNameTextField.topAnchor.constraint(equalTo: nameTextField.bottomAnchor, constant: 16),
            
            submitButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
            submitButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
            submitButton.topAnchor.constraint(equalTo: lastNameTextField.bottomAnchor, constant: 16),
        ])
    }
    
    private func setupBindings() {
        nameTextField.rx.text.orEmpty
            .bind(to: viewModel.name)
            .disposed(by: disposeBag)
        
        lastNameTextField.rx.text.orEmpty
            .bind(to: viewModel.lastName)
            .disposed(by: disposeBag)
        
        viewModel.isSubmitButtonEnable
            .observe(on: MainScheduler.instance)
            .bind(to: submitButton.rx.isEnabled)
            .disposed(by: disposeBag)
        
        viewModel.isSubmitButtonEnable
            .observe(on: MainScheduler.instance)
            .subscribe { [weak self] isEnabled in
                self?.submitButton.backgroundColor = isEnabled ? .systemBlue : .systemGray
            }
            .disposed(by: disposeBag)
        
        submitButton.rx.tap
            .bind(to: viewModel.submitAction)
            .disposed(by: disposeBag)
    }
}

Understanding the SimpleFormMVVMViewController

This view controller utilizes the MVVM architecture:

  1. View (ViewController):
    • Handles the UI and binds it to the ViewModel.
  2. ViewModel:
    • Contains the reactive logic and state management.
  3. Model:
    • Represents the underlying data structure (not explicitly included in this example).

This structure ensures clear separation of concerns, making the code modular, testable, and scalable.


Code Walkthrough

1. UI Setup

The setupUI function creates and arranges three UI elements:

  • nameTextField for the first name.
  • lastNameTextField for the last name.
  • submitButton to trigger the form submission.

The constraints align these elements vertically with consistent spacing:

private func setupUI() {
    view.addSubview(nameTextField)
    view.addSubview(lastNameTextField)
    view.addSubview(submitButton)
    
    NSLayoutConstraint.activate([
        nameTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
        nameTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
        nameTextField.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
        
        lastNameTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
        lastNameTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
        lastNameTextField.topAnchor.constraint(equalTo: nameTextField.bottomAnchor, constant: 16),
        
        submitButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
        submitButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
        submitButton.topAnchor.constraint(equalTo: lastNameTextField.bottomAnchor, constant: 16),
    ])
}

This straightforward layout centers the form on the screen, adhering to best practices for responsive design.


2. Reactive Bindings

The setupBindings function is where the magic of reactive MVVM happens. Each UI component is bound to a corresponding property or action in the ViewModel.

private func setupBindings() {
    nameTextField.rx.text.orEmpty
        .bind(to: viewModel.name)
        .disposed(by: disposeBag)
    
    lastNameTextField.rx.text.orEmpty
        .bind(to: viewModel.lastName)
        .disposed(by: disposeBag)
}
  • TextField Binding:
    • nameTextField and lastNameTextField are bound to viewModel.name and viewModel.lastName.
    • As the user types, the text is updated in the ViewModel.

3. Button State Management

The submitButton is enabled or disabled based on the ViewModel‘s logic, using the isSubmitButtonEnable observable:

viewModel.isSubmitButtonEnable
    .observe(on: MainScheduler.instance)
    .bind(to: submitButton.rx.isEnabled)
    .disposed(by: disposeBag)

viewModel.isSubmitButtonEnable
    .observe(on: MainScheduler.instance)
    .subscribe { [weak self] isEnabled in
        self?.submitButton.backgroundColor = isEnabled ? .systemBlue : .systemGray
    }
    .disposed(by: disposeBag)
  • Reactive Enabling:
    • isSubmitButtonEnable determines if the button should be enabled based on the input validation logic in the ViewModel.
  • Dynamic Styling:
    • Subscribing to isSubmitButtonEnable allows us to update the button’s background color dynamically for better UX.

4. Handling Button Taps

submitButton.rx.tap
    .bind(to: viewModel.submitAction)
    .disposed(by: disposeBag)
  • Button Tap Binding:
    • The submitButton‘s tap event is bound to viewModel.submitAction, triggering the form submission logic.


Understanding the SimpleFormViewModel

The SimpleFormViewModel is a key component in the MVVM (Model-View-ViewModel) architecture, designed to handle the logic and state management of a simple form. It leverages RxSwift to provide reactive streams for managing user input and actions.

import UIKit
import RxSwift
import RxCocoa

class SimpleFormViewModel {
    
    let name = BehaviorRelay<String>(value: "")
    let lastName = BehaviorRelay<String>(value: "")
    let submitAction = PublishSubject<Void>()
    
    private let disposeBag = DisposeBag()
    
    var isSubmitButtonEnable: Observable<Bool> {
        return Observable
            .combineLatest(name, lastName) { name, lastName in
                return !name.isEmpty && !lastName.isEmpty
            }
    }
    
    init() {
        setupBindings()
    }
    
    private func setupBindings() {
        submitAction
            .withLatestFrom(Observable.combineLatest(name, lastName))
            .subscribe(onNext: { name, lastName in
                print(name)
                print(lastName)
            })
            .disposed(by: disposeBag)
    }
}

Code Breakdown

1. Properties

let name = BehaviorRelay<String>(value: "")
let lastName = BehaviorRelay<String>(value: "")
let submitAction = PublishSubject<Void>()
private let disposeBag = DisposeBag()
  • name and lastName:
    • These are BehaviorRelays, which hold the latest value of the user’s input for the name and last name fields. They are used as observable sources that can emit updates whenever their values change.
  • submitAction:
    • A PublishSubject that emits an event when the submit button is tapped. This acts as a trigger for handling form submission.
  • disposeBag:
    • Manages the lifecycle of subscriptions to avoid memory leaks. When the ViewModel is deallocated, all active subscriptions in the disposeBag are disposed of automatically.

2. Reactive Output: isSubmitButtonEnable

var isSubmitButtonEnable: Observable<Bool> {
    return Observable
        .combineLatest(name, lastName) { name, lastName in
            return !name.isEmpty && !lastName.isEmpty
        }
}
  • What it does:
    • Combines the latest values of name and lastName.
    • Emits true if both fields are non-empty; otherwise, emits false.
  • Why it matters:
    • This is used to enable or disable the submit button dynamically based on the form’s validity. For example:
      • If the user types “John” in the name field and leaves lastName empty, the button remains disabled.
      • Once both fields have values, the button becomes enabled.
  • Key RxSwift Operator:
    • combineLatest:
      • Combines the latest emissions from multiple observables and applies a transformation (closure) to produce a single observable output.

3. Initializer: init()

init() {
    setupBindings()
}

The initializer calls setupBindings to configure the reactive logic for handling the form submission action.


4. Private Method: setupBindings()

private func setupBindings() {
    submitAction
        .withLatestFrom(Observable.combineLatest(name, lastName))
        .subscribe(onNext: { name, lastName in
            print(name)
            print(lastName)
        })
        .disposed(by: disposeBag)
}
  • What it does:
    • Listens for taps on the submit button (via submitAction).
    • When the submit button is tapped, retrieves the latest values of name and lastName using withLatestFrom.
    • Prints the values of name and lastName to the console.
  • Key RxSwift Operators:
    • withLatestFrom:
      • Retrieves the latest values from the specified observable (in this case, the combined name and lastName) when the submitAction emits an event.
      • This ensures the form submission always uses the most recent user input.
    • subscribe:
      • Observes and handles the emitted event. Here, it prints the combined name and lastName values to the console.
  • Why it matters:
    • This method encapsulates the form submission logic. The ViewModel is responsible for processing the user’s input, ensuring that the ViewController stays focused on managing the UI.

How It Works Together

The SimpleFormViewModel integrates the user’s inputs and actions as follows:

  1. Dynamic Input Handling:
    • The name and lastName properties are continuously updated as the user types.
    • The isSubmitButtonEnable observable dynamically reflects the form’s validity.
  2. Form Submission:
    • When the submit button is tapped, submitAction emits an event.
    • The latest values of name and lastName are retrieved and processed (in this case, printed to the console).
  3. Decoupling Logic:
    • All business logic (e.g., checking if fields are empty or handling form submissions) resides in the ViewModel, ensuring a clear separation of concerns.

Extending the SimpleFormViewModel

The SimpleFormViewModel can be extended to include more complex logic, such as:

  • Error Handling: Validate the input fields and emit error messages to be displayed in the ViewController.
var nameErrorMessage: Observable<String?> {
    return name.map { $0.isEmpty ? "Name is required" : nil }
}
  • Network Requests: Use submitAction to trigger an API call or perform data processing asynchronously.
private func setupBindings() {
    submitAction
        .withLatestFrom(Observable.combineLatest(name, lastName))
        .flatMapLatest { name, lastName in
            return self.submitForm(name: name, lastName: lastName)
                .catchAndReturn("Submission failed")
        }
        .subscribe(onNext: { response in
            print(response)
        })
        .disposed(by: disposeBag)
}

private func submitForm(name: String, lastName: String) -> Observable<String> {
    // Simulate a network call
    return Observable.just("Form submitted successfully")
        .delay(.seconds(1), scheduler: MainScheduler.instance)}

Unit Tests for SimpleFormMVVMViewController

The provided test class validates the behavior of the SimpleFormMVVMViewController‘s submitButton, ensuring it dynamically responds to user input in the text fields. These tests verify the interaction between the UI and the SimpleFormViewModel.

import XCTest
import RxSwift
import RxCocoa
import RxTest
import RxBlocking

@testable import RXSwift_Exercise

class SimpleFormMVVMViewControllerTests: XCTestCase {
    
    var viewController: SimpleFormMVVMViewController!
    
    override func setUp() {
        super.setUp()
        
        viewController = SimpleFormMVVMViewController()
        _ = viewController.view
    }
    
    override func tearDown() {
        viewController = nil
        
        super.tearDown()
    }
    
    func testSubmitButtonIsDisableWhenFormEmpty() {
        viewController.nameTextField.text = ""
        viewController.nameTextField.sendActions(for: .editingChanged)
        
        viewController.lastNameTextField.text = ""
        viewController.lastNameTextField.sendActions(for: .editingChanged)
        
        XCTAssertFalse(viewController.submitButton.isEnabled)
    }
    
    func testSubmitButtonIsEnabledWhenFormNotEmpty() {
        viewController.nameTextField.text = "Batman"
        viewController.nameTextField.sendActions(for: .editingChanged)
        
        viewController.lastNameTextField.text = "Returns"
        viewController.lastNameTextField.sendActions(for: .editingChanged)
        
        XCTAssertTrue(viewController.submitButton.isEnabled)
    }
}

Test Class Overview

  1. Properties:
    • viewController: An instance of SimpleFormMVVMViewController, which is the subject under test.
  2. Lifecycle Methods:
    • setUp: Initializes the viewController before each test and ensures its view is loaded.
    • tearDown: Cleans up the viewController after each test to avoid shared state or memory leaks.

1. Test: testSubmitButtonIsDisableWhenFormEmpty

func testSubmitButtonIsDisableWhenFormEmpty() {
    viewController.nameTextField.text = ""
    viewController.nameTextField.sendActions(for: .editingChanged)
    
    viewController.lastNameTextField.text = ""
    viewController.lastNameTextField.sendActions(for: .editingChanged)
    
    XCTAssertFalse(viewController.submitButton.isEnabled)
}

Purpose:

This test ensures the submitButton is disabled when both nameTextField and lastNameTextField are empty.

How It Works:

  1. Simulate User Input:
    • Sets the text of nameTextField and lastNameTextField to empty strings ("").
    • Triggers .editingChanged actions using sendActions(for:) to simulate the user clearing the text fields.
  2. Assert Button State:
    • Asserts that submitButton.isEnabled is false.

Expected Behavior:

When the form is empty, the submitButton should remain disabled.


2. Test: testSubmitButtonIsEnabledWhenFormNotEmpty

func testSubmitButtonIsEnabledWhenFormNotEmpty() {
    viewController.nameTextField.text = "Batman"
    viewController.nameTextField.sendActions(for: .editingChanged)
    
    viewController.lastNameTextField.text = "Returns"
    viewController.lastNameTextField.sendActions(for: .editingChanged)
    
    XCTAssertTrue(viewController.submitButton.isEnabled)
}

Purpose:

This test ensures the submitButton is enabled when both nameTextField and lastNameTextField have valid input.

How It Works:

  1. Simulate User Input:
    • Sets the text of nameTextField to "Batman" and lastNameTextField to "Returns".
    • Triggers .editingChanged actions to simulate the user typing in both fields.
  2. Assert Button State:
    • Asserts that submitButton.isEnabled is true.

Expected Behavior:

When both fields have valid input, the submitButton should be enabled.


Key Concepts in These Tests

1. UI and Reactive Binding Validation

These tests indirectly validate the reactive binding between:

  • The nameTextField and lastNameTextField inputs.
  • The submitButton‘s isEnabled state, which is controlled by the isSubmitButtonEnable observable in the SimpleFormViewModel.

By simulating user interaction and asserting the button’s state, these tests confirm that the bindings work correctly.


2. Simulating User Interaction

The use of sendActions(for:) is critical to simulating user input. It triggers the .editingChanged event programmatically, mimicking the behavior of a real user typing in the text fields.


3. State Assertions

The tests rely on the XCTAssertTrue and XCTAssertFalse assertions to check the state of the submitButton. These assertions ensure the button’s state accurately reflects the form’s validity.


Why These Tests Are Important

  1. Form Validation:
    • They confirm the form validation logic works as expected by enabling or disabling the submit button based on input.
  2. MVVM Integration:
    • These tests indirectly validate the interaction between the SimpleFormMVVMViewController (View) and the SimpleFormViewModel.
  3. User Experience Assurance:
    • Ensures a seamless user experience where the button state dynamically reflects the validity of the form inputs.

Next Steps

These tests cover basic functionality. To expand coverage, consider:

  • Validating additional scenarios, such as partial input (e.g., only one text field is filled).
  • Testing the behavior when the submitButton is tapped (e.g., triggering the submitAction and observing the output).
  • Verifying that the submitButton’s appearance changes dynamically (e.g., background color for disabled/enabled states).

These additional tests would further ensure the correctness and reliability of the app’s reactive behavior.

Unit Tests for SimpleFormViewModel

Unit testing ensures the SimpleFormViewModel behaves as expected, especially its reactive properties and actions. The provided test cases validate key aspects, such as form validation and submit action logic, using tools like RxBlocking and RxTest.

import XCTest
import RxSwift
import RxCocoa
import RxTest
import RxBlocking

@testable import RXSwift_Exercise

class SimpleFormViewModelTests: XCTestCase {
    
    var viewModel: SimpleFormViewModel!
    var scheduler: TestScheduler!
    var disposeBag: DisposeBag!
    
    override func setUp() {
        super.setUp()
        viewModel = SimpleFormViewModel()
        scheduler = TestScheduler(initialClock: 0)
        disposeBag = DisposeBag()
    }
    
    override func tearDown() {
        viewModel = nil
        scheduler = nil
        disposeBag = nil
        super.tearDown()
    }
    
    func testIsSubmitButtonEnable_WhenFieldsAreEmpty_ShouldBeDisabled() {
        let isEnabled = try! viewModel.isSubmitButtonEnable
            .toBlocking(timeout: 1.0)
            .first()
        
        XCTAssertFalse(isEnabled ?? true)
    }
    
    func testIsSubmitButtonEnable_WhenFieldsAreFilled_ShouldBeEnabled() {
        viewModel.name.accept("John")
        viewModel.lastName.accept("Doe")
        
        let isEnabled = try! viewModel.isSubmitButtonEnable
            .toBlocking(timeout: 1.0)
            .first()
        
        XCTAssertTrue(isEnabled ?? false)
    }
    
    func testSubmitAction_WhenButtonTapped_ShouldPrintNameAndLastName() {
        viewModel.name.accept("Jane")
        viewModel.lastName.accept("Smith")
        
        let observer = scheduler.createObserver((String, String).self)
        
        viewModel.submitAction
            .withLatestFrom(Observable.combineLatest(viewModel.name, viewModel.lastName))
            .subscribe(observer)
            .disposed(by: disposeBag)
        
        viewModel.submitAction.onNext(())
        
        scheduler.start()
        
        let expectedEvents = [
            Recorded.next(0, ("Jane", "Smith"))
        ]
        
        XCTAssertEqual(observer.events.count, expectedEvents.count, "Wrong Events number")
        for (index, event) in observer.events.enumerated() {
            XCTAssertEqual(event.time, expectedEvents[index].time, "Wrong index: \(index).")
            XCTAssertEqual(event.value.element?.0, expectedEvents[index].value.element?.0, "Wrong first value \(index).")
            XCTAssertEqual(event.value.element?.1, expectedEvents[index].value.element?.1, "Wrong seccond value \(index).")
        }
    }
}

Test Class Overview

  1. Properties:
    • viewModel: The SimpleFormViewModel instance being tested.
    • scheduler: A TestScheduler to simulate and control the timing of events.
    • disposeBag: A container for disposing subscriptions after each test.
  2. Lifecycle Methods:
    • setUp: Initializes the test environment before each test.
    • tearDown: Cleans up resources after each test.

1. Test: testIsSubmitButtonEnable_WhenFieldsAreEmpty_ShouldBeDisabled

func testIsSubmitButtonEnable_WhenFieldsAreEmpty_ShouldBeDisabled() {
    let isEnabled = try! viewModel.isSubmitButtonEnable
        .toBlocking(timeout: 1.0)
        .first()
    
    XCTAssertFalse(isEnabled ?? true)
}

Purpose:

Verifies that the isSubmitButtonEnable observable emits false when both fields (name and lastName) are empty.

How It Works:

  1. Access the Observable:
    • isSubmitButtonEnable is converted to a blocking observable using toBlocking() to capture the value synchronously.
  2. Assert Output:
    • Checks if the first emitted value is false.

Expected Behavior:

The button should be disabled (isEnabled == false) because the fields are empty.


2. Test: testIsSubmitButtonEnable_WhenFieldsAreFilled_ShouldBeEnabled

func testIsSubmitButtonEnable_WhenFieldsAreFilled_ShouldBeEnabled() {
    viewModel.name.accept("John")
    viewModel.lastName.accept("Doe")
    
    let isEnabled = try! viewModel.isSubmitButtonEnable
        .toBlocking(timeout: 1.0)
        .first()
    
    XCTAssertTrue(isEnabled ?? false)
}

Purpose:

Ensures the isSubmitButtonEnable observable emits true when both fields are filled.

How It Works:

  1. Simulate User Input:
    • The name and lastName properties are updated using .accept(), simulating a user entering text into the fields.
  2. Assert Output:
    • Captures the first emitted value from isSubmitButtonEnable and asserts that it is true.

Expected Behavior:

The button should be enabled (isEnabled == true) because both fields have valid input.


3. Test: testSubmitAction_WhenButtonTapped_ShouldPrintNameAndLastName

func testSubmitAction_WhenButtonTapped_ShouldPrintNameAndLastName() {
    viewModel.name.accept("Jane")
    viewModel.lastName.accept("Smith")
    
    let observer = scheduler.createObserver((String, String).self)
    
    viewModel.submitAction
        .withLatestFrom(Observable.combineLatest(viewModel.name, viewModel.lastName))
        .subscribe(observer)
        .disposed(by: disposeBag)
    
    viewModel.submitAction.onNext(())
    
    scheduler.start()
    
    let expectedEvents = [
        Recorded.next(0, ("Jane", "Smith"))
    ]
    
    XCTAssertEqual(observer.events.count, expectedEvents.count, "Wrong Events number")
    for (index, event) in observer.events.enumerated() {
        XCTAssertEqual(event.time, expectedEvents[index].time, "Wrong index: \(index).")
        XCTAssertEqual(event.value.element?.0, expectedEvents[index].value.element?.0, "Wrong first value \(index).")
        XCTAssertEqual(event.value.element?.1, expectedEvents[index].value.element?.1, "Wrong seccond value \(index).")
    }
}

Purpose:

Validates that the submitAction emits the correct name and lastName values when triggered.

How It Works:

  1. Set Up Input:
    • Simulates user input by setting name to "Jane" and lastName to "Smith".
  2. Create an Observer:
    • Uses TestScheduler to create an observer for monitoring the combined name and lastName emitted by submitAction.
  3. Trigger the Action:
    • Simulates a button tap by calling submitAction.onNext(()).
  4. Verify Emitted Events:
    • Compares the emitted events from the observer to the expected events:
      • Recorded.next(0, ("Jane", "Smith")): At time 0, the values "Jane" and "Smith" are emitted.

Expected Behavior:

The combined name and lastName should be emitted as ("Jane", "Smith") when the submit button is tapped.


Key Testing Concepts

1. RxBlocking for Synchronous Testing

RxBlocking converts asynchronous observables into blocking calls, making it easier to test emitted values:

Usage:

try! viewModel.isSubmitButtonEnable.toBlocking(timeout: 1.0).first()

Benefit:

Captures emitted values synchronously for assertions.


2. RxTest for Event Simulation

RxTest provides utilities like TestScheduler and TestableObserver to simulate and observe events in a controlled environment:

  • TestScheduler:
    • Simulates time for reactive streams, allowing precise control over when events are emitted.
  • createObserver:
    • Captures events emitted by an observable for comparison against expected results.
  • Event Format:
    • Recorded.next(time, value):
      • Represents an event emitted at a specific time.

Conclusion

These unit tests ensure the SimpleFormViewModel:

  1. Dynamically enables or disables the submit button based on form input.
  2. Correctly emits the name and lastName values when the submit button is tapped.

By leveraging RxBlocking and RxTest, the tests cover both real-time observable behavior and precise event simulation, providing confidence in the reactive logic’s correctness.

Check out the repository source code

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