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

Managed side effects

Managed side effects

Talk by Vitalii Malakhovskiy.

⁃ расскажу как мы пишем сложную логику, чтобы потом в этом еще и разобраться
⁃ снова про data-driven
⁃ как потом покрыть эту логику тестами
⁃ когда так делать не надо

This talk was made for CocoaHeads Kyiv #15 which took place Jul 28, 2019. (https://cocoaheads.org.ua/cocoaheadskyiv/15)

Video: https://youtu.be/bhEn-VOH0q0

CocoaHeads Ukraine

July 28, 2019
Tweet

More Decks by CocoaHeads Ukraine

Other Decks in Programming

Transcript

  1. images service 1) fetch if already downloaded 2) download 3)

    store and return 19 — Vitalii Malakhovskyi, BetterMe
  2. looks simple - fetch success -> data - fetch fail

    -> download fail -> error - fetch fail -> download success -> store fail -> error - fetch fail -> download success -> store success -> data 21 — Vitalii Malakhovskyi, BetterMe
  3. and then use separate library to mock it 28 —

    Vitalii Malakhovskyi, BetterMe
  4. before - fetch success -> data - fetch fail ->

    download fail -> error - fetch fail -> download success -> store fail -> error - fetch fail -> download success -> store success -> data 29 — Vitalii Malakhovskyi, BetterMe
  5. after - verify m(fetch) - m(fetch) success -> data -

    m(fetch) fail -> verify m(download) - m(fetch) fail -> m(download) fail -> error - m(fetch) fail -> m(download) success -> verify m(store) - m(fetch) fail -> m(download) success -> m(store) fail -> error - m(fetch) fail -> m(download) success -> m(store) success -> data 30 — Vitalii Malakhovskyi, BetterMe
  6. describe("ImagesService") { context("when asking for image") { beforeEach { /*

    setup sut */ } it("should fetch data") { ... } context("but image is not cached") { beforeEach { /* stub fetching */ } it("should download image") { ... } context("and image download succeed") { beforeEach { /* stub downloading */ } it("should store data") { ... } } } } } 32 — Vitalii Malakhovskyi, BetterMe
  7. describe("ImagesService") { context("when asking for image") { beforeEach { /*

    setup sut */ } it("should fetch data") { ... } context("but image is not cached") { beforeEach { /* stub fetching */ } it("should download image") { ... } context("and image download succeed") { beforeEach { /* stub downloading */ } it("should store data") { ... } } } } } 33 — Vitalii Malakhovskyi, BetterMe
  8. used approaches - protocols - dependency injection - covered with

    integration tests (bdd) 35 — Vitalii Malakhovskyi, BetterMe
  9. mocks mocks mocks mocks mocks mocks mocks mocks mocks mocks

    mocks mocks mocks mocks mocks mocks mocks mocks mocks mocks mocks mocks mocks mocks mocks mocks mocks mocks mocks mocks mocks mocks mocks mocks mocks mocks 40 — Vitalii Malakhovskyi, BetterMe
  10. describe("ImagesService") { context("when asking for image") { beforeEach { /*

    setup sut */ } it("should fetch data") { ... } context("but image is not cached") { beforeEach { /* stub fetching */ } it("should download image") { ... } context("and image download succeed") { beforeEach { /* stub downloading */ } it("should store data") { ... } } } } } 47 — Vitalii Malakhovskyi, BetterMe
  11. let's imagine how simple test will look like without quick

    49 — Vitalii Malakhovskyi, BetterMe
  12. fetch failed -> download succeed -> store failed //given /*

    stub fetch */ /* stub download */ /* stub store */ /* setup sut */ //when ... //then ... 50 — Vitalii Malakhovskyi, BetterMe
  13. func testPaymentQueue_when_oneTransactionForEachState_onePayment_noRestorePurchases_oneCompleteTransactions_then_correctCallbacksCalled() { // setup let spy = PaymentQueueSpy() let

    paymentQueueController = PaymentQueueController(paymentQueue: spy) let purchasedProductIdentifier = "com.SwiftyStoreKit.product1" let failedProductIdentifier = "com.SwiftyStoreKit.product2" let restoredProductIdentifier = "com.SwiftyStoreKit.product3" let deferredProductIdentifier = "com.SwiftyStoreKit.product4" let purchasingProductIdentifier = "com.SwiftyStoreKit.product5" let transactions = [ makeTestPaymentTransaction(productIdentifier: purchasedProductIdentifier, transactionState: .purchased), makeTestPaymentTransaction(productIdentifier: failedProductIdentifier, transactionState: .failed), makeTestPaymentTransaction(productIdentifier: restoredProductIdentifier, transactionState: .restored), makeTestPaymentTransaction(productIdentifier: deferredProductIdentifier, transactionState: .deferred), makeTestPaymentTransaction(productIdentifier: purchasingProductIdentifier, transactionState: .purchasing) ] var paymentCallbackCalled = false let testPayment = makeTestPayment(productIdentifier: purchasedProductIdentifier) { result in paymentCallbackCalled = true if case .purchased(let payment) = result { XCTAssertEqual(payment.productId, purchasedProductIdentifier) } else { XCTFail("expected purchased callback with product id") } } var completeTransactionsCallbackCalled = false let completeTransactions = CompleteTransactions(atomically: true) { payments in completeTransactionsCallbackCalled = true XCTAssertEqual(payments.count, 3) XCTAssertEqual(payments[0].productId, failedProductIdentifier) XCTAssertEqual(payments[1].productId, restoredProductIdentifier) XCTAssertEqual(payments[2].productId, deferredProductIdentifier) } // run paymentQueueController.completeTransactions(completeTransactions) paymentQueueController.startPayment(testPayment) paymentQueueController.paymentQueue(SKPaymentQueue(), updatedTransactions: transactions) paymentQueueController.paymentQueueRestoreCompletedTransactionsFinished(SKPaymentQueue()) // verify XCTAssertTrue(paymentCallbackCalled) XCTAssertTrue(completeTransactionsCallbackCalled) } 51 — Vitalii Malakhovskyi, BetterMe
  14. app design is good if it's covered with tests 53

    — Vitalii Malakhovskyi, BetterMe
  15. app design is good if it's easy to cover with

    tests 54 — Vitalii Malakhovskyi, BetterMe
  16. let's change something in downloading - verify m(fetch) - m(fetch)

    success -> data - m(fetch) fail -> verify m(download) - m(fetch) fail -> m(download) fail -> error - m(fetch) fail -> m(download) success -> verify m(store) - m(fetch) fail -> m(download) success -> m(store) fail -> error - m(fetch) fail -> m(download) success -> m(store) success -> data 56 — Vitalii Malakhovskyi, BetterMe
  17. change some - brake all 1) 2) 3) 4) 5)

    6) 7) 60 — Vitalii Malakhovskyi, BetterMe
  18. hard to maintain, hard to add new features, hard cover

    it with tests 61 — Vitalii Malakhovskyi, BetterMe
  19. do { let fetched = try read(url) completion(.success(fetched)) } catch

    { cancel = makeRequest(url).perform { [weak self] result in switch result { case .success(let value): ... case .failure(let error): ... } } } 65 — Vitalii Malakhovskyi, BetterMe
  20. do { let fetched = try read(url) //side effect completion(.success(fetched))

    //logic } catch { // logic, like if else //side effect cancel = makeRequest(url).perform { [weak self] result in switch result { case .success(let value): //logic ... case .failure(let error): //logic ... } } } 66 — Vitalii Malakhovskyi, BetterMe
  21. the more ⬥ we have, the more code paths we

    need to test 69 — Vitalii Malakhovskyi, BetterMe
  22. ̄ makes our code difficult to test, as they need

    mocks 70 — Vitalii Malakhovskyi, BetterMe
  23. separate logic from effects > manage logic & manage side

    effects 71 — Vitalii Malakhovskyi, BetterMe
  24. functional core, imperative shell In fact, it dates back to

    2012, when Gary Bernhardt’s talked about it in Boundaries. 72 — Vitalii Malakhovskyi, BetterMe
  25. in previous chapter /* setup sut */ /* stub fetch

    */ /* stub download */ /* stub store */ preparation of concrete state 76 — Vitalii Malakhovskyi, BetterMe
  26. let's imagine our service as state machine enum State {

    case initial case checking(URL) case downloading(URL) case finished(URL, Data) case failed } 78 — Vitalii Malakhovskyi, BetterMe
  27. ⚠ state shouldn't have impossible state enum State { case

    initial case checking(URL) case downloading(URL) case finished(URL, Data) case failed(Error) } 79 — Vitalii Malakhovskyi, BetterMe
  28. hard setup becomes trivial /* setup sut */ /* stub

    fetching */ /* stub downloading */ /* stub storing */ vs let state = State.downloading(url) 80 — Vitalii Malakhovskyi, BetterMe
  29. side effects - all about implementation details do { let

    fetched = try read(url) completion(.success(fetched)) } catch { cancel = makeRequest(url).perform { [weak self] result in switch result { case .success(let value): ... case .failure(let error): ... } } 83 — Vitalii Malakhovskyi, BetterMe
  30. side effects - dependency injection - frameworks - async behavior

    - protocols 84 — Vitalii Malakhovskyi, BetterMe
  31. dependency injection - weak incapsulation instument 3-rd party framework -

    unwanted details async behavior - harder to test/ understand 85 — Vitalii Malakhovskyi, BetterMe
  32. event is data, data is boundaries - does not depends

    on frameworks - has no execution - data cannot be async - better interface 86 — Vitalii Malakhovskyi, BetterMe
  33. let's focus on what happens, not how enum Event {

    case fetch(URL) case fetchSucceed(Data) case fetchFailed(Error) case download(URL) case downloadSucceed(Data) case downloadFailed(Error) ... } 87 — Vitalii Malakhovskyi, BetterMe
  34. function call or result handling becomes a data func reduce(state:

    State, event: Event) -> State { switch (state, event) { case (.downloading(let url), .downloadSucceed(let data)): return .finished(url, data) case (.downloading, .downloadFailed(let error)): return .failed(error) ... 89 — Vitalii Malakhovskyi, BetterMe
  35. real test let url = URL.random() let data = Data.random()

    let state = reduce(.downloading(url), with: .downloadSucceed(data)) XCTAssertEqual(state, .finished(url, data)) 90 — Vitalii Malakhovskyi, BetterMe
  36. simple test - does not brake if implementation changes -

    breaks if logic changes in concrete place 91 — Vitalii Malakhovskyi, BetterMe
  37. somebody needs to interpret state in terms of side effects

    96 — Vitalii Malakhovskyi, BetterMe
  38. imperative shell - chaos - async - frameworks - dependency

    injection implementation details 97 — Vitalii Malakhovskyi, BetterMe
  39. state effect idle nothing downloading download storing store failed nothing

    succeed nothing 99 — Vitalii Malakhovskyi, BetterMe
  40. class EffectsProducer { func handle(state: State, produce: @escaping (Event?) ->

    ()) { switch state { case .downloading(let url): makeRequest(url).perform { [weak self] data in if let data = try? data.get() { produce(.downloadSucceed(data)) } else { produce(.downloadFailed) } } ... 101 — Vitalii Malakhovskyi, BetterMe
  41. to test observer we might need - mock - stub

    - spy - fake 102 — Vitalii Malakhovskyi, BetterMe
  42. well isolated tests logic effect 1) ✅ 1) 2) ✅

    2) 3) ✅ 3) 4) ✅ 4) 5) ✅ 5) 6) ✅ 6) 7) ✅ 7) 103 — Vitalii Malakhovskyi, BetterMe
  43. whole picture -> state -> observer -> event -> 105

    — Vitalii Malakhovskyi, BetterMe
  44. missing part - store - owns state - mutates state

    (reduce function) - notifies observers about update 107 — Vitalii Malakhovskyi, BetterMe
  45. what is hard - integrate into non-data driven project -

    multiple stores synchronization - side effects sync with logic 109 — Vitalii Malakhovskyi, BetterMe
  46. don't do that if - in production project if you

    just heard about it - you have multiple stores with relations - your logic is simple 110 — Vitalii Malakhovskyi, BetterMe
  47. to recap - found maintainability problem - manage side effects,

    manage logic - how to keep tests maintainable - how tests tell you when your code stinks 112 — Vitalii Malakhovskyi, BetterMe
  48. logic & effects write code write tests maintain code maintain

    tests mixed ✅ +/- ❌ ❌ separate +/- ✅ ✅ ✅ 113 — Vitalii Malakhovskyi, BetterMe