Automate a SwiftUI Hotel Search

Build a SwiftUI hotel search and automate it with native XCTest UI tests.

Introduction

30 Second Summary

Checking a mobile app by hand can prove that one journey works today. Repeating that same check after every change quickly becomes a chore.

In this project, you will build a hotel search with SwiftUI that runs in iOS Simulator. You will use Swift automation to enter a destination, tap Search, and verify the result through XCTest with XCUIAutomation.

What You'll Build

You press one test diamond in Xcode and watch iOS Simulator enter New York, tap Search, display Hotels in New York, and finish with a green passing result.

By the end of this project, you'll have:

  • A working hotel search running in iOS Simulator. You enter New York and see Hotels in New York.
  • A repeatable Swift automation that launches the app without manual input. It enters the destination, taps Search, waits for the result, and validates the displayed message.
  • A failure report you can explain. You compare a designed selector failure with the corrected passing run to see how accessibility identifiers connect the app to its test.
  • Secret Mission: Add a second Swift automation that taps Search with an empty destination and confirms Enter a destination.

Are there any prerequisites?

You need a Windows PC with internet access plus a paid MacinCloud account. The remote Mac provides Xcode and iOS Simulator, so you do not need an iPhone or previous Swift experience.

Before We Start

This checkpoint locks in the Swift hotel-search journey before the hands-on work begins: launch HotelSearchDemo, enter a destination, tap Search, then assert the displayed result. A repeatable XCTest test verifies that journey consistently without relying on manual checks.

Open the Apple Development Environment

Your Windows PC cannot run Xcode or iOS Simulator locally. A remote Mac supplies the Apple environment that the automation needs.

Here, you will use MacinCloud Pay-As-You-Go from Windows Remote Desktop Connection. You will finish at the Xcode welcome window without creating the hotel-search project yet.

In this step, get ready to:
  • Choose a paid MacinCloud configuration after reviewing its billing terms.
  • Connect Windows to the remote Mac through the downloaded .rdp file.
  • Reach the Xcode welcome window on the remote Mac.
Choose a paid MacinCloud configuration

Your remote Mac must provide Xcode 16.1 or later. It must also provide iOS Simulator.

✔️ I see the required Xcode version

The configuration lists Xcode 16.1 or later. This meets the version requirement for the project.

ⓧ I see an older Xcode version

  • Return to the available MacinCloud configurations.
  • Choose a configuration that lists Xcode 16.1 or later.
  • Confirm that the replacement configuration includes iOS Simulator.

ⓧ Xcode is not included

  • Return to the available MacinCloud configurations.
  • Choose a configuration that includes Xcode 16.1 or later.
  • Confirm that the replacement configuration includes iOS Simulator.

Review the Paid Access

This purchase starts paid remote access. Checking the charging rules first keeps the cost predictable.

MacinCloud advertises Pay-As-You-Go access starting at US$4/day. The plan uses prepaid usage credits.

Automatic recharge occurs after usage exceeds the prepaid credit. Review that condition before you purchase.

  • Review the amount of prepaid usage credit included with the selected configuration.
  • Review the condition that triggers automatic recharge.
  • Complete the purchase for the reviewed configuration.

That is the billing decision handled. Your paid configuration is ready for connection.

Connect to the remote Mac

Your .rdp connection file opens the remote Mac through Windows Remote Desktop Connection. The connection files arrive in the Getting Started email.

Finding the right credentials can be fiddly because MacinCloud sends them in a separate email. Both values come from the New MacinCloud Account email.

  • Download the connection files from the Getting Started email.
  • Extract the downloaded connection files on Windows.

You should now see the extracted .rdp connection files for different screen resolutions.

  • Double-click the .rdp file for your preferred screen resolution.

Windows Remote Desktop Connection opens the MacinCloud sign-in window.

  • Enter the username from the New MacinCloud Account email.
  • Enter the password from the same email.
  • Submit the sign-in form.

You'll see the remote Mac desktop inside Windows Remote Desktop Connection. This visible desktop confirms that the connection is active.

