Test-Driven Development: #4 Testing Caching

Test-Driven Development: #4 Testing Caching

There is nothing harder in Software Development than these two, Good Names & Cache Invalidation, but testing makes things a bit easier ๐Ÿคž

ยท

13 min read

Having a way of verifying Caching is very rewarding and beneficial to the team's performance and productivity, however, Cache Invalidation is one of the most daunting tasks in Software Engineering, after picking a good name for your variables and functions that is.

Introduction

Hello There, GIF by Obi-wan Kenobi

How's it going! Hope you all are doing well While the Quote may seem like we are going to test something daunting, but that's not true, because we've already built a good structure in the last post.

So without further ado, let's dive in!

Problem

Now that we can authenticate our user, making the user login each time he opens the app is a bit of a drag, and we have to find some way of caching the user's authentication info so we can improve our UX, let's discuss our options

Observation

Caching User Email & Password in UserDefaults

While that may seem the first thing to come to mind, but is super risky to cache the user's password in UserDefaults since it's not secure

Caching User Email & Password in Keychain

As an improvement, we can store the password in the keychain, however, the same concern may arise, and it would be better to not have to cache the password at all

Asking the backend team to return access token (No Expiry Date)

Having Access token cached instead of email & password provides us with more secure implementation, especially, but make no mistake, such information should be cached only in the Keychain, since it's already sensitive information, however, having no expiry date puts such information at risk, since if an attacker gets this information, he can use it and act as the user he stole the token from.

Asking the backend team to add an expiry date of 25 mins to the access tokens

Adding 25 mins of a lifetime to the access token before it's deleted from the database, minimizes the risk of it being stolen since the window here is 25 mins and not infinity, this gives fewer things to worry about, however, there is an issue here, if the token did expire, we will have to make the user re-login to regenerate a new token, which takes us back to square 1 ๐Ÿค”

Refresh Token comes to the rescue (<- Best option so far)

Refresh Tokens are here to solve such an issue, they have largely more time, probably days or months, but they serve two purposes

  1. If the access token expires, we can use the refresh token to regenerate the access token in the background without forcing the user to re-login
  2. In case of an attack, the backend team can invalidate all refresh and access tokens, hence forcing log out of all users to investigate the attack

Now that we weighed our options, let's go back to Xcode and start doing writing some tests!

Implementation

As a first iteration of how our code should behave, I'd imagine something close to this

func test_GivenUserExists_WhenLogin_TokenIsCached() {
  // Given
  let email = "valid_email@gmail.com"
  let password = "Somevalid1Password"

  let expectedModel = AccessToken()
  network.expectedModel = expectedModel
  // When
  sut.login(email: email, password: password)

  // Then
  XCTAssertTrue(cache.encryptedStorage.contains(expectedModel))
}

So, let's see where will that take us after fixing the compiler issues

Let's first declare a new model, AccessToken

public final class AccessToken: Codable {
  let accessToken: String
  let refreshToken: String

  init(accessToken: String = "", refreshToken: String = "") {
    self.accessToken = accessToken
    self.refreshToken = refreshToken
  }
}

Now for the bigger prize, the CacheManager, as a manager, I expect it to know the following

The key it's going to register our data into, which storage is suitable for that type of data, whether UserDefaults or EncryptedStorage like Keychain

So let's start with a basic protocol that allows us to set our boundaries when dealing with a CacheManager

protocol CacheProtocol {
  func fetch<T: Codable>(_: T.Type, for key: StorageKey) -> T?
  func save<T: Codable>(_ value: T, for key: StorageKey)
  func remove(for key: StorageKey)
}

When something inside our code needs to have something from the cache, all they think about are 3 things

  1. What operation do I need?
  2. What type am I operating on?
  3. The key that helps me get this information back when I need it

hence, if we think in such a simple way, we can end up with something simple like the above protocol, and that's it for the dependent modules on the Persistence Layer

