Why Teams Still Struggle with Tests

Two common anti-patterns show up repeatedly:

  1. Over-investing in flaky UI tests
  2. Under-investing in deterministic unit tests

The result is slow pipelines and low trust in failures.

A Balanced Testing Distribution

Test TypeTarget ShareRuntime GoalMain Purpose
Unit Tests70-80%Under 5 min totalValidate logic and edge cases
Integration Tests15-20%Under 4 min totalValidate module boundaries
UI / End-to-End5-10%Under 8 min totalProtect critical user journeys

If your CI frequently times out, rebalance toward unit and integration tests first.

Swift Testing + Existing XCTest

Swift Testing improves readability and organization, but migration can be incremental:

  • Keep legacy XCTest where stable
  • Write new tests in Swift Testing style
  • Migrate high-value files gradually

That gives modern ergonomics without risking large test rewrites.

A Real Swift Testing Example

Start by moving high-signal business rules first.

import Foundation
import Testing

struct DiscountCalculator {
    func total(price: Decimal, quantity: Int, isVIP: Bool) -> Decimal {
        let subtotal = price * Decimal(quantity)
        let vipDiscount: Decimal = isVIP ? 0.9 : 1.0
        return subtotal * vipDiscount
    }
}

@Suite("DiscountCalculator")
struct DiscountCalculatorTests {
    private let sut = DiscountCalculator()

    @Test("VIP receives 10% discount")
    func vipDiscount() {
        let result = sut.total(price: 50, quantity: 2, isVIP: true)
        #expect(result == 90)
    }

    @Test("Non-VIP pays full amount")
    func noDiscount() {
        let result = sut.total(price: 50, quantity: 2, isVIP: false)
        #expect(result == 100)
    }
}

What to Unit Test First

Start with components that change often and break quietly:

  • Pricing or calculation logic
  • Feature flag branching
  • Parsing/serialization boundaries
  • Retry and timeout policies

These tests deliver the highest return on maintenance effort.

Integration Tests that Actually Matter

Use integration tests for seams:

  • Repository + local database
  • Repository + API client mapping
  • ViewModel + mocked async dependencies

Keep them focused on one boundary per test file.

import Foundation
import XCTest

final class NotesRepositoryIntegrationTests: XCTestCase {
    func test_save_persistsAndQueuesSync() async throws {
        let db = InMemoryDatabase()
        let queue = InMemorySyncQueue()
        let repo = NotesRepository(db: db, queue: queue)

        try await repo.save(id: UUID(), title: "Offline", body: "first")

        XCTAssertEqual(try db.notesCount(), 1)
        XCTAssertEqual(queue.items.count, 1)
        XCTAssertEqual(queue.items.first?.operation, .upsert)
    }
}

Keep UI Tests Minimal and Critical

Run UI tests for flows that generate support tickets when broken:

  • Authentication
  • Subscription purchase path
  • Core content creation flow
  • Recovery flows (logout/login, permissions)

If a UI test does not protect a critical journey, consider deleting it.

CI Rules That Improve Signal โœ…

  • Run all unit tests on every pull request
  • Run integration tests on pull request and merge queue
  • Run full UI suite nightly plus pre-release
  • Quarantine flaky tests and track owner/date to fix

A minimal GitHub Actions setup that reflects this split:

name: ios-tests
on:
  pull_request:
  push:
    branches: [master]

jobs:
  unit-and-integration:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - name: Unit + integration
        run: xcodebuild test -scheme MyApp -only-testing:MyAppTests

  ui-nightly:
    if: github.event_name == 'push'
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - name: UI tests
        run: xcodebuild test -scheme MyApp -only-testing:MyAppUITests

Final Thought

Testing strategy is a product decision, not only an engineering one. Fast and reliable feedback lets teams ship confidently, which users experience as quality.