Section 10/171 menit

10. Testing di Xcode Cloud

10. Testing di Xcode Cloud

Konfigurasi Test di Workflow

Xcode Cloud mendukung unit test dan UI test. Untuk hasil terbaik:

swift
Test Action Configuration:
├── Scheme: MyApp
├── Test Plan: AllTests.xctestplan (pisahkan unit dan UI test)
├── Destinations:
   ├── iPhone 15 Pro  iOS 17.x (simulator terbaru)
   ├── iPhone SE (3rd generation)  iOS 17.x (test device kecil)
   └── iPad Pro 12.9-inch  iPadOS 17.x (jika support iPad)
└── Options:
    ├── Code Coverage: Enabled
    └── Test Repetition: None (atau Fixed  untuk flaky test debugging)

Memisahkan Unit Test dan UI Test

UI test lebih lambat dan lebih flaky — pisahkan ke workflow berbeda:

swift
Workflow "PR Validation":
└── Test Action: scheme MyApp, test plan UnitTests.xctestplan
    (cepat, ~2-5 menit, jalankan di setiap PR)

Workflow "Nightly":
└── Test Action: scheme MyApp, test plan AllTests.xctestplan
    (termasuk UI test, ~15-30 menit, jalankan setiap malam)

Swift Testing di Xcode Cloud

Swift Testing (diperkenalkan Swift 5.9) bekerja seamless di Xcode Cloud:

swift
// Tests/UserServiceTests.swift

import Testing
@testable import MyApp

@Suite("UserService Tests")
struct UserServiceTests {

    @Test("Login berhasil dengan kredensial valid")
    func loginSuccess() async throws {
        let service = UserService(apiClient: MockAPIClient())
        let user = try await service.login(
            email: "test@example.com",
            password: "password123"
        )
        #expect(user.email == "test@example.com")
        #expect(user.isLoggedIn == true)
    }

    @Test("Login gagal dengan password salah", arguments: [
        ("wrong@email.com", "password123"),
        ("test@example.com", "wrongpassword"),
    ])
    func loginFailure(email: String, password: String) async {
        let service = UserService(apiClient: MockAPIClient())
        await #expect(throws: AuthError.invalidCredentials) {
            try await service.login(email: email, password: password)
        }
    }

    // Tag untuk grouping di Xcode Cloud test results
    @Test("Network timeout handling", .tags(.networking))
    func networkTimeout() async {
        let client = MockAPIClient()
        client.shouldTimeout = true
        let service = UserService(apiClient: client)

        await #expect(throws: NetworkError.timeout) {
            try await service.login(email: "test@example.com", password: "pass")
        }
    }
}

extension Tag {
    @Tag static var networking: Self
}

Menangani Flaky Tests

Xcode Cloud punya fitur Test Repetitions untuk menangani test flaky:

swift
Test Action → Options → Test Repetition Mode:
├── None (default)
├── Until Failure — jalankan berulang sampai gagal (debugging)
└── Retry on Failure — coba ulang N kali jika gagal (CI tolerance)
    └── Maximum Repetitions: 3

Untuk jangka panjang, identifikasi dan perbaiki test flaky daripada mengandalkan retry:

swift
// Tests/NetworkTests.swift

// ❌ Test flaky — bergantung pada timing
@Test func networkCallCompletesInTime() async {
    let start = Date()
    _ = try? await apiClient.fetchUser(id: 1)
    let elapsed = Date().timeIntervalSince(start)
    #expect(elapsed < 2.0) // flaky jika server lambat
}

// ✓ Test deterministic — gunakan mock
@Test func networkCallReturnsUser() async throws {
    let mockClient = MockAPIClient()
    mockClient.mockResponse = User(id: 1, name: "Alice")
    
    let user = try await mockClient.fetchUser(id: 1)
    #expect(user.name == "Alice")
}

Code Coverage Report

Xcode Cloud otomatis generate code coverage report yang bisa dilihat di Xcode atau App Store Connect:

swift
Build Results  Test  Code Coverage:
├── Overall: 78%
├── UserService.swift: 92%
├── NetworkClient.swift: 65%
└── ProfileViewController.swift: 43%  butuh lebih banyak test

Untuk enforce minimum coverage, gunakan ci_post_xcodebuild.sh:

swift
#!/bin/zsh
# ci_scripts/ci_post_xcodebuild.sh

if [[ "$CI_XCODEBUILD_ACTION" == "test" ]]; then
    MINIMUM_COVERAGE=70  # persen minimum
    
    # Extract coverage dari xcresult menggunakan xcov atau xcresulttool
    # (contoh konseptual — implementasi aktual bergantung pada tool yang digunakan)
    ACTUAL_COVERAGE=$(xcrun xcresulttool get \
        --format json \
        --path "$CI_RESULT_BUNDLE_PATH" 2>/dev/null \
        | python3 -c "import sys,json; d=json.load(sys.stdin); \
          print(int(d.get('metrics',{}).get('testsCount',{}).get('_value',0)))" \
        || echo "0")
    
    echo "Coverage: ${ACTUAL_COVERAGE}%"
    
    if (( ACTUAL_COVERAGE < MINIMUM_COVERAGE )); then
        echo "❌ Code coverage ${ACTUAL_COVERAGE}% di bawah minimum ${MINIMUM_COVERAGE}%"
        exit 1
    fi
fi