Section 10/171 menit

10. Testing Strategies dengan Tuist

10. Testing Strategies dengan Tuist

Test Plans per Layer

swift
// Tuist/ProjectDescriptionHelpers/TestPlan+Strategy.swift

import ProjectDescription

public extension TestPlan {

    // Fast feedback: hanya unit test tanpa UI test
    static var unitTests: Path {
        "TestPlans/UnitTests.xctestplan"
    }

    // Full coverage: semua test termasuk UI test
    static var allTests: Path {
        "TestPlans/AllTests.xctestplan"
    }

    // Critical path: test untuk core modules saja
    static var coreTests: Path {
        "TestPlans/CoreTests.xctestplan"
    }
}
swift
// TestPlans/UnitTests.xctestplan
{
  "configurations": [
    {
      "id": "unit-tests",
      "name": "Unit Tests",
      "options": {
        "codeCoverageEnabled": true,
        "maximumTestExecutionTimeAllowance": 30,
        "testTimeoutsEnabled": true
      }
    }
  ],
  "testTargets": [
    {"target": {"name": "NetworkKitTests"}, "enabled": true},
    {"target": {"name": "AuthKitTests"}, "enabled": true},
    {"target": {"name": "FeatureHomeTests"}, "enabled": true},
    {"target": {"name": "FeatureCheckoutTests"}, "enabled": true},
    {"target": {"name": "FeatureProfileTests"}, "enabled": true}
  ],
  "version": 1
}

Example Apps untuk Development Isolation

Setiap feature module bisa memiliki standalone example app untuk pengembangan yang terisolasi:

swift
// Tuist/ProjectDescriptionHelpers/Target+ExampleApp.swift

import ProjectDescription

public extension Target {

    static func exampleApp(
        for moduleName: String,
        additionalDependencies: [TargetDependency] = []
    ) -> Target {
        .target(
            name: "\(moduleName)Example",
            destinations: .iOS,
            product: .app,
            bundleId: "com.company.\(moduleName.lowercased()).example",
            deploymentTargets: .iOS("17.0"),
            infoPlist: .extendingDefault(with: [
                "UILaunchScreen": .dictionary([:]),
                "UIMainStoryboardFile": "",
            ]),
            sources: "Example/Sources/**",
            resources: [
                "Example/Resources/**",
            ],
            dependencies: [
                .target(name: moduleName),
            ] + additionalDependencies
        )
    }
}

Manfaat example app:

  • Developer bisa buka Xcode hanya dengan project satu module — compile time ~30 detik vs seluruh workspace
  • SwiftUI previews lebih cepat karena scope kecil
  • Isolasi untuk testing edge case tanpa state dari module lain

Testing dengan Mock Injection

swift
// Projects/Core/NetworkKit/Sources/NetworkKit/NetworkClientProtocol.swift

public protocol NetworkClientProtocol: Sendable {
    func fetch<T: Decodable>(_ request: URLRequest) async throws -> T
}
swift
// Projects/Core/NetworkKit/Sources/NetworkKit/NetworkClient.swift

public actor NetworkClient: NetworkClientProtocol {
    private let session: URLSession

    public init(session: URLSession = .shared) {
        self.session = session
    }

    public func fetch<T: Decodable>(_ request: URLRequest) async throws -> T {
        let (data, response) = try await session.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            throw NetworkError.invalidResponse
        }

        return try JSONDecoder().decode(T.self, from: data)
    }
}
swift
// Projects/Core/NetworkKit/Tests/NetworkKitTests/MockNetworkClient.swift

@testable import NetworkKit
import Foundation

// Mock yang di-share antar test targets via test dependency
actor MockNetworkClient: NetworkClientProtocol {
    private var responses: [String: Any] = [:]
    private(set) var requestHistory: [URLRequest] = []

    func stub<T: Decodable>(url: String, response: T) {
        responses[url] = response
    }

    func fetch<T: Decodable>(_ request: URLRequest) async throws -> T {
        requestHistory.append(request)

        guard let url = request.url?.absoluteString,
              let response = responses[url] as? T else {
            throw NetworkError.invalidResponse
        }

        return response
    }
}