All skills
rshankras avatar

/test-generator

@32566aa

Generate test templates for unit tests, integration tests, and UI tests using Swift Testing and XCTest. Use when adding tests to iOS/macOS apps.

Use this Skill: https://skilld.dev/gh/rshankras/claude-code-apple-skills/test-generator

This session only. Nothing lands on disk.

templates.md

≈5.8k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Test Generator Code Templates

Production-ready Swift templates for unit tests, integration tests, and UI tests. Templates cover Swift Testing (iOS 16+) and XCTest frameworks.

Swift Testing Suite Template

Full test suite using Swift Testing with @Suite, @Test, #expect, parameterized tests, and tags.

import Testing
@testable import YourApp

// MARK: - Custom Tags

extension Tag {
    @Tag static var viewModel: Self
    @Tag static var repository: Self
    @Tag static var networking: Self
    @Tag static var validation: Self
}

// MARK: - Item ViewModel Tests

@Suite("ItemViewModel", .tags(.viewModel))
struct ItemViewModelTests {

    // MARK: - Properties

    let mockRepository: MockItemRepository
    let sut: ItemViewModel

    // MARK: - Initialization (runs before each test)

    init() {
        mockRepository = MockItemRepository()
        sut = ItemViewModel(repository: mockRepository)
    }

    // MARK: - Loading

    @Test("loads items successfully")
    func loadsItemsSuccessfully() async throws {
        mockRepository.stubbedItems = [
            Item(id: "1", title: "First"),
            Item(id: "2", title: "Second"),
            Item(id: "3", title: "Third")
        ]

        await sut.loadItems()

        #expect(sut.items.count == 3)
        #expect(sut.isLoading == false)
        #expect(sut.errorMessage == nil)
    }

    @Test("shows empty state when no items exist")
    func showsEmptyState() async {
        mockRepository.stubbedItems = []

        await sut.loadItems()

        #expect(sut.items.isEmpty)
        #expect(sut.showEmptyState == true)
    }

    @Test("sets loading state during fetch")
    func setsLoadingState() async {
        mockRepository.delay = .milliseconds(100)

        let loadTask = Task { await sut.loadItems() }

        // Allow the task to start
        try? await Task.sleep(for: .milliseconds(10))
        #expect(sut.isLoading == true)

        await loadTask.value
        #expect(sut.isLoading == false)
    }

    // MARK: - Error Handling

    @Test("handles repository failure")
    func handlesRepositoryFailure() async {
        mockRepository.shouldFail = true
        mockRepository.stubbedError = TestError.networkUnavailable

        await sut.loadItems()

        #expect(sut.state == .error)
        #expect(sut.errorMessage != nil)
        #expect(sut.items.isEmpty)
    }

    // MARK: - Parameterized Tests

    @Test("validates email format", arguments: [
        ("user@example.com", true),
        ("user@domain.co.uk", true),
        ("invalid-email", false),
        ("no@tld", false),
        ("@missing-local.com", false),
        ("user@.com", false)
    ])
    func validatesEmailFormat(email: String, isValid: Bool) {
        #expect(EmailValidator.isValid(email) == isValid)
    }

    @Test("filters items by category", arguments: [
        ItemCategory.active,
        ItemCategory.archived,
        ItemCategory.draft
    ])
    func filtersItemsByCategory(category: ItemCategory) async {
        mockRepository.stubbedItems = Item.sampleItems

        await sut.filterItems(by: category)

        let allMatchCategory = sut.items.allSatisfy { $0.category == category }
        #expect(allMatchCategory, "All displayed items should match the selected category")
    }

    // MARK: - Deletion

    @Test("deletes item and refreshes list")
    func deletesItem() async throws {
        let item = Item(id: "1", title: "To Delete")
        mockRepository.stubbedItems = [item]
        await sut.loadItems()

        await sut.deleteItem(item)

        #expect(mockRepository.deleteCallCount == 1)
        #expect(mockRepository.lastDeletedItemID == "1")
    }