Connection Not Opening?

  • Confirm that you extracted the downloaded connection files before opening an .rdp file.
  • Use the credentials from the New MacinCloud Account email.

Help me diagnose my MacinCloud connection. For account-specific steps, use the official MacinCloud RDP access guide.

Launch Xcode on the remote Mac

The Xcode welcome window is the first visible proof that your Apple development tools are available. Reaching it confirms that the remote environment is ready for the project.

Before you launch Xcode, what window do you expect to see if the Apple environment is ready?

  • Press Command-Space bar inside the remote Mac to open Spotlight.
  • Enter Xcode in Spotlight.
  • Press Return to open the selected Xcode result.

You'll see the Xcode welcome window on the remote Mac. This confirms that the remote Apple development environment is ready.

No Xcode Welcome Window?

  • Click inside the remote Mac window before pressing Command-Space bar again.
  • Confirm that your selected MacinCloud configuration includes Xcode 16.1 or later.

Help me launch Xcode on my remote Mac. For remote environment support, use the official MacinCloud RDP access guide.

That's the environment hurdle cleared. Next, you'll turn this remote Apple environment into a running hotel-search app.

Run the SwiftUI Hotel Search

Your remote Mac is ready with Xcode open. The next goal is to turn that empty environment into a hotel search you can use.

A manually working SwiftUI screen gives you a baseline before automation touches it. You will prove that baseline with a New York search in iOS Simulator.

In this step, get ready to:
  • Create the HotelSearchDemo iOS App project with a SwiftUI interface.
  • Build the state-driven hotel search in ContentView.swift.
  • Demonstrate the manual New York search in iOS Simulator.
Create the HotelSearchDemo project

An Xcode project groups the app entry point with the views it displays. The iOS App template creates that structure for you.

  • Choose the option to create a new project from the Xcode welcome window.
  • Select iOS in the template sidebar.
  • Select the App template.
  • Click Next.
  • Enter HotelSearchDemo as the project name.
  • Set Interface to SwiftUI.
  • Set Storage to None.

Why SwiftUI for this app?

SwiftUI connects the interface directly to values stored by the view. When a stored value changes, the visible screen updates to match it.

That direct connection keeps this hotel search small enough to understand in one file.

  • Choose a folder on the remote Mac to store the project.
  • Click Create.

Good start. Xcode now opens HotelSearchDemo with HotelSearchDemoApp.swift and ContentView.swift in the Project navigator.

Build the hotel search view

The state in ContentView stores the typed destination plus the result message. A binding keeps the destination field connected to that state.

  • Select ContentView.swift in the Project navigator.
  • Select all existing code in the editor.
  • Replace the selected code by pasting the code below:
import SwiftUI

struct ContentView: View {
    @State private var destination = ""
    @State private var resultMessage = ""

    var body: some View {
        VStack {
            Text("Hotel Search")

            TextField("Destination", text: $destination)

            Button(action: {
                if destination.isEmpty {
                    resultMessage = "Enter a destination"
                } else {
                    resultMessage = "Hotels in \(destination)"
                }
            }) {
                Text("Search")
            }

            Text(resultMessage)
        }
    }
}

What does this view do?

  • The two @State properties hold the current destination plus the message displayed below the button.
  • The TextField binding writes typed text into destination.
  • The button uses destination.isEmpty to choose the message stored in resultMessage.
  • The Text(resultMessage) view renders the latest message on the screen.
  • Save ContentView.swift.
  • Confirm that the editor shows no red issue markers.

A clean editor confirms that Xcode can parse the view before you launch it.

Seeing red issue markers?

Compare every brace plus each quotation mark with the code block. One missing character can stop Xcode from building the view.

Make sure the file begins with import SwiftUI.

Help me fix my SwiftUI syntax.

Run the manual hotel search

The generated HotelSearchDemoApp.swift file is the app entry point. Its WindowGroup presents ContentView() when Simulator launches.

