Testing iOS Applications
@attheodo
(Breaking the taboos)
Agenda
-
What is testing
-
Configuration
-
Testing Frameworks
-
Nimble fundamendals
-
XCTest fundamendals
-
Testings ViewControllers
-
Pitfalls/Bad practices
-
Going further...
What is testing
-
Tests are code...
-
that run your app's code...
-
and make sure it behaves properly.
Properly?
-
Consistent output for certain input.
-
Proper behaviour for weird input.
Types of Tests
-
Unit Tests
-
Integration Tests
-
User Interface Tests
-
Performance Tests
Cheap
Expensive
Why test?
-
Forced to write better code.
-
Less crapware shipped to customers.
-
Diminished technical debt.
-
Implement new features with confidence.
-
Detect Regressions (things that used to work but broke)
Tests should be FIRST
-
Fast
-
Isolated
-
Repeatable
-
Self-verifying
-
Timely
Configuration
Configuration
Configuration (current project)
Configuration (current project)
Distinguishing testing env
It's useful to know whether your code runs in test mode or in debug/release/testflight mode.
-
Compile time (preprocessor macros)
-
Run-time
-
Launch-time arguments
-
Process info environment
-
ObjC Runtime
Distinguishing testing env
Compile time
Distinguishing testing env
Compile time
Distinguishing testing env
// MARK: Application Lifecycle
func application(
application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?)
-> Bool {
#if !TESTING
registerUINotificationStyles()
exportApplicationMetadataToNSUserDefaults()
loadStoryboard()
IRConnectivityManager.sharedInstance.setup()
#endif
return true
}
Compile time
Distinguishing testing env
Runtime (Launch Args)
Distinguishing testing env
func isTesting() -> Bool {
// get launch arguments from NSProcessInfo
return NSProcessInfo.processInfo().arguments.contains("isTesting")
}
-
Declare it in your AppDelegate/Helpers
-
Use it here and there...
Runtime (Launch Args)
Distinguishing testing env
func isTesting() -> Bool {
// get launch arguments from NSProcessInfo
return NSProcessInfo.processInfo().environment["XCInjectBundle"] != nil
}
-
Essentially checks whether Testing Bundle has been injected...
-
Declare it in your AppDelegate/Helpers
-
Use it here and there...
Runtime (Process Info Environment)
Distinguishing testing env
func isTesting() -> Bool {
return NSClassFromString("XCTestCase") != nil
}
-
Checks with the runtime if `XCTestCase` class has been linked
-
Declare it in your AppDelegate/Helpers
-
Use it here and there...
Runtime (ObjC Runtime)
Testing Frameworks
-
XCUnit
-
Specta
BDD/TDD
-
Kiwi
-
Cedar
-
Quick (Swift)
-
XCUnit
-
Expecta
MATCHERS
-
Nimble (Swift)
Choosing a Testing Framework
-
Good Xcode integration
-
Spec-ing close to your needs/style
BDD/TDD
-
Mocking/Stubbing support
MATCHERS
-
Convenient DSL
-
Async Expectations
My Swifty suggestion as per Jan 2016
-
XCUnit
BDD/TDD
MATCHER
-
Nimble
-
Best Xcode integration
-
Harness is very flexible (barebones)
-
Looks fast so far (122 tests in 5secs)
-
Nimble has great built-in functions and good DSL
Nimble Fundamendals
Actual
Operator
Expected
expect(...)
.to(...)
.notTo(...)
.toNot(...)
equal()
beIdenticalTo()
beLessThan()
beGreaterThan()
beAnInstanceOf()
beAKindOf()
beTrue()
beFalse()
contain()
beEmpty()
Nimble Fundamendals
expect(actual).to(equal(expected))
expect(actual).toNot(equal(expected))
expect(actual).to(beLessThan(expected))
expect(button).to(beAnInstanceOf(UIButton))
expect(string).to(beAKindOf(String))
expect(flag).to(beTrue())
expect(myArray).to(contain('koko'))
expect(myURL).to(contain("http://"))
expect(myURL).to(endWith(".io"))
expect(myArray).to(haveCount(10))
Nimble Fundamendals
expect(actual) == expected
expect(actual) < expected
expect(flag) == true
expect(myArray.contains('koko')) == true
expect(myArray.count) == 10
Nimble Fundamendals
-
C Primitives
-
Lazily computed values
-
Async Expectations
-
Error Handling (assert for exception throwing)
-
Custom Matchers
-
Custom Failure Messages
RTFM
https://github.com/Quick/Nimble
XCTest Fundamendals
@testable import MyAwesomeApp
class MyCalcTests: XCTestCase {
override func setUp() {
super.setUp()
}
override func tearDown() {
super.tearDown()
}
func testItCorrectlyAddsTwoNumbers() {
}
func generateNumbers() -> (a: Int, b: Int) {
return (a: randomNum(), b: randomNum())
}
}
Expose your app's public/internal APIs
Runs before each function that starts with "test"
Runs after each function that starts with "test"
A test case!
A helper method in this
test suite
A test suite
XCTest Fundamendals
import Nimble
@testable import MyAwesomeApp
class MyCalcTests: XCTestCase {
var calc: MyCalc!
override func setUp() {
super.setUp()
calc = MyCalc()
// any additional init setup before each test cases is run
}
override func tearDown() {
super.tearDown()
calc.clearCache()
// any additional teardown before next case is run
}
func testItCorrectlyAddsTwoNumbers() {
let numbers = generateNumbers()
expect(calc.add(numbers.a, numbers.b)) == number.a + numbers.b
}
func generateNumbers() -> (a: Int, b: Int) {
return (a: randomNum(), b: randomNum())
}
}
XCTest Fundamendals
-
⌘ U
Shortcuts you must learn. Speed they give you.
-
ctrl ⌥ ⌘ U
-
⌘ 5
Run all test suites and all their test cases
Run all the cases in current test suite
Show the test navigator
XCTest Fundamendals
Steps towards Continuous Deployment
-
xctool
$ brew install xctool
$ xctool.sh -workspace YourWorkspace.xcworkspace -scheme YourScheme test
-
Scan (Part of fastlane)
$ sudo gem install scan
$ scan --workspace "YourWorkspace.xcworkspace" --scheme "YourScheme" --device "iPhone 6"
XCTest Fundamendals
Now with testing coverage!
XCTest Fundamendals
XCTest Fundamendals
Testing ViewControllers
-
Closest to what your end user sees.
-
Test the crap out of them.
-
But remember, keep them thin.
-
Xcode UI Tests are cool.
-
But quite slow...
-
Treat them as integration-ish tests.
-
Unit-test their dependencies.
Testing ViewControllers
What we need?
-
A setup to trigger viewDidLoad()
-
Reference IBOutlets and trigger events.
-
A way to detect view controller state/hierarchy (UIAlertControllers etc)
-
Ocassionally, a UI stack for pushing/popping vc's.
Testing ViewControllers
A setup to trigger viewDidLoad()
func testViewControllerSetsValueOnViewDidLoad() {
let vc = MyViewController()
let _ = vc.view // This triggers viewDidLoad()
expect(vc.someValueSetOnViewDidLoad) == true
}
-
Accessing view property triggers viewDidLoad()
-
No luck for viewDid/WillAppear() and the rest though...
Testing ViewControllers
A UI stack for poping/pushing VCs
func testViewControllerSetsValueOnViewDidLoad() {
let s = UIStoryboard(name: "Main", bundle: nil)
vc = s.instantiateViewControllerWithIdentifier("VcStoryboardId") as! MyViewController
let dl = UIApplication.sharedApplication().delegate as! AppDelegate
dl.window = UIWindow(frame: UIScreen.mainScreen().bounds)
dl.window!.rootViewController = UINavigationController(rootViewController: vc)
dl.window!.makeKeyAndVisible()
let _ = vc.view // This triggers viewDidLoad()
}
-
Now we have a UIWindow that can interpret touch events etc.
-
We can push pop view controllers using storyboard
Testing ViewControllers
func testPressingLogoutButtonLogoutsTheUser() {
let vc = MyLoginViewController()
let _ = vc.view
expect(vc.logoutButton).to(beAnInstanceOf(UIButton))
// "tap" the logout button
vc.logoutButton.sendActionsForControlEvents(.TouchedUpInside)
expect(MyAuthManager.sharedInstance.isLoggedIn) == false
}
-
Reference IBOutlets and trigger events.
-
IBOutlets are "public" properties of the VC
-
Sending tap actions actually tests they are wired with a UIAction (do not just call their selector)
Testing ViewControllers
func testPressingLogoutButtonPresentsUIAlertWarning() {
let s = UIStoryboard(name: "Main", bundle: nil)
vc = s.instantiateViewControllerWithIdentifier("VcStoryboardId") as! MyViewController
let dl = UIApplication.sharedApplication().delegate as! AppDelegate
dl.window = UIWindow(frame: UIScreen.mainScreen().bounds)
dl.window!.rootViewController = UINavigationController(rootViewController: vc)
dl.window!.makeKeyAndVisible()
let _ = vc.view // This triggers viewDidLoad()
expect(vc.logoutButton).to(beAnInstanceOf(UIButton))
// "tap" the logout button
vc.logoutButton.sendActionsForControlEvents(.TouchedUpInside)
// check that view controller presented a UIAlertController
expect(vc.presentedViewController).to(beAnInstanceOf(UIAlertController))
}
Detect viewcontroller's state/hierarchy
Bad Practices
Specifying small individual units
integrating the whole system
Unit Tests
Integration Tests
Dirty Hybrids
Good
Good
Bad!
(Unclear goal, high maintainance)
Always search for the "sweet spot"
Bad Practices
func testColor() {
// given
let dog = Dog(breed: "German Shepherd")
// then
expect(dog.furColor) == UIColor.brownColor()
}
func testGermanShepherdDogBreedHasProperColor() {
// given
let dog = Dog(breed: "German Shepherd")
// then
expect(dog.furColor) == UIColor.brownColor()
}
Good
Bad!
Shitty names
Bad Practices
Testing private methods
-
Private means Private dude.
-
If you feel the urge to test private methods, there's something wrong with that method.
-
It's probably doing too much.
-
What to do?
-
Uhmm.. refactor?
-
Test the private methods through the public API
-
Extract method into a static class with a contract
Bad Practices
Coupling tests with implementation details
-
NOWHERE in your tests you should call APIs of external libraries.
-
Mocking/stubbing is ok. But make sure you test the mocked/stubbed logic independently.
-
Testing constructors
-
Constructors don't and shouldn't have behaviour
-
They are "implementation details"
Going further....
-
Stubbing/Mocking objects
-
Stubbing Network requests
Continuous Deployment
Questions?
@attheodo
http://attheo.do
Testing iOS Applications
By Thanos Theodoridis
Testing iOS Applications
- 633