    // MARK: - Confirmation (require for optionals)

    @Test("unwraps selected item safely")
    func unwrapsSelectedItem() async throws {
        mockRepository.stubbedItems = [Item(id: "1", title: "First")]
        await sut.loadItems()

        sut.selectItem(at: 0)

        let selected = try #require(sut.selectedItem, "Expected an item to be selected")
        #expect(selected.id == "1")
    }
}

XCTest Class Template

Traditional XCTest class with setUp/tearDown, async tests, and expectations.

import XCTest
@testable import YourApp

final class ItemViewModelXCTests: XCTestCase {

    // MARK: - Properties

    private var sut: ItemViewModel!
    private var mockRepository: MockItemRepository!

    // MARK: - Lifecycle

    override func setUp() {
        super.setUp()
        mockRepository = MockItemRepository()
        sut = ItemViewModel(repository: mockRepository)
    }

    override func tearDown() {
        sut = nil
        mockRepository = nil
        super.tearDown()
    }

    // MARK: - Loading Tests

    func testLoadsItemsSuccessfully() async throws {
        mockRepository.stubbedItems = [
            Item(id: "1", title: "First"),
            Item(id: "2", title: "Second")
        ]

        await sut.loadItems()

        XCTAssertEqual(sut.items.count, 2)
        XCTAssertFalse(sut.isLoading)
        XCTAssertNil(sut.errorMessage)
    }

    func testShowsEmptyState() async {
        mockRepository.stubbedItems = []

        await sut.loadItems()

        XCTAssertTrue(sut.items.isEmpty)
        XCTAssertTrue(sut.showEmptyState)
    }

    // MARK: - Error Handling Tests

    func testHandlesRepositoryFailure() async {
        mockRepository.shouldFail = true

        await sut.loadItems()

        XCTAssertEqual(sut.state, .error)
        XCTAssertNotNil(sut.errorMessage)
        XCTAssertTrue(sut.items.isEmpty)
    }

    // MARK: - Deletion Tests

    func testDeletesItemAndRefreshes() async throws {
        let item = Item(id: "1", title: "To Delete")
        mockRepository.stubbedItems = [item]
        await sut.loadItems()

        await sut.deleteItem(item)

        XCTAssertEqual(mockRepository.deleteCallCount, 1)
        XCTAssertEqual(mockRepository.lastDeletedItemID, "1")
    }

    // MARK: - Unwrapping Tests

    func testSelectedItemUnwrap() async throws {
        mockRepository.stubbedItems = [Item(id: "1", title: "First")]
        await sut.loadItems()

        sut.selectItem(at: 0)

        let selected = try XCTUnwrap(sut.selectedItem, "Expected an item to be selected")
        XCTAssertEqual(selected.id, "1")
    }

    // MARK: - Async Expectation Tests

    func testNotificationPostedOnSave() async {
        let expectation = expectation(forNotification: .itemSaved, object: nil)

        await sut.saveItem(Item(id: "1", title: "New"))

        await fulfillment(of: [expectation], timeout: 2.0)
    }
}

Mock Object Template

Protocol-based mock with call counting, argument capture, stubbed returns, and failure injection.

import Foundation
@testable import YourApp

// MARK: - Protocol

/// Protocol defining repository operations.
/// Both the real implementation and mock conform to this.
protocol ItemRepositoryProtocol: Sendable {
    func fetchItems() async throws -> [Item]
    func fetchItem(id: String) async throws -> Item?
    func saveItem(_ item: Item) async throws
    func deleteItem(id: String) async throws
}

// MARK: - Mock Implementation

/// A mock repository for testing.
///
/// Features:
/// - Stubbed return values for all methods
/// - Call counting for verification
/// - Argument capture for assertion
/// - Configurable failure injection
/// - Optional artificial delay for testing loading states
final class MockItemRepository: ItemRepositoryProtocol, @unchecked Sendable {

    // MARK: - Stubbed Values

