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 🤞
Table of contents
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
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
- 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
- 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
- What operation do I need?
- What type am I operating on?
- 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
- Why did we use generics here?
- Why do we return an
optional
when fetching? - 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 😄🍪
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
- 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
- When introducing a new cache requirement, Xcode will help me know if I missed updating the information, just like in the Network as well
- 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
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