Swift Testing is the standard for unit and integration testing in new Swift code (Xcode 16+). XCUITest (XCTest) remains the UI automation layer — Swift Testing does not replace UI automation.
Stack and scope
| Layer | Framework | When |
|---|---|---|
| Unit / integration / async | Swift Testing | Domain, ViewModels, services, parsers |
| UI automation | XCUITest (XCTestCase) |
Critical flows on Simulator/device |
Performance (XCTMetric) |
XCTest | Until there is an equivalent in Swift Testing |
import Testingin new unit tests.- Do not mix
#expectandXCTAssertin the same test. - Both can live in the same project (separate targets: Unit Tests vs UI Tests).
What to prioritize
- ViewModels /
@Observable: loading, success, error, authz - Domain use cases and policies
- Decoding, validation, formatters
- Regressions (bug → test)
- Few UI tests on paths that generate revenue or data loss (login, checkout, create core entity)
Swift Testing anatomy
import Testing
@Test("Parse cursor rejects invalid input")
func parseCursorRejectsInvalid() {
#expect(throws: CursorError.invalid) {
try parseCursor("%%%")
}
}
@Test
func parseCursorAcceptsValue() throws {
let value = try #require(try? parseCursor("10"))
#expect(value == 10)
}
@Testin free functions or methods of@Suite.#expect— soft assert (continues after failure when it makes sense in the test flow).#require— hard unwrap/precondition; fails and stops the test.- Descriptive names; optional display name in
@Test("...").
Suites, traits, and parallelism
@Suite("TodoViewModel", .tags(.viewModel))
struct TodoViewModelTests {
@Test func loadEmitsReady() async { /* ... */ }
}
- Useful traits:
.tags(...),.enabled(if:),.disabled(...),.timeLimit(...). - Swift Testing runs in parallel by default — no shared mutable global state; use fakes per test or explicit isolation.
- Serialization: only when unavoidable (
.serializedin the suite, if available in the project's toolchain version).
Parameterized
@Test(arguments: [
("", false),
("10", true),
])
func cursorCases(input: String, ok: Bool) {
let result = Result { try parseCursor(input) }
#expect(result.isSuccess == ok)
}
- Independent cases; failure should identify the argument.
Async and concurrency
@Test
func loadTodos() async throws {
let repo = FakeTodoRepository(items: [.init(id: "1", title: "Milk")])
let vm = TodoViewModel(repository: repo)
await vm.load()
#expect(vm.state == .ready(count: 1))
}
async/awaitnative — noexpectationXCTest for new unit tests.- Inject clock/repo; avoid
Task.sleepto synchronize. - Confirmations / conditions: use the Swift Testing confirmation APIs available in the project's toolchain, not fragile sleeps.
@MainActorin UI/ViewModel types when production code requires it.
Fakes and isolation
struct FakeTodoRepository: TodoRepository {
var items: [Todo] = []
var shouldFail = false
func all() async throws -> [Todo] {
if shouldFail { throw RepoError.network }
return items
}
}
- Prefer protocols + fakes over dynamic mocks.
- No real network/disk in unit tests.
- Do not share mutable singletons between parallel tests.
ViewModel / Observation
- Test the ViewModel without hosting
ViewSwiftUI. - Arrange fakes → Act (async calls) → Assert state/
#expect. - Minimum coverage: happy path, empty, network error, forbidden/unauth if it exists.
Interface tests (XCUITest)
Separate UI Test target; classes XCTestCase:
final class LoginUITests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments += ["-UITests", "-reset"]
app.launch()
}
func testLoginHappyPath() {
let email = app.textFields["login.email"]
email.tap()
email.typeText("user@example.com")
app.secureTextFields["login.password"].typeText("secret")
app.buttons["login.submit"].tap()
XCTAssertTrue(app.staticTexts["home.title"].waitForExistence(timeout: 5))
}
}
UI rules:
- Select by accessibility identifier stable (
login.email), not by fragile localized text. - In SwiftUI:
.accessibilityIdentifier("...")on the controls under test. continueAfterFailure = falsein linear flows.- Launch args / environment for test account, feature flags, and skip onboarding.
- Prefer
waitForExistencetosleep. - One flow per test; avoid monolithic suites.
- Run on the Simulator in CI; device only when necessary.
Pyramid: many Swift Testing → few XCUITest.
Migration from unit XCTest
- New unit tests → Swift Testing.
- When touching a logic XCTest file → convert it if the cost is low.
- UI and performance → remain XCTest.
- Do not do a big bang; coexistence is expected.
Anti-patterns
- UI test to validate domain rule (use a unit test)
- Queries by
staticTexts["Entrar"]without identifier (breaks with i18n) - Global state + parallel tests
XCTAssertinside@Test- Fixed sleeps; real network without contract
- Mass UI snapshot without review (only if the team already has a flow)
Integration
- SwiftUI UI:
swiftui-development - Consistent style / a11y identifiers:
better-mobile-interface - Testable MVVM:
mvvm-architecture - Vapor backend:
vapor-swift(same preference for fakes and#expectwhen applicable)
Completion criteria
- New unit tests in Swift Testing (
@Test/#expect/#require) - Risk cases covered; async without flakiness
- Parallel-safe (no shared mutable state)
- UI tests only in critical flows, with stable identifiers
- Relevant suite passes in Xcode /
xcodebuild testof the project scheme