The first Simulator launch can take a little longer while Xcode builds the app. That extra wait is normal for the first run.

Before you run the app, what message do you expect after the button receives New York?

  • Select an available iPhone Simulator from the run destination menu in the Xcode toolbar.
  • Click the Run button in the Xcode toolbar.

After the build finishes, you will see the Hotel Search heading with a Destination field plus a Search button.

  • Click the Destination field in Simulator.
  • Enter New York.
  • Tap the Search button.

You will see Hotels in New York below the button. That is your baseline working: the app accepts a destination plus displays the matching result.

App missing or result not updating?

Confirm that an iPhone Simulator is selected as the run destination. A different destination can prevent the expected Simulator window from opening.

If the button does not update the message, compare the button action in ContentView.swift with the completed file below.

Help me debug the manual hotel search.

✔️ Awesome, I've got everything!

Great. Double check that ContentView.swift is saved plus the manual search displays Hotels in New York.

ⓧ I'd like to double check the full code

Compare both project files with these completed versions.

import SwiftUI

struct ContentView: View {
    @State private var destination = ""
    @State private var resultMessage = ""

    var body: some View {
        VStack {
            Text("Hotel Search")

            TextField("Destination", text: $destination)

            Button(action: {
                if destination.isEmpty {
                    resultMessage = "Enter a destination"
                } else {
                    resultMessage = "Hotels in \(destination)"
                }
            }) {
                Text("Search")
            }

            Text(resultMessage)
        }
    }
}
import SwiftUI

@main
struct HotelSearchDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Your hotel search now works manually in iOS Simulator. Next up, you will write Swift automation that attempts the same journey without your help.

Write the Swift UI Test

Your SwiftUI hotel search already completes a manual New York search in iOS Simulator. That baseline proves the screen works for a person.

Now the same journey needs a repeatable native test. XCTest uses XCUIAutomation to drive the app.

A manual success does not reveal whether automation can locate the same controls. This step puts that boundary under test.

In this step, get ready to:
  • Add a UI Testing Bundle to the project in Xcode.
  • Write a Swift test that launches the app and completes a destination search.
  • Run the test to preserve its first selector failure.
Add the UI testing target

A UI testing target keeps automation code separate from the app target. Xcode uses this target to launch the app from a test method.

  • Select the blue HotelSearchDemo project icon in Xcode's left sidebar.
  • Confirm the project settings appear in the editor.
  • Use the plus button below the targets list.
  • Select UI Testing Bundle.
  • Click Finish.

You'll see a new UI testing target containing a generated Swift test file.

Why use a separate target?

The UI Testing Bundle runs outside the app target. This separation lets the test launch the app as a user would.

Replace the generated test file

The setup method opens a fresh app instance for every test. The test method then describes one complete hotel search.

  • Expand the HotelSearchDemoUITests group in Xcode's left sidebar.
  • Select HotelSearchDemoUITests.swift.
  • Select all generated code in the editor.
  • Replace the generated code by pasting this code:
import XCTest

final class HotelSearchDemoUITests: XCTestCase {
    private var app: XCUIApplication!

    @MainActor override func setUpWithError() throws {
        continueAfterFailure = false
        app = XCUIApplication()
        app.launch()
    }

    @MainActor func testDestinationSearchShowsResults() throws {
        let destinationField = app.textFields["destinationField"]
        XCTAssertTrue(destinationField.waitForExistence(timeout: 5.0))
        destinationField.tap()
        destinationField.typeText("New York")

        app.buttons["searchButton"].tap()

        let resultsMessage = app.staticTexts["resultsMessage"]
        XCTAssertTrue(resultsMessage.waitForExistence(timeout: 5.0))
        XCTAssertEqual(resultsMessage.label, "Hotels in New York")
    }
}

