Kuniwak
TestableDesignExample
Swift

Sample App to learn a testable design (Smalltalk flavored MVC)

Last updated Feb 4, 2025
83
Stars
6
Forks
1
Issues
0
Stars/day
Attention Score
12
Language breakdown
No language data available.
โ–ธ Files click to expand
README

Testable design example for iOS Apps ====================================

Build Status

This is a sample App to learn testable design.

You can learn the following things by reading this implementation:

  • How to make loose coupling for testing
  • How to decouple global variables
  • How to use type-checking as a test

Architecture


This App adopt Smalltalk flavored MVC (it is not Apple MVC). Smalltalk flavored MVC is a architecture that can test easily. You may know major architectures such as MVVM, MVP, Flux and VIPER, but also Smalltalk MVC can make loose coupling.

While there are a lot of architectures, but they share a common important things that we should do. So, learning this implementation is still worth the candle if you choose other architectures.

Sample Code

In our approach, we create a Xib file per UIViewController. And all UIViewControllers have a initializer that require models.

And we should create ViewBindings and Controllers and connect them to the given Model when UIViewController#loadView() is called.

Concrete implementation is below:

class FooViewController: UIViewController {
    private var model: FooModelProtocol
    private var viewBinding: FooViewBindingProtocol?
    private var controller: FooControllerProtocol?

init(model: FooModelProtocol) { self.model = model super.init(nibName: nil, bundle: nil) }

required init?(coder aDecoder: NSCoder) { // NOTE: In this project, we do not want to restore the VC. return nil }

// Connect Model and ViewBinding, Controller. override func loadView() { let rootView = FooRootView() self.view = rootView

let controller = FooController( observing: rootView.barView, willNotifyTo: self.model ) self.controller = controller

self.viewBinding = FooViewBinding( observing: self.model, handling: ( bar: rootView.barView, baz: rootView.bazView ) ) self.viewBinding.delegate = controller } }

// FooModel is a state-machine that can transit to FooModelState.
// Notify change events to others via an observable didChange when
// API was successfully done or failed.
class FooModel: FooModelProtocol {
    private let repository: FooRepositoryProtocol
    private let stateVariable: RxSwift.Variable<FooModelState>

/// An Observable that will notify events when the internal state is changed. var didChange: RxSwift.Observable<FooModelState> { return self.stateVariable.asObservable() }

/// The current state of the model. var currentState: FooModelState { get { return self.stateVariable.value } set { self.stateVariable.value = newValue } }

init( startingWith initialState: FooModelState, fetchingVia repository: FooRepositoryProtocol ) { self.stateVariable = RxSwift.Variable<FooModelState>(initialState) self.repository = repository }

func doSomething() { switch self.currentState { case .preparing: // NOTE: Prevent duplicated calls. return

case .success, .failure: self.currentState = .preparing

self.repository .doSomething() .then { entity in self.currentState = .success(entity) } .catch { error in self.currentState = .failure( because: .unspecified(debugInfo: "\(error)") ) } } } }

// States that FooModel can transit to. enum FooModelState { case preparing case success(Entity) case failure(because: Reason)

enum Reason { case unspecified(debugInfo: String) } }

class FooViewBinding: FooViewBindingProtocol {
    typealias Views = (bar: BarView, baz: BuzzView)
    private let views: Views
    private let model: FooModelProtocol
    private let disposeBag = RxSwift.DisposeBag()

init(observing model: FooModelProtocol, handling views: Views) { self.model = model self.views = views

// NOTE: Change visual by observing model's state transitions. self.model .didChange .subscribe(onNext: { [weak self] state in guard let this = self else { return } switch state { case .preparing: this.views.bar.text = "preparing" case let .success(entity): this.views.bar.text = "success \(entity)" case let .failure(because: reason): this.views.bar.text = "failure \(reason)" } }) .disposed(by: self.disposeBag) } }

class FooController: FooControllerProtocol {
    private let model: FooModelProtocol
    private let view: BarView
    private let disposeBag = RxSwift.DisposeBag()

init( observing view: BarView, willNotifyTo model: FooModelProtocol ) { self.model = model

// NOTE: Observe UI events from BarView and notify to the FooModel. view.rx.tap .asDriver .drive(onNext: { [weak self] _ in guard let this = self else { return }

this.model.doSomething() }) .disposed(by: self.disposeBag) } }

How to Connect among UIViewControllers


In this project, use Navigator class for connecting betweren 2 UIViewControllers.

class FooViewController: UIViewController {
    private let navigator: NavigatorProtocol
    private let sharedModel: FooBarModelProtocol

init( representing sharedModel: FooBarModelProtocol, navigatingBy navigator: NavigatorProtocol ) { self.sharedModel = sharedModel self.navigator = navigator super.init(nibName: nil, bundle: nil) }

required init?(coder aDecoder: NSCoder) { // NOTE: We should not instantiate the ViewController by using UINibs to // eliminate fields that have force unwrapping types. return nil }

@IBAction func buttonDidTap(sender: Any) { let nextViewController = BarViewController( representing: sharedModel ) self.navigator.navigate(to: nextViewController) } }

And also you can use UIStoryboardSegue, but using the Navigator class have two advantages:

