Remove coupling between components to increase test coverage and reliability.
Unit tests, that you write using XCTest, can increase the speed of your development work, by giving you confidence that changes and additions don’t cause regressions in app functionality. Adding unit tests to existing projects can be difficult, because design choices made without considering testability can couple together distinct classes or subsystems, making it impossible to test them in isolation. Coupling in software design manifests as a class or function that can only be used successfully when connected to other code that works in a specific way. Sometimes, this coupling means your tests will attempt network connections or interact with the filesystem, which will make the tests slow and their results non-deterministic. Removing the coupling makes it possible to introduce unit tests, but requires code changes in places where you don’t already have test coverage, which can be risky.
Improve the test coverage of your project by identifying a component you’d like to test, and writing a test case that covers the behavior you want to assert. Use a risk-focused approach to prioritization that covers logic in features which have received a high number of user bug reports, or where a regression would have the highest impact.
When the code you’re testing is coupled to another part of your project or a framework class, make the smallest possible change to the code that isolates the component without changing its behavior. Improve the ability to use the class in a test context with reduced coupling, and keep the changes small to reduce the risk associated with each change.
The following sections propose changes that remove couplings in situations where coupling between the code under consideration and another component blocks testing. Each solution demonstrates how an XCTest case works with the changed code to assert its behavior.
Replace Concrete Type with Protocol
When your code relies on a specific class whose behavior makes testing difficult, create a protocol that lists the methods and properties used by your code. Examples of such problematic dependencies include those that access external state, including user documents or databases, or those that don’t have deterministic results, including network connections or random value generators.
The following listing shows a class in a Cocoa app that uses
NSWorkspace to open a file, which represents an attachment to an email or instant message. The outcome of the
open method depends on whether the user has an application installed that can handle files of the requested type, and whether the application successfully opens the file. All of these variables could introduce test failures, which would slow down development as you investigate “errors” that turn out to be transient problems unrelated to your code.
To test code with this coupling, introduce a protocol that describes how your code interacts with the problematic dependency. Use that protocol in your code, so your class depends on the existence of the methods in the protocol, but not their specific implementation. Write an alternative implementation of the protocol that doesn’t perform the stateful or non-deterministic tasks, and use that implementation to write tests with controlled behavior.
In this listing, a protocol that includes the
open(_:) method is defined, along with an extension to
NSWorkspace that makes it conform to the protocol.
In tests, write a different implementation of the
URLOpener protocol that doesn’t depend on the apps installed on the user’s computer.
Replace Named Type with Metatype Value
When one class in your app creates and uses instances of another class, and the created objects introduce testing difficulties, it can be hard to test the class where they’re created. Parameterize the type of the created object and use a required initializer to create an instance. Examples of this difficult testing situation include a controller that creates a new document on the filesystem in response to a user action, or a method that interprets JSON received from a web service and creates new Core Data managed objects that represent the received data.
In each of these cases, because the objects are created by the code you want to test, you can’t pass in a different object as a parameter to the method. The object doesn’t exist until it’s created by your code, at which point it’s of the type that has the untestable behavior.
The listing below shows a
UIDocument that creates and opens a document object when the user picks a document in the browser. The document object it creates reads and writes data to the file system, so its behavior is not easy to control in a unit test.
To remove the coupling between the code you’re trying to test and the objects it creates, define a variable on the class under test that represents the type of object it should construct. Such a variable is called a metatype value. Set the default value to the type the class already uses. You’ll need to ensure that the initializer used to construct instances is marked
required. This listing shows the document browser view controller delegate with that variable introduced. The delegate creates documents with the type defined by the metatype value.
Set a different value for the metatype in tests, so your code constructs an object that doesn’t have the same untestable behavior. In tests, create a “test dummy” version of the document class: a class with the same interface, but which doesn’t implement the behavior that makes it hard to test. In this case, a dummy document class should not interact with the file system.
Replace the document type with the dummy type in your test case’s
set method, so the delegate being tested creates instances of the dummy document type, which behave deterministically in the tests.
Subclass and Override Untestable Methods
When a class combines custom logic with interactions or behavior that make the class hard to test, introduce a subclass that overrides some of the class’s methods to make the others easier to test. It’s common to design classes that contain both app-specific logic, and interactions with the environment or frameworks that render behavior difficult to control in tests. A common example is a
UIView subclass, which has app-specific code in its action methods and also loads views or presents other view controllers.
Introducing tests for the custom app logic is desirable, to ensure that this logic works as expected and to protect against regressions. The complexity of controlling or working around the interactions between the class and the environment make testing the logic difficult.
As an example, the following iOS view controller populates a label with a user name found in a profile object (and, in principle, it could populate other UI elements with other fields in the profile). It uses
User to find the path to a file, tries to load that as a dictionary, then uses the values in that dictionary to populate the UI.
To overcome this complexity, subclass your view controller and “stub out” the methods that produce complex, untestable interactions, by overriding them with simpler methods. Use the subclass in your tests to verify the behavior of the custom logic, which you don’t override. You may also need to introduce a metatype value, if the code under test creates an instance of the target type.
The following listing introduces a subclass,
Stub, which doesn’t have all of the coupling to
User and the file system in its parent class. Instead, it uses a
User object that’s configured by the caller. Tests using this subclass can easily provide exactly the object needed to trigger the logic they’re testing.
Two tests are needed to fully cover the behavior of
view. One test checks whether the name is correctly set from the profile, if the profile can be loaded. The other test checks whether the placeholder value for the name is used, if the profile is not loaded.
Inject a Singleton
If your code uses a singleton object to gain access to globally-available state or behavior, turn the singleton into a parameter that can be replaced to support isolation for testing. Singleton use can be spread throughout a codebase, which makes it hard to know the singleton’s state when it’s used by the component you’re trying to test. Running tests in different orders may produce different outcomes.
Commonly-used singletons, including
NSApplication and the default
File, have behavior that’s dependent on external state. Components that use these singletons directly introduce more complications for reliable testing.
In this example, a Cocoa view controller represents part of a document inspector in a news app. When the view controller’s represented object changes, it posts a notification to the default notification center, to which other components in the app subscribe.
While a test could register with the default notification center to observe this notification, the use of the singleton notification center makes it possible for other components in the app to interfere with the test outcome. Other code could post the notification, remove observers, or run its own code in response to the notification, all of which may interfere with the outcome of the test.
Replace direct access to the singleton object with a parameter or property that can be controlled from outside the component under test. In the app, continue to use the singleton as the collaborator for the component. In tests, supply an alternative object that’s easier to control.
The following listing shows the result of applying this change to the article inspector view controller listed above. The view controller posts to the notification center defined in its
notification property, which is initialized to be the default center.
In a test case you can substitute a different notification center, which is not used elsewhere in the test suite or the app, and therefore is isolated from the behavior of other tests and modules.
You may need to combine this change with those described in the article sections: Replace Concrete Type with Protocol, and Subclass and Override Untestable Methods, to create the alternative object you use in the test in place of the singleton. You’ll need to do this where the singleton supplies behavior that’s difficult to control in a test, like