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
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.
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)
}
}
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
/**
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
)
}
}
In this project, we control global variables by using test doubles; Stub and Spy.
// 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
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)
}
}
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:
- xUnit Test Patterns: http://xunitpatterns.com/index.html