Breaking Down Swift Take-Home Challenge: Part 2 - Location

ยท

13 min read

Intro

In the first article, we have covered requesting weather for some arbitrary location. Now it's time to implement the second part of the take-home assignment, namely retrieving a new location every ten seconds.

In this article, we will develop fetching and updating the location without binding to a particular framework. We will also see different approaches to testing time-based logic.

Let's begin!


Implementation

Location service protocol

As always, I like starting with protocol. We should not care where we get the location from, be it the CoreLocation framework, a third party, a list of locations, or manual input. Those are implementation details. And implementation details tend to get changed. That's why we should ensure that they are easily swappable and the rest of the code is not affected by such a change.

๐Ÿ’ก To be completely fair, the change of implementation might still affect the code everywhere just because of how different frameworks work. But we should strive for interoperability.

So. We want to be notified about each location change. At first, I went with such an implementation:

protocol LocationServicing {
    var currentLocation: AnyPublisher<CLLocationCoordinate2D, Never> { get }
}

It was funny to contradict myself the very moment I, being on autopilot, wrote a single line of code. CLLocationCoordinate2D is cool and convenient. It has all the fields we need. But it is a dependency on the CoreLocation framework. It violates the principle I described above.

The truly independent implementation is the following:

//  Coordinate.swift
struct Coordinate {
    let latitude: Double
    let longitude: Double
}

//  LocationService.swift
protocol LocationServicing {
    var currentLocation: AnyPublisher<Coordinate, Never> { get }
}

For the observation, it might be tempting to use CurrentValueSubject or PassthroughSubject instead of AnyPublisher. We would need less code on the service side. CurrentValueSubject even allows reading the current value!

The reason is that the more convenient options allow API consumers to send new values thus updating all the other observers. AnyPublisher makes the subscription "get only". Safe API and code in general are what make our lives easier.

Next, I also find it useful if we tell the service that it can now share location updates. So I've added another function:

func start()

For now, there is only one screen. The screen is shown right away so start function seems to violate the YAGNI principle โ€“ we don't really need it. But I find it valuable in this case and here is why. We derive the service behavior โ€“ starting location updates in init โ€“ from the current app structure. If in the future we add new screens, the service will still start when it's being initialized. We introduced an implicit dependency, service depends on the UI.

Location service implementation

I did some super straightforward implementation for our protocol. The main point is that we should loop through locations indefinitely:

final class LocationService: LocationServicing {
    var currentLocation: AnyPublisher<Coordinate, Never> {
        _currentLocation.eraseToAnyPublisher()
    }
    private let _currentLocation = CurrentValueSubject<Coordinate, Never>(Constants.locations[0])

    private var currentLocationIndex = 0

    func start() {
        Task {
            while true {
                _currentLocation.send(Constants.locations[currentLocationIndex])
                try? await Task.sleep(for: .seconds(Constants.delayInSeconds))

                currentLocationIndex = (currentLocationIndex + 1) % Constants.locations.count 
            }
        }
    }
}

private extension LocationService {
    enum Constants {
        static let coordinates = [
            (53.619653, 10.079969),
            // all other locations from the task
            (53.141598, 8.242565)
        ]
            .map(Coordinate.init)

        static let delayInSeconds = 10
    }
}

And, once again, there are some issues in the implementation. What are those from your point of view? We will cover them later in the article.


Location service tests

Now the sweet part, testing!

The first thing we want to see in LocationService is that it has some value initially. That's quite trivial so I'll include the code without comments:

final class LocationServiceTest: XCTestCase {
    private var sut: LocationService!
    private var cancellable: AnyCancellable!

    override func setUp() {
        super.setUp()
        self.sut = .init()
    }

    // MARK: - Initially -

    func testSharesLocationInitially() {
        // given
        var receivedCoordinate: Coordinate!
        cancellable = sut.currentLocation.sink(receiveValue: { receivedCoordinate = $0 })

        // when
        sut.start()

        // then
        XCTAssertNotNil(receivedCoordinate)
    }
}

