Refactoring For Testability: Phase 2 Coverage Improvement
Overview
This article discusses Phase 2 of the test coverage improvement plan, as outlined in plans/xcuitest.md. This phase focuses on making minimal code changes to enable better testing and prepare for a future Swift port. The target for this phase is v3000.1.x (pre-Swift preparation), and it depends on the successful completion of #197 (Phase 1 tests provide a safety net for refactoring).
Refactoring Goals
The primary goal of this phase is to improve the testability of the existing codebase, making it more robust and easier to maintain. This involves several key areas, each designed to address specific challenges and pave the way for future development, particularly the planned Swift port.
2.1 Extract Testable Logic from View Controllers
Improving testable logic is a critical aspect of this refactoring phase. Currently, a significant problem exists where MPDocument methods return early when the editor outlet is nil. This makes headless testing impossible, hindering our ability to thoroughly test the application's logic in isolation. To address this, we propose extracting the business logic from the view controllers into separate, testable classes. This approach allows us to test the core functionality of the application without relying on the user interface.
Consider the following example:
// New class: MPScrollSyncEngine
@interface MPScrollSyncEngine : NSObject
- (NSArray<NSNumber *> *)detectHeaderLocationsInMarkdown:(NSString *)markdown;
- (CGFloat)calculateScrollPositionForEditorOffset:(CGFloat)offset
editorLocations:(NSArray<NSNumber *> *)editorLocs
webViewLocations:(NSArray<NSNumber *> *)webViewLocs;
@end
// MPDocument uses it:
@property (strong) MPScrollSyncEngine *scrollSyncEngine;
By creating a dedicated MPScrollSyncEngine class, we can encapsulate the scroll synchronization logic and test it independently. This not only improves the testability of the code but also promotes a cleaner, more modular design. The following new tests will be enabled as a result of this refactoring:
- testHeaderDetectionInMarkdown
- testScrollPositionCalculation
- testScrollSyncWithImages
- testScrollSyncEdgeCases
These tests will ensure that the scroll synchronization logic functions correctly under various conditions, providing a solid foundation for future development.
2.2 Document Protocol for Testing
Implementing a document protocol is another key step in enhancing testability. To facilitate robust testing, we will define a protocol that outlines the essential properties and methods of a document. This protocol will allow us to create mock documents for integration tests, simulate multi-document scenarios, and avoid the common issue of nil outlets. Furthermore, this protocol is crucial for enabling an incremental Swift port, as it provides a clear interface for Swift classes to interact with the document functionality.
The proposed protocol is as follows:
@protocol MPDocumentInterface
@property (copy) NSString *markdown;
@property (readonly) BOOL isLoading;
- (void)updateHeaderLocations;
@end
@interface MPDocument : NSDocument <MPDocumentInterface>
By adopting this protocol, we gain several benefits. Firstly, we can easily create mock MPDocument objects for testing purposes, allowing us to isolate and test specific components of the application. Secondly, the protocol enables us to test multi-document scenarios, ensuring that the application behaves correctly when handling multiple documents simultaneously. Thirdly, it helps us avoid outlet nil issues, as we can interact with the document through the protocol interface rather than relying on direct access to outlets. Finally, and perhaps most importantly, this protocol enables an incremental Swift port by providing a clear contract for Swift classes to adhere to.
2.3 Preference Observation Protocol
Establishing a preference observation protocol is essential for testing how the application responds to changes in user preferences. This protocol will allow us to test preference changes without needing the UI, verify that components respond correctly to these changes, and even simulate preference corruption. This is also a preparatory step for replacing PAPreferences, making the application more flexible and maintainable.
The proposed protocol is as follows:
@protocol MPPreferenceObserver
- (void)preferenceDidChange:(NSString *)key value:(id)value;
@end
This protocol provides a standardized way for components to observe and react to preference changes. By using this protocol, we can write tests that verify the behavior of components when preferences are modified, ensuring that the application remains consistent and predictable. For example, we can test how the application responds to changes in the user's preferred font size or theme without having to manually interact with the UI. Additionally, this protocol allows us to simulate preference corruption, which can be a valuable tool for identifying and addressing potential issues related to data integrity.
2.4 MPDocument Decomposition
The current state of MPDocument.m, which is 2,178 lines long, exemplifies a