macOS AppKit Testing with XCTest: Unit Testing macOS Desktop Apps

macOS AppKit Testing with XCTest: Unit Testing macOS Desktop Apps

macOS AppKit applications are tested with XCTest, Apple's first-party testing framework. Unlike iOS testing where UIKit is the focus, macOS AppKit has some unique characteristics—NSDocument architecture, menu bar integration, window management—that require specific testing approaches.

This guide covers unit testing AppKit apps with XCTest, including model testing, view controller testing, mocking AppKit dependencies, and UI automation with XCUITest.

XCTest Framework Overview

XCTest is included in Xcode and requires no additional dependencies. Add a test target in Xcode: File → New → Target → macOS Unit Testing Bundle.

Test classes inherit from XCTestCase:

import XCTest
@testable import MyMacApp

final class CalculatorTests: XCTestCase {

    var calculator: Calculator!

    override func setUp() {
        super.setUp()
        calculator = Calculator()
    }

    override func tearDown() {
        calculator = nil
        super.tearDown()
    }

    func testAdd() {
        XCTAssertEqual(calculator.add(2, 3), 5)
    }

    func testDivideByZero() {
        XCTAssertThrowsError(try calculator.divide(10, by: 0)) { error in
            XCTAssertEqual(error as? CalculatorError, .divisionByZero)
        }
    }

    func testPerformanceOfFibonacci() {
        measure {
            _ = calculator.fibonacci(30)
        }
    }
}

Run tests with ⌘U in Xcode or from the command line:

xcodebuild test -scheme MyMacApp -destination <span class="hljs-string">'platform=macOS'

Testing Models

Models should have no AppKit dependencies and are the easiest to test:

// Sources/Models/Document.swift
struct Document: Equatable {
    var title: String
    var content: String
    var modifiedAt: Date
    var isDirty: Bool = false

    mutating func updateContent(_ newContent: String) {
        guard newContent != content else { return }
        content = newContent
        modifiedAt = Date()
        isDirty = true
    }

    mutating func save() {
        isDirty = false
    }
}
// Tests/Models/DocumentTests.swift
import XCTest
@testable import MyMacApp

final class DocumentTests: XCTestCase {

    func testUpdateContent_MarksDocumentDirty() {
        var doc = Document(title: "Note", content: "Hello", modifiedAt: .distantPast)

        doc.updateContent("Hello, world!")

        XCTAssertTrue(doc.isDirty)
        XCTAssertEqual(doc.content, "Hello, world!")
    }

    func testUpdateContent_DoesNotMarkDirty_WhenContentUnchanged() {
        var doc = Document(title: "Note", content: "Hello", modifiedAt: .distantPast)

        doc.updateContent("Hello") // same content

        XCTAssertFalse(doc.isDirty)
    }

    func testUpdateContent_UpdatesModifiedDate() {
        let before = Date()
        var doc = Document(title: "Note", content: "Hello", modifiedAt: .distantPast)

        doc.updateContent("Changed")

        XCTAssertGreaterThanOrEqual(doc.modifiedAt, before)
    }

    func testSave_ClearsDirtyFlag() {
        var doc = Document(title: "Note", content: "Hello", modifiedAt: .now)
        doc.isDirty = true

        doc.save()

        XCTAssertFalse(doc.isDirty)
    }
}

Mocking AppKit Dependencies

AppKit classes like NSUserDefaults, NSWorkspace, and file managers are hard to instantiate in tests. Use protocol-based dependency injection:

// Sources/Services/PreferencesService.swift
protocol UserDefaultsProtocol {
    func object(forKey: String) -> Any?
    func set(_ value: Any?, forKey: String)
    func bool(forKey: String) -> Bool
    func setBool(_ value: Bool, forKey: String)
}

extension UserDefaults: UserDefaultsProtocol {}

class PreferencesService {
    private let defaults: UserDefaultsProtocol

    init(defaults: UserDefaultsProtocol = UserDefaults.standard) {
        self.defaults = defaults
    }

    var showInspector: Bool {
        get { defaults.bool(forKey: "showInspector") }
        set { defaults.setBool(newValue, forKey: "showInspector") }
    }

    var recentFileCount: Int {
        get { defaults.object(forKey: "recentFileCount") as? Int ?? 10 }
        set { defaults.set(newValue, forKey: "recentFileCount") }
    }
}
// Tests/Mocks/MockUserDefaults.swift
class MockUserDefaults: UserDefaultsProtocol {
    var storage: [String: Any] = [:]

