Files
PlantGuide/PlantGuideTests/Mocks/MockNetworkService.swift
Trey t 1ae9c884c8 Rebuild UI test foundation with page objects, wait helpers, and screen objects
Replace brittle localized-string selectors and broken wait helpers with a
robust, identifier-first UI test infrastructure. All 41 UI tests pass on
iOS 26.2 simulator (iPhone 17).

Foundation:
- BaseUITestCase with deterministic launch helpers (launchClean, launchOffline)
- WaitHelpers (waitUntilHittable, waitUntilGone, tapWhenReady) replacing sleep()
- UITestID enum mirroring AccessibilityIdentifiers from the app target
- Screen objects: TabBarScreen, CameraScreen, CollectionScreen, TodayScreen,
  SettingsScreen, PlantDetailScreen

Key fixes:
- Tab navigation uses waitForExistence+tap instead of isHittable (unreliable
  in iOS 26 simulator)
- Tests handle real app state (empty collection, no camera permission)
- Increased timeouts for parallel clone execution
- Added NetworkMonitorProtocol and protocol-typed DI for testability
- Fixed actor-isolation issues in unit test mocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:36:54 -06:00

292 lines
7.5 KiB
Swift

//
// MockNetworkService.swift
// PlantGuideTests
//
// Mock implementations for network-related services for unit testing.
// Provides configurable behavior and call tracking for verification.
//
import Foundation
import Network
@testable import PlantGuide
// MARK: - MockNetworkMonitor
/// Mock implementation of NetworkMonitor for testing
/// Note: This creates a testable version that doesn't actually monitor network state
@Observable
final class MockNetworkMonitor: NetworkMonitorProtocol, @unchecked Sendable {
// MARK: - Properties
/// Current network connectivity status (configurable for tests)
var isConnected: Bool = true
/// Current connection type (configurable for tests)
var connectionType: ConnectionType = .wifi
// MARK: - Call Tracking
private(set) var startMonitoringCallCount = 0
private(set) var stopMonitoringCallCount = 0
// MARK: - Initialization
init(isConnected: Bool = true, connectionType: ConnectionType = .wifi) {
self.isConnected = isConnected
self.connectionType = connectionType
}
// MARK: - Public Methods
func startMonitoring() {
startMonitoringCallCount += 1
}
func stopMonitoring() {
stopMonitoringCallCount += 1
}
// MARK: - Test Helper Methods
/// Simulates a connection state change
func simulateConnectionChange(isConnected: Bool, connectionType: ConnectionType = .wifi) {
self.isConnected = isConnected
self.connectionType = connectionType
}
/// Simulates going offline
func simulateDisconnect() {
isConnected = false
connectionType = .unknown
}
/// Simulates connecting to WiFi
func simulateWiFiConnection() {
isConnected = true
connectionType = .wifi
}
/// Simulates connecting to cellular
func simulateCellularConnection() {
isConnected = true
connectionType = .cellular
}
/// Resets all state for clean test setup
func reset() {
isConnected = true
connectionType = .wifi
startMonitoringCallCount = 0
stopMonitoringCallCount = 0
}
}
// MARK: - MockURLSession
/// Mock implementation of URLSession for testing network requests
final class MockURLSession: @unchecked Sendable {
// MARK: - Call Tracking
private(set) var dataCallCount = 0
private(set) var uploadCallCount = 0
// MARK: - Error Configuration
var shouldThrowOnData = false
var shouldThrowOnUpload = false
var errorToThrow: Error = NSError(
domain: NSURLErrorDomain,
code: NSURLErrorNotConnectedToInternet,
userInfo: [NSLocalizedDescriptionKey: "The Internet connection appears to be offline."]
)
// MARK: - Return Value Configuration
var dataToReturn: Data = Data()
var responseToReturn: URLResponse?
var statusCodeToReturn: Int = 200
// MARK: - Captured Values
private(set) var lastRequestedURL: URL?
private(set) var lastUploadData: Data?
// MARK: - Mock Methods
func data(from url: URL) async throws -> (Data, URLResponse) {
dataCallCount += 1
lastRequestedURL = url
if shouldThrowOnData {
throw errorToThrow
}
let response = responseToReturn ?? HTTPURLResponse(
url: url,
statusCode: statusCodeToReturn,
httpVersion: "HTTP/1.1",
headerFields: nil
)!
return (dataToReturn, response)
}
func upload(for request: URLRequest, from bodyData: Data) async throws -> (Data, URLResponse) {
uploadCallCount += 1
lastRequestedURL = request.url
lastUploadData = bodyData
if shouldThrowOnUpload {
throw errorToThrow
}
let response = responseToReturn ?? HTTPURLResponse(
url: request.url!,
statusCode: statusCodeToReturn,
httpVersion: "HTTP/1.1",
headerFields: nil
)!
return (dataToReturn, response)
}
// MARK: - Helper Methods
/// Resets all state for clean test setup
func reset() {
dataCallCount = 0
uploadCallCount = 0
shouldThrowOnData = false
shouldThrowOnUpload = false
dataToReturn = Data()
responseToReturn = nil
statusCodeToReturn = 200
lastRequestedURL = nil
lastUploadData = nil
}
/// Configures the mock to return JSON data
func configureJSONResponse<T: Encodable>(_ value: T, statusCode: Int = 200) throws {
let encoder = JSONEncoder()
dataToReturn = try encoder.encode(value)
statusCodeToReturn = statusCode
}
/// Configures the mock to return an error response
func configureErrorResponse(statusCode: Int, message: String = "Error") {
statusCodeToReturn = statusCode
dataToReturn = Data(message.utf8)
}
/// Configures the mock to simulate a network error
func configureNetworkError(_ error: URLError.Code = .notConnectedToInternet) {
shouldThrowOnData = true
shouldThrowOnUpload = true
errorToThrow = URLError(error)
}
/// Configures the mock to simulate a timeout
func configureTimeout() {
shouldThrowOnData = true
shouldThrowOnUpload = true
errorToThrow = URLError(.timedOut)
}
}
// MARK: - MockPlantNetAPIService
/// Mock implementation of PlantNet API service for testing
final class MockPlantNetAPIService: @unchecked Sendable {
// MARK: - PlantNet Response Types
struct PlantNetResponse: Codable {
let results: [PlantNetResult]
}
struct PlantNetResult: Codable {
let score: Double
let species: PlantNetSpecies
}
struct PlantNetSpecies: Codable {
let scientificNameWithoutAuthor: String
let commonNames: [String]
}
// MARK: - Call Tracking
private(set) var identifyCallCount = 0
// MARK: - Error Configuration
var shouldThrow = false
var errorToThrow: Error = NSError(
domain: "PlantNetError",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Mock PlantNet API error"]
)
// MARK: - Return Value Configuration
var resultsToReturn: [PlantNetResult] = []
// MARK: - Captured Values
private(set) var lastImageData: Data?
// MARK: - Mock Methods
func identify(imageData: Data) async throws -> PlantNetResponse {
identifyCallCount += 1
lastImageData = imageData
if shouldThrow {
throw errorToThrow
}
return PlantNetResponse(results: resultsToReturn)
}
// MARK: - Helper Methods
/// Resets all state for clean test setup
func reset() {
identifyCallCount = 0
shouldThrow = false
resultsToReturn = []
lastImageData = nil
}
/// Configures mock to return successful plant identification
func configureSuccessfulIdentification() {
resultsToReturn = [
PlantNetResult(
score: 0.95,
species: PlantNetSpecies(
scientificNameWithoutAuthor: "Monstera deliciosa",
commonNames: ["Swiss Cheese Plant", "Monstera"]
)
),
PlantNetResult(
score: 0.72,
species: PlantNetSpecies(
scientificNameWithoutAuthor: "Philodendron bipinnatifidum",
commonNames: ["Split Leaf Philodendron"]
)
)
]
}
/// Configures mock to return no results
func configureNoResults() {
resultsToReturn = []
}
}