Upgrade to Pro — share decks privately, control downloads, hide ads and more …

iOS Unit Testing with Swift 3

David Nix
September 08, 2016

iOS Unit Testing with Swift 3

David Nix

September 08, 2016
Tweet

More Decks by David Nix

Other Decks in Programming

Transcript

  1. » TDD (Test Driven Design) » Quicky discover and fix

    problems » Refactor with confidence » Quick feedback loop » Create reliable, regression-proof apps » Spend less time debugging
  2. Legacy code = code which is not under test Michael

    Feathers, Working Effectively with Legacy Code
  3. From: Chris Lattner re: Swift 4 Reflection: The core team

    is committed to adding powerful dynamic features to Swift. For example, Swift 3 already added nearly all the infrastructure for data reflection (which is already used by the Xcode memory debugger). We should use this infrastructure to build out a powerful user-facing API. Similarly, we would like to design and build out the implementation for dynamic method reflection runtime + API support. source: https://lists.swift.org/pipermail/swift-evolution/Week- of-Mon-20160725/025676.html
  4. » Part of your scheme » Tests are a separate

    bundle and target » Therefore, treated as a separate module
  5. Gives you this boilerplate import XCTest @testable import Swift3_Unit_Testing class

    Swift3_Unit_TestingTests: XCTestCase { override func setUp() { super.setUp() // Put setup code here. This method is called before the invocation of each test method in the class. } override func tearDown() { // Put teardown code here. This method is called after the invocation of each test method in the class. super.tearDown() } func testExample() { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct results. } func testPerformanceExample() { // This is an example of a performance test case. self.measure { // Put the code you want to measure the time of here. } } }
  6. » Inherit from an XCTestCase » Methods under test must

    begin with "test" » New instance of the test case class is created for each test method » Class names do not matter » Append "Test" or "Tests" to your filename (or else focusing won't work for that file) » Ensure your file is included in only your test target
  7. Hold On! Is XCTest the best option? Or should I

    use Quick and Nimble? https://github.com/Quick/Quick https://github.com/Quick/Nimble
  8. Pros: » XCTest guaranteed to work with every version of

    Xcode. » XCTest guaranteed to be tightly integrated with Xcode. » XCTest will not suddenly disappear. Cons: » But I really like BDD.
  9. Run your tests » Command + u -> Build and

    run entire suite » Control + Commad + u -> Run without compiling » Control + Command + Option + u -> Build and run test(s) under cursor (i.e. focus on single test or single file) » Command + Shift + u -> Compile the test suite (Useful for Xcode's autocomplete)
  10. Anatomy of an XCTestCase import XCTest @testable import Swift3_Unit_Testing class

    Swift3_Unit_TestingTests: XCTestCase { let subject = NSObject() // test case instantiated for each `test` method override func setUp() { super.setUp() // Put setup code here. This method is called before the invocation of each test method in the class. } override func tearDown() { // Put teardown code here. This method is called after the invocation of each test method in the class. super.tearDown() } func test_aSillyAssertion() { // a test case XCTAssertNotNil(subject) } func test_anotherAssertion() { // subject is a new instance from the above XCTAssertEqual(subject, subject) } }
  11. Subclass XCTestCase import XCTest class BaseTestCase: XCTestCase { // Easy

    access to common dependencies let commonDependency = NSObject() override func setUp() { // put universal set up here super.setUp() } override func tearDown() { // put universal tear down here super.tearDown() } }
  12. An Example Can you spot the dependency? struct AuthService {

    func authorize(withUsername username: String, password: String, completion: (Error?) -> Void) { let urlStr = "http://example.com/auth?username=\(username)&password=\(password)" let url = URL(string: urlStr)! let task = URLSession.shared.dataTask(with: url) { data, response, error in completion(error) } task.resume() } }
  13. Dependency Injection via initializer struct AuthService { let session: URLSession

    // ... } let service = AuthService(session: URLSession.shared)
  14. struct AuthService { let session: URLSession func authorize(withUsername username: String,

    password: String, completion: (Error?) -> Void) { let urlStr = "http://example.com/auth?username=\(username)&password=\(password)" let url = URL(string: urlStr)! let task = session.dataTask(with: url) { data, response, error in completion(error) } task.resume() } }
  15. Subclass and Override class FakeURLSession: URLSession { let task =

    FakeDataTask() var url: URL? var stubbedError: Error? override func dataTask(with url: URL, completionHandler: (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask { self.url = url completionHandler(nil, nil, stubbedError) return self.task } var didResumeTask: Bool { return self.task.didResume } } class FakeDataTask: URLSessionDataTask { var didResume = false override func resume() { didResume = true } }
  16. Write Your Unit Test func test_authorize() { let session =

    FakeURLSession() let service = AuthService(session: session) service.authorize(withUsername: "bob", password: "dylan", completion: { _ in }) let containsUsername = session.url?.absoluteString.contains("?username=bob") ?? false XCTAssertTrue(containsUsername) let containsPassword = session.url?.absoluteString.contains("&password=dylan") ?? false XCTAssertTrue(containsPassword) XCTAssertTrue(session.didResumeTask) }
  17. Make the Caller's Responsibility Easy struct AuthService { let session:

    URLSession init(session: URLSession = URLSession.shared) { self.session = session } } // then in production code, it's just let service = AuthService()
  18. There is value in creating Fakes If it's easy to

    fake, then the public interface is simple. You can still use OCMock and friends for Objective C types.
  19. Via Function Parameter public func authorize(session: URLSession, withUsername username: String,

    password: String, completion: (Error?) -> Void) { let urlStr = "http://example.com/auth?username=\(username)&password=\(password)" let url = URL(string: urlStr)! let task = session.dataTask(with: url) { data, response, error in completion(error) } task.resume() } You can still use a default value!
  20. Via Public Property Common with delegates in Cocoa Framework struct

    AuthService { var session: URLSession? func authorize(withUsername username: String, password: String, completion: (Error?) -> Void) { let urlStr = "http://example.com/auth?username=\(username)&password=\(password)" let url = URL(string: urlStr)! let task = session?.dataTask(with: url) { data, response, error in completion(error) } task?.resume() } } // Caller var service = AuthService() service.session = URLSession.shared
  21. Via @testable magic public struct AuthService { internal var session:

    URLSession = URLSession.shared public func authorize(withUsername username: String, password: String, completion: (Error?) -> Void) { let urlStr = "http://example.com/auth?username=\(username)&password=\(password)" let url = URL(string: urlStr)! let task = session.dataTask(with: url) { data, response, error in completion(error) } task.resume() } } // Caller @testable import MyApp var service = AuthService() service.session = FakeURLSession()
  22. @testable is an Anti-Pattern* (*Note: my opinion) Tests should not

    care about internal implementation. If they do, makes your code fragile. Public and private is all that really matters. In CirrusMD's codebase, @testable is never written once.
  23. Unfortunately, Swift 3 may make @testable necessary when using 3rd

    party libraries. New access controls in order of most open to most restrictive: 1. Open 2. Public 3. Internal 4. Fileprivate 5. Private
  24. Classes declared as public can no longer be subclassed outside

    of their defining module, and methods declared as public can no longer be overridden outside of their defining module. To allow a class to be externally subclassed or a method to be externally overridden, declare them as open, which is a new access level beyond public. Imported Objective-C classes and methods are now all imported as open rather than public. Unit tests that import a module using an @testable import will still be allowed to subclass public or internal classes as well as override public or internal methods. (SE-0117)
  25. Protocols and Extensions Make your code depend on protocols, not

    concrete types Extend types you don't control to conform to your protocol
  26. URLSession is a type I don't control. Let's protocol him

    the f@#! out. Note: Objective C classes, like NSURLSession from Cocoa will be open by default. I recommend subclass and override. It's a little less work.
  27. Make a custom protocol public protocol NetworkSession { // What

    do we have here? Exact same method signature as defined in URLSession class func dataTask(with url: URL, completionHandler: (Data?, URLResponse?, Error?) -> Swift.Void) -> URLSessionDataTask } public struct AuthService { let session: NetworkSession public init(session: NetworkSession) { self.session = session } public func authorize(withUsername username: String, password: String, completion: (Error?) -> Void) { let urlStr = "http://example.com/auth?username=\(username)&password=\(password)" let url = URL(string: urlStr)! let task = session.dataTask(with: url) { data, response, error in completion(error) } task.resume() } }
  28. Foreign Type PWND extension URLSession: NetworkSession {} // Caller let

    service = AuthService(session: URLSession.shared) // hooray, compiles! This is the "Swifty" way to mock out dependencies.
  29. Define a protocol and extend the foreign type with implementation

    public protocol NetworkService { func makeSyncronousRequest(with url: URL) -> Error? } extension URLSession: NetworkService { public func makeSyncronousRequest(with url: URL) -> Error? { // .. implementation } }
  30. 3rd party Swift libraries can be a pain » No

    initializers » Final classes and structs » Public methods defined in an extension so you can't subclass » I'm looking at you Alamofire » Abandonware » Pro-tip: Expose protocols not concrete types
  31. Words of wisdom Adding a 3rd party library to your

    codebase is like inviting someone to live in your house. They could be fine or they could eat your children. ~ Levi Cook, Spex CTO
  32. Advanced Dependency Injection aka DI Frameworks Another time ... But

    ask me after the talk If you're interested.
  33. Am I currently running the test suite? func IsTestEnvironment() ->

    Bool { return Bundle(identifier: "com.myCompany.myTestBundle") != nil } Please use sparingly!
  34. Get lazy with closures Good news: Prevents implicitly unwrapped optionals

    Bad news: Xcode freaks out and won't autocomplete class MyTestCase: XCTestCase { let session = FakeURLSession() lazy var service: AuthService = { return AuthService(session: self.session) }()
  35. Animations? We don't need no stinkin' animations Preferably, in your

    base test case class: override func setUp() { super.setUp() UIView.setAnimationsEnabled(false) } override func tearDown() { super.tearDown() UIView.setAnimationsEnabled(true) } Not a cure all.
  36. Animations can still get ya func AnimationsEnabled() -> Bool {

    return !IsTestEnvironment() } // In your production code let vc = UIViewController() let modal = UIViewController() vc.present(modal, animated: AnimationsEnabled(), completion: nil) // in your test, this now works XCTAssertNotNil(vc.presentedViewController)
  37. Controllers class MyTestCaseTest: XCTestCase { let controller = UIViewController() override

    func setUp() { super.setUp() controller.loadView() controller.viewDidLoad() } // tests... } Pro tip: Use MVVM, VIPER, MVP or any other pattern to keep your controllers small. Use child view controllers liberally.
  38. Sometimes you need a UIWindow extension UIWindow { static var

    keyWindowForTest: UIWindow { guard let window = UIApplication.shared.keyWindow else { let window = UIWindow() window.makeKeyAndVisible() return window } return window } } // Then in your test override func setUp() { super.setUp() // necessary for presenting view controllers UIWindow.keyWindowForTest.rootViewController = controller }
  39. Table driven tests Built from an array of tuples func

    test_concat() { // Define the "table" let tests: [(arg1: String, arg2: String, expected: String)] = [ ("cat", "hat", "cat hat"), ("red", "yellow", "red yellow"), ("", "", " "), // etc ... ] // Assert once per table row for test in tests { let actual = concat(test.arg1, arg2: test.arg2) XCTAssertEqual(actual, test.expected) } }
  40. Need to add a new test case? Just add a

    new line to the "table". Shameless plug: https://davidnix.io/post/table-driven-tests-in-swift/
  41. Stub out asynchronous code For methods that take callbacks, have

    your fake immediately invoke it with stubbed data. class FakeURLSession: URLSession { var stubbedError: Error? override func dataTask(with url: URL, completionHandler: (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask { // I'm synchronous completionHandler(nil, nil, stubbedError) return FakeDataTask() } }
  42. Use Operation Queues After adding operations to a queue call

    this before making your test assertions. myQueue.waitUntilAllOperationsAreFinished()
  43. Use Futures I typically use a Future pattern. Methods that

    are asynchronous return an instance of a Future class. Then in the test suite, a fake returns an instance of a TestFuture which invokes all code synchronously.
  44. If all else fails As a last resort use XCTest's

    built in async helpers. waitForExpectations(timeout:_ handler:_) Of course, this means you have somehow inject the XCTestExpectation so you can fulfill it during async execution.