Install
$ agentstack add skill-koshkinvv-ios-agent-skills-ios-testing ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
iOS Testing Skill
Core Rules
- Use Swift Testing (
@Test,#expect) for ALL new unit tests -- it is the modern framework (Xcode 16+). - Keep XCTest only for UI tests and performance tests -- Swift Testing does not support these yet.
- Both frameworks can coexist in the same target -- migrate incrementally, never rewrite working tests.
- Use protocol-based dependency injection for testability.
- Use
URLProtocolfor network mocking (NOT mocking URLSession directly). - Use
isStoredInMemoryOnly: truefor SwiftData test containers. - Name tests descriptively:
@Test("Login succeeds with valid credentials"). - One assertion per test is ideal, but pragmatic grouping is fine.
- Test behavior, not implementation details.
- Never use
sleep()in tests -- use expectations, confirmations, orClockinjection.
Framework Choice
| Test Type | Framework | Why | |-----------|-----------|-----| | Unit tests (new) | Swift Testing | Modern, less boilerplate, parameterized tests | | Unit tests (existing) | XCTest | Don't rewrite working tests without reason | | UI tests | XCTest | Swift Testing doesn't support XCUIApplication | | Performance tests | XCTest | measure {} not available in Swift Testing | | Snapshot tests | XCTest + swift-snapshot-testing | Point-Free library, XCTest integration |
Test Organization
MyAppTests/ # Unit test target
Models/
UserTests.swift
OrderTests.swift
ViewModels/
LoginViewModelTests.swift
ProfileViewModelTests.swift
Services/
APIClientTests.swift
AuthServiceTests.swift
Helpers/
Mocks/
MockAPIClient.swift
MockAuthService.swift
TestData/
UserFixtures.swift
JSONFixtures.swift
MyAppUITests/ # UI test target
Screens/ # Page Objects
LoginScreen.swift
HomeScreen.swift
Flows/
OnboardingFlowTests.swift
PurchaseFlowTests.swift
Helpers/
XCUIApplication+Launch.swift
Quick Start: Swift Testing
import Testing
@testable import MyApp
@Suite("AuthService")
struct AuthServiceTests {
let sut: AuthService
let mockAPI: MockAPIClient
init() {
mockAPI = MockAPIClient()
sut = AuthService(api: mockAPI)
}
@Test("Login succeeds with valid credentials")
func loginSuccess() async throws {
mockAPI.loginResult = .success(User.fixture)
let user = try await sut.login(email: "test@example.com", password: "pass123")
#expect(user.email == "test@example.com")
#expect(mockAPI.loginCallCount == 1)
}
@Test("Login fails with invalid credentials")
func loginFailure() async {
mockAPI.loginResult = .failure(AuthError.invalidCredentials)
await #expect(throws: AuthError.invalidCredentials) {
try await sut.login(email: "bad@example.com", password: "wrong")
}
}
@Test("Password validation", arguments: [
("short", false),
("validPass1!", true),
("nouppercase1!", false),
("NOLOWERCASE1!", false),
])
func passwordValidation(password: String, isValid: Bool) {
#expect(sut.isValidPassword(password) == isValid)
}
}
Quick Start: XCTest (UI Tests)
import XCTest
final class LoginUITests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["--uitesting", "--reset-state"]
app.launch()
}
func test_login_withValidCredentials_showsHome() {
let loginScreen = LoginScreen(app: app)
loginScreen
.typeEmail("user@example.com")
.typePassword("password123")
.tapLogin()
let homeScreen = HomeScreen(app: app)
XCTAssertTrue(homeScreen.welcomeLabel.waitForExistence(timeout: 5))
}
}
Protocol-Based Mocking Pattern
// 1. Define protocol
protocol APIClientProtocol: Sendable {
func login(email: String, password: String) async throws -> User
func fetchProfile(id: String) async throws -> Profile
}
// 2. Production implementation
final class APIClient: APIClientProtocol {
func login(email: String, password: String) async throws -> User { /* real impl */ }
func fetchProfile(id: String) async throws -> Profile { /* real impl */ }
}
// 3. Mock for tests
final class MockAPIClient: APIClientProtocol, @unchecked Sendable {
var loginResult: Result = .failure(TestError.notConfigured)
var loginCallCount = 0
var loginReceivedArgs: [(email: String, password: String)] = []
func login(email: String, password: String) async throws -> User {
loginCallCount += 1
loginReceivedArgs.append((email, password))
return try loginResult.get()
}
var fetchProfileResult: Result = .failure(TestError.notConfigured)
var fetchProfileCallCount = 0
func fetchProfile(id: String) async throws -> Profile {
fetchProfileCallCount += 1
return try fetchProfileResult.get()
}
}
URLProtocol Network Mocking
final class MockURLProtocol: URLProtocol {
static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let handler = Self.requestHandler else {
client?.urlProtocolDidFinishLoading(self)
return
}
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
// Usage in test:
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
let session = URLSession(configuration: config)
let apiClient = APIClient(session: session)
MockURLProtocol.requestHandler = { request in
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
let data = try JSONEncoder().encode(User.fixture)
return (response, data)
}
SwiftData Testing
@Test("Saving a user persists it")
func saveUser() throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: User.self, configurations: config)
let context = ModelContext(container)
let user = User(name: "Test", email: "test@example.com")
context.insert(user)
try context.save()
let descriptor = FetchDescriptor()
let users = try context.fetch(descriptor)
#expect(users.count == 1)
#expect(users.first?.name == "Test")
}
Combine Testing
@Test("Publisher emits values correctly")
func publisherEmitsValues() async {
let viewModel = CounterViewModel()
var received: [Int] = []
let cancellable = viewModel.$count.sink { received.append($0) }
viewModel.increment()
viewModel.increment()
#expect(received == [0, 1, 2])
cancellable.cancel()
}
async/await Confirmation (Replaces XCTestExpectation)
@Test("Notification triggers callback")
func notificationCallback() async {
await confirmation("callback received") { confirm in
let observer = NotificationObserver {
confirm()
}
NotificationCenter.default.post(name: .testNotification, object: nil)
}
}
@Test("Delegate called exactly 3 times")
func delegateCalledThreeTimes() async {
await confirmation("delegate called", expectedCount: 3) { confirm in
let delegate = MockDelegate(onCall: { confirm() })
let sut = DataLoader(delegate: delegate)
await sut.loadBatch(count: 3)
}
}
Test Fixtures Pattern
extension User {
static var fixture: User {
User(id: "test-id", name: "Test User", email: "test@example.com")
}
static func fixture(
id: String = "test-id",
name: String = "Test User",
email: String = "test@example.com"
) -> User {
User(id: id, name: name, email: email)
}
}
Common Mistakes to Avoid
- Don't test private methods -- test public behavior instead.
- Don't mock what you don't own -- wrap third-party APIs in your own protocol.
- Don't use
sleep()orTask.sleep()for timing -- useClockinjection or expectations. - Don't share mutable state between tests -- use struct-based
@Suitewithinit(). - Don't forget
@MainActorisolation -- if your SUT is@MainActor, your test must be too. - Don't use
XCTAssertin Swift Testing -- use#expectand#require. - Don't force-unwrap in tests -- use
#requireorXCTUnwrap. - Don't test Apple frameworks -- trust that
UserDefaults.setworks. - Don't write tests after the fact just for coverage -- write tests that catch real bugs.
- Don't ignore flaky tests -- use
withKnownIssueto mark them, then fix root cause.
Migration: XCTest to Swift Testing
| XCTest | Swift Testing | |--------|--------------| | class MyTests: XCTestCase | @Suite struct MyTests | | func testSomething() | @Test func something() | | override func setUp() | init() | | override func tearDown() | deinit | | XCTAssertEqual(a, b) | #expect(a == b) | | XCTAssertNil(x) | #expect(x == nil) | | XCTAssertThrowsError(expr) | #expect(throws: ErrorType.self) { expr } | | XCTUnwrap(optional) | try #require(optional) | | XCTestExpectation + wait | confirmation { } | | XCTSkipIf(condition) | .enabled(if: !condition) trait | | measure { } | No equivalent -- keep in XCTest | | UI tests with XCUIApplication | No equivalent -- keep in XCTest |
References
- [references/swift-testing.md](references/swift-testing.md) -- Swift Testing framework deep dive
- [references/xctest.md](references/xctest.md) -- XCTest assertions, async, performance
- [references/ui-testing.md](references/ui-testing.md) -- XCUIApplication, Page Object, accessibility
- [references/mocking.md](references/mocking.md) -- Protocol mocks, URLProtocol, snapshot testing
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: koshkinvv
- Source: koshkinvv/ios-agent-skills
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.