But how to assert that the next value returned by the service is correct?

Naive approach

Wait till it's delivered! We can utilize expectations with a hardcoded timeout:

    func testCurrentLocationIsChangedAfterDelayNaiveApproach() {
        // given
        let expectation = XCTestExpectation(description: "Second location value should have been shared")

        var receivedCoordinate Coordinate!
        cancellable = sut.currentLocation
            .dropFirst()
            .sink(receiveValue: {
                receivedCoordinate = $0
                expectation.fulfill()
            })

        // when
        sut.start()

        // then
        wait(for: [expectation], timeout: 11.0)
        let expectedCoordinate = Coordinate(latitude: 53.080917, longitude: 8.847533)
        XCTAssertEqual(receivedCoordinate, expectedCoordinate)
    }

Good job! Or not? There are at least three issues:

  1. What if the delay changes in the code? We would have to adjust all tests;

  2. What if the delay is 60 seconds? 20 minutes? We might run wait hours for the test results;

  3. What if one test takes longer due to a lag?

Less naive approach

Compress the time! Let's explore general relativity...

Although we don't need to go that deep. We can inject the delay from outside. Take a look at the modified parts of LocationService:

    private let updateDelay: Duration

    init(updateDelay: Duration = Constants.delay) {
        self.updateDelay = updateDelay
    }

    func start() {
        Task {
            while true {
                try? await Task.sleep(for: updateDelay)
                // ...
            }
        }
    }

Then the new test will look this way

func testCurrentLocationIsChangedAfterDelayLessNaiveApproach() {
        // given
        sut = .init(updateDelay: .milliseconds(10))
        let expectation = XCTestExpectation(description: "Second location value should have been shared")

        var receivedCoordinate: Coordinate!
        cancellable = sut.currentLocation
            .dropFirst()
            .sink(receiveValue: {
                receivedCoordinate = $0
                expectation.fulfill()
            })

        // when
        sut.start()

        // then
        wait(for: [expectation], timeout: 1.0)
        let expectedCoordinate = Coordinate(latitude: 53.080917, longitude: 8.847533)
        XCTAssertEqual(receivedCoordinate, expectedCoordinate)
    }

Coming back to the three issues we had originally. Two are now resolved. And while this test might be a good candidate for an integration test, it still doesn't qualify for a unit test

  1. What if the delay changes? โœ…

    • We control the delay and set it in tests;
  2. What if the delay is 60 seconds? 20 minutes? โœ…

    • It doesn't matter anymore, we set the delay as short as we want;
  3. What if the test takes longer due to a lag? โŒ

    • Even when running locally I had the timeout shooting occasionally;

Before we explore additional options, a short remark.

๐Ÿ’ก It's already great to have more or less stable tests for this part โ€“ in my experience, not many people in their take-home assignment went that far. It's even better if you know the limitations and can explain them. The ultimate test as impressive as teaching a cat to do calculus, is to execute tests in a 100% stable way. But we have to always weigh trade-offs. Remember, we are in a take-home assignment environment. The task should not take more than 8-12 hours. So I will briefly touch upon the most precise way to achieve the final destination in the next section. Even if we don't implement something we should know why we don't do it and how it would be done.

Absolutely stable approach to time testing

To achieve stability, we need to clear two roadblocks:

  1. Waiting for Task.sleep to be over;

  2. Waiting for Task. Invoking a task can already dispatch our code on some other thread meaning the test is not synchronous anymore, it might be completed in the next run loop and it's too late;

    func start() {
        Task {
            // bye-bye, synchronous execution ๐Ÿ‘‹
        }
    }

One way to work around the issues I covered in my other article about testing. The principle behind this is that we need to abstract away Task. Then in tests, we can replace the actual implementation with a Fake and never suspend the function. The same applies to Task.sleep.

