Making legacy Objective-C testable without rewriting it
The first unit test in a legacy codebase is the expensive one. Not because writing a test is hard — because the code was never built to be observed. Singletons reach out to the network in their initializers, view controllers do business logic in viewDidLoad, and everything talks to everything.
The instinct is to refactor first and test later. On a codebase with an active release train, that order is backwards: refactoring without tests is how regressions ship. You need a way to get code under test as it is, and that means creating seams without changing behavior.
func swizzle(_ cls: AnyClass, _ original: Selector, _ replacement: Selector) {
guard let m1 = class_getInstanceMethod(cls, original),
let m2 = class_getInstanceMethod(cls, replacement) else { return }
method_exchangeImplementations(m1, m2)
}
Method swizzling in the test target is one of those seams. It has a bad reputation — deserved, in production code. In a test target it's a scalpel: swap the network call for a stub at runtime, exercise the real logic, and the production code never knows it was observed.
That's how coverage goes from 20% to 80% on a codebase nobody dares to touch: not by rewriting it, but by making it observable one seam at a time — and then writing standards so the next ten thousand lines arrive testable by default.