  • We can implement easily and simply common behavior (eg. sending logs for analysis)
  • We can assert necessary objects at once

Navigator Implementation

/**
 A protocol for wrapper class of UINavigationController#pushViewController(_:UIViewController, animated:Bool).
 */
protocol NavigatorProtocol {
    /**
     Push the specified UIViewController to the held UINavigationController.
     */
    func navigate(to viewController: UIViewController, animated: Bool)
}

class Navigator: NavigatorProtocol { private let navigationController: UINavigationController

init (for navigationController: UINavigationController) { self.navigationController = navigationController }

func navigate(to viewController: UIViewController, animated: Bool) { self.navigationController.pushViewController( viewController, animated: animated ) } }

How to Control Global Variables


In this project, we control global variables by using test doubles; Stub and Spy.

Sample code

Bad Design (fragile tests)

// BAD DESIGN
class UserDefaultsCalculator {
    func read10TimesValue() {
        return UserDefaults.standard.integer(forKey: "foo") * 10
    }

func write10TimesValue(_ value: Int) { UserDefaults.standard.set(value * 10, forKey: "foo") } }

// In production code:
let calc = UserDefaultsCalculator()
let value = calc.read10TimesValue()
calc.write10TimesValue(value)

// In the unit-test A, it is fragile :-( let calc = UserDefaultsCalculator() UserDefaults.standard.set(1, forKey: "foo") XCTAssertEqual(calc.read10TimesValue(), 10)

// In the unit-test B, it is also fragile :-( let calc = UserDefaultsCalculator() calc.write10TimesValue(1) XCTAssertEqual(UserDefaults.standard.integer(forKey: "foo"), 10)

Good Design (robust tests)

// GOOD DESIGN
class UserDefaultsCalculator {
    private let readableRepository: ReadableRepositoryProtocol
    private let writableRepository: WritableRepositoryProtocol

init( reading readableRepository: ReadableRepositoryProtocol, writing writableRepository: WritableRepositoryProtocol ) { self.readableRepository = readableRepository self.writableRepository = writableRepository }

func read10TimesValue() { return self.readableRepository.read() * 10 }

func write10TimesValue(value: Int) { self.writableRepository.write(value * 10) } }

protocol ReadableRepositoryProtocol { func read() -> Int }

class ReadableRepository: ReadableRepositoryProtocol { private let userDefaults: UserDefaults

init(reading userDefaults: UserDefaults) { self.userDefaults = userDefaults }

func read() -> Int { return self.userDefaults.integer(forKey: "foo") } }

protocol WritableRepositoryProtocol { func write(_ value: Int) }

class WritableRepository: WritableRepositoryProtocol { private let userDefaults: UserDefaults

init(reading userDefaults: UserDefaults) { self.userDefaults = userDefaults }

func write(_ value: Int) { self.userDefaults.set(value, forKey: "foo") } }

// In production code:
let calc = UserDefaultsCalculator(
    reading: ReadableRepository(UserDefaults.standard),
    writing: WirtableRepository(UserDefaults.standard)
)
let value = calc.read10TimesValue()
calc.write10TimesValue(value)

// In the unit-test A, it is robust, because // we don't touch actual UserDefaults :-D let calc = UserDefaultsCalculator( reading: ReadableRepositoryStub(firstValue: 1), writing: WritableRepositorySpy() ) XCTAssertEqual(calc.read10TimesValue(), 10)

// In the unit-test B, it is also robust :-D let spy = WritableRepositorySpy() let calc = UserDefaultsCalculator( reading: ReadableRepositoryStub(firstValue: 0), writing: spy ) calc.write10TimesValue(1) XCTAssertEqual(spy.callArgs.last!, 10)

// TestDoubles definitions

class ReadableRepositoryStub: ReadableRepositoryProtocol { var nextValue: Int

init(firstValue: Int) { self.nextValue = firstValue }

func read() { return self.nextValue } }

class WritableRepositorySpy: WritableRepositoryProtocol { private(set) var callArgs = [Int]()

func write(_ value: Int) { self.callArgs.append(value) } }

Testing strategy


We stronlgy agree the blog entry; "Just Say No to More End-to-End Tests".

In this project, we use type-checking instead of other tests (unit tests and integration tests and UI tests) to get feedbacks from tests rapidly. Because type-checking is higher effictiveness than other tests.

For example, we can check registering UITableViewCell to UITableVIew before dequeueing by using type-checking:

class MyCell: UITableViewCell {
    /**
     A class for registration token that will create after registering the cell to the specified UITableView.
     */
    struct RegistrationToken {
        // Hide initializer to other objects.
        fileprivate init() {}
    }

/** Registers the cell class to the specified UITableView and returns a registration token. */ static func register(to tableView: UITableView) -> RegistrationToken { tableView.register(R.nib.myCell) return RegistrationToken() }

/** Dequeues the cell by the specified UITableView. You must have a registration token (it means you must register the cell class before dequeueing). */ static func dequeue( by tableView: UITableView, for indexPath: IndexPath, andMustHave token: RegistrationToken ) -> MyCell { guard let cell = tableView.dequeueReusableCell( withIdentifier: R.reuseIdentifier.myCell.identifier, for: indexPath ) as? MyCell else { // > dequeueReusableCell(withIdentifier:for:) // > // > A UITableViewCell object with the associated reuse identifier. // > This method always returns a valid cell. // > // > https://developer.apple.com/reference/uikit/uitableview/1614878-dequeuereusablecell fatalError("This case must be success") }

// Configuring the cell.

return cell } }

Taken together, we should follow the Test Pyramid:

Ideal test volume is extremely few UI tests and few integration tests and much unit tests and much type checkings.

References


  • xUnit Test Patterns: http://xunitpatterns.com/index.html

ยฉ 2026 GitRepoTrend ยท Kuniwak/TestableDesignExample ยท Updated daily from GitHub