mcpField
I
ios-unit-test

ios-unit-test

github.com/dengineproblemTesting & quality88 installs
Repository

About ios-unit-test

ios-unit-test is a skill for iOS developers using XCTest, covering unit and UI testing patterns and practices. It includes examples for async code, mocks, Combine publishers, performance tests, and test configuration.

Key features

  1. 01Structure tests with Arrange-Act-Assert
  2. 02Use mocks and dependency injection
  3. 03Test async/await code and expectations
  4. 04Test Combine publishers
  5. 05Write XCUITest UI tests
  6. 06Configure test schemes and plans

Use cases

  1. 01Test iOS services with mocked dependencies
  2. 02Verify asynchronous operations and notifications
  3. 03Check publisher output
  4. 04Measure data-processing performance

Install

Copy the command and run it in a terminal.

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

After installing, type/ios-unit-testin Claude Code to confirm it loaded.

Details

From the project README

iOS Unit Testing Expert

Expert in iOS testing with XCTest framework and best practices.

Core Testing Principles

Test Structure and Organization
  • Follow the Arrange-Act-Assert (AAA) pattern
  • Use descriptive test method names explaining scenario and expected outcome
  • Group related tests using nested test classes or test suites
  • Maintain test independence - each test should run in isolation
XCTest Framework Fundamentals
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)
        }
    }
}

Mocking and Dependency Injection

Protocol-Based Mocking
// 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
}
Spy Pattern
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 Testing Patterns

Testing async/await Code
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)
    }
}
Testing with Expectations
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)
}
Testing Combine Publishers
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()
}

View Controller Testing

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)
    }
}

Performance Testing

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()
    }
}

Parameterized Testing

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)
        }
    }
}

UI Testing with XCUITest

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 Configuration

Test Scheme Setup
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
Test Plan Configuration
{
  "configurations" : [
    {
      "name" : "Unit Tests",
      "options" : {
        "targetForVariableExpansion" : { "target" : { "name" : "YourApp" } }
      }
    }
  ],
  "defaultOptions" : {
    "codeCoverage" : true,
    "testTimeoutsEnabled" : true,
    "defaultTestExecutionTimeAllowance" : 60
  },
  "testTargets" : [
    { "target" : { "name" : "YourAppTests" } }
  ],
  "version" : 1
}

Лучшие практики

  1. AAA Pattern — Arrange, Act, Assert для каждого теста
  2. One assertion per test — один логический assert на тест
  3. Descriptive names — test_methodName_condition_expectedResult
  4. Test isolation — каждый тест независим от других
  5. Mock external dependencies — сеть, БД, системные сервисы
  6. Fast tests — unit tests должны выполняться за миллисекунды

Comments

Markdown supported