What does this test do?

  • The import XCTest line gives the file access to XCTest. The import automatically includes XCUIAutomation.
  • The setUpWithError() method creates an XCUIApplication before each test. The continueAfterFailure setting stops the test at its first failed assertion.
  • The test queries the destination field before entering New York. It then taps the search button.
  • The first waitForExistence(timeout:) assertion checks whether the destination field can be found.
  • The final XCTAssertEqual assertion compares the displayed label with Hotels in New York.
  • Save HotelSearchDemoUITests.swift.
  • Confirm a test diamond appears beside testDestinationSearchShowsResults in the editor gutter.

That is the test harness ready. Xcode can now run this method as an individual UI test.

Test diamond missing?

Confirm that HotelSearchDemoUITests.swift remains inside the UI Testing Bundle. Check that HotelSearchDemoUITests inherits from XCTestCase.

If Xcode highlights Swift syntax, compare every brace and quote with the reference below.

Help me diagnose why Xcode does not show the test diamond.

The ContentView.swift file stays unchanged in this step. HotelSearchDemoApp.swift also stays unchanged.

✔️ Awesome, I've got everything!

Your HotelSearchDemoUITests.swift file is saved. The test diamond is visible beside testDestinationSearchShowsResults.

ⓧ I'd like to double check the full code

Your HotelSearchDemoUITests.swift file should match this reference:

import XCTest

final class HotelSearchDemoUITests: XCTestCase {
    private var app: XCUIApplication!

    @MainActor override func setUpWithError() throws {
        continueAfterFailure = false
        app = XCUIApplication()
        app.launch()
    }

    @MainActor func testDestinationSearchShowsResults() throws {
        let destinationField = app.textFields["destinationField"]
        XCTAssertTrue(destinationField.waitForExistence(timeout: 5.0))
        destinationField.tap()
        destinationField.typeText("New York")

        app.buttons["searchButton"].tap()

        let resultsMessage = app.staticTexts["resultsMessage"]
        XCTAssertTrue(resultsMessage.waitForExistence(timeout: 5.0))
        XCTAssertEqual(resultsMessage.label, "Hotels in New York")
    }
}
Capture the selector failure

The test defines the element identifier it expects. Running it now reveals whether the current interface exposes that identifier.

  • Before you run it, predict whether the test can find destinationField in the current interface.
  • Locate the test diamond beside testDestinationSearchShowsResults.
  • Click the test diamond to run only this test.

Xcode launches iOS Simulator. The run stops at the first waitForExistence(timeout:) assertion.

You'll see a red failure because destinationField is not found. This result is intentional.

Why is this failure expected?

Your test queries app.textFields["destinationField"]. The current ContentView has no matching accessibility identifier.

The visible destination field still works for a person. The failed query proves the automation needs a stable selector contract.

  • Preserve the failed run in Xcode Test Report.
  • Keep ContentView.swift unchanged.

The red result has done its job. Next, you will expose stable identifiers so this same test can control the SwiftUI screen.

Add Stable Accessibility Identifiers

The preserved failure from the previous step gives you a precise clue. The first Swift test query cannot find the destination field.

Stable accessibility identifiers give each SwiftUI view a fixed name. XCTest can use those names while visible labels evolve.

In this step, get ready to:
  • Give every SwiftUI control a stable automation name.
  • Match the view identifiers to the existing XCTest queries.
  • Prove the corrected selectors with a passing iOS Simulator run.
Connect the SwiftUI views to the test

The test already queries the app using fixed identifier strings. Your view needs to expose those exact strings before the queries can locate the controls.

Why use stable identifiers?

Visible labels can change during copy edits or localization. A stable identifier gives the automation a separate selector contract.

The strings in ContentView.swift must exactly match the query strings in HotelSearchDemoUITests.swift. That exact match lets each query target the intended view.

  • In Xcode, select ContentView.swift.
  • Replace the VStack block inside var body: some View with the code below:
        VStack {
            Text("Hotel Search")
                .accessibilityIdentifier("title")

            TextField("Destination", text: $destination)
                .accessibilityIdentifier("destinationField")

            Button(action: {
                if destination.isEmpty {
                    resultMessage = "Enter a destination"
                } else {
                    resultMessage = "Hotels in \(destination)"
                }
            }) {
                Text("Search")
            }
            .accessibilityIdentifier("searchButton")

            Text(resultMessage)
                .accessibilityIdentifier("resultsMessage")
        }