    /// Items returned by `fetchItems()`.
    var stubbedItems: [Item] = []

    /// Item returned by `fetchItem(id:)`. Defaults to looking up `stubbedItems` by ID.
    var stubbedItemByID: [String: Item] = [:]

    /// Error thrown when `shouldFail` is `true`.
    var stubbedError: Error = TestError.mockFailure

    // MARK: - Configuration

    /// When `true`, all methods throw `stubbedError`.
    var shouldFail = false

    /// Optional delay before returning results (for testing loading states).
    var delay: Duration?

    // MARK: - Call Tracking: fetchItems

    private(set) var fetchItemsCallCount = 0

    func fetchItems() async throws -> [Item] {
        fetchItemsCallCount += 1
        if let delay { try? await Task.sleep(for: delay) }
        if shouldFail { throw stubbedError }
        return stubbedItems
    }

    // MARK: - Call Tracking: fetchItem

    private(set) var fetchItemCallCount = 0
    private(set) var lastFetchedItemID: String?

    func fetchItem(id: String) async throws -> Item? {
        fetchItemCallCount += 1
        lastFetchedItemID = id
        if shouldFail { throw stubbedError }
        return stubbedItemByID[id] ?? stubbedItems.first { $0.id == id }
    }

    // MARK: - Call Tracking: saveItem

    private(set) var saveCallCount = 0
    private(set) var savedItems: [Item] = []

    func saveItem(_ item: Item) async throws {
        saveCallCount += 1
        savedItems.append(item)
        if shouldFail { throw stubbedError }
    }

    // MARK: - Call Tracking: deleteItem

    private(set) var deleteCallCount = 0
    private(set) var lastDeletedItemID: String?

    func deleteItem(id: String) async throws {
        deleteCallCount += 1
        lastDeletedItemID = id
        if shouldFail { throw stubbedError }
        stubbedItems.removeAll { $0.id == id }
    }

    // MARK: - Reset

    /// Reset all call counts and captured arguments. Useful between tests if sharing a mock.
    func reset() {
        fetchItemsCallCount = 0
        fetchItemCallCount = 0
        lastFetchedItemID = nil
        saveCallCount = 0
        savedItems = []
        deleteCallCount = 0
        lastDeletedItemID = nil
        shouldFail = false
    }
}

// MARK: - Test Errors

/// Common errors used in test mocks.
enum TestError: LocalizedError, Equatable {
    case mockFailure
    case networkUnavailable
    case unauthorized
    case notFound
    case timeout

    var errorDescription: String? {
        switch self {
        case .mockFailure: return "Mock failure for testing"
        case .networkUnavailable: return "Network is unavailable"
        case .unauthorized: return "Unauthorized access"
        case .notFound: return "Resource not found"
        case .timeout: return "Request timed out"
        }
    }
}

UI Test Screen Object Template

Screen Object pattern for XCUITest with page object encapsulation, assertions, and flow navigation.

import XCTest

// MARK: - Base Screen

/// Base class for all screen objects, providing common utilities.
class BaseScreen {
    let app: XCUIApplication

    init(app: XCUIApplication) {
        self.app = app
    }

    /// Wait for an element to exist within a timeout.
    @discardableResult
    func waitForElement(
        _ element: XCUIElement,
        timeout: TimeInterval = 5
    ) -> Bool {
        element.waitForExistence(timeout: timeout)
    }
}

// MARK: - Home Screen

final class HomeScreen: BaseScreen {

    // MARK: - Elements

    var navigationTitle: XCUIElement {
        app.navigationBars["Items"]
    }

    var itemList: XCUIElement {
        app.collectionViews["itemList"]
    }

    var addButton: XCUIElement {
        app.buttons["addItem"]
    }

    var emptyStateLabel: XCUIElement {
        app.staticTexts["No items yet"]
    }

    var searchField: XCUIElement {
        app.searchFields.firstMatch
    }

    func itemCell(at index: Int) -> XCUIElement {
        itemList.cells.element(boundBy: index)
    }

