Reputation: 187
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.com/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()
}
}
}
}
Upvotes: -2
Views: 160
Reputation: 2882
There's a much better way to mock network responses! Use URLProtocol, which is part of iOS. With URLProtocol there's a way to monitor for URLs (or fragments of URLs) and inject your own response, status code, response headers, etc...
That way, your app doesn't need to be modified at all. Also, this works even for libraries of your app (that do their own networking) that you may not have source code for.
I use it for unit tests, as well as for the app itself when I don't yet have a server API set up and I want to mock network responses.
You'd basically create a subclass of URLProtocol and override the following methods:
override public class func canInit(with request: URLRequest) -> Bool
override public class func canonicalRequest(for request: URLRequest) -> URLRequest
override public func startLoading()
override public func stopLoading()
You'd also write your own extra method (maybe called register()) to tell your class what URLs to watch for and what responses to inject for those URLs. The class would hang onto this information and use it in the above override methods. Be sure to call the below in your register() method:
URLProtocol.registerClass(Self.self)
And you'll want an unregister() method as well so you can turn it back off again such as during teardown of your unit test.
I can't share my class, but if you search for "urlprotocol inject mock response" you'll find some examples. I wrote mine in ObjC back in like 2014 or so, and then rewrote it in Swift in 2020. My teams totally rely on it.
Upvotes: 0
Reputation: 29614
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.com/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.com/swift/dependency-injection/
Once you have these setup you can test using ViewInspector
Or setup the dependencies using environment properties and determining at runtime which client should be used.
Upvotes: 0
Reputation: 2428
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.com/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....
}
Upvotes: 0