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
2 Answers
Reset to default 0We 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