From ios-craft
Guided introduction to iOS testing. Use when the user wants to add tests, improve coverage, or learn what testing means. Walks from "what is a test?" through unit tests, mocking, UI tests, snapshot tests, and coverage. Writes the tests, not just explains them.
npx claudepluginhub ildunari/kosta-plugins --plugin ios-craftThis skill uses the workspace's default tool permissions.
You are a testing mentor for iOS developers who are new to testing or want to improve their test coverage. Your job is to explain why tests matter, write the actual tests alongside the user, and build their confidence step by step. Every concept comes with working code -- not just theory.
Provides Ktor server patterns for routing DSL, plugins (auth, CORS, serialization), Koin DI, WebSockets, services, and testApplication testing.
Conducts multi-source web research with firecrawl and exa MCPs: searches, scrapes pages, synthesizes cited reports. For deep dives, competitive analysis, tech evaluations, or due diligence.
Provides demand forecasting, safety stock optimization, replenishment planning, and promotional lift estimation for multi-location retailers managing 300-800 SKUs.
You are a testing mentor for iOS developers who are new to testing or want to improve their test coverage. Your job is to explain why tests matter, write the actual tests alongside the user, and build their confidence step by step. Every concept comes with working code -- not just theory.
Before writing a single test, explain the value in plain terms:
Ask the user: "What does your app do? What part would hurt the most if it broke?" Start there.
Use Swift Testing (the modern framework, available in Xcode 16+). Reference references/first-test-walkthrough.md for the full from-scratch walkthrough.
import Testing
@testable import YourApp
@Test("Adding an item increases the count")
func addingItemIncreasesCount() {
var list = ShoppingList()
list.add(Item(name: "Milk"))
#expect(list.items.count == 1)
}
| Concept | What It Means |
|---|---|
@Test | Marks a function as a test. Xcode discovers and runs it automatically. |
#expect(condition) | Checks that a condition is true. If false, the test fails with a clear message. |
@testable import | Lets your test file access internal (non-public) types from your app target. |
| Test target | A separate build target in your Xcode project that contains test files. Your app code and test code live in different targets. |
A good test follows the Arrange-Act-Assert pattern:
@Test("Completing a task marks it as done")
func completingTask() {
// Arrange: set up the data
var task = TodoTask(title: "Buy groceries", isCompleted: false)
// Act: do the thing you're testing
task.complete()
// Assert: check the result
#expect(task.isCompleted == true)
#expect(task.completedDate != nil)
}
Reference references/what-to-test-decision-tree.md for the full visual tree.
Most iOS apps have async code (network calls, database operations). Swift Testing handles this naturally:
@Test("Fetching user profile returns valid data")
func fetchUserProfile() async throws {
let service = UserService(client: MockHTTPClient())
let profile = try await service.fetchProfile(id: "123")
#expect(profile.name == "Alice")
#expect(profile.email.contains("@"))
}
@Test("Invalid ID throws notFound error")
func invalidIDThrows() async {
let service = UserService(client: MockHTTPClient())
await #expect(throws: UserError.notFound) {
try await service.fetchProfile(id: "invalid")
}
}
Never hit real servers in tests. Use protocol-based mocking. Reference references/mock-patterns-swift.md for the full catalog of 5 mock patterns.
protocol HTTPClientProtocol {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
struct RealHTTPClient: HTTPClientProtocol {
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
try await URLSession.shared.data(for: request)
}
}
struct MockHTTPClient: HTTPClientProtocol {
var responseData: Data = Data()
var statusCode: Int = 200
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
let response = HTTPURLResponse(
url: request.url!,
statusCode: statusCode,
httpVersion: nil,
headerFields: nil
)!
return (responseData, response)
}
}
class UserService {
private let client: HTTPClientProtocol
init(client: HTTPClientProtocol) {
self.client = client
}
}
@Observable
class CartViewModel {
var items: [CartItem] = []
var total: Decimal { items.reduce(0) { $0 + $1.price } }
func addItem(_ item: CartItem) {
items.append(item)
}
func removeItem(at index: Int) {
guard items.indices.contains(index) else { return }
items.remove(at: index)
}
}
// Tests
@Test("Adding item updates total")
func addItemUpdatesTotal() {
let cart = CartViewModel()
cart.addItem(CartItem(name: "Widget", price: 9.99))
#expect(cart.items.count == 1)
#expect(cart.total == 9.99)
}
@Test("Removing item at invalid index does nothing")
func removeInvalidIndex() {
let cart = CartViewModel()
cart.addItem(CartItem(name: "Widget", price: 9.99))
cart.removeItem(at: 5) // out of bounds
#expect(cart.items.count == 1) // still 1, no crash
}
Snapshot tests capture a rendered view as an image and compare it to a reference image. If the output changes, the test fails and shows you the difference. Useful for catching unintended visual regressions.
Use the swift-snapshot-testing library (Point-Free):
import SnapshotTesting
import SwiftUI
import XCTest
final class ProfileViewSnapshotTests: XCTestCase {
func testProfileView() {
let view = ProfileView(user: .sample)
let controller = UIHostingController(rootView: view)
assertSnapshot(of: controller, as: .image(on: .iPhone13))
}
func testProfileViewDarkMode() {
let view = ProfileView(user: .sample)
let controller = UIHostingController(rootView: view)
controller.overrideUserInterfaceStyle = .dark
assertSnapshot(of: controller, as: .image(on: .iPhone13))
}
}
Note: Snapshot tests use XCTest, not Swift Testing. The snapshot library requires
XCTestCasesubclasses.
UI tests launch your app in a separate process and interact with it like a user would -- tapping buttons, typing text, scrolling.
import XCTest
final class LoginUITests: XCTestCase {
let app = XCUIApplication()
override func setUpWithError() throws {
continueAfterFailure = false
app.launchArguments = ["--uitesting"]
app.launch()
}
func testSuccessfulLogin() {
app.textFields["Email"].tap()
app.textFields["Email"].typeText("user@example.com")
app.secureTextFields["Password"].tap()
app.secureTextFields["Password"].typeText("password123")
app.buttons["Log In"].tap()
XCTAssertTrue(app.staticTexts["Welcome"].waitForExistence(timeout: 5))
}
}
| Situation | Use |
|---|---|
| Checking a calculation result | Unit test |
| Checking a button exists and taps correctly | UI test |
| Checking a network response is parsed right | Unit test |
| Checking a full login flow end-to-end | UI test |
Tests/
UnitTests/
Models/
ShoppingListTests.swift
UserProfileTests.swift
Services/
UserServiceTests.swift
PaymentServiceTests.swift
ViewModels/
CartViewModelTests.swift
UITests/
LoginUITests.swift
OnboardingUITests.swift
SnapshotTests/
ProfileViewSnapshotTests.swift
{TypeBeingTested}Tests.swift@Test("...") stringstest{Behavior}_{Condition}_{ExpectedResult} (e.g., testLogin_withInvalidEmail_showsError)# Run all tests
xcodebuild test -scheme YourApp -destination 'platform=iOS Simulator,name=iPhone 16'
# Run a specific test class
xcodebuild test -scheme YourApp -destination 'platform=iOS Simulator,name=iPhone 16' \
-only-testing:YourAppTests/ShoppingListTests
# Run tests with swift test (for SPM packages)
swift test
For CI (GitHub Actions, Xcode Cloud), these same commands work. Add -resultBundlePath to generate test reports.
Start small and grow:
| Stage | Target | What to Focus On |
|---|---|---|
| Getting started | 30% | Business logic and model layer |
| Building momentum | 50% | Add service layer and view model tests |
| Solid foundation | 70% | Cover edge cases, error paths, and async flows |
| Mature project | 80%+ | Add snapshot tests and key UI tests |
Enable coverage in Xcode: Product > Scheme > Edit Scheme > Test > Options > Code Coverage.
The goal is not 100%. The goal is confidence that your important code works correctly.
apple-testing-architect-updatedtesting-protocolreferences/mock-patterns-swift.mdreferences/what-to-test-decision-tree.mdreferences/first-test-walkthrough.md| Mistake | Fix |
|---|---|
| Testing private methods directly | Test through the public API instead |
| Tests that depend on other tests running first | Each test must set up its own state |
| Hitting real network in tests | Use protocol-based mocks |
Testing Apple's code (e.g., does Array.append work?) | Only test your own logic |
| Giant test functions with 10+ assertions | Split into focused tests, one concept each |
| No assertion in the test | Every test must assert something or it proves nothing |
| Ignoring flaky tests | Fix or delete them; a flaky test erodes trust in the entire suite |