Designing Dependencies: The Point

Episode #114 • Aug 24, 2020 • Subscriber-Only

So, what’s the point of forgoing the protocols and designing dependencies with simple data types? It can be summed up in 3 words: testing, testing, testing. We can now easily write tests that exercise every aspect of our application, including its reliance on internet connectivity and location services.

The Point
Introduction
00:05
Testing our feature
02:25
Ergonomic mocking
21:04
Testing reachability
26:28
Testing core location
35:28
Conclusion
46:13

Unlock This Episode

Our Free plan includes 1 subscriber-only episode of your choice, plus weekly updates from our newsletter.

Introduction

We’ve now got a moderately complex application that makes non-trivial use of 3 dependencies: a weather API client, a network path monitor, and a location manager. The weather API dependency was quite simple in that it merely fires off network requests that can either return some data or fail. The path monitor client was a bit more complex in that it bundled up the idea of starting a long-living effect that emits network paths over time as the device connection changes, as well as the ability to cancel that effect. And Core Location was the most complicated by far. It allows you to ask the user for access to their location, and then request their location information, which is a pretty complex state machine that needs to be carefully considered.

So we’ve accomplished some cool things, but I think it’s about time to ask “what’s the point?” We like to do this at the end of each series of episodes on Point-Free because it gives us the opportunity to truly justify all the work we have accomplished so far, and prove that the things we are discussing have real life benefits, and that you could make use of these techniques today.

And this is an important question to ask here because we are advocating for something that isn’t super common in the Swift community. The community already has an answer for managing and controlling dependencies, and that’s protocols. You slap a protocol in front of your dependency, make a live conformance and a mock conformance of the protocol, and then you are good to go. We showed that this protocol-oriented style comes with a bit of boilerplate, but if that’s the only difference from our approach is it really worth deviating from it?

Well, we definitely say that yes, designing your dependencies in this style has tons of benefits, beyond removing some boilerplate. And to prove this we are going to write a full test suite for our feature, which would have been much more difficult to do had we controlled things in a more typical, protocol-oriented fashion.

This episode is for subscribers only.

Subscribe to Point-Free

Access this episode, plus all past and future episodes when you become a subscriber.

See plans and pricing

Already a subscriber? Log in

Exercises

  1. Because our dependencies are simple value types we can more easily transform them. We can even define “higher-order” dependencies, or functions that take a dependency as input and transform it into a new dependency returned as output.

    As an example, try implementing a method on WeatherClient that returns a brand new weather client with all of its endpoints artificially slowed down by a second.

    extension WeatherClient {
      func slowed() -> Self {
        fatalError("unimplemented")
      }
    }
    
    Solution

    You can create a new weather client by passing along all of the existing client’s endpoints with Combine’s delay operator attached.

    extension WeatherClient {
      func slowed() -> Self {
        Self(
          weather: {
            self.weather($0)
              .delay(for: 1, scheduler: DispatchQueue.main)
              .eraseToAnyPublisher()
          },
          searchLocations: {
            self.searchLocations($0)
              .delay(for: 1, scheduler: DispatchQueue.main)
              .eraseToAnyPublisher()
          }
        )
      }
    }
    
  2. Implement a method on LocationClient that can override an existing location client to behave as if it were at a specific location.

    extension LocationClient {
      func located(
        atLatitude latitude: CLLocationDegrees,
        longitude: CLLocationDegrees
      ) -> Self {
        fatalError("unimplemented")
      }
    }
    
    Solution

    You can create a new location client by passing along each endpoint and transforming the delegate publisher to modify the didUpdateLocations event.

    extension LocationClient {
      func located(
        atLatitude latitude: CLLocationDegrees,
        longitude: CLLocationDegrees
      ) -> Self {
        Self(
          authorizationStatus: self.authorizationStatus,
          requestWhenInUseAuthorization: self.requestWhenInUseAuthorization,
          requestLocation: self.requestLocation,
          delegate: self.delegate
            .map { event -> DelegateEvent in
              guard case .didUpdateLocations = event else { return event }
              let location = CLLocation(latitude: latitude, longitude: longitude)
              return .didUpdateLocations([location])
            }
            .eraseToAnyPublisher()
        )
      }
    }