Why Teams Still Struggle with Tests
Two common anti-patterns show up repeatedly:
- Over-investing in flaky UI tests
- Under-investing in deterministic unit tests
The result is slow pipelines and low trust in failures.
A Balanced Testing Distribution
| Test Type | Target Share | Runtime Goal | Main Purpose |
|---|---|---|---|
| Unit Tests | 70-80% | Under 5 min total | Validate logic and edge cases |
| Integration Tests | 15-20% | Under 4 min total | Validate module boundaries |
| UI / End-to-End | 5-10% | Under 8 min total | Protect 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.
Share on
Twitter Facebook LinkedInโ Buy me a coffee! ๐
If you found this article helpful, consider buying me a coffee to support my work! ๐