    func itemCell(titled title: String) -> XCUIElement {
        itemList.cells.staticTexts[title]
    }

    // MARK: - Actions

    @discardableResult
    func tapItem(at index: Int) -> DetailScreen {
        itemCell(at: index).tap()
        return DetailScreen(app: app)
    }

    @discardableResult
    func tapItem(titled title: String) -> DetailScreen {
        itemCell(titled: title).tap()
        return DetailScreen(app: app)
    }

    @discardableResult
    func tapAddButton() -> EditScreen {
        addButton.tap()
        return EditScreen(app: app)
    }

    func search(for query: String) {
        searchField.tap()
        searchField.typeText(query)
    }

    func deleteItem(at index: Int) {
        let cell = itemCell(at: index)
        cell.swipeLeft()
        app.buttons["Delete"].tap()
    }

    // MARK: - Assertions

    func assertItemCount(_ count: Int, file: StaticString = #file, line: UInt = #line) {
        XCTAssertEqual(itemList.cells.count, count, "Expected \(count) items", file: file, line: line)
    }

    func assertEmptyStateVisible(file: StaticString = #file, line: UInt = #line) {
        XCTAssertTrue(emptyStateLabel.exists, "Empty state should be visible", file: file, line: line)
    }

    func assertItemExists(titled title: String, file: StaticString = #file, line: UInt = #line) {
        let cell = itemCell(titled: title)
        XCTAssertTrue(cell.waitForExistence(timeout: 3), "Item '\(title)' should exist", file: file, line: line)
    }
}

// MARK: - Detail Screen

final class DetailScreen: BaseScreen {

    var titleLabel: XCUIElement {
        app.staticTexts["itemTitle"]
    }

    var editButton: XCUIElement {
        app.buttons["Edit"]
    }

    var deleteButton: XCUIElement {
        app.buttons["Delete"]
    }

    var backButton: XCUIElement {
        app.navigationBars.buttons.firstMatch
    }

    @discardableResult
    func tapEdit() -> EditScreen {
        editButton.tap()
        return EditScreen(app: app)
    }

    @discardableResult
    func tapBack() -> HomeScreen {
        backButton.tap()
        return HomeScreen(app: app)
    }

    func assertTitle(_ title: String, file: StaticString = #file, line: UInt = #line) {
        XCTAssertEqual(titleLabel.label, title, file: file, line: line)
    }
}

// MARK: - Edit Screen

final class EditScreen: BaseScreen {

    var titleField: XCUIElement {
        app.textFields["itemTitle"]
    }

    var saveButton: XCUIElement {
        app.buttons["Save"]
    }

    var cancelButton: XCUIElement {
        app.buttons["Cancel"]
    }

    func enterTitle(_ title: String) {
        titleField.tap()
        titleField.clearAndTypeText(title)
    }

    @discardableResult
    func tapSave() -> HomeScreen {
        saveButton.tap()
        return HomeScreen(app: app)
    }

    @discardableResult
    func tapCancel() -> HomeScreen {
        cancelButton.tap()
        return HomeScreen(app: app)
    }
}

// MARK: - UI Test Case

final class ItemFlowUITests: XCTestCase {

    private var app: XCUIApplication!

    override func setUp() {
        super.setUp()
        continueAfterFailure = false

        app = XCUIApplication()
        app.launchArguments = ["--uitesting"]
        app.launchEnvironment = ["DISABLE_ANIMATIONS": "1"]
        app.launch()
    }

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

    func testCreateNewItem() {
        let home = HomeScreen(app: app)

        let edit = home.tapAddButton()
        edit.enterTitle("New Item")
        let updatedHome = edit.tapSave()

        updatedHome.assertItemExists(titled: "New Item")
    }

    func testDeleteItem() {
        let home = HomeScreen(app: app)
        home.assertItemCount(3)

        home.deleteItem(at: 0)

        home.assertItemCount(2)
    }

