Initial commit
This commit is contained in:
540
skills/expertise/iphone-apps/references/testing.md
Normal file
540
skills/expertise/iphone-apps/references/testing.md
Normal file
@@ -0,0 +1,540 @@
|
||||
# Testing
|
||||
|
||||
Unit tests, UI tests, snapshot tests, and testing patterns for iOS apps.
|
||||
|
||||
## Swift Testing (Xcode 16+)
|
||||
|
||||
### Basic Tests
|
||||
|
||||
```swift
|
||||
import Testing
|
||||
@testable import MyApp
|
||||
|
||||
@Suite("Item Tests")
|
||||
struct ItemTests {
|
||||
@Test("Create item with name")
|
||||
func createItem() {
|
||||
let item = Item(name: "Test")
|
||||
#expect(item.name == "Test")
|
||||
#expect(item.isCompleted == false)
|
||||
}
|
||||
|
||||
@Test("Toggle completion")
|
||||
func toggleCompletion() {
|
||||
var item = Item(name: "Test")
|
||||
item.isCompleted = true
|
||||
#expect(item.isCompleted == true)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Async Tests
|
||||
|
||||
```swift
|
||||
@Test("Fetch items from network")
|
||||
func fetchItems() async throws {
|
||||
let service = MockNetworkService()
|
||||
service.mockResult = [Item(name: "Test")]
|
||||
|
||||
let viewModel = ItemListViewModel(networkService: service)
|
||||
await viewModel.load()
|
||||
|
||||
#expect(viewModel.items.count == 1)
|
||||
#expect(viewModel.items[0].name == "Test")
|
||||
}
|
||||
|
||||
@Test("Handle network error")
|
||||
func handleNetworkError() async {
|
||||
let service = MockNetworkService()
|
||||
service.mockError = NetworkError.noConnection
|
||||
|
||||
let viewModel = ItemListViewModel(networkService: service)
|
||||
await viewModel.load()
|
||||
|
||||
#expect(viewModel.items.isEmpty)
|
||||
#expect(viewModel.error != nil)
|
||||
}
|
||||
```
|
||||
|
||||
### Parameterized Tests
|
||||
|
||||
```swift
|
||||
@Test("Validate email", arguments: [
|
||||
("test@example.com", true),
|
||||
("invalid", false),
|
||||
("@example.com", false),
|
||||
("test@", false)
|
||||
])
|
||||
func validateEmail(email: String, expected: Bool) {
|
||||
let isValid = EmailValidator.isValid(email)
|
||||
#expect(isValid == expected)
|
||||
}
|
||||
```
|
||||
|
||||
### Test Lifecycle
|
||||
|
||||
```swift
|
||||
@Suite("Database Tests")
|
||||
struct DatabaseTests {
|
||||
let database: TestDatabase
|
||||
|
||||
init() async throws {
|
||||
database = try await TestDatabase.create()
|
||||
}
|
||||
|
||||
@Test func insertItem() async throws {
|
||||
try await database.insert(Item(name: "Test"))
|
||||
let items = try await database.fetchAll()
|
||||
#expect(items.count == 1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## XCTest (Traditional)
|
||||
|
||||
### Basic XCTest
|
||||
|
||||
```swift
|
||||
import XCTest
|
||||
@testable import MyApp
|
||||
|
||||
class ItemTests: XCTestCase {
|
||||
var sut: Item!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
sut = Item(name: "Test")
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
sut = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testCreateItem() {
|
||||
XCTAssertEqual(sut.name, "Test")
|
||||
XCTAssertFalse(sut.isCompleted)
|
||||
}
|
||||
|
||||
func testToggleCompletion() {
|
||||
sut.isCompleted = true
|
||||
XCTAssertTrue(sut.isCompleted)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Async XCTest
|
||||
|
||||
```swift
|
||||
func testFetchItems() async throws {
|
||||
let service = MockNetworkService()
|
||||
service.mockResult = [Item(name: "Test")]
|
||||
|
||||
let viewModel = ItemListViewModel(networkService: service)
|
||||
await viewModel.load()
|
||||
|
||||
XCTAssertEqual(viewModel.items.count, 1)
|
||||
}
|
||||
```
|
||||
|
||||
## Mocking
|
||||
|
||||
### Protocol-Based Mocks
|
||||
|
||||
```swift
|
||||
// Protocol
|
||||
protocol NetworkServiceProtocol {
|
||||
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T
|
||||
}
|
||||
|
||||
// Mock
|
||||
class MockNetworkService: NetworkServiceProtocol {
|
||||
var mockResult: Any?
|
||||
var mockError: Error?
|
||||
var fetchCallCount = 0
|
||||
|
||||
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
|
||||
fetchCallCount += 1
|
||||
|
||||
if let error = mockError {
|
||||
throw error
|
||||
}
|
||||
|
||||
guard let result = mockResult as? T else {
|
||||
fatalError("Mock result type mismatch")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing with Mocks
|
||||
|
||||
```swift
|
||||
@Test func loadItemsCallsNetwork() async {
|
||||
let mock = MockNetworkService()
|
||||
mock.mockResult = [Item]()
|
||||
|
||||
let viewModel = ItemListViewModel(networkService: mock)
|
||||
await viewModel.load()
|
||||
|
||||
#expect(mock.fetchCallCount == 1)
|
||||
}
|
||||
```
|
||||
|
||||
## Testing SwiftUI Views
|
||||
|
||||
### View Tests with ViewInspector
|
||||
|
||||
```swift
|
||||
import ViewInspector
|
||||
@testable import MyApp
|
||||
|
||||
@Test func itemRowDisplaysName() throws {
|
||||
let item = Item(name: "Test Item")
|
||||
let view = ItemRow(item: item)
|
||||
|
||||
let text = try view.inspect().hStack().text(0).string()
|
||||
#expect(text == "Test Item")
|
||||
}
|
||||
```
|
||||
|
||||
### Testing View Models
|
||||
|
||||
```swift
|
||||
@Test func viewModelUpdatesOnSelection() async {
|
||||
let viewModel = ItemListViewModel()
|
||||
viewModel.items = [Item(name: "A"), Item(name: "B")]
|
||||
|
||||
viewModel.select(viewModel.items[0])
|
||||
|
||||
#expect(viewModel.selectedItem?.name == "A")
|
||||
}
|
||||
```
|
||||
|
||||
## UI Testing
|
||||
|
||||
### Basic UI Test
|
||||
|
||||
```swift
|
||||
import XCTest
|
||||
|
||||
class MyAppUITests: XCTestCase {
|
||||
let app = XCUIApplication()
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app.launchArguments = ["--uitesting"]
|
||||
app.launch()
|
||||
}
|
||||
|
||||
func testAddItem() {
|
||||
// Tap add button
|
||||
app.buttons["Add"].tap()
|
||||
|
||||
// Enter name
|
||||
let textField = app.textFields["Item name"]
|
||||
textField.tap()
|
||||
textField.typeText("New Item")
|
||||
|
||||
// Save
|
||||
app.buttons["Save"].tap()
|
||||
|
||||
// Verify
|
||||
XCTAssertTrue(app.staticTexts["New Item"].exists)
|
||||
}
|
||||
|
||||
func testSwipeToDelete() {
|
||||
// Assume item exists
|
||||
let cell = app.cells["Item Row"].firstMatch
|
||||
|
||||
// Swipe and delete
|
||||
cell.swipeLeft()
|
||||
app.buttons["Delete"].tap()
|
||||
|
||||
// Verify
|
||||
XCTAssertFalse(cell.exists)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Accessibility Identifiers
|
||||
|
||||
```swift
|
||||
struct ItemRow: View {
|
||||
let item: Item
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(item.name)
|
||||
}
|
||||
.accessibilityIdentifier("Item Row")
|
||||
}
|
||||
}
|
||||
|
||||
struct NewItemView: View {
|
||||
@State private var name = ""
|
||||
|
||||
var body: some View {
|
||||
TextField("Item name", text: $name)
|
||||
.accessibilityIdentifier("Item name")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Launch Arguments for Testing
|
||||
|
||||
```swift
|
||||
@main
|
||||
struct MyApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.onAppear {
|
||||
if CommandLine.arguments.contains("--uitesting") {
|
||||
// Use mock data
|
||||
// Skip onboarding
|
||||
// Clear state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Snapshot Testing
|
||||
|
||||
Using swift-snapshot-testing:
|
||||
|
||||
```swift
|
||||
import SnapshotTesting
|
||||
import XCTest
|
||||
@testable import MyApp
|
||||
|
||||
class SnapshotTests: XCTestCase {
|
||||
func testItemRow() {
|
||||
let item = Item(name: "Test", subtitle: "Subtitle")
|
||||
let view = ItemRow(item: item)
|
||||
.frame(width: 375)
|
||||
|
||||
assertSnapshot(of: view, as: .image)
|
||||
}
|
||||
|
||||
func testItemRowDarkMode() {
|
||||
let item = Item(name: "Test", subtitle: "Subtitle")
|
||||
let view = ItemRow(item: item)
|
||||
.frame(width: 375)
|
||||
.preferredColorScheme(.dark)
|
||||
|
||||
assertSnapshot(of: view, as: .image, named: "dark")
|
||||
}
|
||||
|
||||
func testItemRowLargeText() {
|
||||
let item = Item(name: "Test", subtitle: "Subtitle")
|
||||
let view = ItemRow(item: item)
|
||||
.frame(width: 375)
|
||||
.environment(\.sizeCategory, .accessibilityExtraLarge)
|
||||
|
||||
assertSnapshot(of: view, as: .image, named: "large-text")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing SwiftData
|
||||
|
||||
```swift
|
||||
@Suite("SwiftData Tests")
|
||||
struct SwiftDataTests {
|
||||
@Test func insertAndFetch() async throws {
|
||||
// In-memory container for testing
|
||||
let config = ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
let container = try ModelContainer(for: Item.self, configurations: config)
|
||||
let context = container.mainContext
|
||||
|
||||
// Insert
|
||||
let item = Item(name: "Test")
|
||||
context.insert(item)
|
||||
try context.save()
|
||||
|
||||
// Fetch
|
||||
let descriptor = FetchDescriptor<Item>()
|
||||
let items = try context.fetch(descriptor)
|
||||
|
||||
#expect(items.count == 1)
|
||||
#expect(items[0].name == "Test")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Network Calls
|
||||
|
||||
### Using URLProtocol
|
||||
|
||||
```swift
|
||||
class MockURLProtocol: URLProtocol {
|
||||
static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
|
||||
return request
|
||||
}
|
||||
|
||||
override func startLoading() {
|
||||
guard let handler = MockURLProtocol.requestHandler else {
|
||||
fatalError("Handler not set")
|
||||
}
|
||||
|
||||
do {
|
||||
let (response, data) = try handler(request)
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
} catch {
|
||||
client?.urlProtocol(self, didFailWithError: error)
|
||||
}
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
}
|
||||
|
||||
@Test func fetchItemsReturnsData() async throws {
|
||||
// Configure mock
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.protocolClasses = [MockURLProtocol.self]
|
||||
let session = URLSession(configuration: config)
|
||||
|
||||
let mockItems = [Item(name: "Test")]
|
||||
let mockData = try JSONEncoder().encode(mockItems)
|
||||
|
||||
MockURLProtocol.requestHandler = { request in
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: nil
|
||||
)!
|
||||
return (response, mockData)
|
||||
}
|
||||
|
||||
// Test
|
||||
let service = NetworkService(session: session)
|
||||
let items: [Item] = try await service.fetch(.items)
|
||||
|
||||
#expect(items.count == 1)
|
||||
}
|
||||
```
|
||||
|
||||
## Test Helpers
|
||||
|
||||
### Factory Methods
|
||||
|
||||
```swift
|
||||
extension Item {
|
||||
static func sample(
|
||||
name: String = "Sample",
|
||||
isCompleted: Bool = false,
|
||||
priority: Int = 0
|
||||
) -> Item {
|
||||
Item(name: name, isCompleted: isCompleted, priority: priority)
|
||||
}
|
||||
|
||||
static var samples: [Item] {
|
||||
[
|
||||
.sample(name: "First"),
|
||||
.sample(name: "Second", isCompleted: true),
|
||||
.sample(name: "Third", priority: 5)
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Async Test Utilities
|
||||
|
||||
```swift
|
||||
func waitForCondition(
|
||||
timeout: TimeInterval = 1.0,
|
||||
condition: @escaping () -> Bool
|
||||
) async throws {
|
||||
let start = Date()
|
||||
while !condition() {
|
||||
if Date().timeIntervalSince(start) > timeout {
|
||||
throw TestError.timeout
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
|
||||
}
|
||||
}
|
||||
|
||||
enum TestError: Error {
|
||||
case timeout
|
||||
}
|
||||
```
|
||||
|
||||
## Running Tests from CLI
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
xcodebuild test \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16'
|
||||
|
||||
# Run specific test
|
||||
xcodebuild test \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
-only-testing:MyAppTests/ItemTests
|
||||
|
||||
# With code coverage
|
||||
xcodebuild test \
|
||||
-project MyApp.xcodeproj \
|
||||
-scheme MyApp \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||
-enableCodeCoverage YES \
|
||||
-resultBundlePath TestResults.xcresult
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Test Naming
|
||||
|
||||
```swift
|
||||
// Describe what is being tested and expected outcome
|
||||
@Test func itemListViewModel_load_setsItemsFromNetwork()
|
||||
@Test func purchaseService_purchaseProduct_updatesEntitlements()
|
||||
```
|
||||
|
||||
### Arrange-Act-Assert
|
||||
|
||||
```swift
|
||||
@Test func toggleCompletion() {
|
||||
// Arrange
|
||||
var item = Item(name: "Test")
|
||||
|
||||
// Act
|
||||
item.isCompleted.toggle()
|
||||
|
||||
// Assert
|
||||
#expect(item.isCompleted == true)
|
||||
}
|
||||
```
|
||||
|
||||
### One Assertion Per Test
|
||||
|
||||
Focus each test on a single behavior:
|
||||
|
||||
```swift
|
||||
// Good
|
||||
@Test func loadSetsItems() async { ... }
|
||||
@Test func loadSetsLoadingFalse() async { ... }
|
||||
@Test func loadClearsError() async { ... }
|
||||
|
||||
// Avoid
|
||||
@Test func loadWorks() async {
|
||||
// Too many assertions
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user