In this talk we are going tο… talk about what unit testing is, how you can apply it to ensure your code works as expected and how TDD can help you write new and refactor existing code. Also about how to decide what to test and how you can test real world iOS apps. Finally, we will go through a few tips and tricks that made unit testing in Swift and Xcode 'click' for me and can hopefully help you too.
18. Seriously now, what should I test?
• Code that is very critical - unless it has been tested
otherwise
• Code that is complex (high cyclomatic complexity)
• Code that is likely to stick around but change often
• Code that I want to make cleaner
• Code that has turned out to be buggy
• New code
19. What are unit tests?
Code that tests if a specific unit of code works as
expected
20. A unit is the smallest testable piece of
code in the program.
• Object oriented -> Classes
• Functional -> functions
21. The anatomy of a unit
• The System Under Test (SUT)
• It's collaborators
class ColaWallet {
private var euros: Int
private let colaProvider: ColaProvider
init(euros: Int, colaProvider: ColaProvider) {
self.euros = euros
self.colaProvider = colaProvider
}
func buy(numberOfBottles: Int) -> [ColaBottle] {
euros -= 2
return colaProvider.get(numberOfBottles: numberOfBottles)
}
}
23. Arrange
• Create collaborators that you need to pass to the SUT upon
creation
• Create the sut and inject these collaborators
24. class ColaWalletTests: XCTestCase {
func testBuyMethod() {
// Arrange
let colaProvider = ColaProvider(colaBottles: 50)
let sut = ColaWallet(euros: 20, colaProvider: colaProvider)
// Act
let bottlesJustBought = sut.buy(numberOfBottles: 2)
// Assert
XCTAssertEqual(sut.euros, 16)
XCTAssertEqual(bottlesJustBought.count, 2)
XCTAssertEqual(colaProvider.colaBottles, 48)
}
}
25. Act
• Perform the action we are testing
class ColaWalletTests: XCTestCase {
func testBuyMethod() {
// Arrange
let colaProvider = ColaProvider(colaBottles: 50)
let sut = ColaWallet(euros: 20, colaProvider: colaProvider)
// Act
let bottlesJustBought = sut.buy(numberOfBottles: 2)
// Assert
XCTAssertEqual(sut.euros, 16)
XCTAssertEqual(bottlesJustBought.count, 2)
XCTAssertEqual(colaProvider.colaBottles, 48)
}
}
26. Assert
Check if the action performed as expected by checking
• the return value (if exists)
• how the sut's or the collaborator's state was affected
27. class ColaWalletTests: XCTestCase {
func testBuyMethod() {
// Arrange
let colaProvider = ColaProvider(colaBottles: 50)
let sut = ColaWallet(euros: 20, colaProvider: colaProvider)
// Act
let bottlesJustBought = sut.buy(numberOfBottles: 2)
// Assert
XCTAssertEqual(sut.euros, 16)
XCTAssertEqual(bottlesJustBought.count, 2)
XCTAssertEqual(colaProvider.colaBottles, 48)
}
}
28. Good unit tests should be
1. [F]ast
2. [I]solated
3. [R]epeatable
4. [S]elf-validating
5. [T]imely
29. How can we make tests fast,
isolated and repeatable?
• Avoid shared state between tests
• Avoid slow actions (DB Access, accessing network)
• Avoid actions you cannot control their result
30. The usual suspects:
• URLSession.shared
• UserDefaults.standard
• Date()
• UIApplication.shared
• FileManager.default
...
• Your own singletons
31. How to deal with them
1. Locate collaborators that are singletons or use singletons
indirectly via their own dependencies
2. Extract them as properties
3. Make them injectable
4. Create a protocol that wraps the signatures of their methods
that SUT uses
5. In the tests, inject a test double that conforms to this protocol
32. Example: User Defaults
Before
class SettingsViewController {
func saveTheme(isDark: Bool) {
UserDefaults.standard.set(isDark, forKey: darkTheme)
}
func isThemeDark() -> Bool {
return UserDefaults.standard.bool(forKey: darkTheme)
}
}
class SettingsViewControllerTests: XCTestCase {
var sut: SettingsViewController!
override func setUp() {
super.setUp()
UserDefaults.standard.set(nil, forKey: darkTheme)
sut = SettingsViewController()
}
func testSavingTheme() {
sut.saveTheme(isDark: true)
XCTAssertTrue(UserDefaults.standard.bool(forKey: darkTheme))
}
}
33. Extract injectable property
class SettingsViewController {
let defaults: UserDefaults
public init(store: UserDefaults) {
self.defaults = store
}
func saveTheme(isDark: Bool) {
defaults.set(isDark, forKey: darkTheme)
}
func isThemeDark() -> Bool {
return defaults.bool(forKey: darkTheme)
}
}
34. Use a protocol instead of the real UserDefaults class
protocol KeyValueStore {
func bool(forKey defaultName: String) -> Bool
func set(_ value: Bool, forKey defaultName: String)
}
extension UserDefaults: KeyValueStore {}
class SettingsViewController {
let store: KeyValueStore
public init(_ store: KeyValueStore) {
self.store = store
}
func saveTheme(isDark: Bool) {
store.set(isDark, forKey: darkTheme)
}
func isThemeDark() -> Bool {
return store.bool(forKey: darkTheme)
}
}
35. In the tests inject the SUT with an InMemoryStore that
conforms to the protocol
class InMemoryStore: KeyValueStore {
var booleanKeyValues = [String: Bool]()
func bool(forKey defaultName: String) -> Bool {
return booleanKeyValues[defaultName] ?? false
}
func set(_ value: Bool, forKey defaultName: String) {
booleanKeyValues[defaultName] = value
}
}
class SettingsViewControllerTests: XCTestCase {
var sut: SettingsViewController!
var store: InMemoryStore!
override func setUp() {
super.setUp()
store = InMemoryStore()
sut = SettingsViewController(store)
}
func testSavingTheme() {
sut.saveTheme(isDark: true)
XCTAssertTrue(store.bool(forKey: darkTheme))
}
}
40. Dummy
An object we just want to pass as an argument but don't care
about how it's used. Usually it returns nil or does nothing in it's
methods' implementations
class DummyPersonManager: PersonManager {
func findPerson(withName name: String) -> String? { return nil }
func store(_ person: Person) {}
}
41. Fake
Same behaviour with the collaborator but lighter
implementation without unwanted sidefects
Example: InMemoryStore we saw earlier
42. Stub
An object whose method's return values we control because the
SUT uses it and we want to be able to predict the result of a unit test.
struct AlwaysValidValidatorStub: FormDataValidator {
func isValid(_ data: FormData) -> Bool { return true }
}
// configurable across different tests
struct ValidatorStub: FormDataValidator {
var isValid: Bool
func isValid(_ data: FormData) -> Bool { return isValid }
}
43. Spy
!
A Stub that can also track which of it's methods where called, with what
parameters, how many times, etc.
struct AlwaysValidValidatorSpy: FormDataValidator {
var isValidCallParameters: [FormData] = []
var isValidCalled { return !isValidCallParameters.isEmpty }
func isValid(_ data: FormData) -> Bool {
isValidCallParameters.append(data)
return true
}
}
44. Mock
A Spy that can also verify that a specific set of preconditions have happened
struct ChrisLattnerValidatorMock: FormDataValidator {
var isValidCallParameters: [FormData] = []
var isValidCalled { return !isValidCallParameters.isEmpty }
func isValid(_ data: FormData) -> Bool {
isValidCallParameters.append(data)
return true
}
func validate() -> Bool {
return isValidCallParameters.map { $0.name }.contains("Chris Lattner")
}
}
45. Test Driven Developent (TDD)
Software development process relying on unit tests and on the
RED -> GREEN -> REFACTOR cycle
46.
47. The 3 laws of TDD
• You are not allowed to write any production code unless it is
to make a failing unit test pass.
• You are not allowed to write any more of a unit test than is
sufficient to fail; and compilation failures are failures.
• You are not allowed to write any more production code than
is sufficient to pass the one failing unit test.
51. TDD benefits
1. Breaks complex problems into small simple problems you can focus
on -> confidence when writing new code
2. Tests serve as up to date documentation
3. writing test first makes production code testable / losely coupled
4. Reduced debugging (localized test failures in the code modified
since last success)
5. Refactor more frequently and without fear (in every cycle)
6. Forces us to raise unit coverage
53. Use Xcode keyboard shortcuts for testing
• ⌘ U Runs all tests
• ctrl ⌥ ⌘ U Runs the current test method
• ctrl ⌥ ⌘ G Re-runs the last run
• ctrl ⌘ U Run all tests without building
58. Mock a collaborator if
• it is slow (e.g. network, db, ui)
• it's methods have undeterministic results (e.g. network, db,
current time)
• it's uninspectable (analytics)
• it's methods heve deterministic but difficult to predict
results (high cyclomatic complexity)
59. Testing SUT without mocking
• many codepaths
• complex Arrange
• code losely coupled with tests
60. Mock collabs | test collabs separately
• minimum codepaths
• easier arrange step
• tests tightly coupled with code
• more fragile tests
61. struct SUT {
let collab1: Collab
// would need 2*50 = 100 tests to test everything through the SUT without mocking collaborator
// needs 2 tests for testing this function if collaborator is mocked + 50 tests for the collaborator
func doSth(baz: Bool) -> Int {
if baz {
return collab.calculate() // 1
} else {
return collab.calculate() // 2
}
}
}
struct Collab {
let a: A
// would need 50 tests
func calculate() -> Int {
switch a.value {
case 1:
return 99
case 2:
return 100
case 3:
return 12
// .
// .
default: // case 50
return 50
}
}
}
68. Use factories
for creating complex collaborators if you don't mock them
The problem
Deep dependency graph + Swift type safety = Arrange step
hell
!
73. Seems legit!
func testCanNotifyIfPersonHasValidEmailOrIsNearby() {
let kostas = Person.fromJSONFile(withName: "kostas")
XCTAssertTrue(sut.canNotify(kostas))
}
But!
• No way to tell from the unit test which parts of the person object are being used by
the tested mehtod
• JSON == no type safety -> Each time the collaborator type changes you have to
manually edit countless JSON files
74. We want to go from
func testCanNotifyIfPersonHasValidEmailOrIsNearby() {
let sut = Notifier(location: .athensOffice)
let address = Address(city: "",
country: "",
street: "",
number: "",
postCode: "",
floor: 4,
coords: CLLocationCoordinate2D(latitude: 37.98, longitude: 23.73))
let kostas = Person(id: 0,
firstName: "",
lastName: "",
address: address,
email: "krem@mailcom",
phone: 0)
XCTAssertTrue(sut.canNotify(kostas))
}
75. To
func testCanNotifyIfPersonHasValidEmailOrIsNearby() {
let sut = Notifier(location: .athensOffice)
let kostas = Person.make(
address: .make(coords: CLLocationCoordinate2D(latitude: 37.98, longitude: 23.73)),
email: "")
XCTAssertTrue(sut.canNotify(kostas))
}
What we need is initializers or static factory methods with
default values for all parameters so that we can provide only
the ones we need for our test.
78. TDD + Playgrounds =
• How to do it
• Faster feedback
!
• Playgrounds are still unstable
☹
• Diffucult to use dependencies
• Which pushes you to think twice about design
• You can also use TestDrive to easily use pods
• No full inline failure explanation (only in logs)
79. Behavior-Driven Development
• Uses a DSL and human language sentences
• Closer to user story specs
• Given When Then structure
• Nesting contexts -> keeps tests dry
81. Take small steps!
• Remember the 3 rules of TDD.
• Stay GREEN as much as possible
• Refactoring == changing code - not behaviour while
GREEN
82.
83. legacy code is simply
code without tests
— Michael Feathers
Working Effectively with Legacy Code
84.
85.
86.
87. Resources on Testing / TDD
• StoryPointCalculator Demo project
• AutoMake sourcery template
• Quality coding blog
• GeePaw Blog
• Refactoring: Improving the Design of Existing Code
• Working Effectively with Legacy Code
• Swift by Sundell blog- Testing category
• BDD with Quick and Nimble