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

Testing in Swift: The Pursuit of Happiness

Testing in Swift: The Pursuit of Happiness

Luis Recuenco

April 03, 2019
Tweet

More Decks by Luis Recuenco

Other Decks in Programming

Transcript

  1. Three basic questions 1. What's the purpose of testing? 2.

    What makes code difficult to test? 3. What to test?
  2. What's the purpose of testing? • Proving correct behavior •

    Changeability • Refactoring with confidence
  3. What's the purpose of testing? • Proving correct behavior •

    Changeability • Refactoring with confidence • Reduce bugs
  4. What's the purpose of testing? • Proving correct behavior •

    Changeability • Refactoring with confidence • Reduce bugs • Documentation
  5. What's the purpose of testing? • Proving correct behavior •

    Changeability • Refactoring with confidence • Reduce bugs • Documentation • Application design when doing tests first
  6. func apply( to job: Job, api: API, currentUser: User, )

    { guard currentUser.canApply else { return } api.apply(to: job) }
  7. class ViewModel { init(api: API) { ... } var state:

    State func fetchData() { guard api.canFetchData else { return } api.fetchData() } func numberOfItems() -> Int { ... } }
  8. class ViewModel { init(api: API) { ... } var state:

    State func fetchData() { // Incoming command guard api.canFetchData else { return } api.fetchData() } func numberOfItems() -> Int { ... } }
  9. class ViewModel { init(api: API) { ... } var state:

    State func fetchData() {} guard api.canFetchData else { return } api.fetchData() } func numberOfItems() -> Int { ... } // Incoming query }
  10. class ViewModel { init(api: API) { ... } var state:

    State { ... } func fetchData() { guard api.canFetchData else { return } api.fetchData() // Outgoing command } func numberOfItems() -> Int { ... } }
  11. class ViewModel { init(api: API) { ... } var state:

    State { ... } func fetchData() { guard api.canFetchData else { return } // Outgoing query api.fetchData() } func numberOfItems() -> Int { ... } }
  12. class ViewModel { init(api: API) { ... } var state:

    State { ... } // Side effect func fetchData() { guard api.canFetchData else { return } // Outgoing query api.fetchData() } func numberOfItems() -> Int { ... } }
  13. Testing incoming command // Arrange let mockedApi = API(mockedData) let

    sut = ViewModel(api: mockedAPI) // Act sut.fetchData() // Assert assert(sut.state, expectedState) // Assert public side effect
  14. Testing outgoing command // Arrange let mockedApi = API(mockedData) expect(mockedApi,

    #selector(fetchData)) let sut = ViewModel(api: mockedAPI) // Act sut.fetchData() // Assert mockedData.verify() // Verify mock
  15. Testing incoming query // Arrange let sut = ViewModel(state: State(items:

    5)) // Assert assert(sut.numberOfItems, 5) // Assert return value
  16. Incoming Outgoing Sent to self Query assert return value -

    - Command assert side effect via public api mocking -
  17. class MockAPIClient: APIClient { override func fetchData<T>(_ callback: (T) ->

    Void) { // Do nothing } } class MockService: Service { init() { super.init(apiClient: MockAPIClient()) } }
  18. class MockViewModel: ViewModel { init() { super.init(service: MockService()) } override

    func fetchData() { /* Nothing */ } override func numberOfItems() -> Int { return 5 } } // Test let viewController = ViewController(viewModel: MockViewModel()) assert(viewController.numberOfRows(inSection: 0), 5)
  19. NO

  20. Two types of dependencies: 1. Stable: view model, interactor, service,

    parsers 2. Volatile: database, network, I/O, navigator
  21. Pros • Inversion of control • Explicit dependencies • Isolation

    • Loosely coupling • Promote good separation of concerns and SRP • Object creation responsibility
  22. Cons • Code coupled to the DI framework you use

    • Boilerplate infra for injector • Ugly factories and providers all around when going extreme • Touch 10 classes for a tiny naive change • Writing more code to support testing than the actual code you are testing
  23. Problems 1. Context available everywhere, for everybody. 2. You don't

    know what you class depends upon. What to control? Not explicit.
  24. Problems 1. Context available everywhere, for everybody. 2. You don't

    know what you class depends upon. What to control? Not explicit. 3. Problems might arise if run in parallel
  25. enum Effect { case apply(id: Job.Id) } func apply( to

    job: Job, currentUser: User, ) -> Effect? { guard currentUser.canApply else { return nil } return .apply(id: job.id) }
  26. enum Effect { case apply(id: Job.Id) } func apply( to

    job: Job, api: API, currentUser: User, ) -> Effect? { guard currentUser.canApply else { return nil } return .apply(id: job.id) }
  27. class EffectHandler { func handle(effect: Effect) { switch effect {

    case .apply(let id): Current.apiClient.apply(id) // actual effect } } }
  28. Navigator Navigation is the value func handle(navigation: Navigation) enum Navigation

    { case modal(Screen) case push(Screen) } enum Screen { case job(String) case privateInfo var viewController: UIViewController { switch self { case .process(let id): ... case .privateInfo: ... } }
  29. protocol State: Equatable { associatedtype Event = NoEvent associatedtype Effect

    = NoEffect mutating func handle(event: Event) -> Effect? }
  30. class Store<S: State> { init<E: Effects>(effects: E, initialState: S) where

    E.S == S { self.effects = .init(effects) _state = initialState } func subscribe(_ block: @escaping (S) -> Void) -> Subscription<S> { ... } func dispatch(event: S.Event) -> Future<S, NoError> { ... } }
  31. struct JobListViewState { var jobs = LoadingState<[Job], Error>() enum Event

    { case viewDidLoad case userDidRefresh case jobsDownloaded([Job]) case errorDidArise(Error) case applyToJob(Job) } enum Effect { case downloadJobs case markJobAsApplied(Job.Id) } }
  32. mutating func handle(event: Event) -> Effect? { switch event {

    case .userDidRefresh: guard !jobs.isLoading else { return nil } jobs.toLoading() return .downloadJobs case .jobsDownloaded(let jobs): jobs.toLoaded(with: jobs) return nil case .applyToJob(let job): return .markJobAsApplied(job.id) } }
  33. Views can subscribe... subscribe(to: store) subscribe(to: store, keyPath: \.jobs) subscribe(to:

    store, transform: viewStateFromStoreState) subscribe(to: store, predicate: { $0.jobs.isLoaded })
  34. Views can subscribe... subscribe(to: store) subscribe(to: store, keyPath: \.jobs) subscribe(to:

    store, transform: viewStateFromStoreState) subscribe(to: store, predicate: { $0.jobs.isLoaded }) ...and send events store.dispatch(event: .userDidRefresh)
  35. Jobs are downloaded when the view is shown let state

    = JobListViewState() assertEqual(state.handle(.viewDidLoad), .downloadJobs)
  36. class JobListViewEffects { func handle(effect: S.Effect) -> Future<S.Event, NoError>? {

    switch effect { case .downloadJobs: return Current.apiClient .send(request: DownloadJobsRequest()) .map(S.Event.jobsDownloaded) } } }
  37. extension Job { static func mock( id: String = "4-8-15-16-23-42",

    name: String = "Waiter", applicants: [User] = [User].mock(), ) -> Job { return self.init(id: id, name: name) } } let mockJob = Job.mock(name: "Barista")
  38. extension Array where Element == Job { static func template(jsonFile:

    String = "jobs.json") -> [Job] { // Read from file and decode using Decodable } } let mockJob = [Job].template().first!.with(\.name, "Barista")
  39. protocol APIClientProtocol { func send<T: Decodable>(request: HTTPRequest) -> Future<T, Error>

    } class APIClientMock: APIClientProtocol { subscript(request: HTTPRequest) -> Response { get { return requestToJSONMap[request.hash]! } set(newValue) { requestToJSONMap[request.hash] = newValue } } }
  40. protocol APIClientProtocol { func send<T: Decodable>(request: HTTPRequest) -> Future<T, Error>

    } class APIClientMock: APIClientProtocol { subscript(request: HTTPRequest) -> Response { get { return requestToJSONMap[request.hash]! } set(newValue) { requestToJSONMap[request.hash] = newValue } } func send<T: Decodable>(request: HTTPRequest) -> Future<T, Error> { switch self[request] { case .success(let json): return Future(value: try! decodeJSON(from: json.fileName) as T) case .failure(let failure): return Future(error: generateError(from: failure)) } } }
  41. ImmediateExecutionContext struct Environment { var executionContext: ExecutionContext { get {

    return DefaultThreadingModel() } set { DefaultThreadingModel = { newValue } } } } extension Environment { static let mock: Environment = { var environment = Environment() environment.executionContext = ImmediateExecutionContext return environment }() }
  42. Recipe for happiness 1. Stop doing DI for controlling coeffects

    2. Control effects modelling them as values
  43. Recipe for happiness 1. Stop doing DI for controlling coeffects

    2. Control effects modelling them as values 3. Separate logic from effects
  44. Recipe for happiness 1. Stop doing DI for controlling coeffects

    2. Control effects modelling them as values 3. Separate logic from effects 4. Unit tests for logic. Integration tests for the rest
  45. Recipe for happiness 1. Stop doing DI for controlling coeffects

    2. Control effects modelling them as values 3. Separate logic from effects 4. Unit tests for logic. Integration tests for the rest 5. Leverage state snapshots
  46. Recipe for happiness 1. Stop doing DI for controlling coeffects

    2. Control effects modelling them as values 3. Separate logic from effects 4. Unit tests for logic. Integration tests for the rest 5. Leverage state snapshots 6. Use jsons to avoid boilerplate fixtures
  47. Recipe for happiness 1. Stop doing DI for controlling coeffects

    2. Control effects modelling them as values 3. Separate logic from effects 4. Unit tests for logic. Integration tests for the rest 5. Leverage state snapshots 6. Use jsons to avoid boilerplate fixtures 7. Make your tests synchronous