mcpField
I
ios-unit-test

ios-unit-test

github.com/dengineproblemテスト・品質88 回インストール
リポジトリ

ios-unit-test について

ios-unit-testは、XCTestを使うiOS開発者向けのスキルで、ユニットテストとUIテストのパターンや実践方法を扱います。非同期コード、モック、Combineパブリッシャー、パフォーマンステスト、テスト設定の例を含みます。

主な機能

  1. 01Arrange-Act-Assertによるテスト構成
  2. 02モックと依存性注入の利用
  3. 03async/awaitコードとExpectationのテスト
  4. 04Combineパブリッシャーのテスト
  5. 05XCUITestによるUIテスト
  6. 06テストスキームとプランの設定

活用シーン

  1. 01モック依存を使ったiOSサービスのテスト
  2. 02非同期処理と通知の検証
  3. 03パブリッシャー出力の確認
  4. 04データ処理性能の測定

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/dengineproblem/agents-monorepo --skill ios-unit-test

インストール後、Claude Code で/ios-unit-testを入力して読み込まれたか確認します。

詳細

iOS のユニットテストエキスパート

XCTest フレームワークとベストプラクティスに精通した、iOS テストのエキスパートです。

テストの基本原則

テストの構造と構成
  • Arrange-Act-Assert(AAA)パターンに従います
  • シナリオと期待される結果が分かる、具体的なテストメソッド名を使用します
  • ネストされたテストクラスまたはテストスイートを使用して、関連するテストをグループ化します
  • テストの独立性を保ちます。各テストは単独で実行できるようにします
XCTest フレームワークの基本
import XCTest
@testable import YourApp

class UserServiceTests: XCTestCase {
    // System Under Test
    var sut: UserService!
    var mockNetworkManager: MockNetworkManager!

    override func setUpWithError() throws {
        try super.setUpWithError()
        mockNetworkManager = MockNetworkManager()
        sut = UserService(networkManager: mockNetworkManager)
    }

    override func tearDownWithError() throws {
        sut = nil
        mockNetworkManager = nil
        try super.tearDownWithError()
    }

    // MARK: - fetchUser Tests

    func test_fetchUser_withValidId_returnsUser() async throws {
        // Arrange
        let expectedUser = User(id: "123", name: "John Doe")
        mockNetworkManager.fetchUserResult = .success(expectedUser)

        // Act
        let result = try await sut.fetchUser(id: "123")

        // Assert
        XCTAssertEqual(result.id, expectedUser.id)
        XCTAssertEqual(result.name, expectedUser.name)
        XCTAssertEqual(mockNetworkManager.fetchUserCallCount, 1)
        XCTAssertEqual(mockNetworkManager.lastFetchedUserId, "123")
    }

    func test_fetchUser_withInvalidId_throwsError() async {
        // Arrange
        mockNetworkManager.fetchUserResult = .failure(NetworkError.notFound)

        // Act & Assert
        do {
            _ = try await sut.fetchUser(id: "invalid")
            XCTFail("Expected error to be thrown")
        } catch {
            XCTAssertTrue(error is NetworkError)
            XCTAssertEqual(error as? NetworkError, .notFound)
        }
    }
}

モックと依存性注入

プロトコルベースのモック
// Protocol definition
protocol NetworkManagerProtocol {
    func fetchUser(id: String) async throws -> User
    func saveUser(_ user: User) async throws
}

// Mock implementation
class MockNetworkManager: NetworkManagerProtocol {
    // Call tracking
    var fetchUserCallCount = 0
    var lastFetchedUserId: String?
    var saveUserCallCount = 0
    var lastSavedUser: User?

    // Configurable results
    var fetchUserResult: Result<User, Error>?
    var saveUserResult: Result<Void, Error> = .success(())

    func fetchUser(id: String) async throws -> User {
        fetchUserCallCount += 1
        lastFetchedUserId = id

        switch fetchUserResult {
        case .success(let user):
            return user
        case .failure(let error):
            throw error
        case .none:
            throw TestError.noMockResult
        }
    }

    func saveUser(_ user: User) async throws {
        saveUserCallCount += 1
        lastSavedUser = user

        switch saveUserResult {
        case .success:
            return
        case .failure(let error):
            throw error
        }
    }

    // Reset for reuse
    func reset() {
        fetchUserCallCount = 0
        lastFetchedUserId = nil
        saveUserCallCount = 0
        lastSavedUser = nil
        fetchUserResult = nil
        saveUserResult = .success(())
    }
}

enum TestError: Error {
    case noMockResult
}
スパイパターン
class NetworkManagerSpy: NetworkManagerProtocol {
    private(set) var messages: [Message] = []

    enum Message: Equatable {
        case fetchUser(id: String)
        case saveUser(User)
    }

    var stubbedFetchUserResult: Result<User, Error> = .failure(TestError.noMockResult)

    func fetchUser(id: String) async throws -> User {
        messages.append(.fetchUser(id: id))
        return try stubbedFetchUserResult.get()
    }

    func saveUser(_ user: User) async throws {
        messages.append(.saveUser(user))
    }
}