Now, let's start with a couple of whys

  1. Why did we use generics here?
  2. Why do we return an optional when fetching?
  3. Why don't we replace optionals with throws?

To answer these questions, we have to know why those may need to be used, and if there is a need to use generics and/or throws, let's use them.

Discussion

Generics Usage Justifications

The need for generics arises from avoiding duplicating code, for example, If I needed to cache an AccessToken I can do that normally without writing any abstraction, but I'll have to be specific, later down the road, we will have to cache other things, and without proposing an abstracted structure, we will end up with lots of specific functions that store just 1 thing, so let's see what we might end up within such scenario...

protocol CacheProtocol {
  func fetchAccessToken() -> AccessToken
  func save(accessToken: AccessToken)
  func removeAccessToken()
  func fetchProfile() -> Profile
  func save(profile: Profile)
  func removeProfile()

// Too much duplication may follow, tread with caution โ˜ข๏ธ
}

Now if we compare and weigh the two examples between using Generics and being specific, you will find that using Generics cuts down on the code, and there is also one hidden aspect that may not be visible in the generics approach, which is, "Single Responsibility Principle", when you see the 2nd option, the Single Responsibility here is very clear, when am removing profile, am doing nothing but removing it, but in the generics, its not visible here, because you might think...

Dude, this function can be used to store, delete, and fetch anything... where is the Single Responsibility here?

Storing, Deleting, and Fetching and that's it, am not doing anything outside the description of the method name

But there is 1 extra thing that violates another one of the SOLID Principles

Tip: try to guess the violation and get a cookie after, regardless of guessing it right or not ๐Ÿ˜„๐Ÿช

A dancing Cookie

Got your cookie? Great!

And the violation is... Open/Closed Principle

See, whenever you have a need to add something to cache, you've to add it to the protocol, hence changing it's internal, and doing so, will force you to edit every class that provides caching thru this protocol, hence more work to be done, hence more pressure on delivery and less convenience, while the Generics approach doesn't have such issues, just 3 functions and a StorageKey

Usage of Optionals Justification

Sometimes being simple provide us with a good reason not to write overly complicated code, and that came when I can know if something is present in the cache via it being non-nil or it is not there yet if it is nil, and that's why using Optionals here made perfect sense

Favoring Optionals over throws

It's true that using throws here is appropriate, I think more appropriate, but why did I pick usage of optionals over "throwing errors"? because there is no need until now to tell the user we failed to find the AccessToken, or saving it failed, however, internally we can log this, so we developers can know if there is an issue with our caching, plus at the current stage, this may introduce overengineering, and do we need that? I guess not.

StorageKey

Now, onto the next step which is, StorageKey

public enum StorageKey: CaseIterable {
    case accessToken

    var identifier: String {
        switch self {
        case .accessToken:
            return "accessToken"
        }
    }

    var suitableStorage: SupportedStorage {
        switch self {
        case .accessToken:
            return .encrypted
        }
    }
}

extension StorageKey {
  enum SupportedStorage {
    case encrypted
  }
}

Question, why did we add SupportedStorage Enum information to the key? why not make this the responsibility of the CacheProtocol by some sort of switch?

A key must know its Lock, right? or how else would it open it? and that's why it made sense for the StorageKey to know its suitable Storage Environment

Ok...? but why an enum? wouldn't that cause some OCP violation? Not really, there are 3 reasons for using enums in this particular case

  1. Similar to the Network building, a network needs to know which APIs is it hitting, and that's a finite number, hence an enum approach here might not be all bad
  2. When introducing a new cache requirement, Xcode will help me know if I missed updating the information, just like in the Network as well
  3. Favoring strong types over hard-coded strings, which is very error-prone and may cause hard to detect bugs

Back to our test environment, let's add our MockedCache

@testable import TDD // <- Don't forget this

final class MockedCache: CacheProtocol {

  var storage: [StorageKey: Codable] = [:]

