53 KiB
source, confidence, namespace, last_synced, alwaysApply
| source | confidence | namespace | last_synced | alwaysApply |
|---|---|---|---|---|
| ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/testing.mdc | 0.9 | work | 2026-04-28 | false |
Testing Guidelines & Best Practices
This guide covers testing practices and patterns for the DuckDuckGo browser on iOS and macOS platforms.
🚨 MANDATORY: Testing Execution Rules
NEVER Run Tests Without Permission
NEVER execute any test commands without EXPLICIT user permission or unles user explicitly asked to in their prompt.
Required Testing Workflow:
- Write or modify test code as requested
- if user did not ask to run tests in their prompt, STOP before running any test commands:
swift testnpm testxcodebuild testfastlane test- Any other test execution commands
- ASK the user: "Should I run the tests?"
- WAIT for explicit permission (e.g., "yes", "run tests", "test it")
- Only then execute test commands
This rule applies to ALL test execution - unit tests, integration tests, UI tests, performance tests, etc.
Future Improvements
This guide is a living document. Consider these areas for future improvements:
- Tab Extensions Testing: Expand patterns for testing complex tab extension interactions and lifecycle management
- WebKit Integration Testing: Add comprehensive patterns for testing WKWebView configurations, user scripts, and content blocking integration
- Privacy Feature Testing: Develop specialized testing approaches for tracker protection, HTTPS upgrade, and content blocking rule validation
- Cross-Platform Testing: Create patterns for testing SharedPackages functionality across iOS and macOS with consistent behavior validation
- Fire Button Integration Testing: Add comprehensive testing patterns for data clearing workflows across all browser components
- Autofill and Credential Testing: Expand testing approaches for AutofillCredentialProvider, password management, and form filling scenarios
- Sync Testing: Develop patterns for testing bookmark sync, conflict resolution, and cross-device data consistency
- AI Chat Integration Testing: Add testing patterns for AI chat functionality, context management, and user interaction flows
- Feature Flag Testing: Expand MockFeatureFlagger usage patterns and integration testing with real feature configurations
Unit Tests
What to Include
Unit tests should focus on testing individual components, functions, or classes in isolation. They should be:
- Fast: Run quickly (< 1 second per test)
- Independent: Not depend on external systems or other tests
- Deterministic: Always produce the same result given the same input
- Focused: Test one specific behavior or functionality
✅ When to Write Unit Tests
Model Logic
Testing business logic, data transformations, and model behavior:
func testBookmarkFolderCreation() {
let folder = BookmarkFolder(title: "Test Folder")
XCTAssertEqual(folder.title, "Test Folder")
XCTAssertTrue(folder.children.isEmpty)
}
Algorithms/Parsers
Testing parsing logic, URL manipulation, search algorithms:
func testURLSchemeDetection() {
let detector = URLSchemeDetector()
XCTAssertTrue(detector.isValidURL("https://duckduckgo.com"))
XCTAssertFalse(detector.isValidURL("invalid-url"))
}
Utility Functions
Testing helper functions, extensions, formatters:
func testDateFormatter() {
let formatter = DateFormatter.shortDate
let date = Date(timeIntervalSince1970: 1640995200) // 2022-01-01
XCTAssertEqual(formatter.string(from: date), "1/1/22")
}
State Management
Testing ViewModels, state transitions, and data flow:
func testViewModelStateTransition() {
let viewModel = SearchViewModel()
viewModel.performSearch("test query")
XCTAssertEqual(viewModel.state, .loading)
}
❌ What to Avoid
Simple Property Toggles
Testing trivial getters/setters:
// ❌ DON'T test this
func testIsEnabledToggle() {
feature.isEnabled = true
XCTAssertTrue(feature.isEnabled)
}
Complex UI Interactions
Use Integration or UI tests instead.
External Dependencies
File system, network calls, databases.
State/Strategy Pattern Switching
These are better suited for integration tests:
// ❌ DON'T test state switching in unit tests
func testStateSwitching() {
stateMachine.transition(to: .loading)
stateMachine.transition(to: .loaded)
// This is brittle and doesn't test real behavior
}
Mocks and Test Helpers
The DuckDuckGo browser project includes multiple mock categories for testing different components and scenarios:
Mock Categories
Unit Tests Mocks
For testing individual components in isolation:
- UI mocks: MockWindow, MockTabViewItemDelegate
- WebView mocks: WebViewMock, WKSecurityOriginMock
- Storage mocks: FileStoreMock, UserDefaultsMock
- Feature-specific mocks: MockBookmarkManager, MockFireproofDomains, MockAIChatPreferencesStorage
Integration Tests Mocks
For testing component interactions and workflows:
- Content blocking mocks
- Tab navigation mocks
- Fire integration mocks
- Onboarding flow mocks
- System integration mocks
BSK Tests Mocks
For testing BrowserServicesKit functionality:
- Feature flag mocks: MockFeatureFlagger
- Privacy configuration mocks: MockPrivacyConfiguration
- Statistics mocks: MockStatisticsStore
- Variant management mocks: MockVariantManager
- Network mocks: MockAPIService
Mock Usage Examples
UI Testing with MockWindow
func testViewControllerLifecycle() {
autoreleasepool {
let mockWindow = MockWindow()
let viewController = MyViewController()
mockWindow.contentViewController = viewController
viewController.viewDidLoad() // Safe to call with MockWindow
XCTAssertNotNil(viewController.view)
} // Ensures proper cleanup
}
WebKit Testing with WebViewMock
func testGeolocationPermission() {
autoreleasepool {
let mockWebView = WebViewMock()
let origin = WKSecurityOriginMock.new(url: URL(string: "https://example.com")!)
let frameInfo = WKFrameInfoMock(webView: mockWebView, securityOrigin: origin,
request: URLRequest(url: URL(string: "https://example.com")!),
isMainFrame: true)
// Test permission handling without actual web content
permissionManager.requestGeolocationPermission(for: frameInfo) { granted in
XCTAssertTrue(granted)
}
}
}
Feature Flag Testing with MockFeatureFlagger
⚠️ CRITICAL: MockFeatureFlagger is REQUIRED for feature tests throughout the entire codebase (iOS, macOS, BSK). Tests will crash without it due to DefaultFeatureFlagger assertions that prevent real feature flag usage in test environments.
func testFeatureFlaggedBehavior() {
let mockFlagger = MockFeatureFlagger()
mockFlagger.enabledFeatureFlags = [.contextualOnboarding]
let feature = SomeFeature(featureFlagger: mockFlagger)
XCTAssertTrue(feature.isOnboardingEnabled)
}
// Alternative approach using featuresStub
func testWithFeatureStub() {
let mockFlagger = MockFeatureFlagger()
mockFlagger.featuresStub = [
"contextualOnboarding": true,
"duckPlayer": false
]
let component = TestComponent(featureFlagger: mockFlagger)
XCTAssertTrue(component.hasOnboarding)
XCTAssertFalse(component.hasDuckPlayer)
}
// Testing feature flag injection into Tab
func testTabWithFeatureFlags() {
autoreleasepool {
let mockFlagger = MockFeatureFlagger()
mockFlagger.enabledFeatureFlags = [.tabCrashDebugging]
let tab = Tab(content: .newtab, featureFlagger: mockFlagger)
XCTAssertTrue(tab.canKillWebContentProcess)
}
}
Why MockFeatureFlagger is Essential:
- Test Isolation: Prevents real feature flag configurations from affecting test outcomes
- Crash Prevention: DefaultFeatureFlagger includes assertions that crash tests if not using MockFeatureFlagger
- Controlled Environment: Ensures predictable test behavior regardless of remote configuration
- Performance: Avoids network calls and complex privacy configuration setup
Mock Guidelines
When to Use Each Mock Category
- Unit Tests Mocks: Testing individual classes, methods, and components in isolation
- Integration Tests Mocks: Testing workflows, component interactions, and system behavior
- BSK Tests Mocks: Testing shared functionality, feature flags, and cross-platform components
Memory Management with Mocks
- Always use
autoreleasepool {}for UI and WebKit mocks - Use shared mock instances when testing multiple scenarios
- Reset mock state between tests to prevent pollution
- Be careful with reactive mocks that use Combine publishers
Mock Selection Criteria
When creating mocks:
- Check existing mocks first - Use available mocks before creating new ones
- Create what you need - Don't hesitate to create mocks for single-use scenarios if no suitable mock exists
- Focus on behavior - Mock the interfaces and protocols your code depends on
- Make them realistic - Mocks should behave similarly to real implementations
Avoid "Testing Mocks" (Anti-patterns)
Golden Rule: "If you can omit mocking, omit mocking" - The more you mock, the more you deviate from the real system, decreasing test confidence.
Testing Implementation Details Instead of Behavior
// ❌ BAD - Testing that internal methods are called
func testUserServiceCallsCorrectMethods() {
let mockRepository = MockUserRepository()
let userService = UserService(repository: mockRepository)
userService.getCurrentUser()
XCTAssertTrue(mockRepository.lookupSessionsCalled) // Testing HOW, not WHAT
XCTAssertTrue(mockRepository.getLatestSessionCalled)
}
// ✅ GOOD - Testing the actual outcome
func testUserServiceReturnsCurrentUser() {
let mockRepository = MockUserRepository()
mockRepository.mockUser = User(id: "123", name: "John")
let userService = UserService(repository: mockRepository)
let user = userService.getCurrentUser()
XCTAssertEqual(user?.name, "John") // Testing WHAT happens, not HOW
}
Over-mocking Creates a Fake System
// ❌ BAD - Testing mostly mock interactions
func testCompleteWorkflow() {
let mockAuth = MockAuthService()
let mockAPI = MockAPIService()
let mockStorage = MockStorage()
let mockLogger = MockLogger()
let mockAnalytics = MockAnalytics()
// ... testing interactions between mocks, not real behavior
}
Complex Scenarios Need Integration Testing
// ❌ BAD - Complex mocking setup for user flows
func testBookmarkSyncFlow() {
let mockNetworkMonitor = MockNetworkMonitor()
let mockSyncService = MockSyncService()
let mockBookmarkStore = MockBookmarkStore()
let mockConflictResolver = MockConflictResolver()
// ... 50 lines of mock setup for a flow that should be integration tested
}
// ✅ BETTER - Use Integration Test instead
// This complex scenario is better tested as an integration test
// with real components, providing higher confidence and less maintenance
When complex mocking suggests better alternatives:
- If you're setting up many interconnected mocks → Consider an Integration Test with real components
- If you're mocking user interaction flows → Consider a UI Test that exercises the actual user journey
- If mock setup takes longer than the test logic → You're probably over-mocking
Memory Management - TestRunHelper
The project includes TestRunHelper that provides automatic memory management and tracking during testing.
TestRunHelper Features (Automatically Enabled)
- Autorelease Tracking: Automatically enabled for all tests, tracks specific classes (WKWebView, NSWindow, NSWindowController, NSViewController, TabBarItemCellView)
- View Hierarchy Tracking: Monitors NSView creations and validates deallocation with 3-second timeout
- UI Presentation Prevention: Fails tests that try to present real UI with
fatalError("Unit Tests should not present UI. Use MockWindow if needed.") - Test Instance Variables Clearing: Monitors test case instance variables and ensures they're cleared after test completion
- Shared Resource Management: Shared WKProcessPool for WebKit tests, temporary directory cleanup
Memory Issue Detection
TestRunHelper will break/assert in these scenarios:
- CI Environment:
fatalError()with test failure - Local Development:
breakByRaisingSigInt()with debugging guidance - Tab Deallocation Check: Tab.deinit includes comprehensive WebKit object deallocation verification
Override allowedNonNilVariables for Legitimate Cases
override var allowedNonNilVariables: Set<String> {
["constantData", "sharedManager", "staticConfiguration"]
}
When to Use autoreleasepool
Always Required:
// 1. View Controller lifecycle testing
func testViewControllerLifecycle() {
autoreleasepool {
let window = MockWindow()
let viewController = MyViewController()
window.contentViewController = viewController
// Test lifecycle methods
} // Ensures immediate cleanup
}
// 2. Heavy object creation in loops
func testBatchOperations() {
for i in 0..<1000 {
autoreleasepool {
let object = HeavyObject(data: testData[i])
processObject(object)
} // Releases object immediately instead of waiting for test end
}
}
// 3. WebKit and content blocking tests
func testContentBlockingRules() {
autoreleasepool {
let rulesManager = MockContentBlockerRulesManager()
let webView = WebViewMock()
// Test content blocking behavior
} // WebKit objects can be memory intensive
}
// 4. File system and database operations
func testDatabaseOperations() {
autoreleasepool {
let context = managedObjectContext
let objects = createTestObjects(count: 100)
context.save()
} // Ensures Core Data cleanup
}
Memory Management Best Practices
- Use
autoreleasepoolwhen creating more than a few objects in a test - Always wrap view controller tests that involve UI hierarchy
- Use for any test involving file I/O, network operations, or large data
- Wrap tearDown operations when dealing with complex object hierarchies
- Essential for batch operations and performance tests
Template for Memory-Safe Testing
func testFeature() {
autoreleasepool {
// 1. Setup mocks and dependencies
let mockWindow = MockWindow()
let mockManager = MockSomeManager()
// 2. Create objects under test
let objectUnderTest = MyObject(dependencies: mockManager)
// 3. Perform test operations
objectUnderTest.performAction()
// 4. Assert results
XCTAssertEqual(objectUnderTest.state, .expected)
// 5. Objects are automatically released at end of autoreleasepool
}
}
AutoreleaseTracker
What it is: AutoreleaseTracker creates tracker objects that are autoreleased alongside specific tracked objects, maintaining malloc stack traces for debugging memory leaks.
What it tracks: WKWebView, NSWindow, NSWindowController, NSViewController, TabBarItemCellView
How to debug autorelease issues:
- Enable MallocStackLogging in Tests scheme → Arguments → Environment Variables
- Run your test and wait for deallocation timeout
- Open Memory Browser in Xcode (Debug → Debug Workflow → View Memory)
- Search for "AutoreleaseTracker" in Memory Browser
- Analyze the stack trace to see where the autorelease call was made
Integration Tests
What to Include
Integration tests verify that multiple components work together correctly. They test:
- Component Interactions: How different modules communicate
- Data Flow: End-to-end data processing
- System Integration: External services, APIs, databases
✅ When to Write Integration Tests
Complex Feature Workflows
Testing complete user journeys:
func testBookmarkSyncFlow() {
// Test bookmark creation -> sync -> retrieval
let bookmark = createBookmark()
syncManager.sync()
let retrievedBookmark = bookmarkStore.fetch(by: bookmark.id)
XCTAssertEqual(bookmark.title, retrievedBookmark?.title)
}
State Management
Testing complex state transitions in real scenarios.
Cross-Module Communication
Testing how different packages interact.
Database Operations
Testing Core Data models and persistence.
❌ Integration Test Anti-patterns
- Debug Information: Don't include debug prints, use
Logger.testsinstead - Real UI Dependencies: Prefer mock windows and views
- Heavy Resource Usage: Avoid tests that load unnecessary resources
- External Network Calls: Use mocked services
Best Practices for Integration Tests
class IntegrationTestCase: XCTestCase {
override func setUp() {
super.setUp()
// Use mock window to avoid UI dependencies
let mockWindow = MockWindow()
setupTestEnvironment(window: mockWindow)
}
override func tearDown() {
// Ensure all resources are properly released
cleanupTestEnvironment()
super.tearDown()
}
func testFeatureIntegration() {
// Test real component interaction without UI
let coordinator = FeatureCoordinator()
let result = coordinator.performAction()
XCTAssertNotNil(result)
}
}
BSK Tests
BSK (BrowserServicesKit) tests are specialized for the shared browser services package.
Logging in BSK Tests
Getting BSK Test Logs
From GitHub Actions: Download the bsk-xctest-log-stream.log artifact from failed builds
Local Development:
# Use real-time log streaming (in separate terminal)
log stream --debug --info --predicate 'process == "xctest"' --style syslog &
swift test
# For Swift Testing framework tests (using @Test macro):
swift test --enable-swift-testing
# For specific test target with Swift Testing:
swift test --filter PersistenceTests --enable-swift-testing
⚠️ AI Assistant Note: These commands are examples for manual execution only. Never run test commands automatically without explicit user permission.
Note: Use
--enable-swift-testingflag when running tests that use the Swift Testing framework (@Testmacro) instead of XCTest. This enables better test output and proper test discovery for Swift Testing tests.
Enabling Private Data in Console App
By default, macOS Console app shows <private> instead of actual values for privacy. To see private data for debugging:
-
Install the logging plist (located at
SharedPackages/BrowserServicesKit/com.apple.system.logging.plist):sudo cp com.apple.system.logging.plist /Library/Preferences/Logging/com.apple.system.logging.plist -
Restart Console app to apply changes
-
Private data will now be visible in system logs for debugging purposes
Note: Only use this for debugging - remove the plist when done to restore privacy protection.
Tab Extensions Testing
Tab Extensions are modular components that provide specific functionality to tabs. For comprehensive guidance on Tab Extensions architecture and implementation patterns, see the Tab Extensions Knowledge Sharing.
Key Files to Reference
TabExtensions.swift- Extension registration and architecture patternsTabExtensionsBuilder.swift- Extension initialization and dependency injectionTab+Navigation.swift- setupNavigationDelegate for navigation handling
Basic Tab Extension Testing
class MyTabExtensionTests: XCTestCase {
var extensionsBuilder: TestTabExtensionsBuilder!
override func setUp() {
super.setUp()
// Load only the extension you want to test
extensionsBuilder = TestTabExtensionsBuilder(load: [MyTabExtension.self])
}
func testExtensionFunctionality() {
autoreleasepool {
let tab = Tab(content: .none, extensionsBuilder: extensionsBuilder)
let myExtension = tab.extensions.myExtension
XCTAssertNotNil(myExtension)
myExtension?.performAction()
XCTAssertEqual(myExtension?.publicProperty, "expected_value")
}
}
}
Advanced Testing with Mocked Dependencies
class AdClickAttributionTabExtensionTests: XCTestCase {
func testExtensionWithMockedDependencies() {
let extensionsBuilder = TestTabExtensionsBuilder(load: [AdClickAttributionTabExtension.self]) { builder in { args, dependencies in
builder.override {
AdClickAttributionTabExtension(
// Override with mocked dependencies
userContentControllerFuture: Future { $0(.success(self.mockUserContentController)) },
dependencies: dependencies.privacyFeatures.contentBlocking
) { _ in (logic: self.mockLogic, detection: self.mockDetection) }
}
}}
autoreleasepool {
let tab = Tab(content: .none, extensionsBuilder: extensionsBuilder)
// Test extension behavior with mocked dependencies
}
}
}
Tab Extension Testing Guidelines
✅ Do:
- Use TestTabExtensionsBuilder for controlled extension loading
- Load only required extensions for isolated testing
- Test through public protocol interfaces
- Use autoreleasepool for memory management
- Mock dependencies through override patterns
❌ Don't:
- Load all extensions unless testing integration
- Access private implementation details
- Create real UI (use MockWindow)
- Ignore proper setup and teardown
UI Tests
For comprehensive UI testing guidelines, patterns, and best practices specifically for the DuckDuckGo macOS browser, see UI Testing Guidelines & Best Practices.
The UI testing documentation covers:
- Element Access Patterns: Accessibility IDs, element variables, extension usage
- Window and Tab Management: Multi-window operations, navigation modifiers, validation patterns
- Element Interaction: Timing best practices, middle-click handling, context menus
- Test Server Integration: Local test server setup and content creation
- Performance Optimizations: Pasteboard usage, avoiding slow operations
- Build Architecture: Compatibility requirements across macOS versions
- Advanced Patterns: Tab extensions, settings testing, bookmark/history workflows
Snapshot Testing
Overview
The project uses swift-snapshot-testing for snapshot testing, which captures and compares outputs to detect regressions.
Available snapshot types:
- Image snapshots: Visual UI testing
- JSON snapshots: Data structure testing
- Inline snapshots: Text-based output testing
When to Use Snapshot Testing
✅ Excellent for:
Complex Data Structures: Testing JSON responses, complex model transformations
// Example: Testing complex suggestion results
func testSuggestionResults() {
let suggestions = suggestionLoader.getSuggestions(for: "duck")
assertInlineSnapshot(of: suggestions.encoded(), as: .lines, matches: """
Suggestions:
- Search: "duck" (score: 100)
- Bookmark: "DuckDuckGo" (https://duckduckgo.com)
- History: "Duck typing" (https://en.wikipedia.org/wiki/Duck_typing)
""")
}
Visual Regression Testing: UI components, layouts, visual elements
Algorithm Output Validation: Testing complex calculations, parsing results
Cross-Platform Consistency: Ensuring consistent output across iOS/macOS
Image Snapshot Testing
Perfect for visual regression testing:
import SnapshotTesting
func testButtonAppearance() {
for appearanceName: NSAppearance.Name in [.aqua, .darkAqua] {
NSApp.appearance = .init(named: appearanceName)!
let button = createStyledButton()
assertSnapshot(of: button, as: .image(perceptualPrecision: 0.9), named: appearanceName.rawValue)
}
}
Image snapshot guidelines:
- Test in both light and dark modes
- Use
perceptualPrecision: 0.9for UI components (allows minor anti-aliasing differences) - Use
perceptualPrecision: 1.0for pixel-perfect requirements - Create consistent test environments (fixed window sizes, scale factors)
JSON Snapshot Testing
Ideal for data structure validation:
func testBookmarkImport() {
let importResult = bookmarkImporter.importFromHTML(testFile)
assertSnapshot(of: importResult.bookmarks, as: .json, named: "imported_bookmarks")
}
JSON snapshot benefits:
- Catches structural changes in data models
- Validates complex transformations
- Provides clear diffs for debugging
- Language-agnostic format
Inline Snapshot Testing
Best for text-based output and algorithm results:
func testSearchSuggestions() {
let results = searchEngine.getSuggestions(for: "privacy")
assertInlineSnapshot(of: results.encoded(), as: .lines, matches: """
Privacy Settings
Privacy Policy
Privacy Tools
""")
}
Inline snapshot advantages:
- Snapshots live in the test file
- Easy to review changes in pull requests
- No external files to manage
- Great for small, predictable outputs
Snapshot Testing Best Practices
Guidelines
- Make tests deterministic: Use fixed dates, sorted collections, consistent ordering
- Test meaningful changes: Don't snapshot trivial variations
- Use descriptive names:
named: "dark_mode_large_text"instead ofnamed: "test1" - Review snapshot changes: Always review generated snapshots during development
- Keep snapshots small: Large snapshots are hard to review and maintain
Memory Management with Snapshots
func testComplexView() {
autoreleasepool {
let window = SnapshotWindow()
let view = ComplexView(data: testData)
window.contentView = view
assertSnapshot(of: view, as: .image())
} // Ensures immediate cleanup
}
❌ When NOT to Use Snapshot Testing
- Highly dynamic content: Real-time data, user-specific content
- Performance testing: Snapshot testing is about correctness, not speed
- Simple value comparisons: Use
XCTAssertEqualfor basic assertions - Flaky systems: Outputs that vary between runs
Test Logging and Debugging
Using Logger.tests for Test Information
NEVER use print() in tests. ALWAYS use Logger.tests for debug output:
import os.log
✅ // GOOD: Use Logger.tests for test debugging
func testComplexFlow() {
Logger.tests.info("Starting complex flow test")
Logger.tests.debug("Setting up test data with \(testData.count) items")
Logger.tests.debug("DEBUG: requestCount = \(requestCount), currentState = \(service.currentState)")
// Perform test operations
Logger.tests.log("Test completed successfully")
}
❌ // BAD: Using print() statements
func testComplexFlow() {
print("Starting test") // Never use print()
print("DEBUG: requestCount = \(requestCount)") // Use Logger.tests.debug() instead
}
Benefits of Logger.tests:
- Structured logging that integrates with Xcode and CI systems
- Proper log levels (info, debug, error)
- Automatic collection in CI artifacts
- Better performance than print() statements
Comprehensive Failure Logging
For CI debugging, provide comprehensive failure logs:
func testDataProcessing() {
Logger.tests.info("Testing data processing with \(inputData.count) items")
do {
let result = try processor.process(inputData)
Logger.tests.debug("Processing completed with \(result.count) results")
XCTAssertEqual(result.count, expectedCount,
"Expected \(expectedCount) results, got \(result.count). Input: \(inputData)")
} catch {
Logger.tests.error("Processing failed: \(error)")
XCTFail("Processing failed with error: \(error)")
}
}
Best practices for failure logging:
- Include relevant context in assertion messages
- Log the actual values that caused failures
- Use structured logging for complex data
- Include test setup information that might affect results
Best Practices
Test Naming
XCTest Syntax (traditional):
func testBookmarkCreation_WhenValidData_ShouldSucceed() { }
func testUserAuthentication_WithInvalidCredentials_ShouldFail() { }
@Test Syntax (Swift Testing - preferred for new tests):
@Test("Bookmark creation succeeds with valid data")
func bookmark_creation_with_valid_data() { }
@Test("User authentication fails with invalid credentials")
func user_authentication_fails_with_invalid_credentials() { }
@Test("External URLs are validated correctly")
func external_urls_are_valid() { }
@Test Naming Guidelines:
- Use clear, descriptive text in
@Test("description") - Function names should be simple and readable (no "test" prefix needed)
- Use underscores for better readability in function names
- Focus the description on what behavior is being verified
Test Organization
Group related tests using // MARK: comments:
// MARK: - Bookmark Creation Tests
// MARK: - Bookmark Deletion Tests
Async Testing
Basic Async Testing:
func testAsyncOperation() async {
let result = await service.fetchData()
XCTAssertNotNil(result)
}
Async Testing with Timeouts:
// Using withTimeout for async operations
func testAsyncOperationWithTimeout() async throws {
let result = try await withTimeout(5.0) {
return try await service.performLongOperation()
}
XCTAssertNotNil(result)
}
// Custom timeout error
func testAsyncOperationWithCustomError() async throws {
do {
_ = try await withTimeout(1.0, throwing: CustomTimeoutError()) {
return try await service.performSlowOperation()
}
XCTFail("Should have timed out")
} catch is CustomTimeoutError {
// Expected timeout
}
}
Publisher Testing with Timeouts:
// Testing publisher with timeout using extensions
func testPublisherWithTimeout() async throws {
let subject = PassthroughSubject<String, Never>()
// Use timeout extension for publishers
let future = subject.timeout(2.0, "Publisher timeout").first().promise()
DispatchQueue.main.async {
subject.send("test value")
}
let result = try await future.get()
XCTAssertEqual(result, "test value")
}
// For testing actual async behavior, use proper event-driven patterns:
func testPublisherWithAsyncBehavior() {
let expectation = expectation(description: "Should receive published value")
let subject = PassthroughSubject<String, Never>()
let cancellable = subject
.sink { value in
XCTAssertEqual(value, "test value")
expectation.fulfill()
}
// Trigger the real event that should cause publication
service.performActionThatPublishes() // This internally calls subject.send()
wait(for: [expectation], timeout: 1.0)
cancellable.cancel()
}
Async Timeout Guidelines:
- Use
withTimeout()for Task-based async operations - prevents indefinite hanging - Use publisher
.timeout()extensions for Combine workflows - handles stream timeouts gracefully - Choose appropriate timeout values: Short for unit tests (1-5s), longer for integration tests (10s+)
- Always test timeout scenarios - ensure your code handles timeouts properly
- Use descriptive timeout messages - helps with debugging when timeouts occur
🚨 CRITICAL: Async Testing Anti-Patterns
❌ NEVER Use These Timing Patterns in Tests
NEVER use arbitrary delays in tests - they make tests flaky, slow, and unreliable:
❌ // BAD: Arbitrary time delays
func testBadPattern() {
// NEVER DO THIS
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
expectation.fulfill()
}
// OR THIS
Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { _ in
expectation.fulfill()
}
// OR THIS
Thread.sleep(forTimeInterval: 0.5)
wait(for: [expectation], timeout: 5.0)
}
✅ Use Event-Driven Testing Instead
Replace timing delays with event-driven expectations:
Pattern 1: Callback-Based Expectations
✅ // GOOD: Event-driven testing with callbacks
class MockService {
var onEventTriggered: (() -> Void)?
func triggerEvent() {
// Do work...
onEventTriggered?() // Signal completion
}
}
func testGoodPattern() {
let expectation = expectation(description: "Event should be triggered")
mockService.onEventTriggered = {
expectation.fulfill()
}
// Trigger the actual event
mockService.triggerEvent()
wait(for: [expectation], timeout: 1.0) // Short timeout for deterministic events
}
Pattern 2: Publisher-Based Expectations
✅ // GOOD: Combine publisher testing
func testPublisherPattern() {
let expectation = expectation(description: "Publisher should emit value")
let cancellable = service.statePublisher
.compactMap { $0 }
.first()
.sink { value in
XCTAssertEqual(value, .expectedState)
expectation.fulfill()
}
// Trigger the state change
service.updateState(.expectedState)
wait(for: [expectation], timeout: 1.0)
cancellable.cancel()
}
Pattern 3: Predicate-Based Expectations
✅ // GOOD: Condition-based waiting
func testPredicatePattern() {
// Trigger the operation
service.startOperation()
// Wait for specific condition to be true
let predicate = NSPredicate { _, _ in
service.isOperationComplete
}
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: nil)
wait(for: [expectation], timeout: 2.0)
XCTAssertTrue(service.isOperationComplete)
}
Pattern 4: Inverted Expectations for "Should Not Happen"
✅ // GOOD: Testing that something should NOT happen
func testShouldNotHappen() {
let expectation = expectation(description: "Should not trigger unwanted behavior")
expectation.isInverted = true // Test passes if expectation is NOT fulfilled
mockService.onUnwantedEvent = {
expectation.fulfill() // This would fail the test
}
// Trigger some action
service.performAction()
wait(for: [expectation], timeout: 1.0) // Short timeout for negative tests
// Verify expected behavior occurred instead
XCTAssertTrue(service.didPerformExpectedBehavior)
}
Why Event-Driven Testing Is Superior
- Deterministic: Tests wait for actual events, not arbitrary time
- Fast: No unnecessary delays - tests complete as soon as events occur
- Reliable: Eliminates race conditions and timing-dependent failures
- Maintainable: Clear relationship between triggers and expectations
- Debuggable: Failures point to actual logic issues, not timing problems
Migration Strategy
When you see these patterns in existing tests:
- Identify the real event the test is waiting for
- Add callback/publisher to the mock or service to signal that event
- Replace arbitrary delays with event-driven expectations
- Use shorter timeouts (1-2s) since events are deterministic
Remember: Good tests are event-driven, not time-driven!
⏰ Time/Date Testing Patterns
Critical Design Principle
ALWAYS inject time/date dependencies into your classes - NEVER use Date() or Task.sleep() directly in production code that needs testing.
🚫 FORBIDDEN: Task.sleep() in Tests
NEVER use Task.sleep() in any test code:
// ❌ NEVER DO THIS
try await Task.sleep(nanoseconds: 100_000_000) // Creates flaky tests
try await Task.sleep(for: .seconds(1)) // Unreliable timing
try? await Task.sleep(interval: 0.1) // Arbitrary delays
// ✅ DO THIS INSTEAD
let expectation = expectation(description: "Wait for async operation")
someAsyncOperation {
expectation.fulfill()
}
await fulfillment(of: [expectation], timeout: 1.0)
Why Task.sleep() is banned:
- Flaky tests - Real-world timing varies
- Slow tests - Fixed delays waste time
- Unreliable - May not wait long enough or wait too long
- Hides race conditions - Masks real timing issues
Timeout Guidelines
- Keep test timeouts reasonable: maximum 5 seconds for most async operations
- Use shorter timeouts (1-3 seconds) when possible to catch issues faster
- Only use longer timeouts for truly slow operations (network requests, file I/O)
🚫 NEVER Change Tests to Match Wrong Behavior
When tests fail, fix the code, not the test:
// ❌ WRONG: Changing test to match broken behavior
XCTAssertEqual(result.count, 3, "Should return 3 items") // Changed from 5 to 3 to make test pass
// ✅ RIGHT: Fix the actual code issue
// Fix the logic to properly return all expected items
XCTAssertEqual(result.count, 5, "Should return all 5 items as originally designed")
Tests should verify correct behavior, not accommodate bugs. If a test fails:
- First - Check if the production code has a bug
- Fix the bug in the production code
- Only then update the test if the expected behavior has legitimately changed
Never adjust tests to hide problems in the implementation.
When a class needs current time or sleep functionality, inject these dependencies through the initializer:
✅ // GOOD: Injectable time dependencies
class MyService {
private let dateProvider: () -> Date
private let sleeper: Sleeper
init(dateProvider: @escaping () -> Date = Date.init,
sleeper: Sleeper = .default) {
self.dateProvider = dateProvider
self.sleeper = sleeper
}
func performOperation() async throws {
let now = dateProvider() // ✅ Testable
// ... do work ...
try await sleeper.sleep(for: 1.0) // ✅ Testable
}
}
❌ // BAD: Hard-coded time dependencies
class MyService {
func performOperation() async throws {
let now = Date() // ❌ Not testable
// ... do work ...
try await Task.sleep(nanoseconds: 1_000_000_000) // ❌ Not testable
}
}
Pattern 1: TestClock for Async Sleep Testing
Use TestClock<Duration> for testing code that uses Task.sleep() or periodic operations:
✅ // GOOD: TestClock pattern for async sleep testing
func testPeriodicUpdates() async throws {
let clock = TestClock<Duration>()
let sleeper = Sleeper(clock: clock)
// Inject the test sleeper
let service = MyPeriodicService(sleeper: sleeper, interval: 2.0)
let expectation1 = expectation(description: "First update")
let expectation2 = expectation(description: "Second update")
var updateCount = 0
service.onUpdate = {
updateCount += 1
if updateCount == 1 {
expectation1.fulfill()
} else if updateCount == 2 {
expectation2.fulfill()
}
}
// Start the periodic task
let task = service.startPeriodicUpdates()
// Wait for first update (immediate)
await fulfillment(of: [expectation1], timeout: 1.0)
XCTAssertEqual(updateCount, 1)
// Advance clock by 2 seconds to trigger next update
await clock.advance(by: .seconds(2))
await fulfillment(of: [expectation2], timeout: 1.0)
XCTAssertEqual(updateCount, 2)
task.cancel()
}
// Production service with injectable sleeper
class MyPeriodicService {
private let sleeper: Sleeper
private let interval: TimeInterval
var onUpdate: (() -> Void)?
init(sleeper: Sleeper = .default, interval: TimeInterval) {
self.sleeper = sleeper
self.interval = interval
}
func startPeriodicUpdates() -> Task<Void, Error> {
Task.periodic(interval: interval, sleeper: sleeper) {
await self.performUpdate()
}
}
private func performUpdate() async {
// Do work...
onUpdate?()
}
}
TestClock Best Practices
ALWAYS use Task.megaYield(count: N) after clock.advance() to allow async processing:
✅ // GOOD: Proper megaYield usage
await clock.advance(by: .seconds(5))
await Task.megaYield(count: 5) // Allow async tasks to process
❌ // BAD: Multiple consecutive megaYield calls
await clock.advance(by: .seconds(5))
await Task.megaYield()
await Task.megaYield()
await Task.megaYield()
❌ // BAD: No megaYield after clock advance
await clock.advance(by: .seconds(5))
// Missing yield - async tasks may not complete
Why megaYield is critical:
- TestClock advancement is synchronous, but triggered async tasks need time to process
megaYield(count: N)ensures async operations complete before assertions- Use
count: 5as a standard (allows multiple yield cycles)
Pattern 2: MockDateProvider for Date Testing
Use MockDateProvider or TimeTraveller for testing code that needs current date:
✅ // GOOD: MockDateProvider pattern
class MockDateProvider {
private var date: Date
init(date: Date = Date()) {
self.date = date
}
func setNowDate(_ date: Date) {
self.date = date
}
func advanceBy(_ timeInterval: TimeInterval) {
date.addTimeInterval(timeInterval)
}
func getDate() -> Date {
date
}
}
func testDateBasedLogic() {
let mockDateProvider = MockDateProvider(date: Date(timeIntervalSince1970: 0))
let service = MyService(dateProvider: mockDateProvider.getDate)
// Test with specific date
let result1 = service.processData()
XCTAssertEqual(result1.timestamp, Date(timeIntervalSince1970: 0))
// Advance time and test again
mockDateProvider.advanceBy(TimeInterval.days(1))
let result2 = service.processData()
XCTAssertEqual(result2.timestamp, Date(timeIntervalSince1970: 86400))
}
// Production service with injectable date provider
class MyService {
private let dateProvider: () -> Date
init(dateProvider: @escaping () -> Date = Date.init) {
self.dateProvider = dateProvider
}
func processData() -> DataResult {
return DataResult(timestamp: dateProvider(), data: "...")
}
}
Pattern 3: Protocol-Based Date Injection
For more complex date/time scenarios, use protocol injection:
✅ // GOOD: Protocol-based date injection
protocol CurrentDateProviding {
var currentDate: Date { get }
}
extension Date: CurrentDateProviding {
public var currentDate: Date { self }
}
class MockDateProvider: CurrentDateProviding {
var currentDate: Date
init(currentDate: Date = Date()) {
self.currentDate = currentDate
}
}
class MyService {
private let dateProvider: CurrentDateProviding
init(dateProvider: CurrentDateProviding = Date()) {
self.dateProvider = dateProvider
}
func isExpired(_ item: Item) -> Bool {
return item.expiryDate < dateProvider.currentDate
}
}
func testExpiryLogic() {
let mockDateProvider = MockDateProvider(currentDate: Date(timeIntervalSince1970: 1000))
let service = MyService(dateProvider: mockDateProvider)
let expiredItem = Item(expiryDate: Date(timeIntervalSince1970: 500))
let validItem = Item(expiryDate: Date(timeIntervalSince1970: 1500))
XCTAssertTrue(service.isExpired(expiredItem))
XCTAssertFalse(service.isExpired(validItem))
}
Pattern 4: Combined TestClock and MockDateProvider
For services that need both current time and sleep capabilities:
✅ // GOOD: Combined time and sleep mocking
func testServiceWithTimeAndSleep() async throws {
let mockDateProvider = MockDateProvider(date: Date(timeIntervalSince1970: 0))
let clock = TestClock<Duration>()
let sleeper = Sleeper(clock: clock)
let service = MyTimedService(
dateProvider: mockDateProvider.getDate,
sleeper: sleeper
)
let expectation = expectation(description: "Operation should complete")
service.onOperationComplete = { result in
// Verify the result includes the correct timestamp
XCTAssertEqual(result.startTime, Date(timeIntervalSince1970: 0))
expectation.fulfill()
}
// Start operation
let task = service.startOperation()
// Advance mock time (affects dateProvider)
mockDateProvider.advanceBy(5.0)
// Advance test clock (affects sleeper)
await clock.advance(by: .seconds(1))
await fulfillment(of: [expectation], timeout: 1.0)
task.cancel()
}
Time Testing Best Practices
- Always inject time dependencies - Never use
Date()orTask.sleep()directly in production code - Use TestClock for async operations - When testing
Task.sleep(),Task.periodic, orSleeper - Use MockDateProvider for date logic - When testing date comparisons, timestamps, or date-based decisions
- Test time progression - Use
advance()methods to test how your code behaves over time - Test boundary conditions - Test behavior at midnight, month boundaries, leap years, etc.
- Keep time control granular - Advance time by specific amounts rather than arbitrary delays
Common Time Testing Mistakes
❌ Don't use real time in tests:
// BAD: Unreliable and slow
func testBadTimePattern() async {
service.scheduleTask()
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
XCTAssertTrue(service.taskCompleted)
}
✅ Use mock time instead:
// GOOD: Fast and deterministic
func testGoodTimePattern() async {
let clock = TestClock<Duration>()
let service = MyService(sleeper: Sleeper(clock: clock))
let expectation = expectation(description: "Task should complete")
service.onTaskComplete = { expectation.fulfill() }
service.scheduleTask()
await clock.advance(by: .seconds(2))
await fulfillment(of: [expectation], timeout: 1.0)
}
Remember: Control time in tests, don't wait for it!
Error Testing
Test both success and failure cases:
func testNetworkError_ShouldHandleGracefully() {
// Test error handling
}
Skipping Tests
Use XCTSkip to temporarily disable problematic tests:
// XCTest syntax
func testFlakySomething() throws {
throw XCTSkip("Flaky test - temporarily disabled")
}
// @Test syntax
@Test("Temporarily disabled flaky test")
func flaky_behavior() throws {
throw XCTSkip("Flaky test - investigating timing issues")
}
XCTSkip Guidelines:
- Use
throw XCTSkip("reason")to temporarily disable flaky or problematic tests - Function must be marked as
throwsto use XCTSkip - Include a clear reason in the skip message for future investigation
CI/CD Integration
Tests are automatically run in GitHub Actions with:
- Parallel execution for iOS and macOS
- Automatic log collection on failures
- JUnit report generation
- Crash report collection
- Asana integration for failure tracking
Debugging Failed Tests
1. Check Logs
Download log artifacts from GitHub Actions
2. Reproduce Locally
Use command line tools for consistent reproduction:
Run macOS tests
xcodebuild test
-scheme "macOS Browser"
-configuration "Debug"
-skipPackagePluginValidation
-skipMacroValidation
-allowProvisioningUpdates=NO
-only-testing:Unit\ Tests
```bash
# Run specific macOS Unit Test (e.g., HotspotDetectionServiceTests)
xcodebuild test \
-scheme "macOS Browser" \
-configuration "Debug" \
-skipPackagePluginValidation \
-skipMacroValidation \
-only-testing:Unit\ Tests/HotspotDetectionServiceTests \
-allowProvisioningUpdates=NO
Integration Tests
# Run specific macOS Integration Test (e.g., DownloadsIntegrationTests)
xcodebuild test \
-scheme "macOS Browser" \
-configuration "Debug" \
-skipPackagePluginValidation \
-skipMacroValidation \
-only-testing:Integration\ Tests/DownloadsIntegrationTests \
-allowProvisioningUpdates=NO
# Run all Integration Tests
xcodebuild test \
-scheme "macOS Browser" \
-configuration "Debug" \
-skipPackagePluginValidation \
-skipMacroValidation \
-only-testing:Integration\ Tests \
-allowProvisioningUpdates=NO
UI Tests
# Run iOS UI tests
xcodebuild test \
-scheme "iOS Browser" \
-workspace DuckDuckGo.xcworkspace \
-destination "platform=iOS Simulator,name=iPhone 15 Pro" \
-only-testing:UITests
# Run macOS UI tests
xcodebuild test \
-scheme "macOS UI Tests" \
-configuration "Review" \
-skipPackagePluginValidation \
-skipMacroValidation \
-allowProvisioningUpdates=NO \
-only-testing:UI\ Tests
Run specific macOS UI test case
xcodebuild test
-scheme "macOS UI Tests"
-configuration "Review"
-skipPackagePluginValidation
-skipMacroValidation
-allowProvisioningUpdates=NO
-only-testing:UI\ Tests/DownloadsUITests
> ⚠️ **AI Assistant Note**: Never run test commands automatically without explicit user permission.
### 3. Reproduce Flaky Tests
Reduce timeouts to increase failure rate locally:
```swift
// Temporarily reduce timeouts to catch race conditions
let shortTimeout = 0.1 // Instead of 5.0
XCTAssertTrue(element.waitForExistence(timeout: shortTimeout))
4. Run Multiple Times
Use Xcode's repeat testing options:
- Test Navigator: Right-click test → "Run [TestName] Repeatedly..."
- Test Settings: Set "Run" to repeat 10-100 times
- Without Building: Hold Option when clicking Run button for "Run Without Building"
- Command Line: Use
--repeat-countwith xcodebuild
5. Memory Debugging
Use Memory Browser and allocation tracking:
# Enable malloc stack logging for detailed memory traces
export MallocStackLogging=1
# Run tests with memory debugging
xcodebuild test -scheme YourScheme
In Xcode:
- Memory Browser: Debug → Debug Workflow → View Memory
- Malloc Stack Logging: Enable in scheme environment variables
- AutoreleaseTracker: Search for "AutoreleaseTracker" in Memory Browser to find leaked objects
- Stack Traces: View allocation stack traces for retained objects
For questions or improvements to this guide, please contribute to the documentation or reach out to the iOS/macOS team.
Recovering UI Automation Mode (MANDATORY when runner fails to initialize)
When every UI test fails immediately with errors like:
- "The test runner failed to initialize for UI testing. (Underlying Error: Timed out while enabling automation mode.)"
Follow these steps in order:
- Verify and re-grant Privacy permissions
- System Settings → Privacy & Security → Accessibility: enable Terminal and Xcode/Xcode-beta
- System Settings → Privacy & Security → Automation: under Xcode/Xcode-beta, allow controlling “System Events” and Finder
- Refresh the user session
- Quit Xcode and Terminal
- Log out and back in (preferred) or reboot the machine
- After login, open Xcode once to re-establish automation trust prompts
- Optional: Reset TCC entries (you will need to re-grant prompts)
tccutil reset Accessibility com.apple.dt.Xcode com.apple.dt.Xcode-Beta com.apple.Terminal
tccutil reset AppleEvents com.apple.dt.Xcode com.apple.dt.Xcode-Beta com.apple.Terminal
- Sanity-check with a minimal known-green test
- Run a single previously passing UI test/class (e.g., HTTPSUpgradeUITests) before running failing classes
Notes:
- Restarting
testmanagerdis restricted by SIP on recent macOS versions and usually not necessary once you refresh the session. - Record environment-related failures in
failing-ui-tests.mdand resume class-by-class once automation is restored.
Parsing xcresult Failures (MANDATORY)
Always extract failures from .xcresult to drive fixes. Use these exact steps:
- Dump legacy JSON to a temp file
xcrun xcresulttool get object --format json --legacy --path \
"/Users/admin/Library/Developer/Xcode/DerivedData/DuckDuckGo-<HASH>/Logs/Test/Test-macOS UI Tests-YYYY.MM.DD_HH-MM-SS-+ZZZZ.xcresult" \
> /tmp/xc_root.json
- Pull top-level failure summaries from ActionResult.issues.testFailureSummaries
python3 - << 'PY'
import json, re
j=json.load(open('/tmp/xc_root.json'))
vals = j.get('actions',{}).get('_values') or []
if not vals:
raise SystemExit('no actions in xcresult json')
act = vals[0]
fails = ((act.get('actionResult',{})
.get('issues',{})
.get('testFailureSummaries',{})
.get('_values')) or [])
def decode_url(url):
if not url: return ('','')
m = re.match(r'^file:\/\/(.*?)#.*StartingLineNumber=(\d+)', url)
return (m.group(1), m.group(2)) if m else (url,'')
for f in fails:
name = (f.get('testCaseName') or {}).get('_value','')
msg = (f.get('message') or {}).get('_value','')
url = (f.get('documentLocationInCreatingWorkspace') or {}).get('url',{}).get('_value','')
filePath, line = decode_url(url)
print(f"{name}\t{' '.join(msg.split())}\t{filePath}\t{line}")
PY
Notes:
- Use
--legacy; the non-legacy command is deprecated and returns no JSON here. - If per-test details aren't under the top-level summaries, traverse
actions._values[].actionResult.testsRefand followsummaryRefids for each leaf test to collectfailureSummaries. - Never guess failures by console output; always parse
.xcresult.
- Update
failing-ui-tests.mdwith a flat list of ❌ tests and exact reasons (includefile:linewhen available). Then fix tests one by one, marking progress as:
- ❓ fixed/unchecked
- ✅ validated
- ❌ still failing — reason