
Test Generator
- 3 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates unit, integration, and UI test templates for iOS/macOS apps using Swift Testing and XCTest.
About
Generates test templates for unit, integration, and UI tests using Swift Testing and XCTest. A developer uses it when adding tests to iOS/macOS apps.
- Unit, integration, and UI test templates
- Supports Swift Testing and XCTest
Test Generator by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #1,649 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill test-generatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates unit, integration, and UI test templates for iOS/macOS apps using Swift Testing and XCTest.
Files
Test Generator
Generate test templates for unit tests, integration tests, and UI tests in iOS/macOS apps.
When This Skill Activates
Use this skill when the user:
- Asks to "add tests" or "write tests" for their app
- Asks about unit testing, UI testing, or XCTest
- Wants to test ViewModels, services, or repositories
- Mentions TDD or test-driven development
- Asks about Swift Testing framework (
@Test,#expect,@Suite) - Wants mock objects or test helpers
- Asks about snapshot testing or preview tests
Decision Tree
What tests do you need?
|
+-- Unit tests for business logic
| +-- Swift Testing (@Test, #expect) -- recommended for iOS 16+
| +-- XCTest -- for iOS 13-15 support or existing XCTest projects
|
+-- Integration tests (component interactions)
| +-- Protocol-based mocks with dependency injection
|
+-- UI tests
| +-- XCUITest with Screen Object pattern
|
+-- Snapshot/preview tests
+-- PreviewSnapshots or swift-snapshot-testingPre-Generation Checks
1. Project Context Detection
- [ ] Identify existing test targets and test runner
- [ ] Detect testing framework already in use (Swift Testing vs XCTest)
- [ ] Verify deployment target (Swift Testing requires iOS 16+ / macOS 13+)
- [ ] Identify project architecture pattern (MVVM, TCA, Repository, etc.)
- [ ] Locate source file directories
2. Conflict Detection
Search for existing test infrastructure:
Glob: **/*Tests.swift, **/*Tests/**/*.swift, **/*Spec.swift
Grep: "import XCTest" or "import Testing" or "@Suite" or "@Test"
Grep: "MockItemRepository" or "protocol.*Repository" or "class Mock"If existing tests are found:
- Ask user whether to follow the existing framework (XCTest vs Swift Testing) or migrate
- Check for existing mock objects to reuse or extend
- Identify existing test helpers and factories
If a test target already exists:
- Add new tests to the existing target -- do NOT create a new target
- Follow the existing directory structure and naming conventions
3. Architecture Detection
Grep: "ViewModel" or "Reducer" or "UseCase" or "Repository" or "Service"
Glob: **/*ViewModel.swift, **/*Reducer.swift, **/*Repository.swiftThis determines which test templates to generate (ViewModel tests, Reducer tests, etc.).
Configuration Questions
1. Testing Framework
- Swift Testing (Recommended, iOS 16+) - Modern, expressive syntax
- XCTest - Traditional framework, all iOS versions
- Both - Mix of frameworks
2. Test Types to Generate
- Unit Tests - Test individual components in isolation
- Integration Tests - Test component interactions
- UI Tests - Test user interface and flows
- All - Complete test coverage
3. Architecture Pattern
- MVVM - ViewModel tests
- TCA - Reducer tests
- Repository - Data layer tests
- Custom - Based on project structure
Generated Files
Unit Tests
Tests/UnitTests/
├── ViewModelTests/
│ └── ItemViewModelTests.swift
├── ServiceTests/
│ └── APIClientTests.swift
└── RepositoryTests/
└── ItemRepositoryTests.swiftUI Tests
Tests/UITests/
├── Screens/
│ └── HomeScreenTests.swift
├── Flows/
│ └── OnboardingFlowTests.swift
└── Helpers/
└── TestHelpers.swiftSwift Testing (Modern)
Basic Test Structure
import Testing
@testable import YourApp
@Suite("Item ViewModel Tests")
struct ItemViewModelTests {
@Test("loads items successfully")
func loadsItems() async throws {
let mockRepository = MockItemRepository()
let viewModel = ItemViewModel(repository: mockRepository)
await viewModel.loadItems()
#expect(viewModel.items.count == 3)
#expect(viewModel.isLoading == false)
}
@Test("handles empty state")
func handlesEmptyState() async {
let mockRepository = MockItemRepository(items: [])
let viewModel = ItemViewModel(repository: mockRepository)
await viewModel.loadItems()
#expect(viewModel.items.isEmpty)
#expect(viewModel.showEmptyState)
}
}Parameterized Tests
@Test("validates email format", arguments: [
("valid@email.com", true),
("invalid", false),
("no@tld", false),
("test@domain.co.uk", true)
])
func validatesEmail(email: String, isValid: Bool) {
#expect(EmailValidator.isValid(email) == isValid)
}XCTest (Traditional)
Basic Test Structure
import XCTest
@testable import YourApp
final class ItemViewModelTests: XCTestCase {
var sut: ItemViewModel!
var mockRepository: MockItemRepository!
override func setUp() {
super.setUp()
mockRepository = MockItemRepository()
sut = ItemViewModel(repository: mockRepository)
}
override func tearDown() {
sut = nil
mockRepository = nil
super.tearDown()
}
func testLoadsItems() async throws {
await sut.loadItems()
XCTAssertEqual(sut.items.count, 3)
XCTAssertFalse(sut.isLoading)
}
}Test Patterns
Testing ViewModels
@Suite("ViewModel Tests")
struct ViewModelTests {
@Test("state transitions correctly")
func stateTransitions() async {
let vm = ItemViewModel(repository: MockItemRepository())
#expect(vm.state == .idle)
await vm.loadItems()
#expect(vm.state == .loaded)
}
@Test("error handling")
func errorHandling() async {
let failingRepo = MockItemRepository(shouldFail: true)
let vm = ItemViewModel(repository: failingRepo)
await vm.loadItems()
#expect(vm.state == .error)
#expect(vm.errorMessage != nil)
}
}Testing Async Code
@Test("fetches data asynchronously")
func fetchesData() async throws {
let service = APIService()
let result = try await service.fetchItems()
#expect(result.count > 0)
}
@Test("times out appropriately")
func timesOut() async {
await #expect(throws: TimeoutError.self) {
try await withTimeout(seconds: 1) {
try await Task.sleep(for: .seconds(5))
}
}
}Mock Creation
Protocol-Based Mocks
protocol ItemRepository {
func fetchItems() async throws -> [Item]
func saveItem(_ item: Item) async throws
}
final class MockItemRepository: ItemRepository {
var items: [Item] = []
var shouldFail = false
var saveCallCount = 0
func fetchItems() async throws -> [Item] {
if shouldFail {
throw TestError.mockFailure
}
return items
}
func saveItem(_ item: Item) async throws {
saveCallCount += 1
items.append(item)
}
}UI Testing
Screen Object Pattern
import XCTest
final class HomeScreen {
let app: XCUIApplication
init(app: XCUIApplication) {
self.app = app
}
var itemList: XCUIElement {
app.collectionViews["itemList"]
}
var addButton: XCUIElement {
app.buttons["addItem"]
}
func tapItem(at index: Int) {
itemList.cells.element(boundBy: index).tap()
}
func addNewItem(title: String) {
addButton.tap()
app.textFields["itemTitle"].tap()
app.textFields["itemTitle"].typeText(title)
app.buttons["save"].tap()
}
}Integration Steps
1. Add Test Target
In Xcode: 1. File > New > Target 2. Choose "Unit Testing Bundle" or "UI Testing Bundle" 3. Name appropriately (e.g., YourAppTests)
2. Configure Test Scheme
1. Edit Scheme > Test 2. Add test targets 3. Configure code coverage
3. Run Tests
# Command line
xcodebuild test -scheme YourApp -destination 'platform=iOS Simulator,name=iPhone 16'
# With coverage
xcodebuild test -scheme YourApp -enableCodeCoverage YESBest Practices
1. Test one thing per test - Clear, focused tests 2. Use descriptive names - Tests as documentation 3. Arrange-Act-Assert - Clear test structure 4. Mock external dependencies - Isolate units 5. Test edge cases - Empty, nil, error states 6. Keep tests fast - No real network/disk
Top 5 Mistakes
| # | Mistake | Why It's Wrong | Fix |
|---|---|---|---|
| 1 | Testing implementation details instead of behavior | Tests break on every refactor, providing no safety net | Test public API and observable outcomes, not internal state |
| 2 | Sharing mutable state between tests | Tests pass individually but fail when run together (order-dependent) | Create fresh instances in each test; use init() in @Suite structs or setUp() in XCTest |
| 3 | Using XCTAssertTrue(result != nil) instead of XCTUnwrap | Failure message is useless ("XCTAssertTrue failed") with no context | Use let value = try XCTUnwrap(result) or #expect(result != nil) with Swift Testing |
| 4 | Not testing error paths | Only happy-path coverage; errors crash in production | Always test with shouldFail = true mocks and verify error state |
| 5 | Real network calls in unit tests | Tests are slow, flaky, and fail offline | Use protocol-based mocks; reserve real network calls for integration test schemes |
Review Checklist
Before finishing test generation, verify:
- [ ] Naming: Test names describe the behavior, not the method (
loadsItemsSuccessfullynottestLoadItems) - [ ] Isolation: Each test creates its own dependencies -- no shared mutable state
- [ ] No real I/O: Unit tests use mocks for network, disk, and database
- [ ] Async handling: Async tests use
async throws(Swift Testing) orasync throwswith expectations (XCTest) - [ ] Error paths tested: At least one test per function verifies error/failure behavior
- [ ] Edge cases: Empty collections, nil optionals, boundary values are tested
- [ ] Assertions are specific: Using
#expect(items.count == 3)not#expect(!items.isEmpty) - [ ] Mock call verification: Mocks track call counts and received arguments where needed
- [ ] No force unwraps in tests: Use
try #require()(Swift Testing) orXCTUnwrap(XCTest) - [ ] Tests compile and run: Verify with
xcodebuild testor Xcode test navigator
References
- templates.md -- Production-ready code templates for test suites, mocks, and helpers
- Swift Testing
- XCTest Framework
- Testing Your Apps in Xcode
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
}Testing Patterns
Best practices for testing iOS/macOS apps with Swift Testing and XCTest.
Swift Testing Framework (iOS 16+)
Test Suite Structure
import Testing
@testable import YourApp
/// Group related tests with @Suite
@Suite("User Authentication Tests")
struct AuthenticationTests {
// Shared setup for all tests in suite
let authService: AuthService
init() {
authService = AuthService(client: MockAPIClient())
}
@Test("successful login with valid credentials")
func successfulLogin() async throws {
let result = try await authService.login(
email: "test@example.com",
password: "password123"
)
#expect(result.isAuthenticated)
#expect(result.user.email == "test@example.com")
}
@Test("fails with invalid credentials")
func failsWithInvalidCredentials() async {
await #expect(throws: AuthError.invalidCredentials) {
try await authService.login(
email: "test@example.com",
password: "wrong"
)
}
}
}Parameterized Tests
@Suite("Validation Tests")
struct ValidationTests {
@Test("email validation", arguments: [
("valid@email.com", true),
("user@domain.co.uk", true),
("name+tag@example.org", true),
("invalid", false),
("@nodomain.com", false),
("spaces in@email.com", false),
("", false)
])
func emailValidation(email: String, expectedValid: Bool) {
let result = Validator.isValidEmail(email)
#expect(result == expectedValid, "Email '\(email)' validation failed")
}
@Test("password strength", arguments: [
("weak", PasswordStrength.weak),
("Medium1", .medium),
("Strong1!", .strong),
("VeryStr0ng!Pass", .strong)
])
func passwordStrength(password: String, expected: PasswordStrength) {
#expect(Validator.passwordStrength(password) == expected)
}
}Test Traits
@Suite("Feature Tests")
struct FeatureTests {
@Test("premium feature access", .tags(.premium))
func premiumFeatureAccess() async throws {
// Test premium feature
}
@Test("slow integration test", .timeLimit(.minutes(5)))
func slowIntegrationTest() async throws {
// Long-running test
}
@Test("disabled pending fix", .disabled("Waiting for API fix"))
func disabledTest() {
// Won't run
}
@Test("iOS only", .enabled(if: ProcessInfo.processInfo.isMacCatalystApp == false))
func iOSOnlyTest() {
// Only runs on iOS
}
}
extension Tag {
@Tag static var premium: Self
@Tag static var integration: Self
@Tag static var slow: Self
}Expectations
@Suite("Expectations")
struct ExpectationTests {
@Test("basic expectations")
func basicExpectations() {
let value = 42
#expect(value == 42)
#expect(value > 0)
#expect(value != 0)
}
@Test("optional expectations")
func optionalExpectations() throws {
let optional: String? = "hello"
// Unwrap and continue
let unwrapped = try #require(optional)
#expect(unwrapped == "hello")
// Check nil
let nilValue: String? = nil
#expect(nilValue == nil)
}
@Test("collection expectations")
func collectionExpectations() {
let items = ["a", "b", "c"]
#expect(items.count == 3)
#expect(items.contains("b"))
#expect(!items.isEmpty)
}
@Test("throwing expectations")
func throwingExpectations() async {
await #expect(throws: NetworkError.self) {
try await failingOperation()
}
// Specific error
await #expect(throws: NetworkError.timeout) {
try await timeoutOperation()
}
}
}XCTest Framework
Test Case Structure
import XCTest
@testable import YourApp
final class ItemViewModelTests: XCTestCase {
// MARK: - Properties
private var sut: ItemViewModel! // System Under Test
private var mockRepository: MockItemRepository!
// MARK: - Setup & Teardown
override func setUp() {
super.setUp()
mockRepository = MockItemRepository()
sut = ItemViewModel(repository: mockRepository)
}
override func tearDown() {
sut = nil
mockRepository = nil
super.tearDown()
}
// MARK: - Tests
func test_loadItems_whenSuccessful_updatesItems() async throws {
// Arrange
mockRepository.items = [Item(id: "1", name: "Test")]
// Act
await sut.loadItems()
// Assert
XCTAssertEqual(sut.items.count, 1)
XCTAssertEqual(sut.items.first?.name, "Test")
}
func test_loadItems_whenFails_showsError() async {
// Arrange
mockRepository.shouldFail = true
// Act
await sut.loadItems()
// Assert
XCTAssertTrue(sut.showError)
XCTAssertNotNil(sut.errorMessage)
}
}Async Testing
final class AsyncTests: XCTestCase {
func test_asyncOperation_completesSuccessfully() async throws {
let service = DataService()
let result = try await service.fetchData()
XCTAssertFalse(result.isEmpty)
}
func test_asyncOperation_withTimeout() async throws {
let expectation = expectation(description: "Data loaded")
Task {
_ = try await service.fetchData()
expectation.fulfill()
}
await fulfillment(of: [expectation], timeout: 5.0)
}
}Mock Objects
Protocol-Based Mocking
// Protocol
protocol ItemRepository: Sendable {
func fetchItems() async throws -> [Item]
func saveItem(_ item: Item) async throws
func deleteItem(id: String) async throws
}
// Mock Implementation
final class MockItemRepository: ItemRepository, @unchecked Sendable {
// Configurable state
var items: [Item] = []
var shouldFail = false
var error: Error = MockError.general
// Call tracking
var fetchCallCount = 0
var saveCallCount = 0
var deleteCallCount = 0
var lastSavedItem: Item?
var lastDeletedId: String?
func fetchItems() async throws -> [Item] {
fetchCallCount += 1
if shouldFail { throw error }
return items
}
func saveItem(_ item: Item) async throws {
saveCallCount += 1
lastSavedItem = item
if shouldFail { throw error }
items.append(item)
}
func deleteItem(id: String) async throws {
deleteCallCount += 1
lastDeletedId = id
if shouldFail { throw error }
items.removeAll { $0.id == id }
}
func reset() {
items = []
shouldFail = false
fetchCallCount = 0
saveCallCount = 0
deleteCallCount = 0
lastSavedItem = nil
lastDeletedId = nil
}
}
enum MockError: Error {
case general
case network
case notFound
}Spy Pattern
final class SpyAnalytics: AnalyticsService {
var trackedEvents: [(name: String, properties: [String: Any])] = []
func track(event: String, properties: [String: Any]) {
trackedEvents.append((event, properties))
}
func hasTracked(event: String) -> Bool {
trackedEvents.contains { $0.name == event }
}
func eventCount(for event: String) -> Int {
trackedEvents.filter { $0.name == event }.count
}
}ViewModel Testing
Testing State Changes
@Suite("ItemViewModel State Tests")
struct ItemViewModelStateTests {
@Test("initial state is idle")
func initialState() {
let vm = ItemViewModel(repository: MockItemRepository())
#expect(vm.state == .idle)
}
@Test("loading state during fetch")
func loadingState() async {
let slowRepo = SlowMockRepository(delay: 0.1)
let vm = ItemViewModel(repository: slowRepo)
async let loadTask = vm.loadItems()
// Check loading state
try? await Task.sleep(for: .milliseconds(50))
#expect(vm.state == .loading)
await loadTask
#expect(vm.state == .loaded)
}
@Test("error state on failure")
func errorState() async {
let failingRepo = MockItemRepository(shouldFail: true)
let vm = ItemViewModel(repository: failingRepo)
await vm.loadItems()
#expect(vm.state == .error)
}
}Testing User Actions
@Suite("User Action Tests")
struct UserActionTests {
@Test("adding item updates list")
func addingItem() async throws {
let repo = MockItemRepository()
let vm = ItemViewModel(repository: repo)
await vm.addItem(title: "New Item")
#expect(vm.items.count == 1)
#expect(vm.items.first?.title == "New Item")
#expect(repo.saveCallCount == 1)
}
@Test("deleting item removes from list")
func deletingItem() async throws {
let repo = MockItemRepository(items: [
Item(id: "1", title: "Item 1"),
Item(id: "2", title: "Item 2")
])
let vm = ItemViewModel(repository: repo)
await vm.loadItems()
await vm.deleteItem(id: "1")
#expect(vm.items.count == 1)
#expect(vm.items.first?.id == "2")
}
}UI Testing
Page Object Pattern
import XCTest
// Base screen
class BaseScreen {
let app: XCUIApplication
init(app: XCUIApplication) {
self.app = app
}
func waitForElement(_ element: XCUIElement, timeout: TimeInterval = 5) -> Bool {
element.waitForExistence(timeout: timeout)
}
}
// Home screen
class HomeScreen: BaseScreen {
var itemList: XCUIElement {
app.collectionViews["itemList"]
}
var addButton: XCUIElement {
app.buttons["addButton"]
}
var itemCells: XCUIElementQuery {
itemList.cells
}
@discardableResult
func tapAddButton() -> AddItemScreen {
addButton.tap()
return AddItemScreen(app: app)
}
func tapItem(at index: Int) -> ItemDetailScreen {
itemCells.element(boundBy: index).tap()
return ItemDetailScreen(app: app)
}
func itemCount() -> Int {
itemCells.count
}
}
// Add item screen
class AddItemScreen: BaseScreen {
var titleField: XCUIElement {
app.textFields["titleField"]
}
var saveButton: XCUIElement {
app.buttons["saveButton"]
}
func enterTitle(_ title: String) -> Self {
titleField.tap()
titleField.typeText(title)
return self
}
@discardableResult
func tapSave() -> HomeScreen {
saveButton.tap()
return HomeScreen(app: app)
}
}UI Test Cases
import XCTest
final class ItemFlowUITests: XCTestCase {
var app: XCUIApplication!
var homeScreen: HomeScreen!
override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["--uitesting"]
app.launch()
homeScreen = HomeScreen(app: app)
}
func test_addItem_flow() {
// Initial state
let initialCount = homeScreen.itemCount()
// Add new item
homeScreen
.tapAddButton()
.enterTitle("Test Item")
.tapSave()
// Verify
XCTAssertEqual(homeScreen.itemCount(), initialCount + 1)
}
func test_viewItemDetail_flow() {
// Tap first item
let detailScreen = homeScreen.tapItem(at: 0)
// Verify detail screen appeared
XCTAssertTrue(detailScreen.titleLabel.exists)
}
}Test Utilities
Test Data Builders
struct ItemBuilder {
private var id = UUID().uuidString
private var title = "Test Item"
private var isCompleted = false
private var createdAt = Date()
func with(id: String) -> Self {
var copy = self
copy.id = id
return copy
}
func with(title: String) -> Self {
var copy = self
copy.title = title
return copy
}
func completed() -> Self {
var copy = self
copy.isCompleted = true
return copy
}
func build() -> Item {
Item(
id: id,
title: title,
isCompleted: isCompleted,
createdAt: createdAt
)
}
}
// Usage
let item = ItemBuilder()
.with(title: "Custom Title")
.completed()
.build()Async Test Helpers
extension XCTestCase {
func waitForAsync(
timeout: TimeInterval = 5,
condition: @escaping () async -> Bool
) async throws {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if await condition() {
return
}
try await Task.sleep(for: .milliseconds(100))
}
XCTFail("Condition not met within \(timeout) seconds")
}
}Best Practices
Naming Conventions
// Swift Testing - use descriptive strings
@Test("user can successfully log in with valid credentials")
// XCTest - use method naming pattern
func test_login_withValidCredentials_succeeds()
func test_login_withInvalidPassword_fails()
func test_logout_clearsUserSession()Test Organization
Tests/
├── UnitTests/
│ ├── ViewModels/
│ ├── Services/
│ ├── Repositories/
│ └── Utilities/
├── IntegrationTests/
│ ├── API/
│ └── Database/
├── UITests/
│ ├── Screens/
│ ├── Flows/
│ └── Helpers/
└── Mocks/
├── MockAPIClient.swift
├── MockRepository.swift
└── TestData.swiftCode Coverage
# Generate coverage report
xcodebuild test \
-scheme YourApp \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-enableCodeCoverage YES \
-resultBundlePath TestResults.xcresult
# View coverage
xcrun xccov view --report TestResults.xcresult