  func fetch<T: Codable>(_: T.Type, for key: StorageKey) -> T? {
    storage[key] as? T
  }

  func save<T: Codable>(_ value: T, for key: StorageKey) {
    storage[key] = value
  }

  func remove(for key: StorageKey) {
    storage[key] = nil
  }
}

Making it Compile

Remember when we talked about Mocking in the Network Post? you will find that here is very similar, since we have a simple declaration of storage which is just a dictionary, key/value and you just edit it when a function is called, and this is what we will focus on when running our tests

Now to the tests themselves, let's start by educating the test case about our cache

  var network: MockedNetwork!
  var cache: MockedCache! // <- Added
  var sut: LoginViewModel!

  override func setUp() {
    super.setUp()
    network = .init()
    cache = .init() // <- Added
    sut = .init(network: network)
  }

  override func tearDown() {
    super.tearDown()
    network = nil
    cache = nil // <- Added
    sut = nil
  }
...
  func test_GivenUserExists_WhenLogin_TokenIsCached() {
    // Given
    let email = "valid_email@gmail.com"
    let password = "Somevalid1Password"

    let expectedModel = AccessToken()
    network.expectedModel = expectedModel
    // When
    sut.login(email: email, password: password)

    // Then
    XCTAssertNotNil(cache.storage[.accessToken]) // <- Changed, Note
  }

Note: If we go back to our key, we will find that it must be stored in encrypted storage, hence, self-verifying making sure that gets to the Keychain every time, and not doing so will trigger a compile error, making it another safety net beside the unit tests

Build and test, and viola, it compiled and failed ๐Ÿฅณ

Like in the network, we need to tell the ViewModel to use our MockedCache

Tip: Try to do that on your own then compare your implementation with the following one, only this time, we are not getting cookies after we are done ๐Ÿ˜œ

  let network: NetworkProtocol
  let cache: CacheProtocol // <- Added

  init(
    network: NetworkProtocol,
    cache: CacheProtocol // <- Added
  ) {
    self.network = network
    self.cache = cache // <- Added
  }

  func login(email: String, password: String) {
    if email.isEmpty {
      validationErrors.append(.emailIsEmpty)
    }

    guard validationErrors.isEmpty else {
      onError?(validationErrors)
      return
    }

    self.validationErrors.removeAll()
    self.networkErrors.removeAll()

    network.call(
      api: AuthEndpoint.login(email: email, password: password),
      expected: AccessToken.self // <- Changed, Note here
    ) { results in
        switch results {
        case let .success(accessToken):
          self.cache.save(accessToken, for: .accessToken) // <- Added
        case let .failure(error):
          self.networkErrors.append(error)
        }
      }
  }

Note: Before we finish up, remember our talk with our backend team to send us AccessToken? we can now expect this instead of the whole user when logging in, and just fetch it in another API call when needed ๐Ÿ’ก

Now let's try to build our tests... Aaaand yep, we need to inject our Cache in both production & test environments, let's do that

First, let's declare our production version of the Cache

final class CacheManager: CacheProtocol {
  func fetch<T: Codable>(_: T.Type, for key: StorageKey) -> T? {
    // TODO: - Implement
    return nil
  }

  func save<T: Codable>(_ value: T, for key: StorageKey) {
    // TODO: - Implement
  }

  func remove(for key: StorageKey) {
    // TODO: - Implement
  }
}

And let's leave it like that for now, since we will refactor it after our test passes, then let's inject it to our LoginViewModel in the ViewController.swift

  private lazy var viewModel = LoginViewModel(
    network: URLSessionNetwork(),
    cache: CacheManager()
  )

Now, that our app is compiling, let's also fix our tests by injecting our MockedCache there as well

  override func setUp() {
    super.setUp()
    network = .init()
    cache = .init()
    sut = .init(network: network, cache: cache) // <- Added
  }

Now build & test, and now the tests are passing!