    func object(forKey key: String) -> Any? { storage[key] }
    func set(_ value: Any?, forKey key: String) { storage[key] = value }
    func bool(forKey key: String) -> Bool { storage[key] as? Bool ?? false }
    func setBool(_ value: Bool, forKey key: String) { storage[key] = value }
}
// Tests/Services/PreferencesServiceTests.swift
final class PreferencesServiceTests: XCTestCase {

    var mockDefaults: MockUserDefaults!
    var sut: PreferencesService!

    override func setUp() {
        super.setUp()
        mockDefaults = MockUserDefaults()
        sut = PreferencesService(defaults: mockDefaults)
    }

    func testShowInspector_DefaultsToFalse() {
        XCTAssertFalse(sut.showInspector)
    }

    func testShowInspector_CanBeSet() {
        sut.showInspector = true
        XCTAssertTrue(sut.showInspector)
    }

    func testRecentFileCount_DefaultsToTen() {
        XCTAssertEqual(sut.recentFileCount, 10)
    }

    func testRecentFileCount_PersistsValue() {
        sut.recentFileCount = 25
        XCTAssertEqual(sut.recentFileCount, 25)
    }
}

Testing View Controllers

AppKit view controllers require careful setup. Avoid testing UI rendering; focus on logic, state changes, and action handling:

// Sources/ViewControllers/EditorViewController.swift
class EditorViewController: NSViewController {
    @IBOutlet weak var textView: NSTextView!
    @IBOutlet weak var wordCountLabel: NSTextField!

    var viewModel: EditorViewModel!

    override func viewDidLoad() {
        super.viewDidLoad()
        setupBindings()
    }

    private func setupBindings() {
        viewModel.onWordCountChanged = { [weak self] count in
            self?.wordCountLabel.stringValue = "\(count) words"
        }
    }

    @IBAction func saveDocument(_ sender: Any) {
        viewModel.save()
    }

    @IBAction func textChanged(_ sender: NSTextView) {
        viewModel.updateContent(sender.string)
    }
}

Test the ViewModel that drives it:

// Tests/ViewModels/EditorViewModelTests.swift
final class EditorViewModelTests: XCTestCase {

    var sut: EditorViewModel!
    var mockDocumentService: MockDocumentService!

    override func setUp() {
        super.setUp()
        mockDocumentService = MockDocumentService()
        sut = EditorViewModel(documentService: mockDocumentService)
    }

    func testUpdateContent_UpdatesWordCount() {
        var receivedCount: Int?
        sut.onWordCountChanged = { receivedCount = $0 }

        sut.updateContent("Hello world foo")

        XCTAssertEqual(receivedCount, 3)
    }

    func testUpdateContent_EmptyString_ReturnsZeroWordCount() {
        var receivedCount: Int?
        sut.onWordCountChanged = { receivedCount = $0 }

        sut.updateContent("")

        XCTAssertEqual(receivedCount, 0)
    }

    func testSave_CallsDocumentService() {
        sut.updateContent("Some content")
        sut.save()

        XCTAssertTrue(mockDocumentService.saveCalled)
    }

    func testSave_WithEmptyContent_DoesNotCallService() {
        // Don't update content — keep it empty
        sut.save()

        XCTAssertFalse(mockDocumentService.saveCalled)
    }
}

Testing Async Operations

XCTest supports async/await natively in Swift 5.5+:

// Tests/Services/FileServiceTests.swift
final class FileServiceTests: XCTestCase {

    var sut: FileService!
    var tempDirectory: URL!

    override func setUp() async throws {
        try await super.setUp()
        tempDirectory = FileManager.default.temporaryDirectory
            .appendingPathComponent(UUID().uuidString)
        try FileManager.default.createDirectory(at: tempDirectory,
                                                withIntermediateDirectories: true)
        sut = FileService()
    }

    override func tearDown() async throws {
        try? FileManager.default.removeItem(at: tempDirectory)
        try await super.tearDown()
    }

    func testReadFile_ReturnsContent() async throws {
        let fileURL = tempDirectory.appendingPathComponent("test.txt")
        try "Hello, world!".write(to: fileURL, atomically: true, encoding: .utf8)

        let content = try await sut.readFile(at: fileURL)

        XCTAssertEqual(content, "Hello, world!")
    }

    func testReadFile_ThrowsForMissingFile() async {
        let missingURL = tempDirectory.appendingPathComponent("missing.txt")

        do {
            _ = try await sut.readFile(at: missingURL)
            XCTFail("Expected error to be thrown")
        } catch {
            XCTAssertTrue(error is FileServiceError)
        }
    }

