swift-testing-conventions
Testing conventions for Swift/SwiftUI projects using Clean Architecture. Covers Swift Testing framework for unit/integration tests, XCUITest for UI and E2E tests, manual protocol-based mocking, async testing patterns, and test organization. Use when writing tests, reviewing test code, setting up test infrastructure, or discussing testing strategy in Swift projects.
$ Installer
git clone https://github.com/benaor/claude-config /tmp/claude-config && cp -r /tmp/claude-config/.claude/skills/swiftui-testing-conventions ~/.claude/skills/claude-config// tip: Run this command in your terminal to install the skill
name: swift-testing-conventions description: Testing conventions for Swift/SwiftUI projects using Clean Architecture. Covers Swift Testing framework for unit/integration tests, XCUITest for UI and E2E tests, manual protocol-based mocking, async testing patterns, and test organization. Use when writing tests, reviewing test code, setting up test infrastructure, or discussing testing strategy in Swift projects.
Swift Testing Conventions
Testing conventions for Swift/SwiftUI projects with Clean Architecture (domain + app layers).
Testing Pyramid
┌─────────┐
│ E2E │ ← {app}E2ETests (XCUITest) - Full user flows
┌┴─────────┴┐
│ UI │ ← {app}UITests (XCUITest) - Component interactions
┌┴───────────┴┐
│ Integration │ ← Swift Testing - UseCases + real adapters, ViewModels + repos
┌┴─────────────┴┐
│ Unit │ ← Swift Testing - Entities, UseCases, ViewModels in isolation
└───────────────┘
Distribution target: Unit > Integration > UI > E2E
FIRST Principles
| Principle | Description |
|---|---|
| Fast | Unit tests < 100ms, Integration < 500ms |
| Independent | No shared state, any order execution |
| Repeatable | Same result every run, no external dependencies |
| Self-validating | Pass/fail without manual inspection |
| Timely | Written with or before production code |
File Organization
root/
├── domain/
├── domainTests/ # Swift Testing target
│ ├── {BoundedContext}/
│ │ ├── Entities/ # Entity tests
│ │ ├── UseCases/ # UseCase tests
│ │ └── Stubs/ # Context-specific stubs
│ └── TestUtils/
│ ├── Builders/ # Entity builders
│ └── Stubs/ # Shared stubs
├── {app}/
├── {app}Tests/ # Swift Testing target
│ ├── ViewModels/
│ └── TestUtils/
│ ├── Builders/
│ └── Stubs/
├── {app}UITests/ # XCUITest target - Component tests
│ ├── Components/
│ ├── Pages/ # Page Objects
│ └── Helpers/
└── {app}E2ETests/ # XCUITest target - Flow tests
├── Flows/
├── Pages/
└── Helpers/
Naming Conventions
| Type | Pattern | Example |
|---|---|---|
| Test file | {ClassName}Tests.swift | SessionViewModelTests.swift |
| Stub | {Protocol}Stub.swift | SessionRepositoryStub.swift |
| Builder | {Entity}Builder.swift | SessionBuilder.swift |
| Page Object | {Screen}Page.swift | HomePage.swift |
Test Anatomy (Swift Testing)
Structure
import Testing
@testable import Domain
@Suite("SessionUseCase")
struct SessionUseCaseTests {
// MARK: - Dependencies (recreated per test)
var repository: SessionRepositoryStub
var monitor: MonitorStub
var sut: GetSessionUseCase
init() {
repository = SessionRepositoryStub()
monitor = MonitorStub()
sut = GetSessionUseCase(repository: repository, monitor: monitor)
}
@Test("should return session when repository has active session")
func shouldReturnSession_whenRepositoryHasActiveSession() async {
// Given
let expected = SessionBuilder().withStatus(.active).build()
repository.getSessionResult = .success(expected)
// When
let result = await sut.execute()
// Then
#expect(result == .success(expected))
}
@Test("should return nil when repository is empty")
func shouldReturnNil_whenRepositoryIsEmpty() async {
// Given
repository.getSessionResult = .success(nil)
// When
let result = await sut.execute()
// Then
#expect(try result.get() == nil)
}
}
Naming Convention
Pattern: shouldExpectedBehavior_whenCondition()
// ✅ Good
func shouldReturnError_whenNetworkFails() async
func shouldUpdateState_whenUserTapsStart()
func shouldCallRepository_whenExecuted() async
// ❌ Bad
func testGetSession() // No behavior described
func test_session_returns() // Unclear condition
func itWorks() // Meaningless
Assertions
// Basic
#expect(value == expected)
#expect(value != nil)
#expect(array.isEmpty)
// Optionals
#expect(optional == nil)
let unwrapped = try #require(optional) // Fails test if nil
// Errors
#expect(throws: ValidationError.self) {
try sut.validate(invalidInput)
}
// Boolean
#expect(user.isActive)
#expect(!session.isExpired)
Test Doubles
Types by Layer
| Layer | Test Double | Purpose |
|---|---|---|
| Domain (Entities, UseCases) | Stub | Return predefined data |
| UI (ViewModels) | Stub | Return predefined data |
| Infrastructure (Adapters) | Spy/Mock | Verify interactions |
Protocol Stub Pattern
// Port (in production code)
protocol SessionRepositoryProtocol {
func getSession() async -> Result<Session?, RepositoryError>
func saveSession(_ session: Session) async -> Result<Void, RepositoryError>
}
// Stub (in test code)
final class SessionRepositoryStub: SessionRepositoryProtocol {
// Configurable results
var getSessionResult: Result<Session?, RepositoryError> = .success(nil)
var saveSessionResult: Result<Void, RepositoryError> = .success(())
// Call tracking (Spy capability)
private(set) var saveSessionCalls: [Session] = []
func getSession() async -> Result<Session?, RepositoryError> {
getSessionResult
}
func saveSession(_ session: Session) async -> Result<Void, RepositoryError> {
saveSessionCalls.append(session)
return saveSessionResult
}
}
When to Use Each
// Stub: Testing output based on input
@Test func shouldDisplayError_whenRepositoryFails() async {
repository.getSessionResult = .failure(.notFound) // Stub behavior
await sut.loadSession()
#expect(sut.errorMessage != nil)
}
// Spy: Verifying side effects
@Test func shouldSaveSession_whenUserTapsConfirm() async {
await sut.confirmSession()
#expect(repository.saveSessionCalls.count == 1) // Spy verification
#expect(repository.saveSessionCalls.first?.status == .confirmed)
}
Builder Pattern
final class SessionBuilder {
private var id: UUID = UUID()
private var status: SessionStatus = .idle
private var startDate: Date = Date()
private var duration: TimeInterval = 3600
func withId(_ id: UUID) -> Self {
self.id = id
return self
}
func withStatus(_ status: SessionStatus) -> Self {
self.status = status
return self
}
func withStartDate(_ date: Date) -> Self {
self.startDate = date
return self
}
func withDuration(_ duration: TimeInterval) -> Self {
self.duration = duration
return self
}
func build() -> Session {
Session(
id: id,
status: status,
startDate: startDate,
duration: duration
)
}
}
// Usage
let session = SessionBuilder()
.withStatus(.active)
.withDuration(1800)
.build()
Test Isolation
Swift Testing (struct-based)
Each test gets fresh instances via init():
@Suite struct ViewModelTests {
var repository: SessionRepositoryStub
var sut: SessionViewModel
init() {
// Fresh instances for each test
repository = SessionRepositoryStub()
sut = SessionViewModel(repository: repository)
}
}
MainActor Isolation
For ViewModels with @MainActor:
@Suite("SessionViewModel")
@MainActor
struct SessionViewModelTests {
var sut: SessionViewModel
var repository: SessionRepositoryStub
init() {
repository = SessionRepositoryStub()
sut = SessionViewModel(repository: repository)
}
@Test func shouldUpdateState_whenLoaded() async {
repository.getSessionResult = .success(SessionBuilder().build())
await sut.load()
#expect(sut.state == .loaded)
}
}
Test DIContainer
For integration tests requiring DI:
final class TestDIContainer {
static func configured(
sessionRepository: SessionRepositoryProtocol = SessionRepositoryStub(),
monitor: MonitorProtocol = MonitorStub()
) -> DIContainer {
let container = DIContainer()
container.register(SessionRepositoryProtocol.self, implementation: sessionRepository)
container.register(MonitorProtocol.self, implementation: monitor)
return container
}
}
Async Testing
Basic Async
@Test func shouldFetchData_whenCalled() async {
let result = await sut.fetchData()
#expect(result.isSuccess)
}
Async with Throws
@Test func shouldThrow_whenInvalidInput() async throws {
await #expect(throws: ValidationError.self) {
try await sut.process(invalidInput)
}
}
Testing Published Properties
@Test func shouldUpdatePublishedState() async {
// Given
repository.getSessionResult = .success(SessionBuilder().build())
// When
await sut.load()
// Then - MainActor ensures @Published updates are visible
#expect(sut.session != nil)
}
Confirmation (for callbacks/delegates)
@Test func shouldNotifyDelegate_whenComplete() async {
await confirmation { confirm in
sut.onComplete = { confirm() }
await sut.execute()
}
}
// With timeout
@Test func shouldComplete_withinTimeout() async {
await confirmation(timeout: .seconds(2)) { confirm in
sut.onComplete = { confirm() }
await sut.start()
}
}
XCUITest Patterns
Page Object Pattern
// Pages/HomePage.swift
struct HomePage {
let app: XCUIApplication
// MARK: - Elements
var startButton: XCUIElement {
app.buttons["start-session-button"]
}
var sessionStatus: XCUIElement {
app.staticTexts["session-status-label"]
}
var settingsButton: XCUIElement {
app.buttons["settings-button"]
}
// MARK: - Actions
@discardableResult
func tapStart() -> SessionPage {
startButton.tap()
return SessionPage(app: app)
}
func tapSettings() -> SettingsPage {
settingsButton.tap()
return SettingsPage(app: app)
}
// MARK: - Assertions
func assertSessionStatus(_ expected: String) -> Self {
XCTAssertEqual(sessionStatus.label, expected)
return self
}
}
Accessibility Identifiers
In production code:
Button("Start Session") {
viewModel.startSession()
}
.accessibilityIdentifier("start-session-button")
Text(viewModel.statusText)
.accessibilityIdentifier("session-status-label")
UI Test Structure
// {app}UITests/Components/StartButtonTests.swift
final class StartButtonTests: XCTestCase {
var app: XCUIApplication!
var homePage: HomePage!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["--ui-testing"]
app.launch()
homePage = HomePage(app: app)
}
func test_startButton_shouldBeVisible_whenAppLaunches() {
XCTAssertTrue(homePage.startButton.exists)
XCTAssertTrue(homePage.startButton.isEnabled)
}
func test_startButton_shouldNavigateToSession_whenTapped() {
let sessionPage = homePage.tapStart()
XCTAssertTrue(sessionPage.timerLabel.waitForExistence(timeout: 2))
}
}
E2E Test Structure
// {app}E2ETests/Flows/SessionFlowTests.swift
final class SessionFlowTests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["--e2e-testing", "--reset-state"]
app.launch()
}
func test_completeSessionFlow_shouldShowSummary() {
HomePage(app: app)
.tapStart()
.waitForTimerToStart()
.tapPause()
.tapStop()
.assertSummaryVisible()
}
}
Launch Arguments for Test Modes
// In App
@main
struct MyApp: App {
init() {
#if DEBUG
if CommandLine.arguments.contains("--ui-testing") {
configureForUITesting()
}
if CommandLine.arguments.contains("--reset-state") {
resetAllState()
}
#endif
}
}
Anti-Patterns
❌ Testing Implementation Details
// ❌ Bad: Tests internal state
@Test func shouldSetInternalFlag() async {
await sut.load()
#expect(sut.internalLoadingFlag == true) // Implementation detail
}
// ✅ Good: Tests observable behavior
@Test func shouldShowLoading_whenFetching() async {
let task = Task { await sut.load() }
#expect(sut.isLoading == true)
await task.value
}
❌ Over-Mocking
// ❌ Bad: Mocking everything
@Test func shouldWork() async {
let mockA = MockA()
let mockB = MockB()
let mockC = MockC()
let mockD = MockD()
// 10 more mocks...
}
// ✅ Good: Only mock boundaries
@Test func shouldWork() async {
let repository = SessionRepositoryStub() // External boundary only
let sut = GetSessionUseCase(repository: repository)
}
❌ Flaky Tests
// ❌ Bad: Race condition
@Test func shouldUpdate() async {
sut.startAsync()
#expect(sut.value == expected) // May not be updated yet
}
// ✅ Good: Await completion
@Test func shouldUpdate() async {
await sut.startAsync()
#expect(sut.value == expected)
}
❌ Test Interdependence
// ❌ Bad: Shared mutable state
static var sharedSession: Session?
@Test func test1() { Self.sharedSession = SessionBuilder().build() }
@Test func test2() { #expect(Self.sharedSession != nil) } // Depends on test1
// ✅ Good: Independent setup
@Test func test1() {
let session = SessionBuilder().build()
// use session
}
❌ Magic Values
// ❌ Bad: Unexplained values
#expect(result.count == 3)
// ✅ Good: Explicit setup
let items = [item1, item2, item3]
repository.itemsResult = .success(items)
let result = await sut.getItems()
#expect(result.count == items.count)
Workflows
For step-by-step procedures, see references/workflows.md:
- Write Tests Workflow — Identify test type → Follow type-specific steps → Verify quality
- Refactor Test Workflow — Identify problem → Diagnose root cause → Apply fix → Verify
References
For templates and utilities, see references/test-utils.md:
- Protocol Stub template
- Builder template
- TestDIContainer
- XCUITest helpers
- Common assertions
Repository