What do these identifiers do?

  • The title identifier gives the heading a stable automation name.
  • The destinationField identifier matches the existing text-field query.
  • The searchButton identifier matches the existing button query.
  • The resultsMessage identifier matches the existing result-text query.
  • Save ContentView.swift.

✔️ Awesome, I've got everything!

Your saved ContentView.swift now contains all four accessibility identifiers.

ⓧ I'd like to double check the full code

  • Compare ContentView.swift with the complete version below.
import SwiftUI

struct ContentView: View {
    @State private var destination = ""
    @State private var resultMessage = ""

    var body: some View {
        VStack {
            Text("Hotel Search")
                .accessibilityIdentifier("title")

            TextField("Destination", text: $destination)
                .accessibilityIdentifier("destinationField")

            Button(action: {
                if destination.isEmpty {
                    resultMessage = "Enter a destination"
                } else {
                    resultMessage = "Hotels in \(destination)"
                }
            }) {
                Text("Search")
            }
            .accessibilityIdentifier("searchButton")

            Text(resultMessage)
                .accessibilityIdentifier("resultsMessage")
        }
    }
}
Rerun the Swift automation

The test's selector strings now have matching views in the app. A rerun checks the complete hotel-search journey through those identifiers.

  • In Xcode, select HotelSearchDemoUITests.swift.

Before the rerun, consider whether the first wait can now find destinationField.

  • Click the test diamond beside testDestinationSearchShowsResults().

The simulator launches the app. The automation enters New York into the destination field.

The automation taps Search. It finds Hotels in New York through resultsMessage.

You did it. Xcode now displays a green passing indicator for the complete automated search.

Test still red?

  • Compare destinationField in ContentView.swift with the matching test query.
  • Confirm .accessibilityIdentifier("searchButton") remains attached to the Search button.
  • Confirm .accessibilityIdentifier("resultsMessage") remains attached to Text(resultMessage).

Help me diagnose my identifier mismatch.

Your SwiftUI controls now expose a stable contract to the Swift test. Next, you will compare this green run with the preserved failure in Xcode's Test Report.

Inspect the Test Report

Your corrected Swift automation now completes the hotel search with a green result in Xcode.

A passing icon confirms the latest outcome. The earlier Test Report reveals what the automation saw when destinationField was still absent.

In this step, get ready to:
  • Compare the preserved failed run with the later passing run.
  • Inspect the failed query activity for destinationField.
  • Rerun testDestinationSearchShowsResults to confirm the latest result remains green.
Compare the failed and passing runs

The Report Navigator keeps a history of Xcode test runs. Older runs can be a little fiddly to tell apart, so their red or green status gives you the clearest anchor.

  • Press Command-9 on the remote Mac to open the Report Navigator.
  • Select the earlier test run marked with a red failure.
  • Locate testDestinationSearchShowsResults in the selected report.
  • Select the later test run marked with a green result.

You will see a red result for the earlier run. You will see a green result for the later run.

What does this comparison prove?

Both records came from the same testDestinationSearchShowsResults method. The app's selector surface changed between the two runs.

The report gives you evidence that stable identifiers turned a failed query into a repeatable pass.

Inspect the selector failure

A failed activity narrows the diagnosis to the exact expectation that Xcode could not satisfy. Its captured UI context shows what the automation could access at that moment.

  • Return to the earlier test run marked with a red failure.
  • Double-click testDestinationSearchShowsResults to open its Test Report.
  • Select the failed activity for XCTAssertTrue(destinationField.waitForExistence(timeout: 5.0)).
  • Inspect the captured UI context for the visible Hotel Search screen.

You will see that the hotel-search app was visible. The destinationField query still timed out because that identifier was absent in the earlier run.

What does the failed activity prove?

Xcode launched the app successfully, which is why the captured interface is visible.

