Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 042 / 100

Testing Swift apps: Swift & iOS best practices explained

A practical testing strategy.

Expona Admin7 min read

Why testing matters in iOS development

Most iOS teams we work with ship features with confidence only when they have a solid testing strategy in place. Testing Swift apps: Swift & iOS best practices explained is about building a safety net that lets you refactor, add features, and deploy without fear. The cost of a bug reaching production is high—lost user sessions, support tickets, app reviews tanking—but the cost of finding it during development is nearly zero.

The challenge is knowing where to invest your testing effort. Unit tests alone won't catch UI bugs. UI tests alone are slow and brittle. The answer is a balanced pyramid: many fast unit tests, fewer integration tests, and a small set of critical UI tests that protect your revenue flows.

Swift Testing — @Test and #expect in modern Xcode

Xcode 16 introduced the Swift Testing framework, which replaces XCTest for most new code. The syntax is cleaner, the output is better, and it's designed by people who've shipped real apps. Instead of XCTestCase subclasses, you write simple functions decorated with @Test.

Here's a real example from a shopping cart feature we tested recently:

swift
import Testing

@Test func totalIncludesTax() {
    let cart = Cart(items: [.init(price: 100)])
    #expect(cart.total(tax: 0.1) == 110)
}

@Test func emptyCartReturnsZero() {
    let cart = Cart(items: [])
    #expect(cart.total(tax: 0.0) == 0)
}

@Test func multipleItemsWithTax() {
    let items = [
        Cart.Item(price: 50),
        Cart.Item(price: 75)
    ]
    let cart = Cart(items: items)
    #expect(cart.total(tax: 0.2) == 150)  // (50 + 75) * 1.2
}

The #expect macro is more readable than XCTest assertions. It's not just syntax sugar—it gives you better error messages. When a test fails, you see the actual and expected values inline, not buried in a comparison log.

You can also add arguments and control test execution:

swift
@Test(arguments: [0.0, 0.1, 0.25]) func taxRatesCalculateCorrectly(rate: Double) {
    let cart = Cart(items: [.init(price: 100)])
    let expected = 100 * (1 + rate)
    #expect(cart.total(tax: rate) == expected)
}

@Test(.enabled(if: false)) func skippedTest() {
    // This won't run
}

In production, we keep all our model tests in Swift Testing. It's faster to write, faster to run, and the team picks it up immediately.

Unit tests — Models and view models without UI

Unit tests are your first line of defense. They test one thing in isolation: business logic, data transformation, calculations. They run in milliseconds and don't touch the UI framework.

A typical unit test suite covers your models and view models. Here's a payment processing view model we tested:

swift
@MainActor
class PaymentViewModel: ObservableObject {
    @Published var isProcessing = false
    @Published var error: String?
    
    private let service: PaymentService
    
    init(service: PaymentService) {
        self.service = service
    }
    
    func processPayment(amount: Double, cardToken: String) async {
        isProcessing = true
        error = nil
        
        do {
            _ = try await service.charge(amount: amount, token: cardToken)
            isProcessing = false
        } catch let error as PaymentError {
            self.error = error.userMessage
            isProcessing = false
        }
    }
}

And the tests:

swift
@Test @MainActor func successfulPaymentClearsError() async {
    let mockService = MockPaymentService(shouldSucceed: true)
    let viewModel = PaymentViewModel(service: mockService)
    
    await viewModel.processPayment(amount: 99.99, cardToken: "tok_123")
    
    #expect(viewModel.error == nil)
    #expect(!viewModel.isProcessing)
}

@Test @MainActor func failedPaymentSetsError() async {
    let mockService = MockPaymentService(shouldSucceed: false)
    let viewModel = PaymentViewModel(service: mockService)
    
    await viewModel.processPayment(amount: 99.99, cardToken: "tok_invalid")
    
    #expect(viewModel.error == "Card declined")
    #expect(!viewModel.isProcessing)
}

The key insight: mock the dependencies (here, PaymentService), test the logic in isolation, verify state changes. You're testing the view model's job: manage state and coordinate with services.

Unit tests should be the bulk of your test suite. They're cheap to write, run in seconds, and catch most bugs before they reach the UI layer.

UI tests — XCUITest for critical flows

UI tests are slow, fragile, and expensive to maintain. They're also essential for catching integration problems that unit tests miss. We use them for critical user journeys: checkout flows, login, critical payment paths.

