最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

ios - How can I mock network responses and state for UI tests using XCTest? - Stack Overflow

programmeradmin3浏览0评论

For my new SwiftUI app I'm looking to add tests. Using Swift Testing I've started to write some unit tests. I'm looking to add UI tests as well. The app relies heavily on network calls to fetch data to display and perform actions. State is also important, the app is only usable if the user is logged in for example. Using some online tutorials I've added a simple UI test which opens the app and logs in a user. This test actually runs the app, performing the login by contacting an actual authentication server. To test other views this would need to be done each time before running the actual test to test a specific view.

By default SwiftUI views come with a preview, to get those to work I've seen it recommended to use protocols for the ViewModels used by the views and creating a mock version so the previews work. Is that the preferred way to mock network requests and state in for XCTest UI tests? If so, how should those mocks be injected as the UI tests seem to start the whole app and not specific views.

let app = XCUIApplication()
app.launch()

App Structure

Below is a simplified example of the structure of my app. For my tests I'd like to mock the isAuthenticated state in the AuthenticationModel and mock the network calls, either in the AuthenticationModel or in the APIClient.

Based on the comments and answers thus far I've introduced a protocol and use a basic form of dependency injection to add the network layer (APIClient) to the AuthenticationModel. This should allow me to mock the networking layer, however, it is unclear to me how I can use the mock implementation in the XCTest.

import SwiftUI

struct ContentView: View {
    @EnvironmentObject var authModel: AuthenticationModel
    
    var body: some View {
        if authModel.isAuthenticated {
            VStack {
                Text("Welcome \(authModel.username)")
                
                Button {
                    Task {
                        await authModel.logout()
                    }
                } label: {
                    Text("Logout")
                }
                .buttonStyle(.borderedProminent)
            }
            .padding()
        } else {
            VStack {
                TextField("Username", text: $authModel.username)
                    .multilineTextAlignment(.center)
                TextField("Password", text: $authModel.password)
                    .multilineTextAlignment(.center)
                Button {
                    Task {
                        await authModel.login()
                    }
                } label: {
                    Text("Login")
                }
                .buttonStyle(.borderedProminent)
            }
            .padding()
        }
    }
}

#Preview {
    let authModel = AuthenticationModel(MockAPIClient())
    
    ContentView().environmentObject(authModel)
}
import Foundation

class AuthenticationModel: ObservableObject {
    @Published var isAuthenticated = false
    @Published var username = ""
    @Published var password = ""
    
    init(_ apiClient: APIClient) {
        self.apiClient = apiClient
    }
    
    @MainActor
    func login() async {
        if !username.isEmpty && !password.isEmpty {
            await apiClient.postRequest(
                with: URL(string: "www.example/login")!,
                andBody: LoginRequest(username: username, password: password))
            isAuthenticated = true
        }
    }
    
    @MainActor
    func logout() async {
        username = ""
        password = ""
        isAuthenticated = false
    }
}
import Foundation

protocol APIClient {
    func getRequest<T: Decodable>(with url: URL) async -> T?
    
    func postRequest<T: Encodable>(with url: URL, andBody body: T) async
}

struct RealAPIClient: APIClient {
    func getRequest<T: Decodable>(with url: URL) async -> T? {
        print("Real Login")
        return nil
    }
    
    func postRequest<T: Encodable>(with url: URL, andBody body: T) async {
        print("Real POST")
    }
}

struct MockAPIClient: APIClient {
    func getRequest<T: Decodable>(with url: URL) async -> T? {
        print("Mock Login")
        return nil
    }
    
    func postRequest<T: Encodable>(with url: URL, andBody body: T) async {
        print("Mock POST")
    }
}

struct LoginRequest: Encodable {
    let username: String
    let password: String
}
import SwiftUI

@main
struct MockTestsApp: App {
    @StateObject private var authModel = AuthenticationModel(RealAPIClient())
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(authModel)
        }
    }
}

UI Test

An example of a basic UI test where the user logs in. For this test I'd like to mock the login request so the test isn't dependent on an external authentication server. Additionally, I want to write a test where the user is already logged in, so I can test UIs that are only available to logged in users without having to perform a login for each of those tests.

import XCTest

final class MockTestsUITests: XCTestCase {

    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.

        // In UI tests it is usually best to stop immediately when a failure occurs.
        continueAfterFailure = false

        // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }
    