非同期テストのパターン

async/await コードのテスト
func test_fetchUser_withValidId_returnsUser() async throws {
    // Arrange
    let expectedUser = User(id: "123", name: "John Doe")
    mockNetworkManager.fetchUserResult = .success(expectedUser)

    // Act
    let result = try await sut.fetchUser(id: "123")

    // Assert
    XCTAssertEqual(result, expectedUser)
}

func test_fetchUser_withNetworkError_throwsError() async {
    // Arrange
    mockNetworkManager.fetchUserResult = .failure(NetworkError.connectionFailed)

    // Act & Assert
    await XCTAssertThrowsError(try await sut.fetchUser(id: "123")) { error in
        XCTAssertEqual(error as? NetworkError, .connectionFailed)
    }
}
XCTestExpectation を使ったテスト
func test_notificationObserver_receivesNotification() {
    // Arrange
    let expectation = XCTestExpectation(description: "Notification received")
    let notificationName = Notification.Name("TestNotification")

    let observer = NotificationCenter.default.addObserver(
        forName: notificationName,
        object: nil,
        queue: nil
    ) { _ in
        expectation.fulfill()
    }

    // Act
    NotificationCenter.default.post(name: notificationName, object: nil)

    // Assert
    wait(for: [expectation], timeout: 1.0)

    // Cleanup
    NotificationCenter.default.removeObserver(observer)
}

func test_delegateCallback_isCalledOnSuccess() {
    // Arrange
    let expectation = XCTestExpectation(description: "Delegate called")
    let mockDelegate = MockDelegate()
    mockDelegate.onSuccessCalled = { expectation.fulfill() }
    sut.delegate = mockDelegate

    // Act
    sut.performOperation()

    // Assert
    wait(for: [expectation], timeout: 2.0)
    XCTAssertTrue(mockDelegate.successCallCount == 1)
}
Combine パブリッシャーのテスト
import Combine

func test_userPublisher_emitsUser() {
    // Arrange
    var receivedUser: User?
    var receivedError: Error?
    let expectation = XCTestExpectation(description: "Publisher emits")

    let cancellable = sut.userPublisher
        .sink(
            receiveCompletion: { completion in
                if case .failure(let error) = completion {
                    receivedError = error
                }
                expectation.fulfill()
            },
            receiveValue: { user in
                receivedUser = user
            }
        )

    // Act
    sut.loadUser(id: "123")

    // Assert
    wait(for: [expectation], timeout: 2.0)
    XCTAssertNotNil(receivedUser)
    XCTAssertNil(receivedError)
    cancellable.cancel()
}

ビューコントローラーのテスト

class LoginViewControllerTests: XCTestCase {
    var sut: LoginViewController!
    var mockAuthService: MockAuthService!

    override func setUpWithError() throws {
        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        sut = storyboard.instantiateViewController(
            withIdentifier: "LoginViewController"
        ) as? LoginViewController

        mockAuthService = MockAuthService()
        sut.authService = mockAuthService

        // Load view hierarchy
        sut.loadViewIfNeeded()
    }

    override func tearDownWithError() throws {
        sut = nil
        mockAuthService = nil
    }

    func test_outlets_areConnected() {
        XCTAssertNotNil(sut.emailTextField)
        XCTAssertNotNil(sut.passwordTextField)
        XCTAssertNotNil(sut.loginButton)
        XCTAssertNotNil(sut.errorLabel)
    }

    func test_loginButton_tap_callsAuthService() {
        // Arrange
        sut.emailTextField.text = "[email protected]"
        sut.passwordTextField.text = "password123"

        // Act
        sut.loginButton.sendActions(for: .touchUpInside)

        // Assert
        XCTAssertEqual(mockAuthService.loginCallCount, 1)
        XCTAssertEqual(mockAuthService.lastLoginEmail, "[email protected]")
        XCTAssertEqual(mockAuthService.lastLoginPassword, "password123")
    }

    func test_loginButton_withEmptyEmail_showsError() {
        // Arrange
        sut.emailTextField.text = ""
        sut.passwordTextField.text = "password"

        // Act
        sut.loginButton.sendActions(for: .touchUpInside)

        // Assert
        XCTAssertEqual(mockAuthService.loginCallCount, 0)
        XCTAssertFalse(sut.errorLabel.isHidden)
        XCTAssertEqual(sut.errorLabel.text, "Email is required")
    }

    func test_successfulLogin_navigatesToHome() {
        // Arrange
        mockAuthService.loginResult = .success(User(id: "1", name: "Test"))
        let mockNavigator = MockNavigator()
        sut.navigator = mockNavigator

        sut.emailTextField.text = "[email protected]"
        sut.passwordTextField.text = "password"

        // Act
        sut.loginButton.sendActions(for: .touchUpInside)

        // Assert
        XCTAssertTrue(mockNavigator.didNavigateToHome)
    }
}

パフォーマンステスト

func test_dataProcessing_performance() {
    let largeDataSet = generateLargeDataSet(count: 10000)

    measure {
        _ = sut.processData(largeDataSet)
    }
}