The other approach involves rewriting the implementation using Timers. Instead of using tasks, we might have better luck utilizing timers. It also feels like a bit cleaner solution compared to invoking sleep. The sad thing is that we still have to mock the timers. A mocked timer would invoke the closure right away without any delays or dispatches.

I will cover this approach in a separate article. You can find it here: <if you still see this placeholder, there is no article. See the meme instead>


Customization options

It's cool to send the same test project to all the companies no matter what their critical points are, isn't it? But if you want to be the coolest cat in the neighborhood, you need customization. We will dive deeper into the topic in the next articles. But let's explore a short example. You know that the company you are applying to is using real location tracking. Then you might consider implementing an additional service that works with real location. Thanks to the interfaces, we only change the implementation. The location service could look the following way:

final class RealLocationService: NSObject, LocationServicing { // please don't name it this way
    // I'll rename the other one to DemoLocationService and I leave this name here
    // to underline that I am not replacing the implementation but have it as an addition
    var currentLocation: AnyPublisher<Coordinate, Never> {
        _currentLocation.eraseToAnyPublisher()
    }
    private let _currentLocation = CurrentValueSubject<Coordinate, Never>(.init(latitude: 0, longitude: 0))

    private let locationManager = CLLocationManager()

    func start() {
        locationManager.delegate = self
    }
}

extension RealLocationService: CLLocationManagerDelegate {
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let coordinate = locations.first?.coordinate else {
            return
        }
        _currentLocation.send(.init(latitude: coordinate.latitude, longitude: coordinate.longitude))
    }
}

One thing in this code makes me cringe. The initial location being a pair of zeros is lame.

private let _currentLocation = CurrentValueSubject<Coordinate, Never>(.init(latitude: 0, longitude: 0))

What should we do before the first location update happens? How do we communicate failures? That is anything but swifty. To properly handle and display all the possibilities, we need to know the state of fetching the coordinate. My default solution is to introduce a state object and then modify the implementation in the following way:

// LocationState.swift
enum LocationState {
    case loading
    case loaded(coordinate: Coordinate)
    case error
}

// LocationService.swift
final class RealLocationService: NSObject, LocationServicing {
    var currentLocation: AnyPublisher<LocationState, Never> {
        _currentLocation.eraseToAnyPublisher()
    }
    private let _currentLocation = CurrentValueSubject<LocationState, Never>(.loading)

    private let locationManager = CLLocationManager()

    func start() {
        locationManager.delegate = self
        locationManager.startUpdatingLocation()
    }
}

extension RealLocationService: CLLocationManagerDelegate {
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let coordinate = locations.first?.coordinate else {
            return
        }
        let coordinate = Coordinate(latitude: coordinate.latitude, longitude: coordinate.longitude)
        _currentLocation.send(.loaded(coordinate: coordinate))
    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        _currentLocation.send(.error)
    }
}

๐Ÿ’ก As it happens, within even such a simple implementation we got an illustration that a clear border will not always save us from changing the protocol.

And even though the main service is still the stubbed one, I'd still change the original implementation. This way our interface becomes more resilient without compromising clarity.

๐Ÿ’ก If you decide to do real location management, do not forget to handle all the cases. Ask for permission, handle all the states, and provide proper texts. Also,RealLocationServiceis hardly testable now because it usesCLLocationManagerdirectly so we are not in control of locations. As long as the service is that simple we can also skip the tests. If you are still interested in the testing approach, you can see it in thiscommit. As the commit was added after the section below had been written, you might see some code "from the future" in there.


Solving issues

Below there are four possible issues that I've discovered while writing the text. I wonder if you can find more, please share in the comments.

Starting two times

Both services can be started two times. The fix is extremely trivial but shows that you think about the services working correctly.

    private var hasStarted = false