    func testWriteFile_CreatesFile() async throws {
        let fileURL = tempDirectory.appendingPathComponent("output.txt")

        try await sut.writeFile(content: "New content", to: fileURL)

        let exists = FileManager.default.fileExists(atPath: fileURL.path)
        XCTAssertTrue(exists)
    }
}

Testing Notifications

Use XCTNSNotificationExpectation for Notification Center:

func testDocumentSaved_PostsNotification() {
    let expectation = XCTNSNotificationExpectation(
        name: .documentDidSave,
        object: nil
    )

    sut.save()

    wait(for: [expectation], timeout: 2.0)
}

func testMultipleNotifications_AllReceived() {
    var notifications: [Notification] = []
    let observer = NotificationCenter.default.addObserver(
        forName: .documentDidChange,
        object: nil,
        queue: .main
    ) { notification in
        notifications.append(notification)
    }
    defer { NotificationCenter.default.removeObserver(observer) }

    sut.updateContent("First change")
    sut.updateContent("Second change")
    sut.updateContent("Third change")

    XCTAssertEqual(notifications.count, 3)
}

UI Testing with XCUITest

XCUITest launches the full app and automates UI interactions:

// UITests/LoginFlowTests.swift
import XCTest

final class LoginFlowTests: XCTestCase {

    var app: XCUIApplication!

    override func setUp() {
        super.setUp()
        continueAfterFailure = false
        app = XCUIApplication()
        app.launchArguments = ["--uitesting"] // disable animations, use test data
        app.launch()
    }

    override func tearDown() {
        app.terminate()
        super.tearDown()
    }

    func testSuccessfulLogin() {
        let usernameField = app.textFields["usernameField"]
        let passwordField = app.secureTextFields["passwordField"]
        let loginButton = app.buttons["loginButton"]

        XCTAssertTrue(usernameField.exists)
        usernameField.click()
        usernameField.typeText("admin")

        passwordField.click()
        passwordField.typeText("password")

        loginButton.click()

        let dashboard = app.windows.firstMatch.staticTexts["Dashboard"]
        XCTAssertTrue(dashboard.waitForExistence(timeout: 5))
    }

    func testInvalidLoginShowsAlert() {
        let usernameField = app.textFields["usernameField"]
        let passwordField = app.secureTextFields["passwordField"]
        let loginButton = app.buttons["loginButton"]

        usernameField.click()
        usernameField.typeText("invalid")
        passwordField.click()
        passwordField.typeText("wrong")
        loginButton.click()

        let alert = app.sheets.firstMatch
        XCTAssertTrue(alert.waitForExistence(timeout: 3))
        XCTAssertTrue(alert.staticTexts["Invalid credentials"].exists)
    }
}

Set accessibility identifiers in your view controllers:

// In viewDidLoad or viewWillAppear:
usernameField.setAccessibilityIdentifier("usernameField")
passwordField.setAccessibilityIdentifier("passwordField")
loginButton.setAccessibilityIdentifier("loginButton")

CI with Xcode Cloud or GitHub Actions

# .github/workflows/test.yml
name: Test macOS App

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 unit tests
        run: |
          xcodebuild test \
            -scheme MyMacApp \
            -destination 'platform=macOS' \
            -resultBundlePath TestResults.xcresult \
            | xcpretty

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: TestResults.xcresult

Common Pitfalls

Using UserDefaults.standard directly. Always inject UserDefaults through a protocol. Tests that write to UserDefaults.standard pollute shared state and can interfere with each other.

Testing UI in unit tests. Don't instantiate NSView or NSWindow in unit tests. Test logic in ViewModels. Save AppKit types for UI tests.

Forgetting continueAfterFailure = false. UI tests should stop on the first failure. Without this, tests continue executing after assertions fail, producing confusing subsequent errors.

Missing accessibility identifiers. XCUITest locates elements by accessibility identifier. Add them to every interactive control during development—don't leave it for later.

Not cleaning up temp files. File-based tests should always clean up in tearDown. Use defer blocks to ensure cleanup even when tests throw.

Summary

XCTest is a capable framework for macOS AppKit testing. The pattern is consistent: keep business logic in models and ViewModels that have no AppKit dependencies, mock AppKit interfaces with protocols, and reserve XCUITest for the UI automation layer. With this structure, the vast majority of your test suite runs fast and reliably, with UI tests reserved for critical path verification.

Read more