    func testNavigateToDetailAndBack() {
        let home = HomeScreen(app: app)

        let detail = home.tapItem(at: 0)
        detail.assertTitle("First Item")

        let backHome = detail.tapBack()
        backHome.assertItemCount(3)
    }
}

// MARK: - XCUIElement Helpers

extension XCUIElement {
    /// Clear existing text and type new text.
    func clearAndTypeText(_ text: String) {
        guard let currentValue = value as? String, !currentValue.isEmpty else {
            typeText(text)
            return
        }
        let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count)
        typeText(deleteString)
        typeText(text)
    }
}

Test Helpers Template

Factory methods, test fixtures, and convenience extensions for building test data.

import Foundation
@testable import YourApp

// MARK: - Test Fixtures

/// Factory methods for creating test data.
/// Use these instead of constructing models directly in each test.
enum TestFixtures {

    // MARK: - Items

    /// A single default item for simple tests.
    static let defaultItem = Item(
        id: "test-1",
        title: "Test Item",
        description: "A test item for unit tests",
        category: .active,
        createdAt: Date(timeIntervalSince1970: 1_700_000_000)
    )

    /// A collection of sample items covering various states.
    static let sampleItems: [Item] = [
        Item(id: "1", title: "Active Item", category: .active, createdAt: .now),
        Item(id: "2", title: "Archived Item", category: .archived, createdAt: .now),
        Item(id: "3", title: "Draft Item", category: .draft, createdAt: .now)
    ]

    /// An empty array for testing empty states.
    static let noItems: [Item] = []

    // MARK: - Users

    static let defaultUser = User(
        id: "user-1",
        name: "Test User",
        email: "test@example.com"
    )

    // MARK: - Builders

    /// Create an item with customized properties.
    static func item(
        id: String = UUID().uuidString,
        title: String = "Test Item",
        description: String = "Description",
        category: ItemCategory = .active,
        createdAt: Date = .now
    ) -> Item {
        Item(
            id: id,
            title: title,
            description: description,
            category: category,
            createdAt: createdAt
        )
    }

    /// Create a batch of items for list tests.
    static func items(count: Int, category: ItemCategory = .active) -> [Item] {
        (0..<count).map { index in
            item(
                id: "item-\(index)",
                title: "Item \(index)",
                category: category
            )
        }
    }
}

// MARK: - Model Extensions for Testing

extension Item {
    /// A collection of sample items covering all categories.
    static let sampleItems = TestFixtures.sampleItems
}

// MARK: - Async Test Helpers

/// Utilities for testing asynchronous code.
enum AsyncTestHelpers {

    /// Run an async block with a timeout. Throws if the block does not complete in time.
    static func withTimeout<T>(
        seconds: TimeInterval = 5,
        operation: @escaping @Sendable () async throws -> T
    ) async throws -> T {
        try await withThrowingTaskGroup(of: T.self) { group in
            group.addTask {
                try await operation()
            }
            group.addTask {
                try await Task.sleep(for: .seconds(seconds))
                throw TestError.timeout
            }
            let result = try await group.next()!
            group.cancelAll()
            return result
        }
    }
}

// MARK: - Date Helpers

extension Date {
    /// Create a date relative to now for test assertions.
    static func hoursFromNow(_ hours: Double) -> Date {
        Date().addingTimeInterval(hours * 3600)
    }

    /// Create a date in the past for test data.
    static func daysAgo(_ days: Int) -> Date {
        Calendar.current.date(byAdding: .day, value: -days, to: Date())!
    }
}

// MARK: - JSON Test Helpers

/// Helpers for testing Codable conformance.
enum CodableTestHelpers {

    /// Encode and decode a value, verifying round-trip fidelity.
    static func verifyRoundTrip<T: Codable & Equatable>(
        _ value: T,
        file: StaticString = #file,
        line: UInt = #line
    ) throws -> T {
        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601

        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601

        let data = try encoder.encode(value)
        let decoded = try decoder.decode(T.self, from: data)
        return decoded
    }