    @MainActor
    func testLogin() throws {
        let app = XCUIApplication()
        app.launch()
        
        app.textFields["Username"].tap()
        app.textFields["Username"].typeText("Example")
        
        app.textFields["Password"].tap()
        app.textFields["Password"].typeText("Password")
        
        app.buttons["Login"].tap()
        
        XCUIApplication().staticTexts["Welcome Example"].tap()
        
        // This test works, but I'd like to mock the login request somehow. With the current test implementation the test is dependent on a server to perform an actual login.
    }

    @MainActor
    func testOtherFunctionality() throws {
        // Mock the authentication state so a login doesn't have to be performed by this test
        // Test actual other functionality
    }

    @MainActor
    func testLaunchPerformance() throws {
        if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
            // This measures how long it takes to launch your application.
            measure(metrics: [XCTApplicationLaunchMetric()]) {
                XCUIApplication().launch()
            }
        }
    }
}

For my new SwiftUI app I'm looking to add tests. Using Swift Testing I've started to write some unit tests. I'm looking to add UI tests as well. The app relies heavily on network calls to fetch data to display and perform actions. State is also important, the app is only usable if the user is logged in for example. Using some online tutorials I've added a simple UI test which opens the app and logs in a user. This test actually runs the app, performing the login by contacting an actual authentication server. To test other views this would need to be done each time before running the actual test to test a specific view.

By default SwiftUI views come with a preview, to get those to work I've seen it recommended to use protocols for the ViewModels used by the views and creating a mock version so the previews work. Is that the preferred way to mock network requests and state in for XCTest UI tests? If so, how should those mocks be injected as the UI tests seem to start the whole app and not specific views.

let app = XCUIApplication()
app.launch()

App Structure

Below is a simplified example of the structure of my app. For my tests I'd like to mock the isAuthenticated state in the AuthenticationModel and mock the network calls, either in the AuthenticationModel or in the APIClient.

Based on the comments and answers thus far I've introduced a protocol and use a basic form of dependency injection to add the network layer (APIClient) to the AuthenticationModel. This should allow me to mock the networking layer, however, it is unclear to me how I can use the mock implementation in the XCTest.

import SwiftUI

struct ContentView: View {
    @EnvironmentObject var authModel: AuthenticationModel
    
    var body: some View {
        if authModel.isAuthenticated {
            VStack {
                Text("Welcome \(authModel.username)")
                
                Button {
                    Task {
                        await authModel.logout()
                    }
                } label: {
                    Text("Logout")
                }
                .buttonStyle(.borderedProminent)
            }
            .padding()
        } else {
            VStack {
                TextField("Username", text: $authModel.username)
                    .multilineTextAlignment(.center)
                TextField("Password", text: $authModel.password)
                    .multilineTextAlignment(.center)
                Button {
                    Task {
                        await authModel.login()
                    }
                } label: {
                    Text("Login")
                }
                .buttonStyle(.borderedProminent)
            }
            .padding()
        }
    }
}

#Preview {
    let authModel = AuthenticationModel(MockAPIClient())
    
    ContentView().environmentObject(authModel)
}
import Foundation

class AuthenticationModel: ObservableObject {
    @Published var isAuthenticated = false
    @Published var username = ""
    @Published var password = ""
    
    init(_ apiClient: APIClient) {
        self.apiClient = apiClient
    }
    
    @MainActor
    func login() async {
        if !username.isEmpty && !password.isEmpty {
            await apiClient.postRequest(
                with: URL(string: "www.example/login")!,
                andBody: LoginRequest(username: username, password: password))
            isAuthenticated = true
        }
    }
    
    @MainActor
    func logout() async {
        username = ""
        password = ""
        isAuthenticated = false
    }
}
import Foundation

protocol APIClient {
    func getRequest<T: Decodable>(with url: URL) async -> T?
    
    func postRequest<T: Encodable>(with url: URL, andBody body: T) async
}

struct RealAPIClient: APIClient {
    func getRequest<T: Decodable>(with url: URL) async -> T? {
        print("Real Login")
        return nil
    }
    
    func postRequest<T: Encodable>(with url: URL, andBody body: T) async {
        print("Real POST")
    }
}

struct MockAPIClient: APIClient {
    func getRequest<T: Decodable>(with url: URL) async -> T? {
        print("Mock Login")
        return nil
    }
    
    func postRequest<T: Encodable>(with url: URL, andBody body: T) async {
        print("Mock POST")
    }
}

struct LoginRequest: Encodable {
    let username: String
    let password: String
}
import SwiftUI

@main
struct MockTestsApp: App {
    @StateObject private var authModel = AuthenticationModel(RealAPIClient())
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(authModel)
        }
    }
}

UI Test

An example of a basic UI test where the user logs in. For this test I'd like to mock the login request so the test isn't dependent on an external authentication server. Additionally, I want to write a test where the user is already logged in, so I can test UIs that are only available to logged in users without having to perform a login for each of those tests.

