| name | swift-testing-patterns |
| description | Guides expert-level swift testing patterns implementation: swift and mobile decision frameworks, production-ready patterns, and concrete templates for swift testing patterns workflows.
Use when the user asks about swift testing patterns, swift testing patterns configuration, or swift best practices for swift projects.
Do NOT use when the user needs a different languages runtimes capability -- check sibling skills in the languages runtimes subcategory.
|
| license | Apache-2.0 |
| metadata | {"author":"foundry-skills","version":"1.0.0","tags":"swift mobile testing","category":"software-engineering","subcategory":"languages-runtimes","depends":"","disclaimer":"none","difficulty":"intermediate"} |
Swift Testing Patterns
When to Use
Use this skill when:
- User is writing unit tests, integration tests, or UI tests for a Swift application and wants to apply idiomatic patterns beyond the basics
- User is migrating from XCTest to the new Swift Testing framework (introduced in Xcode 15 / Swift 5.9) and needs to understand the differences and migration path
- User wants to implement testable architecture patterns in Swift -- dependency injection, protocol-based mocking, or actor-isolated testing
- User is dealing with async/await testing challenges, including actors, async sequences, and structured concurrency in tests
- User wants to reduce test fragility, improve test isolation, or speed up a slow test suite in a Swift or SwiftUI project
- User is building a test pyramid strategy for an iOS, macOS, watchOS, or visionOS project and needs guidance on unit vs integration vs UI test boundaries
- User asks about property-based testing, snapshot testing, or contract testing in a Swift context
Do NOT use this skill when:
- User needs Objective-C testing patterns -- the runtime model, OCMock usage, and method swizzling patterns differ substantially; use a dedicated Objective-C skill
- User is asking about Xcode configuration, test targets setup, or CI/CD pipeline configuration for test runners -- those are infrastructure concerns, not code patterns
- User needs XCUITest or Accessibility Inspector workflows for UI automation scripting -- that is a dedicated test automation skill
- User is asking about performance testing with XCTMetrics or the Instruments profiling tool -- that crosses into performance profiling territory
- User wants general iOS architecture advice (MVVM, TCA, VIPER) not specifically tied to testability -- use an architecture skill and cross-reference testability concerns
- User is testing a server-side Swift application on Vapor or Hummingbird -- those frameworks have distinct testing idioms around request/response lifecycle
- User is asking about test coverage reporting tooling (slather, Codecov integration) -- that is a CI tooling concern
Process
1. Assess the Swift Version and Testing Framework Landscape
Before recommending patterns, establish which testing framework the project uses:
- Determine if the project targets Swift 5.9+ with Xcode 15+, which unlocks the native
Swift Testing framework (import Testing). If so, prefer Swift Testing patterns for new tests. XCTest remains valid for UI tests and legacy code.
- If the project is on Swift 5.8 or earlier, all patterns must use XCTest exclusively. Do not recommend
@Test, #expect, or @Suite macros -- they will not compile.
- Identify the deployment target. Async/await in tests requires iOS 13+/macOS 10.15+ for the runtime, but test code itself can use
async throws with XCTestExpectation workarounds on earlier targets.
- Check whether the project uses a third-party assertion library (Quick/Nimble, PointFree's swift-custom-dump) and factor that into pattern recommendations. Do not recommend replacing established libraries mid-project unless there is a clear problem.
- Confirm whether the project has a modular architecture with Swift Package Manager modules. Tests for SPM modules must live in the
Tests/ target within the package manifest and cannot reference UIKit by default.
2. Classify the Test Categories and Define the Test Pyramid
Establish clear boundaries between test layers before writing a single test:
- Unit tests in Swift test a single type in complete isolation. The type under test should have all dependencies replaced by test doubles. Target execution time under 0.1 seconds per test. A suite of 500 unit tests should complete in under 10 seconds.
- Integration tests test the interaction between 2-4 real collaborators (e.g., a real
URLSession against a local mock server using URLProtocol stubbing). These are acceptable at 0.5-2 seconds per test.
- Snapshot tests (using swift-snapshot-testing from Point-Free) verify rendered output of SwiftUI views or UIKit views. Treat these as integration tests; they depend on simulator resolution and OS version. Always parameterize by device type.
- UI tests (XCUITest) are end-to-end and should number no more than 20-30 critical path tests. Target under 3 minutes total suite time. Use
XCUIApplication().launch() with launch arguments to pre-seed state.
- A healthy iOS project typically has a ratio of 70% unit / 20% integration / 10% UI tests by count. Invert this ratio only when the codebase is predominantly glue code with little business logic.
3. Design for Testability -- Dependency Injection Patterns
This is the highest-leverage step. Untestable code is almost always caused by uncontrolled dependencies:
- Protocol-based injection is the most common Swift pattern. Define a protocol that abstracts the dependency, provide the real implementation in production, and inject a test double in tests. Keep protocols narrow -- a
UserFetching protocol with one method is better than a fat NetworkService protocol with 20 methods.
- Closure injection is lighter-weight than protocols for single-function dependencies. Instead of a
DateProviding protocol, inject a () -> Date closure and default it to { Date() }. In tests, pass { Date(timeIntervalSince1970: 1_700_000_000) } for deterministic time.
@Environment injection in SwiftUI -- never read from a global environment in view models or reducers. Pass environment values through initializers or use SwiftUI's .environment() modifier with custom EnvironmentKey types to inject fakes in PreviewProvider and test hosts.
- Actor-isolated dependencies -- when a dependency is an
actor, inject it via a protocol that the actor conforms to. Do not access actor state directly in tests; send messages through the actor interface to avoid data races in concurrent test execution.
- Avoid
@Singleton and static var shared patterns entirely in tested code. If a singleton exists in a third-party SDK, wrap it behind a protocol at the boundary.
4. Implement Test Doubles Correctly
Swift's strong type system creates specific constraints on how doubles are built:
- Manual mocks are preferred in Swift over generated mocks (unlike Java/Kotlin where Mockito is standard). Generate manual mocks using a consistent template: one
Bool property per method to track calls, one stored property per method to configure return values or thrown errors.
@MockActor pattern for actors -- when mocking an actor-isolated type, make the mock a final class that conforms to the protocol. You do not need to make the mock itself an actor unless the protocol has nonisolated requirements that conflict.
Result-based stubs -- configure test doubles to return Result<T, Error> values rather than optionals. This forces test authors to handle both success and failure paths: var stubbedResult: Result<User, Error> = .success(.fixture()).
- Spy pattern for side effects -- track call counts and arguments:
var capturedRequests: [URLRequest] = []. Assert on these arrays in tests to verify behavior without overspecifying internal implementation.
- Never use
@discardableResult on protocol methods you intend to spy on -- you will miss call verification.
- For
Combine publishers, use PassthroughSubject or CurrentValueSubject as injectable stubs: var userSubject = PassthroughSubject<User, Never>() and call userSubject.send(.fixture()) in tests.
5. Write Tests Using Swift Testing Framework Idioms (Swift 5.9+)
When the project supports Swift Testing, apply these patterns:
@Test functions replace func testXxx() naming conventions. Test functions can be named naturally: @Test func fetchingUserSucceedsWhenNetworkResponds().
#expect macro replaces XCTAssert*. It captures the full expression in failure output: #expect(user.name == "Ada Lovelace") prints both sides on failure without additional message scaffolding.
#require macro is the Swift Testing equivalent of XCTUnwrap -- it throws on failure rather than continuing: let user = try #require(response.user). Use this to gate subsequent assertions on non-nil values.
@Suite struct organization replaces XCTestCase subclasses. Group related tests in a struct: @Suite("UserRepository") struct UserRepositoryTests. Each @Suite gets its own instance per test, providing automatic isolation without setUp/tearDown.
- Parameterized tests with
@Test(arguments:) eliminate copy-paste test variations: @Test(arguments: [("ada@example.com", true), ("not-an-email", false)]) func emailValidation(input: String, expected: Bool).
- Test tags with
@Tag enable selective test execution in CI: @Test(.tags(.networking)) func .... Define tags as static extensions on Tag to ensure compile-time safety.
withKnownIssue replaces XCTExpectFailure for tests that are expected to fail temporarily during a migration or while a bug is tracked.
6. Handle Async/Concurrency Testing
Async testing is one of the most error-prone areas in modern Swift:
- In XCTest,
async throws test methods are supported directly since Xcode 13. Mark the test func testFetch() async throws and await directly. Do not use XCTestExpectation for async/await -- it is a legacy pattern for callback-based APIs only.
- For callback-based APIs being tested, use
withCheckedThrowingContinuation inside a test helper to bridge to async/await rather than managing raw XCTestExpectation fulfillment counts.
- Testing actors -- access actor-isolated state from the test using
await: let state = await viewModel.state. Assert on the returned value. Never access actor properties from the main thread without await.
MainActor-isolated ViewModels -- mark the test function @MainActor when testing types annotated with @MainActor. This prevents Expression is 'async' but is not marked with 'await' warnings and ensures correct thread context.
- Testing
AsyncSequence -- collect values into an array using var values: [Int] = []; for await v in sequence { values.append(v) }. Add a cancellation timeout using Task and Task.sleep to prevent test hangs when a sequence never completes.
- Clock injection for deterministic timing -- use Swift's
Clock protocol (available from Swift 5.7) to inject a TestClock or ManualClock instead of Task.sleep(nanoseconds:). The swift-clocks library from Point-Free provides TestClock with advance(by:) for deterministic control.
- For Combine pipelines, use the
XCTestExpectation + sink pattern or convert to AsyncStream using values property and test with async/await.
7. Structure Test Files and Fixtures
Test organization directly impacts maintainability:
- Mirror the source directory structure in the test target. If source has
Sources/Features/User/UserRepository.swift, tests live in Tests/Features/User/UserRepositoryTests.swift.
- Test fixtures using static factory methods -- create a
UserFixtures.swift file in a TestSupport module (or test target helper group): extension User { static func fixture(name: String = "Ada Lovelace", email: String = "ada@example.com") -> User }. This makes tests readable and isolates fixture evolution from test logic.
- Builder pattern for complex fixtures -- when a model has 10+ properties, use a builder:
User.Builder().withName("Ada").withRole(.admin).build(). This avoids positional argument errors in fixture factory calls.
@discardableResult on builder methods -- always include so chained calls do not generate warnings.
- Keep test helper types in a separate
TestSupport Swift package target that is listed as a .testTarget dependency. This prevents test helpers from leaking into the production binary.
- Use
#file, #line forwarding in custom assertion helpers: func assertUser(_ user: User, file: StaticString = #file, line: UInt = #line). This ensures Xcode highlights the correct call site when an assertion fails, not the line inside the helper function.
8. Validate, Tune, and Maintain the Test Suite
A test suite is production code and requires the same discipline:
- Run tests with
--parallel in Xcode (Product > Test > Enabled Parallelization). Swift Testing runs parallel by default; XCTest requires explicit opt-in. Ensure tests pass under parallelization before enabling.
- Identify flaky tests by running the suite 10 times in CI using
xcodebuild test -maximum-concurrent-test-device-destinations 4. Any test that fails in fewer than 10/10 runs is flaky and must be fixed or quarantined.
- Track test execution time per test. Any unit test exceeding 0.5 seconds is likely doing real I/O or waiting on a real timer. Replace with injected fakes.
- Use
swift test --filter (SPM) or xcodebuild -only-testing: to run specific test subsets during development. Do not run the full suite on every local save -- reserve that for CI.
- Add
XCTSkip (XCTest) or @Test(.disabled(...)) (Swift Testing) with a ticket number for tests that are temporarily broken: @Test(.disabled("Blocked on JIRA-1234: UserRepository migration")). Never delete a failing test -- quarantine it with documentation.
- Review test coverage with
xcodebuild test -enableCodeCoverage YES. Target 80% line coverage on business logic modules. Do not chase 100% -- testing deinit and description overrides provides no safety signal.
Output Format
When advising on Swift testing patterns, structure your response as follows:
## Swift Testing Assessment
### Project Context
- Swift version: [version]
- Testing framework: [XCTest | Swift Testing | Mixed]
- Test pyramid status: [current ratio and target ratio]
- Primary gap identified: [isolation | async | organization | coverage]
### Dependency Injection Strategy
| Dependency Type | Injection Pattern | Test Double Type | Notes |
|------------------------|-----------------------|------------------|------------------------------|
| [e.g., NetworkService] | Protocol injection | Manual mock | Stub Result<T,Error> return |
| [e.g., Current date] | Closure injection | Closure literal | () -> Date |
| [e.g., UserDefaults] | Protocol injection | In-memory stub | Implement with Dictionary |
| [e.g., Async stream] | AsyncStream injection | AsyncStream.init | Use continuation for control |
### Pattern Recommendations
#### Unit Test Pattern
[Specific XCTest or Swift Testing code example for this project's context]
#### Integration Test Pattern
[Specific URLProtocol stubbing or service integration code example]
#### Async Test Pattern
[Specific async/await or Clock injection code example]
### Test Fixture Strategy
[Factory method or builder pattern code for this domain]
### Migration Path (if applicable)
Step 1: [First concrete action]
Step 2: [Second concrete action]
Step 3: [Third concrete action]
### Metrics Targets
- Unit test execution time: < [X] seconds for [N] tests
- Flaky test threshold: 0 flaky tests in CI
- Coverage target: [X]% on business logic, [Y]% overall
Rules
-
NEVER test implementation details -- test observable behavior. A test that calls a private method via @testable import to verify an intermediate calculation is testing the wrong thing. Test the public interface's output for a given input. Refactoring internal code must not break tests unless behavior changes.
-
ALWAYS use @testable import only for accessing internal types, not for bypassing encapsulation. If you find yourself needing @testable import to call a private function directly, the code has a testability design problem. Redesign the public interface to expose the behavior under test.
-
NEVER use sleep() or Task.sleep in tests for synchronization. This is the number one cause of flaky tests and slow suites. Inject a Clock or use a ManualClock to advance time deterministically. Use XCTestExpectation with explicit waitForExpectations(timeout: 2.0) only for callback-based code that cannot be refactored.
-
ALWAYS isolate filesystem, UserDefaults, and Keychain access in tests. Tests that write to real UserDefaults.standard or the real Keychain will pollute state between test runs. Inject a UserDefaultsProtocol backed by an in-memory dictionary. For filesystem tests, use FileManager.default.temporaryDirectory and clean up in tearDown or deinit.
-
NEVER share mutable state between test methods in the same XCTestCase subclass. Declare all mutable test state as private var and re-initialize it in setUp(). If using Swift Testing, the @Suite struct instantiation per test handles this automatically -- do not add mutating setUp() equivalents.
-
ALWAYS parameterize tests that share the same logical structure with different inputs. Three tests that differ only in input values must become one parameterized test using @Test(arguments:) in Swift Testing or a data-driven loop with XCTContext.runActivity in XCTest. Duplicate test bodies are maintenance debt.
-
NEVER mock types you do not own. Do not create a mock conformance for URLSession, CLLocationManager, or directly. Wrap them in your own protocol first, then mock your protocol. This protects you from breaking changes in Apple SDKs and keeps the mock surface minimal.
Edge Cases
Testing @MainActor-isolated SwiftUI ViewModels
When a ViewModel is annotated with @MainActor, test functions must also be @MainActor or the compiler will require await on every property access. Annotate the entire XCTestCase subclass with @MainActor rather than individual test methods. In Swift Testing, annotate the @Suite struct. Be aware that @MainActor on an XCTestCase subclass runs setUp and tearDown on the main thread -- this is usually correct but can deadlock if setUp calls async code that itself dispatches to MainActor from within a Task. Use await MainActor.run {} to safely dispatch when needed.
Testing Code That Uses @Observable (Swift 5.9 Observation Framework)
@Observable types do not expose a publisher or combine stream by default. To test reactive behavior, observe property changes by reading the property after triggering the action: await viewModel.triggerFetch(); #expect(viewModel.users.count == 3). If you need to assert that a property change fires at least once, use withObservationTracking in a test helper that records observed changes. Do not attempt to use Combine sink patterns on @Observable types unless you have manually bridged to a publisher.
Testing Error Propagation Through throws and async throws
When verifying that a function throws a specific error type, use #expect(throws: NetworkError.self) in Swift Testing or XCTAssertThrowsError with type-casting in XCTest. Never use a bare try? in a test that is supposed to verify error throwing -- silent failure converts a thrown error into nil and the test passes incorrectly. For async throwing functions in XCTest, use await XCTAssertThrowsError(try await sut.fetch()) -- note that this requires Xcode 13.2+ where the async overload of XCTAssertThrowsError is available. In earlier Xcode versions, use a do/catch with XCTFail in the catch block.
Testing Code Using Sendable and Structured Concurrency
Strict concurrency checking (-strict-concurrency=complete) can cause test helpers to generate warnings or errors when they capture values in closures across actor boundaries. When writing test helpers that use Task {} internally, annotate them with @Sendable where necessary and use @unchecked Sendable sparingly only on types that you have verified are thread-safe through locks or serial queues. Test targets should compile with the same concurrency checking level as the production target to catch real issues.
Snapshot Tests Across Device Configurations
Point-Free's swift-snapshot-testing library stores reference images in the repository. These images are resolution- and OS-version-dependent. Establish a strict policy: snapshot tests run only on a single designated simulator configuration (e.g., iPhone 15 Pro, iOS 17.2) on CI. Use XCTSkip guarded by a runtime check when not on the designated device: try XCTSkipUnless(UIDevice.current.name.contains("iPhone 15 Pro"), "Snapshot tests require iPhone 15 Pro simulator"). Record mode (record: true) must be disabled before committing -- use an environment variable SNAPSHOT_RECORD to gate this, never hardcode true.
Testing Combine Pipelines with Backpressure and Buffering
When testing Combine pipelines that use .buffer(size:prefetch:whenFull:) or .debounce(for:scheduler:), you must inject a fake Scheduler. Use ImmediateScheduler.shared for synchronous pipelines or TestScheduler from the CombineSchedulers library (Point-Free) for time-sensitive pipelines. A test that uses RunLoop.main or DispatchQueue.main as the scheduler will behave differently in a headless CI test runner versus an interactive session. Never rely on RunLoop.main advancing automatically in a test -- it may not.
Modular SPM Projects with Cross-Module Dependency Injection
In a multi-module Swift package, test targets cannot import types marked internal from sibling modules. Structure the codebase so that protocols defining injection points are public and live in a dedicated Interfaces module. The concrete implementations live in a Implementations module. The test target imports Interfaces and provides its own test double conformances. This avoids the circular dependency that arises when the test target tries to import the real Implementations module just to access a protocol.
Legacy XCTestCase Migration to Swift Testing
Do not migrate XCTestCase subclasses that contain setUp, tearDown, and more than 30 test methods all at once. Migrate one logical group at a time. Critical difference: Swift Testing @Suite structs do not run XCTest assertions -- XCTAssertEqual compiles but does not integrate with the Swift Testing runner. Audit the test file for XCTAssert* calls and replace them with #expect during migration. Mixed files (some @Test functions in a class that also inherits XCTestCase) are not supported -- a type must be one or the other.
Example
Input: "I have a Swift 5.9 iOS app with a UserRepository that fetches users from a REST API using async/await. The repository is hard-coded to use URLSession.shared. Tests currently don't exist. I'm on Xcode 15. How do I add tests?"
Output:
Swift Testing Assessment
Project Context
- Swift version: 5.9 with Xcode 15
- Testing framework: Swift Testing available (use for new tests)
- Test pyramid status: 0% coverage -- starting from scratch
- Primary gaps: No dependency injection, direct
URLSession.shared usage
Step 1 -- Refactor UserRepository for Testability
The core problem is that URLSession.shared is a global singleton. Introduce a protocol:
public protocol HTTPClient: Sendable {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: HTTPClient {}
public final class UserRepository: Sendable {
private let client: any HTTPClient
private let baseURL: URL
private let decoder: JSONDecoder
public init(
client: any HTTPClient = URLSession.shared,
baseURL: URL = URL(string: "https://api.example.com")!,
decoder: JSONDecoder = .init()
) {
self.client client
.baseURL baseURL
.decoder decoder
}
(: ) -> {
request (url: baseURL.appendingPathComponent())
(data, response) client.data(for: request)
httpResponse response ,
().contains(httpResponse.statusCode) {
.invalidResponse
}
decoder.decode(., from: data)
}
}
: , {
invalidResponse
decodingFailed
}
Why this works: Production code uses the default URLSession.shared with zero behavioral change. Tests inject a fake HTTPClient with no network I/O.
Step 2 -- Create the Test Double
import Foundation
@testable import YourModule
final class MockHTTPClient: HTTPClient, @unchecked Sendable {
var stubbedResult: Result<(Data, URLResponse), Error> =
.failure(MockHTTPClientError.notConfigured)
private(set) var capturedRequests: [URLRequest] = []
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
capturedRequests.append(request)
return try stubbedResult.get()
}
}
enum MockHTTPClientError: Error {
case notConfigured
}
Step 3 -- Create Test Fixtures
import Foundation
@testable import YourModule
extension User {
static func fixture(
id: String = "usr_001",
name: String = "Ada Lovelace",
email: String = "ada@example.com",
role: User.Role = .standard
) -> User {
User(id: id, name: name, email: email, role: role)
}
}
extension HTTPURLResponse {
static func fixture(
url: URL = URL(string: "https://api.example.com")!,
statusCode: Int = 200
) -> HTTPURLResponse {
HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil)!
}
}
Step 4 -- Write Tests Using Swift Testing
import Testing
import Foundation
@testable import YourModule
@Suite("UserRepository")
struct UserRepositoryTests {
let client = MockHTTPClient()
let baseURL = URL(string: "https://api.example.com")!
var sut: UserRepository {
UserRepository(client: client, baseURL: baseURL)
}
@Test("returns decoded user when API responds with 200")
func fetchUser_with200Response_returnsDecodedUser() async throws {
let expectedUser = User.fixture()
let userData = try JSONEncoder().encode(expectedUser)
client.stubbedResult = .success((userData, HTTPURLResponse.fixture(statusCode: 200)))
let user = try await sut.fetchUser(id: "usr_001")
#expect(user.name == "Ada Lovelace")
#expect(user.id )
}
()
() {
expectedUser .fixture()
userData ().encode(expectedUser)
client.stubbedResult .success((userData, .fixture()))
sut.fetchUser(id: )
request #require(client.capturedRequests.first)
#expect(request.url.absoluteString )
}
()
() {
client.stubbedResult .success(((), .fixture(statusCode: )))
#expect(throws: .invalidResponse) {
sut.fetchUser(id: )
}
}
()
() {
: {}
client.stubbedResult .failure(())
#expect(throws: .) {
sut.fetchUser(id: )
}
}
(
,
arguments: [, , , , ]
)
(: ) {
client.stubbedResult .success(((), .fixture(statusCode: statusCode)))
#expect(throws: .invalidResponse) {
sut.fetchUser(id: )
}
}
}
Step 5 -- Verify Suite Performance
After running the suite:
Test Suite 'UserRepositoryTests' passed
Executed 8 tests in 0.031 seconds
Eight tests in 31 milliseconds -- well under the 0.1-second-per-test target. The suite has zero network I/O, zero filesystem access, and zero global state mutation.
Dependency Injection Strategy Summary
| Dependency | Injection Pattern | Test Double | Notes |
|---|
URLSession / HTTPClient | Protocol injection | MockHTTPClient (manual) | Conforms via extension URLSession: HTTPClient |
JSONDecoder | Initializer injection | Default value in production | Override in tests with misconfigured decoder to test decoding errors |
URL (baseURL) | Initializer injection | Hardcoded localhost URL | Prevents accidental real network calls in tests |
| Current date (if needed) | Closure () -> Date | { Date(timeIntervalSince1970: 0) } | Deterministic timestamp assertions |
Metrics Targets
| Metric | Target | Current |
|---|
| Unit test execution time | < 2 seconds for full suite | 0.031 seconds for 8 tests |
| Flaky test count | 0 | 0 (no async timers, no real I/O) |
| Business logic coverage | > 80% | ~95% for UserRepository |
| Tests per public method | 3-5 (happy path + 2-4 failure modes) | 4 for fetchUser(id:) |