Handling Instantiation Issues in iOS Testing: Two Effective Approaches
When writing iOS tests, especially for lifecycle methods like sceneWillResignActive, developers often encounter challenges when dealing with UIScene. This happens because certain objects, such as UIScene, are tightly coupled to the system and lack public initializers, making them difficult to instantiate directly for testing purposes. This article explores two effective approaches to handle this scenario: simulating UIScene with NSClassFromString and extracting logic into a testable function.
The Problem: Testing sceneWillResignActive
Consider a typical method in your SceneDelegate that handles cache validation when the app enters the background:
// SceneDelegate.swift
func sceneWillResignActive(_ scene: UIScene) {
store.deleteExpiredCache()
}
You want to write a test for this behavior to ensure that expired feed cache is deleted when the app transitions to the background. However, you face the following challenges:
1. No Public Initializer: UIScene cannot be instantiated directly, making it impossible to call sceneWillResignActive with a real UIScene instance.
2. System Dependency: Tests relying on UIApplication.shared.connectedScenes may fail unpredictably, especially in CI environments, where connectedScenes.first might be nil due to timing or resource constraints.
To address these issues, we can use one of two approaches.
Approach 1: Simulating UIScene with NSClassFromString
NSClassFromString is a powerful Objective-C API that allows you to dynamically access and instantiate classes by name at runtime. By using this technique, you can create a mock UIScene instance for testing purposes.
Implementation
Here’s how you can use NSClassFromString to simulate UIScene:
private func enterBackground(with store: InMemoryFeedStore) throws {
let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store)
// Dynamically create a UIScene instance
let sceneClass = NSClassFromString("UIScene") as? NSObject.Type
let scene = try XCTUnwrap(sceneClass?.init() as? UIScene)
// Call sceneWillResignActive with the mock scene
sut.sceneWillResignActive(scene)
}
Test Example
func test_onEnteringBackground_deletesExpiredFeedCache() throws {
let store = InMemoryFeedStore()
// Simulate entering background
try enterBackground(with: store)
// Assert that the expired cache was deleted
XCTAssertTrue(store.didDeleteExpiredCache, "Expected expired feed cache to be deleted when entering background")
}
Why Use This Approach?
- Tests Real-World Behavior: By calling sceneWillResignActive, you simulate the app’s actual behavior during lifecycle transitions.
- No Code Refactoring Needed: The production code remains unchanged, as the dynamic creation of UIScene is isolated to the test.
Downsides
- Dynamic Instantiation: Relies on runtime behavior, which might feel less explicit.
- Less Readable: Developers unfamiliar with NSClassFromString might find it harder to understand.
Approach 2: Extracting Logic into a Testable Function
An alternative approach is to refactor the sceneWillResignActive method to extract its core logic into a separate, testable function. This allows you to test the business logic directly without needing to simulate the UIScene.
Refactoring
Modify the SceneDelegate to include a validateCache function:
// SceneDelegate.swift
// refactor logic into public method
func validateCache() {
store.deleteExpiredCache()
}
func sceneWillResignActive(_ scene: UIScene) {
validateCache()
}
Test Example
func test_validateCache_deletesExpiredFeedCache() {
let store = InMemoryFeedStore()
let sut = SceneDelegate(httpClient: HTTPClientStub.offline, store: store)
// Directly test validateCache
sut.validateCache()
// Assert that the expired cache was deleted
XCTAssertTrue(store.didDeleteExpiredCache, "Expected expired feed cache to be deleted")
}
Why Use This Approach?
- Simplifies Testing: Focuses on testing the core business logic without relying on system objects like UIScene.
- Improves Code Clarity: Refactoring often leads to cleaner, more modular code.
Downsides
- Indirect Testing: Does not test the actual lifecycle method, so you must rely on integration tests to verify the full behavior.
- Requires Refactoring: Changes production code to support testing, which might not always be desirable.
Comparing the Two Approaches
Which Approach Should You Choose?
- Choose Simulating UIScene: When you want to test the actual app behavior during lifecycle transitions without modifying production code.
- Choose Extracting Logic: When you aim to isolate and test business logic directly, or if refactoring the code aligns with your project’s goals.
Conclusion
Testing lifecycle events in iOS can be challenging due to the tight coupling with system objects. By either simulating `UIScene` with `NSClassFromString` or extracting core logic into a testable function, you can ensure robust and reliable tests while maintaining clean code. Choose the approach that best fits your use case and project requirements.
By leveraging these strategies, you can overcome the limitations of untestable objects and build confidence in your app’s lifecycle handling.