TDD Workflow in Swift and Xcode

TDD Workflow in Swift and Xcode

Test-driven development (TDD) is a discipline, not a framework. You write a failing test, make it pass with the simplest possible code, then refactor without breaking it. This article walks through the complete TDD cycle in Swift, using Xcode's built-in tooling and xcodebuild for CI.

The Red-Green-Refactor Cycle

  1. Red — write a test that describes the behaviour you want. It must fail because the production code doesn't exist yet.
  2. Green — write the minimum code to make the test pass. Don't over-engineer.
  3. Refactor — clean up both the production code and the test. Re-run to confirm nothing broke.

Let's build a PasswordValidator from scratch using this cycle.

Red: Write the Failing Test

Create a new test file PasswordValidatorTests.swift:

import XCTest
@testable import MyApp

final class PasswordValidatorTests: XCTestCase {

    func testPasswordTooShortFails() {
        let validator = PasswordValidator()
        XCTAssertFalse(validator.isValid("abc"))
    }
}

Run with ⌘U. The compiler fails because PasswordValidator doesn't exist yet. That's the red state.

Green: Make It Compile and Pass

Create PasswordValidator.swift:

struct PasswordValidator {
    func isValid(_ password: String) -> Bool {
        password.count >= 8
    }
}

Run ⌘U again. The test passes.

Refactor: Add More Rules via More Cycles

Add a new failing test:

func testPasswordWithoutUppercaseFails() {
    let validator = PasswordValidator()
    XCTAssertFalse(validator.isValid("alllowercase1"))
}

func testValidPasswordPasses() {
    let validator = PasswordValidator()
    XCTAssertTrue(validator.isValid("Str0ng!Pass"))
}

Red. Update production code:

struct PasswordValidator {
    private let minimumLength = 8

    func isValid(_ password: String) -> Bool {
        guard password.count >= minimumLength else { return false }
        let hasUppercase = password.contains { $0.isUppercase }
        return hasUppercase
    }
}

Green. Continue the cycle for digits, special characters, etc.

The Xcode Test Navigator

Open ⌘6 to see the test navigator. Key interactions:

  • Run individual test: click the diamond icon next to any test method.
  • Run a test class: click the diamond next to the class name.
  • Re-run last test: ⌃⌥⌘G — fastest way to iterate on the current red test.
  • Filter by status: the toolbar at the bottom of the navigator filters to failed, passed, or skipped tests.

Continuous Testing

Enable Run Tests After Build in the scheme to get instant feedback on every save. Or use xcodebuild with -only-testing flags in a terminal watch loop:

# One-liner: re-run a single test class whenever files change (requires entr)
find . -name "*.swift" | entr -c xcodebuild test \
  -scheme MyApp \
  -destination "platform=iOS Simulator,name=iPhone 15,OS=17.4" \
  -only-testing:MyAppTests/PasswordValidatorTests

Test Naming Conventions

Good test names read like specifications. The given_when_then or test<Subject><Scenario> patterns work well:

// Descriptive: what is tested, under what condition, what the outcome is
func testPasswordValidator_whenPasswordIsTooShort_returnsFalse() { ... }
func testPasswordValidator_whenAllRequirementsMet_returnsTrue() { ... }

// Swift Testing uses string descriptions instead:
@Test("Returns false when password is shorter than 8 characters")
func passwordTooShort() { ... }

Avoid names like testCase1 or test2. When a test fails in CI, the name is the first hint about what broke.

Test Doubles in TDD

TDD naturally drives you toward protocol-based design because testable code requires injectable dependencies. When a new test needs a dependency, extract it as a protocol immediately:

// Red: new test requires an email sender
func testRegistrationSendsWelcomeEmail() {
    let sender = MockEmailSender()
    let service = RegistrationService(emailSender: sender)
    service.register(email: "alice@example.com", password: "Str0ng!Pass")
    XCTAssertEqual(sender.sentEmails.count, 1)
    XCTAssertEqual(sender.sentEmails[0].to, "alice@example.com")
}

The test won't compile until you add the EmailSender protocol and inject it. TDD forces the right architecture.

Running Tests in CI with xcodebuild

Basic Invocation

xcodebuild test \
  -project MyApp.xcodeproj \
  -scheme MyApp \
  -destination "platform=iOS Simulator,name=iPhone 15,OS=17.4" \
  -resultBundlePath TestResults.xcresult

Use -derivedDataPath to cache build artifacts between CI runs:

xcodebuild test \
  -project MyApp.xcodeproj \
  -scheme MyApp \
  -destination "platform=iOS Simulator,name=iPhone 15,OS=17.4" \
  -derivedDataPath .derived-data \
  -resultBundlePath TestResults.xcresult

Swift Package Tests

swift test --parallel

Add --filter to run a subset:

swift test --filter PasswordValidatorTests

GitHub Actions Example

name: iOS Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4

      - name: Select Xcode
        run: sudo xcode-select -s /Applications/Xcode_15.4.app

      - name: Run tests
        run: |
          xcodebuild test \
            -project MyApp.xcodeproj \
            -scheme MyApp \
            -destination "platform=iOS Simulator,name=iPhone 15,OS=17.4" \
            -resultBundlePath TestResults.xcresult \
            CODE_SIGNING_ALLOWED=NO

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: TestResults
          path: TestResults.xcresult

CODE_SIGNING_ALLOWED=NO skips code signing for simulator-only test builds—common in CI.

Parsing Results with xcpretty

xcodebuild output is verbose. Pipe it through xcpretty for readable logs:

gem install xcpretty

xcodebuild test \
  -scheme MyApp \
  -destination "platform=iOS Simulator,name=iPhone 15,OS=17.4" \
  | xcpretty --report junit --output test-results.xml

JUnit XML output integrates with GitHub Actions, CircleCI, and Jenkins test reporters.

Structuring Test Files

Mirror the production source tree:

MyApp/
  Sources/
    Auth/
      PasswordValidator.swift
    Checkout/
      CheckoutService.swift
MyAppTests/
  Auth/
    PasswordValidatorTests.swift
  Checkout/
    CheckoutServiceTests.swift

One test file per production file, one test class per production type. Navigating between implementation and test is ⌃⌘T in Xcode (jump to counterpart).

Key Takeaways

  • Write the failing test first. Compiling red is still red.
  • Use ⌃⌥⌘G to re-run the last test and tighten the feedback loop.
  • Protocol-injected dependencies emerge naturally from TDD—don't fight the pattern.
  • Run xcodebuild test in CI with a pinned simulator destination to avoid environment drift.
  • Mirror your source tree in your test tree for easy navigation.

A disciplined TDD workflow builds confidence locally. For continuous coverage in production, HelpMeTest monitors your app 24/7 and reports failures before your users encounter them.

Read more

Start now free