Here's a checkout flow test using XCUITest:

swift
import XCTest

final class CheckoutUITests: XCTestCase {
    let app = XCUIApplication()
    
    override func setUp() {
        super.setUp()
        app.launch()
    }
    
    func testCompleteCheckout() {
        // Navigate to cart
        app.tabBars.buttons["Cart"].tap()
        #expect(app.staticTexts["Cart Items"].exists)
        
        // Tap checkout
        app.buttons["Proceed to Checkout"].tap()
        #expect(app.staticTexts["Shipping Address"].exists)
        
        // Fill shipping
        let addressField = app.textFields["Address"]
        addressField.tap()
        addressField.typeText("123 Main St")
        
        // Fill email
        let emailField = app.textFields["Email"]
        emailField.tap()
        emailField.typeText("user@example.com")
        
        // Continue to payment
        app.buttons["Continue"].tap()
        #expect(app.staticTexts["Payment Method"].exists)
        
        // Complete payment
        app.buttons["Pay Now"].tap()
        
        // Verify success
        #expect(app.staticTexts["Order Confirmed"].waitForExistence(timeout: 3))
    }
}

Key practices we follow:

  • Use staticTexts, buttons, and accessibility identifiers to find elements reliably.
  • Wait for elements with waitForExistence(timeout:) instead of hard sleeps.
  • Test the happy path and one failure scenario per test (e.g., invalid card).
  • Keep UI tests focused on user-visible outcomes, not implementation details.

In our experience, 5–10 critical UI tests catch 80% of integration issues. More than that, and you're fighting maintenance overhead.

Mocks — Protocol-based fakes for services

Mocks are essential for unit testing. A mock conforms to the same protocol as your real service but returns canned data or fails in controlled ways.

Here's a real PaymentService protocol and two implementations:

swift
protocol PaymentService {
    func charge(amount: Double, token: String) async throws -> PaymentReceipt
}

class LivePaymentService: PaymentService {
    private let stripe = Stripe()
    
    func charge(amount: Double, token: String) async throws -> PaymentReceipt {
        let result = try await stripe.createCharge(
            amount: Int(amount * 100),
            source: token
        )
        return PaymentReceipt(id: result.id, amount: amount)
    }
}

class MockPaymentService: PaymentService {
    let shouldSucceed: Bool
    
    init(shouldSucceed: Bool = true) {
        self.shouldSucceed = shouldSucceed
    }
    
    func charge(amount: Double, token: String) async throws -> PaymentReceipt {
        if !shouldSucceed {
            throw PaymentError.declined
        }
        return PaymentReceipt(id: UUID().uuidString, amount: amount)
    }
}

In your view model tests, inject the mock. In production, inject the real service. Your business logic doesn't know the difference. This is the dependency injection pattern, and it's the foundation of testable iOS code.

We also use mocks for network calls, database operations, and any external service. The rule: if it's slow, external, or non-deterministic, mock it in unit tests.

Common mistakes

  • Testing implementation details instead of behavior. Don't test that a property changed; test that the UI updated or the user got the right result. Refactoring becomes painful if tests are tightly coupled to the code structure.
  • Skipping unit tests to save time. We've seen teams write only UI tests and then spend weeks chasing flaky failures. Unit tests are the fast feedback loop. Invest in them first.
  • Not mocking external dependencies. If your unit test makes real network calls or database queries, it's not a unit test anymore. It's an integration test, and it's slow and fragile. Mock aggressively.
  • Over-testing trivial code. Don't test SwiftUI body views or simple getters. Test the logic that matters: calculations, state transitions, error handling.
  • Ignoring accessibility in UI tests. Use accessibilityIdentifier to label important UI elements. It makes tests more readable and your app more accessible to users.

Key takeaways

  • Use Swift Testing (@Test, #expect) for all new unit tests. It's cleaner, faster, and the future of iOS testing.
  • Build a test pyramid: many unit tests (fast, cheap), fewer integration tests, a few critical UI tests (slow, expensive).
  • Mock all external dependencies—network, database, third-party APIs—so unit tests run in milliseconds and deterministically.
  • Write UI tests only for critical user journeys like checkout or login. Don't test every screen; focus on revenue and user satisfaction flows.
  • Test behavior and outcomes, not implementation. Your tests should survive refactoring.
  • Keep tests maintainable by using accessibility identifiers and waiting for elements instead of hard sleeps.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles