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
- Red — write a test that describes the behaviour you want. It must fail because the production code doesn't exist yet.
- Green — write the minimum code to make the test pass. Don't over-engineer.
- 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/PasswordValidatorTestsTest 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.xcresultUse -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.xcresultSwift Package Tests
swift test --parallelAdd --filter to run a subset:
swift test --filter PasswordValidatorTestsGitHub 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.xcresultCODE_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.xmlJUnit 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.swiftOne 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 testin 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.