import XCTest

final class MockTestsUITests: XCTestCase {

    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.

        // In UI tests it is usually best to stop immediately when a failure occurs.
        continueAfterFailure = false

        // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }
    
    @MainActor
    func testLogin() throws {
        let app = XCUIApplication()
        app.launch()
        
        app.textFields["Username"].tap()
        app.textFields["Username"].typeText("Example")
        
        app.textFields["Password"].tap()
        app.textFields["Password"].typeText("Password")
        
        app.buttons["Login"].tap()
        
        XCUIApplication().staticTexts["Welcome Example"].tap()
        
        // This test works, but I'd like to mock the login request somehow. With the current test implementation the test is dependent on a server to perform an actual login.
    }

    @MainActor
    func testOtherFunctionality() throws {
        // Mock the authentication state so a login doesn't have to be performed by this test
        // Test actual other functionality
    }

    @MainActor
    func testLaunchPerformance() throws {
        if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
            // This measures how long it takes to launch your application.
            measure(metrics: [XCTApplicationLaunchMetric()]) {
                XCUIApplication().launch()
            }
        }
    }
}
Share Improve this question edited 9 hours ago Pieter asked 2 days ago PieterPieter 1771 gold badge3 silver badges15 bronze badges 2
  • 2 Rather than replacing the ViewModel with a mock version, you would want to replace the dependencies of the ViewModel/Model with a mock version, i.e. the service function, which is actually a dependency of the Model, the ViewModel uses. So, you actually inject the mock service function into the Model. A ViewModel is defined by pure logic - and an API to the Model. This won't change, and you won't replace this with a mock, of course - it is the part you want to test. – CouchDeveloper Commented 2 days ago
  • In SwiftUI the View structs are view models already and State/Binding is for the view data. For testing network logic you could put the logic into an async func, in a struct and test that. That design would position you well for Previews too because usually these structs are injected into the Environment so they can be mocked for Previews. – malhal Commented 2 days ago
Add a comment  | 

2 Answers 2

Reset to default 0

We can use local json file for mocking network response:

protocol ModelMockable: Decodable {
    static func mock(fileName: String) throws -> Self
}

extension ModelMockable {
    static func mock(fileName: String = "\(Self.self)") throws -> Self {
        // 1. tries to get url for file name located on Bundle(for our case tests Bundle)
        guard let jsonURL = Bundle.tests.url(forResource: fileName, withExtension: "json") else {
            throw Tests.MockNotFound(message: "Unable to find json mocks for \(fileName)") // custom error
        }

        // 2. Creates Data from json url
        let json = try Data(contentsOf: jsonURL)
        // 3. Returns Model class from json data
        return try .init(jsonData: json)
    }
}

Now we need have a json file locally added to the project(on our case Tests bundle) for example ProductResponse.json:

{
    "id": 258963,
    "identifier": "T90XXX450",
    "name": "Sample product name",
    "type": "SAMPLE_TYPE",
    "productInfo": {
        "text": "Produkt information texts",
        "link": "https://sampleproduct/products/258963"
    }
}

And our Model class(ProductResponse.swift) looks like:

struct ProductResponse: Decodable, Hashable {
    let id: Int?
    let name: String?
    let identifier: String?
    let type: String?
    let productInfo: [ProductInfo]?
}

struct ProductInfo: Decodable, Hashable {
    let text: String?
    let link: String?
}

For making this model mockable first we need to make a extension for ProductResponse:

extension ProductResponse: ModelMockable {}

Now when we need to mock this response we can simply use static function mock():

/// Tests product response type
func testProductType() throws {
    // given
    guard let expectedResponse = try? ProductResponse.mock() else {
        return
    }
    // do rest test based on the response....
}

What should be mocked is APIClient and this is done using protocol and some kind of dependency injection.

The more SwiftUI approach is to use EnvironmentValues

https://developer.apple/documentation/swiftui/entry()

You would bridge the Environment and View model by passing the client as a function argument.

But you can also create your own dependency injection with something like this and separate the client/service from the view.

https://www.avanderlee/swift/dependency-injection/

Once you have these setup you can test using ViewInspector

https://www.kodeco/books/swiftui-cookbook/v1.0/chapters/9-testing-swiftui-views-with-viewinspector

Or setup the dependencies using environment properties and determining at runtime which client should be used.

https://forums.swift./t/how-do-you-know-if-youre-running-unit-tests-when-calling-swift-test/49711/3

发布评论

评论列表(0)

  1. 暂无评论