I am using swift UI and I need to make a network request to get my app version from itunes api and decide if the app is out of date.
I understand how unit tests work but im more curious about better testing principals for an observable object class.
below is my class. Ideally I want to have my 2 properties to be private and have a function I call from my view to decide if the user is out of date. I dont want anything to be able to change those properties.
so I make them private, I make the network request and parse the data and set my currentAppVersion property to the app version apple has on app store.
I then have an extension that gets whatever app version the user is on. Again i want this private.
final class AppUpdateViewModel: ObservableObject {
@Published
private var currentAppVersion: String?
private var usersAppVersion: String = Bundle.appVersionNumber() // Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
private func fetchCurrentAppVersion() async -> Void {
do {
let res = try await NetworkingManger.shared.request(.getAppMetaDataFromApple, type: AppUpdateModel.self)
self.currentAppVersion = res.results[0].version
} catch {
print("app updates ERR: ", error.localizedDescription)
}
}
public func appNeedsUpdate() -> Bool {
// Split version string into components
guard let currentAppVersionParts = currentAppVersion?.split(separator: ".") else { return false }
let curAppVersMajor = currentAppVersionParts[0]
let curAppVersMinor = currentAppVersionParts[1]
let curAppVersPatch = currentAppVersionParts[2]
let userAppVersionParts = usersAppVersion.split(separator: ".")
let userAppVersMajor = userAppVersionParts[0]
let userAppVersMinor = userAppVersionParts[1]
let userAppVersPatch = userAppVersionParts[2]
return ( curAppVersMajor > userAppVersMajor ||
(curAppVersMajor == userAppVersMajor && curAppVersMinor > userAppVersMinor) ||
(curAppVersMajor == userAppVersMajor && curAppVersMinor == userAppVersMinor && curAppVersPatch > userAppVersPatch) )
}
}
now I need to call my functions
struct myView: View {
@StateObject private var appUpdateVM = AppUpdateViewModel()
@State private var isOutOfDate = false
var body: some View {
Text("Hello")
.onAppear {
isOutOfDate = appUpdateVM.appNeedsUpdate()
}
}
}
this is simplified and not built out but this is the general idea. Now when trying to test against this update is where I have issues.
my variables are private so in my test class I cannot set them
final class AppVersionTests: XCTestCase {
var appUpdateVM: AppUpdateViewModel!
func testAppNeedsPatchUpdateSameMajorSameMinorDifferentPatch() -> Void {
// (1.0.5, 1.0.4)
appUpdateVM.currentAppVersion = "1.0.5" // Error accessing due to private level
appUpdateVM.usersAppVersion = "1.0.4" // Error accessing due to private level
let needsUpdate = appUpdateVM.appNeedsUpdate()
XCTAssertTrue(needsUpdate)
}
So I understand I will need to mock out my network request but how can i make this smooth all while maintaining the private level vars?
After a weekend of working on this I came up with the solution I was seeking. It goes along with the other answer but Ill go a little different route and show how I used dependency injection to mock the network manager.
first off, I wanted to keep use of private vars in my vm so they cannot be changed. we can solve this with private(set) var myVar: String
next I needed to understand how to mock out my network requests. I am using a singleton as a network manager but this will be the same process if you just pass a reference to your netwokring manager thats not a singleton.
I now make my network manager conform to the following protocol. This is so I can mock out the responses I get and I can pass any type of networkManager in to my Observable object as long as it has my 2 request functions(conforms to my protocol).
protocol NetworkingManagerImplementation {
func request<T: Codable>(session: URLSession,
_ endPoint: Endpoint,
type: T.Type) async throws -> T
func request(session: URLSession,
_ endPoint: Endpoint) async throws
}
When I create my mock, I pass in an enum type success or error so I can test when my networkmanager errors out(You can definitely go further in depth with testing with enums and throwing errors).
enum AppUpdateMockType { case success, error }
class NetworkManagerAppUpdateSuccessMock: NetworkingManagerImplementation {
private var mockType: AppUpdateMockType
init(mockType: AppUpdateMockType) {
self.mockType = mockType
}
enum MockError: Error {
case mockError
}
func request<T>(session: URLSession, _ endPoint: Endpoint, type: T.Type) async throws -> T where T: Decodable, T: Encodable {
switch mockType {
case .success:
return Bundle.main.decode("AppVersionResponse.json") as T
case .error:
throw MockError.mockError
}
}
func request(session: URLSession, _ endPoint: Endpoint) async throws {}
}
I now inject my network manager which defaults sets to my NetwokringManager.shared singleton instance. Difference here is I can now pass in any type of networking manager as long as it conforms to my implementation protocol. The key here is I can now mock out my responses from my network manager.
final class AppUpdateViewModel: ObservableObject {
@Published
private(set) var currentAppVersion: String?
private(set) var usersAppVersion: String
private(set) var error: Error?
private var networkManager: NetworkingManagerImplementation
init(usersAppVersion: String = Bundle.appVersionNumber(), networkingManager: NetworkingManagerImplementation = NetworkingManger.shared) {
self.usersAppVersion = usersAppVersion
self.networkManager = networkingManager
}
@MainActor
func fetchCurrentAppVersion() async {
do {
let res = try await networkManager.request(session: .shared, .getAppMetaDataFromApple, type: AppUpdateModel.self)
self.currentAppVersion = res.results[0].version
} catch {
self.error = error
}
}
public func appNeedsUpdate() -> Bool {
// Split version string into components
guard let currentAppVersionParts = currentAppVersion?.split(separator: ".") else { return false }
let curAppVersMajor = currentAppVersionParts[0]
let curAppVersMinor = currentAppVersionParts[1]
let curAppVersPatch = currentAppVersionParts[2]
let userAppVersionParts = usersAppVersion.split(separator: ".")
let userAppVersMajor = userAppVersionParts[0]
let userAppVersMinor = userAppVersionParts[1]
let userAppVersPatch = userAppVersionParts[2]
return ( curAppVersMajor > userAppVersMajor ||
(curAppVersMajor == userAppVersMajor && curAppVersMinor > userAppVersMinor) ||
(curAppVersMajor == userAppVersMajor && curAppVersMinor == userAppVersMinor && curAppVersPatch > userAppVersPatch) )
}
}
Now for the testing part... here is a small part of the class
I now have my networkMock that conforms to my protocol and I have my appUpdateViewModel
I pass in .success to my mock so I mock a successful response from the api
And finally I pass in the mock to my viewmodel. This now mocks out the actual network request and returns whatever data I want which keeps it consistent for testing.
final class AppVersionTests: XCTestCase {
private var networkMock: NetworkManagerAppUpdateSuccessMock!
private var appUpdateVM: AppUpdateViewModel!
override func setUp() async throws {
try await super.setUp()
networkMock = NetworkManagerAppUpdateSuccessMock(mockType: .success)
}
override func tearDown() async throws {
networkMock = nil
appUpdateVM = nil
try await super.tearDown()
}
func test_appVersion_should_match_isValid() async {
appUpdateVM = AppUpdateViewModel(usersAppVersion: "1.7.2", networkingManager: networkMock)
await appUpdateVM.fetchCurrentAppVersion()
XCTAssertEqual(appUpdateVM.currentAppVersion, "1.7.2", "App version should be 1.7.2")
}
I now mock out my network request function which then sets my private vars. So no more wondering how to set private vars from outside my class which keeps things safer and cleaner. I can now call function that set vars and run tests all with fake data.