// ...

    func start() {
        guard !hasStarted else {
            return
        }
        hasStarted = true
// ...

๐Ÿ’ก In the best, as soon as we discover a bug or missing functionality, we need to write a test for the case. Here we would validate that starting service multiple times does not bring more updates. However, I could not make it work reliably with the asynchronous tests we have inDemoLocationService.

Not stopping ever

How do we stop the updates? There are multiple ways. We can replace the hasStarted flag with a reference to the task. And cancel it when stopping. Just don't forget to check for cancellation and exit the infinite loop! I've noticed interesting behavior when the Task if that is omitted. In my project, the Task.sleep stopped pausing the execution and I've got a massive amount of updates.

Then the hasStarted flag also became obsolete.

    private var updateLocationTask: Task<Void, Never>?

// ...

    func start() {
        guard updateLocationTask == nil else {
            return
        }
        updateLocationTask = Task {
            while true {
                try? await Task.sleep(for: updateDelay)
                do {
                    try updateLocationIfNotCancelled()
                } catch {
                    return
                }
            }
        }
    }

    private func updateLocationIfNotCancelled() throws {
        try Task.checkCancellation()
        currentCoordinateIndex = (currentCoordinateIndex + 1) % Constants.coordinates.count
        _currentLocation.send(.loaded(data: Constants.coordinates[currentCoordinateIndex]))
    }

We can even write a test that validates that the stop function works.

    func testLocationUpdatesAreStopped() {
        // given
        sut = .init(updateDelay: .milliseconds(10))
        var receivedLocationStates = [DataState<Coordinate>]()
        cancellable = sut.currentLocation
            .dropFirst()
            .sink(receiveValue: {
                receivedLocationStates.append($0)
            })
        sut.start()

        // when
        sut.stop()
        _ = XCTWaiter.wait(for: [expectation(description: "Wait for 2 seconds")], timeout: 2.0)

        // then
        XCTAssertEqual(receivedLocationStates.count, 0)
    }

Hardcoding values

Have you noticed that I am super annoying when it comes to tests? That's another case now! If we want to write code without any hardcoding whatsoever, we can also inject the stubbed coordinates we have in constants! I mean those little fellows:

private extension DemoLocationService {
    enum Constants {
        static let coordinates = [
            (53.619653, 10.079969),
            // ...
            (53.141598, 8.242565),
        ]
        .map(Coordinate.init)
    }
}

When doing so, we do not hardcode the expected coordinate:

let expectedCoordinate = Coordinate(latitude: 53.080917, longitude: 8.847533)

Does it add much value? Not really. We should avoid repeating ourselves. However, as long as we do not use different sets of the stubbed coordinates I'd still consider the stubbing approach to be fine as it is. We need to weigh tradeoffs.

๐Ÿ’ก It is a good point for customization. If you see that the company is particularly interested in testing or they are proud of its architecture, it might be worth a shot. The same applies if the company works in some very strict field like finance or health. Showing that you care about the code working correctly is more valuable in such companies compared to random startups.

Main Actor

If you try to incorporate LocationService in the current state of the code, you will most likely see the publishing errors.

Publishing changes from background threads is not allowed; 
make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

If we were using the LocationService directly in the view, it could become a problem and would require us to add main actor notation or receiving on the main loop or something else. But as I am not a fan of doing everything in the view, I'll show how I worked around that in the next article!


Closing down

We are one step closer to the project that will change the world. The WeatherCat take-solve-and-rock assignment is getting into shape.

As you see, the primary implementation is super short. I think I've spent 30 minutes writing all the code and another 60 minutes reshaping the code and adding tests. Of course, having multiple services and different testing methods has its price and is estimated at around an additional 90 minutes. And, I guess, another five hours to put my thoughts into readable text.

Instead of a summary

  • Do clean architecture;

  • Write tests;

  • Do not overcomplicate things if there is not too much time.

P.S.

Just one last thought about customization. Remember those stubbed coordinates? You can replace them with the locations of the offices. Probably, no one will notice it. But if the reviewers do, you learn two things:

  1. Interviewers do pay great attention to detail;

  2. Interviewers will be pleased to see that you also care even about such small things;

Now that's it. Until the next time ๐Ÿ‘‹

ย