/// Key technologies FIleManager / GoogleSignIn / PDFKit / UICollectionView / UICollectionViewDiffableDataSource / UITableView / UITableViewDiffableDataSource / UIKit / XCTest
The application was designed to replace script-baced invoicing system with more efficient iOS invoicing application. The client requests (implemented functionalities) are as follows.
QATester / iOS Developer
This post mainly describes the knowledge and techniques I used throgh testing.
Tests must follow "FIRST" principle
Tests that takes 1/10th of a second to run is a slow unit test.
Tests have no side effects that would persist beyond the test run.
Calling a function with the same input will always yield the same output.
This means using assertions to pass or fail without human verification.
Tests have more value when written before the production code.
How tests are launched?
This gives tests the ecosystem they need to verify interactions with UIKit.
As part of step 3, UIKit gives the app delegate a chance to set up anything the app needs to launch. This may include things like the following:
These are things we don’t want to have happen while running unit tests, therefore AppDelegate must be bypassed to prevent unintended side-effect
https://mokacoding.com/blog/prevent-unit-tests-from-loading-app-delegate-in-swift/
// main.swift import UIKit private func delegateClassName() -> String? { return NSClassFromString("XCTestCase") == nil ? NSStringFromClass(AppDelegate.self) : nil } UIApplicationMain( CommandLine.argc, CommandLine.unsafeArgv, nil, delegateClassName() ) // AppDelegate.swift import UIKit class AppDelegate: UIResponder, UIApplicationDelegate { // ... }
Q) What type of tests are difficult (or untestable)?
A) Ones violate FIRST principle.
Examples include the following:
Globals aren’t a problem if they’re read-only, such as string constants. It’s when we can change the value of a global that we run into the challenges of shared mutable state. One test can set a value that affects a following test.
Examples include the following:
We need each test to run in a clean room.
Earlier test runs or manual testing should not change the outcome of automated tests.
And automated tests should leave no trace that affect later manual testing.
We expect different results for the following:
This section shows some of techniques to isolate difficult dipendencies from test codes.
// Before Adding Backdoor class Singleton { static let shared = Singleton() func doSomething() {} } func main() { Singleton.shared.doSomething() }
// Backdoor applied class MySingleton { static var shared: MySingleton { #if DEBUG if let stubbedInstance = stubbedInstance { return stubbedInstance } #endif return instance } #if DEBUG static var stubbedInstance: MySingleton? #endif static let instance = MySingleton() func doSomething() {} } func main() { MySingleton.stubbedInstance = MySingleton() MySingleton.shared.doSomething() }
Note:
Use this technique when you own singletons.
But in general, you should avoid mixing test code into production code.
Conditional compilation makes code hard to read, reason about, and maintain.
// Before Subclassing and Overriding class Act { static let shared = Act() func doSomething() {} } class ExampleVC: UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) Act.shared.doSomething() } } class ExampleVCTests: XCTestCase { func test_viewDidAppear() { let sut = ExampleVC() // this contains singleton, violates isolation principle sut.loadViewIfNeeded() sut.viewDidAppear(false) } }
// Subclassing and Overriding applied class Act { static let shared = Act() func doSomething() {} } // extract singleton to method for later overriding class ExampleVC: UIViewController { func act() -> Act { Act.shared } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) Act.shared.doSomething() } } class TestableOverrideVC: ExampleVC { override func act() -> Act { Act() } } class OverrideVCTests: XCTestCase { func test_viewDidAppear() { let sut = TestableOverrideVC() // singleton is isolated now sut.loadViewIfNeeded() sut.viewDidAppear(false) } }
Note
Use this technique when do not you own singletons.
The idea is to create a subclass of production code that lives only in test code, or a test-specific subclass.
It gives us a way to override methods that are problematic for testing.
Subclass and Override Method can only be applied to a class that permits subclassing:
// Dependency injection(through property) applied class InstancePropertyVC: UIViewController { lazy var act = Act.shared override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) act.doSomething() } } class InstancePropertyVCTests: XCTestCase { func test_viewDidAppear() { let sut = InstancePropertyVC() sut.act = Act() // singleton is isolated now sut.loadViewIfNeeded() sut.viewDidAppear(false) } }
// Extract behaviour into protocol for a dependency(persistence, etc...) protocol UserDefaultsProtocol { func object(forKey defaultName: String) -> Any? func set(_ value: Any?, forKey defaultName: String) func value(forKey key: String) -> Any? }
// Define Fake-class implementing the protocol class FakeUserDefaults: UserDefaultsProtocol { var store: [String: Any] = [:] func object(forKey defaultName: String) -> Any? { if let obj = store[defaultName] { return obj } return nil } func set(_ value: Any?, forKey defaultName: String) { store[defaultName] = value } func value(forKey key: String) -> Any? { if let val = store[key] { return val } return nil } }
// Declare the dependency as a Protocol-type variable inside main Class class ExampleViewModel: ObservableObject { let userDefaults: UserDefaultsProtocol = UserDefaults.standard }
// Replace dependency with Fake inside test code final class ExampleViewModelTests: XCTesst { private var sut: ExampleViewModel! override func setUpWithError() throws { try super.setUpWithError() sut = ExampleViewModel() sut.userDefaults = FakeUserDefaults() } }
Built-in methods like Data().write(url: URL) have persisting side-effect throughout Tests.
Suppose that Data() is an output of another built-in method, it cannot be replaced with protocol-type object.
In this situation, using wrapper class, you can add indirection layer and seperate dependencies.
// Before removing dependency struct SomeViewModel: ObservableObject { static func getData() -> Data { ... } static func write(url: URL) throws { let data = getData() try data.write(to: url) // this has side effect to test environment, which violates FIRST principle (I for Isolation) } } // Test code final class SomeViewModelTests: XCTesst { private var sut: SomeViewModel! override func setUpWithError() throws { try super.setUpWithError() sut = SomeViewModel() } ... func test_write() throws { try sut.write(url: URL(fileURLWithPath: "dummy_path")) // this is not testable as having persisting side-effect } }
// Introduce Wrapper class for Data class protocol DataHandlerProtocol { func write(data: Data, url: URL, options: Data.WritingOptions) throws } class DataHandler: DataHandlerProtocol { func write(data: Data, url: URL, options: Data.WritingOptions) throws { try data.write(to: url, options: options) } }
// declare Wrapper class in production code struct SomeViewModel: ObservableObject { static var dataHandler: DataHandlerProtocol = DataHandler() static func getData() -> Data { ... } static func saveFile(url: URL) throws { let data = getData() try dataHandler.write(data: data, url: fileURL, options: []) // use wrapper object } }
// Create Fake Wrapper class and inject to test code class FakeDataHandler: DataHandlerProtocol { var files: [String: Data] = [:] // use local variable as a storage, instead of persisting store func write(data: Data, url: URL, options: Data.WritingOptions) throws { files[url.absoluteString] = data } } // Test code final class SomeViewModelTests: XCTesst { private var sut: SomeViewModel! override func setUpWithError() throws { try super.setUpWithError() sut = SomeViewModel() sut.dataHandler = FakeDataHandler() // dependency is removed! } ... func test_write() throws { let data = sut.getData() try sut.write(data: data, url: URL(fileURLWithPath: "dummy_path"), options: []) XCTAssertEqual(sut.files.first, data) } }