Search code examples
swiftswiftuixctestobservableobject

unit testing an ObservableObject class in swiftUI that depends on a network request


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?


Solution

  • 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.