So, after we got here, we need to refactor our production CacheManager to have a way of storing Sensitive Data into our Keychain, for quickness, I'll just drop an SPM package that offers me convenient APIs to deal with Keychain over rebuilding the wheel

Defining EncryptedStorage

First, let's define some structure to our Storages

public protocol ReadableStorageProtocol {
  func fetchValue<T: Codable>(_ type: T.Type, for key: StorageKey) -> T?
}

public protocol WritableStorageProtocol {
  func save<T: Codable>(value: T, for key: StorageKey)
  func remove(for key: StorageKey)
}

public typealias StorageProtocol = ReadableStorageProtocol & WritableStorageProtocol

Then, let's define our first Storage, the EncryptedStorage

import KeychainSwift <- Don't forget to add that

public final class EncryptedStorage {
  private let keychain = KeychainSwift()
}

extension EncryptedStorage: StorageProtocol {
  public func fetchValue<T: Codable>(_ type: T.Type, for key: StorageKey) -> T? {
    keychain.getData(key.identifier)?.decode(type)
  }

  public func save<T: Codable>(value: T, for key: StorageKey) {
    guard let data = value.encode() else { return }
    keychain.set(data, forKey: key.identifier)
  }

  public func remove(for key: StorageKey) {
    keychain.delete(key.identifier)
  }
}

// Addition of Helper Methods Starts
extension Encodable {
    func asDictionary() -> [String: Any] {
      guard let data = self.encode() else { return [:] }
      let serialized = (try? JSONSerialization.jsonObject(with: data, options: .allowFragments)) ?? nil
      return serialized as? [String: Any] ?? [String: Any]()
    }

    func encode() -> Data? {
      do {
        return try JSONEncoder().encode(self)
      } catch {
      #if DEBUG
        debugPrint("[Encoding Error] Failed to Encode \(self)\nError: \(error)")
      #endif
        return nil
      }
    }
}

extension Data {
    func decode<T: Decodable>(_ object: T.Type) -> T? {
        do {
            return (try JSONDecoder().decode(T.self, from: self))
        } catch {
          #if DEBUG
            debugPrint("[Parse Error] Failed to Parse Object with this type: \(object)\nError: \(error)")
          #endif
            return nil
        }
    }
}
// Addition of helper methods ends

You will find that we have introduced 2 utility functions, Decode and Encode, so that we don't have to worry about redoing what we always do, and also in case of an error, we just log it with mentioning the reason of course

Wrapping it up

Now, back to our CacheManager again, let's give him instructions to cache our information

final class CacheManager: CacheProtocol {
  private let encryptedStorage: EncryptedStorage = .init() // <- Note

  func fetch<T: Codable>(_ type: T.Type, for key: StorageKey) -> T? {
    selectSuitableStorage(from: key).fetchValue(type, for: key)
  }

  func save<T: Codable>(_ value: T, for key: StorageKey) {
    selectSuitableStorage(from: key).save(value: value, for: key)
  }

  func remove(for key: StorageKey) {
    selectSuitableStorage(from: key).remove(for: key)
  }

  private func selectSuitableStorage(from key: StorageKey) -> StorageProtocol { // <- Note
    switch key.suitableStorage {
    case .encrypted:
      return encryptedStorage
    }
  }
}

Notice the access modifiers? these are important since they don't need to be exposed outside the bounds of the CacheManager, we just need to cache and retrieve, how? too many details for anyone beyond the protocol

Phewwww! Phew, that was a lot!

Conclusion

We managed to test our Cache and implement a cache mechanism in TDD! isn't that great? not only does our test validate that the tokens get cached after being successfully authenticated, but it also verifies that it gets cached in the correct storage!

Hopefully, you were able to benefit from today's blog post and If you happen to have any questions or don't see something that is right, please educate us and I am more than happy to answer any question you might have :))

You can find the final version of the code here on GitHub

ย