    /// Decode a value from a JSON string.
    static func decode<T: Decodable>(_ type: T.Type, from json: String) throws -> T {
        let data = Data(json.utf8)
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        return try decoder.decode(type, from: data)
    }
}

Patterns: Good and Bad

Test Isolation

// ✅ Good: Each test creates its own dependencies (Swift Testing)
@Suite("Tests")
struct MyTests {
    let sut: ItemViewModel
    let mock: MockItemRepository

    init() {
        mock = MockItemRepository()
        sut = ItemViewModel(repository: mock)
    }
}

// ❌ Bad: Shared mutable state without reset
@Suite("Tests")
struct MyTests {
    static let mock = MockItemRepository()  // Shared across all tests!
    static let sut = ItemViewModel(repository: mock)
}

Assertions

// ✅ Good: Specific assertions with context
#expect(items.count == 3, "Expected 3 items after loading")
#expect(viewModel.state == .loaded)
let first = try #require(items.first)
#expect(first.title == "Expected Title")

// ❌ Bad: Vague assertions
#expect(!items.isEmpty)           // How many should there be?
#expect(viewModel.state != .idle) // What state SHOULD it be?

Error Testing

// ✅ Good: Test specific error type (Swift Testing)
@Test("throws notFound for missing item")
func throwsNotFound() async {
    let repo = MockItemRepository()
    repo.shouldFail = true
    repo.stubbedError = TestError.notFound

    await #expect(throws: TestError.notFound) {
        try await repo.fetchItem(id: "missing")
    }
}

// ❌ Bad: Only test that "some" error is thrown
@Test("throws error")
func throwsError() async {
    await #expect(throws: (any Error).self) {
        try await repo.fetchItem(id: "missing")
    }
}

Async Tests

// ✅ Good: Use async/await directly (Swift Testing)
@Test("fetches items asynchronously")
func fetchesItems() async throws {
    let items = try await service.fetchItems()
    #expect(items.count > 0)
}

// ❌ Bad: Using XCTest expectations for simple async (when you could use async/await)
func testFetchesItems() {
    let exp = expectation(description: "fetch")
    Task {
        let items = try await service.fetchItems()
        XCTAssertGreaterThan(items.count, 0)
        exp.fulfill()
    }
    wait(for: [exp], timeout: 5)
}

Optional Handling in Tests

// ✅ Good: Use #require to unwrap (Swift Testing)
@Test("selected item has correct title")
func selectedItemTitle() async throws {
    await sut.loadItems()
    sut.selectItem(at: 0)

    let selected = try #require(sut.selectedItem)
    #expect(selected.title == "First")
}

// ✅ Good: Use XCTUnwrap (XCTest)
func testSelectedItemTitle() async throws {
    await sut.loadItems()
    sut.selectItem(at: 0)

    let selected = try XCTUnwrap(sut.selectedItem)
    XCTAssertEqual(selected.title, "First")
}

// ❌ Bad: Force unwrap in tests
func testSelectedItemTitle() async {
    await sut.loadItems()
    sut.selectItem(at: 0)

    XCTAssertEqual(sut.selectedItem!.title, "First")  // Crash if nil
}

Source: SKILL.md on GitHub

No alerts2mo3 checks · Risk SAFE
  • Gen Agent Trust Hub2mo

    The test-generator skill is a secure development utility for generating iOS/macOS test suites. It analyzes local project structure to provide relevant code templates for Swift Testing and XCTest frameworks without any malicious behaviors.

  • Socket2mo

    No alerts

  • Snyk2mo

    Risk: LOW · No issues

Signed by skilld at 32566aa. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Steadyupdated 3 months ago
What it can do
Reads files Edits files Runs commands
last_verified
2026-07-16
review_by
2027-06-22
os_version
iOS 27 / macOS 27
All 7 allowed tools
ReadWriteEditGlobGrepBashAskUserQuestion

README badge

README badge for rshankras/claude-code-apple-skills/test-generator