dánwòdánwò

Platforms

Native iOS apps

Test a SwiftUI or UIKit app on an iOS Simulator, built from its own Xcode project, with no change to the app.

danwo tests a native iOS app, written in SwiftUI or UIKit, the same way it tests an Expo app. It builds your Xcode project for the Simulator, routes the simulator's traffic through its proxy, and runs your tests, invariants and fault matrix against it.

You don't change the app. It keeps calling its real hosts over HTTPS; danwo trusts its own certificate in the simulator and points the simulator's proxy at itself, so URLSession reaches the emulators and your local backend instead.

Build the app

npx @danwo/cli build-ios ios
built ios/.danwo/builds/6cd4650c2c5179f7.app (dev.danwo.examples.swiftui) in 7 s (log: ios/.danwo/builds/build-ios.log)

ios is the folder that holds your .xcodeproj or .xcworkspace. danwo:

  1. finds the workspace, or else the project, at the top of the folder;
  2. runs pod install for a workspace with a Podfile and no Pods/ yet;
  3. picks the scheme: --scheme, else the only one, else the one named like the project;
  4. runs xcodebuild for the Simulator, Release, for this Mac's CPU only, unsigned;
  5. lets the kept app make plain HTTP calls, and reads its bundle id.

An unchanged project isn't built again: the build is keyed by a hash of its sources.

OptionWhat it doesDefault
--scheme <name>the scheme to buildthe only one, or the project's name
--configuration <name>the build configurationRelease
--forcebuilds again even when the sources are the sameoff
--verboseprints xcodebuild's outputoff

Share your scheme (Product › Scheme › Manage Schemes › Shared) so it is in the repository. Without a shared scheme, xcodebuild can't see it on another machine.

Bind the simulator in the config

buildIos from @danwo/mobile does the same build from your config, so a run always has a current build. It returns the app and its bundle id for withIosDevice:

danwo.config.ts · the device
const build = await buildIos({ project: "ios" })

return withIosDevice(backend, {
  worker: workerIndex,
  app: { bundleId: build.bundleId ?? "dev.danwo.examples.swiftui", app: build.app },
})

backend is your environment with a proxy, from appEnvironment or startServices. Route your API's production host to the local copy with a service of its own, so the app calls the same URL it ships with:

danwo.config.ts · the API's route
const api: Service = {
  name: "api",
  routes: [{ hosts: ["api.lumen.example"], handler: forwardTo("http://127.0.0.1:8787") }],
  close: async () => undefined,
}

The full config, with Postgres and Resend, is in examples/with-swiftui.

Make the screen findable

Locators read the simulator's accessibility tree. In SwiftUI, two modifiers do the work:

ios/Lumen/WaitlistView.swift · the field
TextField("[email protected]", text: $email)
    .accessibilityLabel("Work email")      // getByLabel("Work email")
    .accessibilityIdentifier("email")      // getByTestId("email")
SwiftUILocator
.accessibilityIdentifiergetByTestId
.accessibilityLabelgetByLabel, and a button's name
Button("Join")getByRole("button", { name: "Join" })
Text("You're on the list")getByText

A text field's placeholder isn't its name, so give it an accessibilityLabel. In UIKit, set accessibilityIdentifier and accessibilityLabel on the view.

What reaches the proxy

WhatReaches the proxy
URLSession, and libraries built on ityes, with no change to the app
SFSafariViewController, sign-in sessionsyes
plain HTTPyes: the danwo build allows it, as App Transport Security otherwise refuses it
an app that pins its certificatesno: turn pinning off in the build you test

Next

On this page

Run it on every pull request

A GitHub check and one comment per pull request, with the flight records attached.

Set up CI