The 5.0 second wait ended without finding destinationField. That points directly to the missing accessibility identifier in the earlier app state.

Confirm the latest test still passes

Report inspection reads the evidence without changing your project files. A fresh run confirms the current app still exposes every selector the test needs.

  • Switch back to HotelSearchDemoUITests.swift in the Xcode editor.

Before you rerun the test, consider whether its latest result should stay green.

  • Click the test diamond beside testDestinationSearchShowsResults.

The iOS Simulator launches the app. The automation enters New York.

The automation taps Search. The result becomes Hotels in New York.

Xcode displays a green passing indicator.

You have closed the diagnostic loop with a fresh green result from testDestinationSearchShowsResults.

Secret mission

Automate the Empty Search

Your successful hotel search is already automated. Add a second Swift test that leaves the destination empty and verifies the app displays Enter a destination.

Clean Up Your Resources

Your HotelSearchDemo project remains on a paid MacinCloud Pay-As-You-Go remote Mac. Choose the cleanup option that matches your next move.

Cost warning

MacinCloud advertises Pay-As-You-Go access starting at US$4/day. Its terms use prepaid credits with automatic recharge after usage exceeds that credit.

Logging out at the end of each remote session stops usage tracking correctly.

Resources you used:

  • The HotelSearchDemo Xcode project stored on the remote Mac.
  • The SwiftUI app target inside HotelSearchDemo.
  • The UI Testing Bundle inside HotelSearchDemo.
  • The MacinCloud remote session linked to prepaid usage credits.
  • The MacinCloud automatic-recharge setting linked to future usage.

Keep everything running

No project cleanup is needed while you are still building. The paid remote session is appropriate only while you are actively working.

  • Save HotelSearchDemo in Xcode.
  • Keep the HotelSearchDemo project on the remote Mac for your next session.
  • Click the Apple icon at the top left when you finish working.
  • Choose Log Out to stop session tracking correctly.

Pause - I'll come back to this later

Pausing preserves the project while stopping paid session tracking. Your files remain ready for a later session.

  • Save HotelSearchDemo in Xcode.
  • Click the Apple icon at the top left.
  • Choose Log Out.

Your project remains on the remote Mac. The paid session is now closed for usage tracking.

Delete - I don't want to use this again

Removing the remote project copy is permanent. A backup gives you a route back if you want the Swift code later.

  • Save all changes to HotelSearchDemo in Xcode.
  • Copy the HotelSearchDemo project folder to durable storage if you want a backup.
  • Close the HotelSearchDemo project window in Xcode.
  • Open the remote Mac's file browser from the Dock.
  • Search for the HotelSearchDemo project folder.
  • Confirm the folder contains ContentView.swift, HotelSearchDemoApp.swift, and HotelSearchDemoUITests.swift.
  • Move the HotelSearchDemo project folder to the remote Mac's deleted-items area.
  • Permanently remove the project folder from the deleted-items area.

Search again for HotelSearchDemo. You should see no matching project folder.

That's the permanent remote-project cleanup done.

  • Click the Apple icon at the top left.
  • Choose Log Out.
  • Return to your MacinCloud account billing controls.
  • Confirm whether automatic recharge is enabled.
  • Disable automatic recharge if you no longer want paid access.

The paid remote session is closed. Your automatic-recharge choice now matches your future plans.

Nice Work!

Nice Work!

You did it! Your Swift automation now controls a SwiftUI hotel search in iOS Simulator with two passing scenarios.

You've learned how to:

  • Built a state-driven SwiftUI screen. The screen turns New York into Hotels in New York.
  • Wrote a native XCTest UI test that launches the app. It types New York. It taps Search. It waits for resultsMessage. It validates the displayed label.
  • Diagnosed the designed selector failure in Xcode Test Report. Added stable accessibility identifiers to connect SwiftUI controls with XCUIAutomation queries.
  • Secret Mission: Added testEmptyDestinationShowsPrompt() to verify that an empty search displays Enter a destination.

Ready to quiz yourself?