func test_dataProcessing_performanceWithOptions() {
    let options = XCTMeasureOptions()
    options.iterationCount = 10

    measure(options: options) {
        _ = sut.processData(generateLargeDataSet(count: 5000))
    }
}

func test_memoryUsage_withLargeDataSet() {
    let options = XCTMeasureOptions()
    options.iterationCount = 5

    measure(metrics: [XCTMemoryMetric()], options: options) {
        autoreleasepool {
            let data = sut.loadLargeDataSet()
            sut.processData(data)
        }
    }
}

func test_cpuUsage_duringOperation() {
    measure(metrics: [XCTCPUMetric()]) {
        sut.performCPUIntensiveOperation()
    }
}

パラメーター化テスト

func test_emailValidation_withVariousInputs() {
    let testCases: [(email: String, isValid: Bool)] = [
        ("[email protected]", true),
        ("[email protected]", true),
        ("invalid.email", false),
        ("", false),
        ("@example.com", false),
        ("test@", false),
        ("[email protected]", false),
        ("test@domain", false)
    ]

    for testCase in testCases {
        let result = sut.isValidEmail(testCase.email)
        XCTAssertEqual(
            result,
            testCase.isValid,
            "Failed for email: '\(testCase.email)' - expected \(testCase.isValid), got \(result)"
        )
    }
}

// Using XCTestCase subclass for cleaner parameterized tests
class EmailValidationTests: XCTestCase {
    struct TestCase {
        let input: String
        let expected: Bool
        let file: StaticString
        let line: UInt

        init(_ input: String, _ expected: Bool,
             file: StaticString = #file, line: UInt = #line) {
            self.input = input
            self.expected = expected
            self.file = file
            self.line = line
        }
    }

    func test_isValidEmail() {
        let testCases = [
            TestCase("[email protected]", true),
            TestCase("invalid", false),
            TestCase("", false)
        ]

        for testCase in testCases {
            let result = EmailValidator.isValid(testCase.input)
            XCTAssertEqual(result, testCase.expected,
                          file: testCase.file, line: testCase.line)
        }
    }
}

XCUITest による UI テスト

class LoginUITests: XCTestCase {
    var app: XCUIApplication!

    override func setUpWithError() throws {
        continueAfterFailure = false
        app = XCUIApplication()
        app.launchArguments = ["--uitesting"]
        app.launch()
    }

    func test_loginFlow_withValidCredentials_showsHomeScreen() {
        // Navigate to login
        let loginButton = app.buttons["LoginButton"]
        XCTAssertTrue(loginButton.waitForExistence(timeout: 5))

        // Enter credentials
        let emailField = app.textFields["EmailTextField"]
        emailField.tap()
        emailField.typeText("[email protected]")

        let passwordField = app.secureTextFields["PasswordTextField"]
        passwordField.tap()
        passwordField.typeText("password123")

        // Tap login
        loginButton.tap()

        // Verify home screen
        let homeTitle = app.staticTexts["Welcome"]
        XCTAssertTrue(homeTitle.waitForExistence(timeout: 10))
    }

    func test_loginFlow_withInvalidCredentials_showsError() {
        let emailField = app.textFields["EmailTextField"]
        emailField.tap()
        emailField.typeText("[email protected]")

        let passwordField = app.secureTextFields["PasswordTextField"]
        passwordField.tap()
        passwordField.typeText("wrongpassword")

        app.buttons["LoginButton"].tap()

        let errorLabel = app.staticTexts["ErrorLabel"]
        XCTAssertTrue(errorLabel.waitForExistence(timeout: 5))
        XCTAssertEqual(errorLabel.label, "Invalid credentials")
    }
}

テストの設定

テストスキームの設定
test_scheme_configuration:
  unit_tests:
    targets: ["YourAppTests"]
    coverage: true
    parallel: true

  ui_tests:
    targets: ["YourAppUITests"]
    coverage: false
    parallel: false
    launch_arguments: ["--uitesting", "--reset-state"]

  integration_tests:
    targets: ["YourAppIntegrationTests"]
    coverage: true
    parallel: false
テストプランの設定
{
  "configurations" : [
    {
      "name" : "Unit Tests",
      "options" : {
        "targetForVariableExpansion" : { "target" : { "name" : "YourApp" } }
      }
    }
  ],
  "defaultOptions" : {
    "codeCoverage" : true,
    "testTimeoutsEnabled" : true,
    "defaultTestExecutionTimeAllowance" : 60
  },
  "testTargets" : [
    { "target" : { "name" : "YourAppTests" } }
  ],
  "version" : 1
}

ベストプラクティス

  1. AAA パターン — 各テストで Arrange、Act、Assert を行います
  2. テストごとに 1 つのアサーション — テストごとに論理的なアサーションを 1 つだけ行います
  3. 具体的な名前 — test_methodName_condition_expectedResult
  4. テストの独立性 — 各テストはほかのテストから独立させます
  5. 外部依存関係をモック化 — ネットワーク、データベース、システムサービス
  6. 高速なテスト — ユニットテストは数ミリ秒で実行できるようにします

コメント