Many of the best practices for writing testable code also conform to general code best practices. Code that is easily testable often also tends to be highly maintainable and resilient against changing business requirements. This blog post provides a brief overview of key criteria for writing highly testable code.
Separation of concerns
One of the keys to writing highly testable code is to ensure that there is a strong separation between the different parts of an application, with clear, simple APIs for interaction between those parts. Distinct areas of functionality like data retrieval, data processing, data display, and event handling should typically be separated into individual subsystems and further broken up into individual modules.
Aside from aiding in general code maintenance, ensuring a strong level of separation between different parts of your code makes it possible for components to be easily replaced with mock objects during testing. This allows units of code to be tested in total isolation from the rest of the system, eliminating failures caused by unintentional side-effects or incorrect interactions between multiple parts of the system, and also makes writing tests easier by reducing the amount of setup that needs to occur in order to run application components successfully.
For example, if component B is inappropriately modifying the internals of component A at runtime, those two components can no longer be separated or maintained in isolation. This is bad for your codebase’s maintainability, and it also makes it much more difficult to author unit tests for component A since you have to also have a copy of component B that needs to be configured to affect the desired change on component A. This is a simplified example; in normal apps, there can be tens of interrelated components like this, which can make real unit testing all but impossible.
Separating concerns also allows tests to be smaller, which makes any test failures that occur faster and easier to investigate and correct than they would be in a less granular system. For example, not knowing that component B modifies component A, if some part of component B changes that causes a test failure in component A, much more work is necessary in order to isolate that the source of the problem is actually coming from unrelated code running in component B. This sort of problem is again exacerbated in real apps that will often have multiple layers of indirection when not properly authored.
Object-oriented code design
Along the same vein as separation of concerns, following an object-oriented code design pattern is also extremely important when writing testable code. Code that runs procedurally in response to an external condition, like an anonymous function that executes when a browser DOM becomes ready, is impossible to unit test because there are no individual units of functionality that can be called, only one large blob of code held in closure. It also means that there is no way for multiple tests to execute fully independently of each other, which often leads to difficult-to-debug test failures caused by side effects being carried forward from previous tests.
Using an object-oriented code design pattern with constructor functions means that each test can instantiate a fresh copy of any object under test. This ensures that the potential for state being carried from one test to the next is minimised. It also makes it possible to use dependency injection to define mock dependencies during testing, which is described in more detail below.
Loose coupling / dependency injection
Tight coupling between components, where component A specifically requests module B instead of exposing a mechanism where a module like B can be passed to component A, increases testing difficulty by requiring the explicit dependencies of the component to be redefined from within the module loader instead of simply passing an alternative to component A during testing. It also makes it difficult to modify the behaviour of components at runtime by making it impossible for alternative implementations of external dependencies to be provided to different instances of a component. The name of the mechanism for passing dependencies into an object, instead of having an object reaching outside of itself for its dependencies, is called dependency injection.
For example, instead of here, where a specific store is given as an explicit dependency:
define([ 'app/stores/storeSingleton' ], function (storeSingleton) {
function Component() {}
Component.prototype.show = function (id) {
storeSingleton.get(id).then(function () {
// ...
});
};
return Component;
});
You could instead write the component so it accepts a store instance at construction time:
define([], function () {
function Component(store) {
this.store = store;
}
Component.prototype.show = function (id) {
this.store.get(id).then(function () {
// ...
});
};
return Component;
});
Communication between components can also be tightly or loosely coupled. A component that listens for events on another object would need a direct reference to the object in order to be able to listen for changes if the object only emits the events on itself:
define([], function () {
function Component(store) {
store.on('insert', function (record) {
// ...
});
}
return Component;
});
This is fine for components that normally have direct relationships, but works poorly in cases where a component may want to respond to events on multiple instances of an object, or where a component may want to respond to events on objects that that would either leak with a direct reference, or are created at unknown future moments, or that come from unknown places. In these instances, the use of a pub/sub hub, such as that provided by dojo/topic
, allows “global” notifications to be generated that can be handled by anyone at any time:
define([ 'dojo/topic' ], function (topic) {
function Component() {
topic.subscribe('/store/insert', function (store, record) {
// ...
});
}
return Component;
});
While this does introduce a direct dependency to the pub/sub hub, it enables components within the application to interact in complete isolation from each other, which means tests can simply dispatch compatible topics to test consumers, and monitor the hub for the correct messages from producers.
Elimination of globals
One of the primary goals of the AMD module system is to provide a mechanism that eliminates global variables in place of explicitly requested, relocatable module dependencies. This explicit dependency requirement becomes especially relevant when performing testing, for three reasons:
- Use of global variables encourages state to be shared across different components and tests. The order in which test suites are executed should not impact the correct functionality of any other test, but reusing objects that maintain any sort of state will cause brittle cross-dependencies between tests that should not exist.
- Components tested in isolation will not be able to rely on global variables that have been defined in scripts declared in the HTML parent, since when components are tested in isolation, they are no longer loaded along with the original HTML file. For example, if your main HTML file defines a global variable that defines where a service endpoint should exist, code that relies on that variable being populated will fail.
- Using a module system like AMD provides a standard mechanism (the
map
configuration) that guarantees that even explicit dependencies can be mocked without the possibility of race conditions. When using global variables, it becomes more difficult to ensure that a script will not load and hold a reference to an object inside the global scope that needs to be replaced with a mock object for testing.
In the case where application-global or system-global variables are necessary to simplify the architecture of an application, these properties are best either placed on a global application singleton module that can be relocated by the module loader, or stored on an object that can be generated in tests and passed to children as an injected dependency.
API naming conventions
When writing unit tests, it is important to be able to determine which properties and methods of an object are public and which are private implementation details of the object itself. This is because unit tests should only test the publicly defined APIs, as these are the only APIs that are guaranteed to exist and produce stable results. As long as the implementation of a public method generates the same results, it should not matter to unit tests how that result is produced. An added bonus of not testing private APIs is that, in conjunction with code coverage analysis, dead code belonging to implementations that have been subsequently factored out of use can be easily identified and removed. In JavaScript, private/protected properties and methods are conventionally annotated using an underscore prefix.
Clear code and documentation
Tests are not a substitute for a clear, well-maintained codebase. In fact, in order to write accurate tests, it is necessary that code is kept clean enough that test authors and future maintainers can quickly understand the purpose of each unit of code being tested and how it fits into the overall application.
One of the most effective ways to ensure this level of visibility for test authors is to ensure that general best practices for code authorship are followed. At a minimum:
- Public classes, properties, methods, and parameters should be documented using a standard code documentation format like JSDoc
- Variables and parameters should be clear and understandable, with no abbreviations, truncations, or non-standard terminology that will cause the meaning of the code to be lost or distorted
- Code comments should be provided for potentially confusing or odd-looking code, describing why (not what) the code is doing what it is doing
As touched upon when discussing API naming conventions, using idioms that are common to the language and libraries being used will ensure that test authors are able to complete their work quickly and without confusion.
By following these guidelines when authoring your own code, you’ll ensure that your applications are in excellent shape for testing and will run well long into the future.
If you’re still not sure where to start, or would like extra assistance making your code more testable and reliable, we’re here to help! Get in touch today for a free 30-minute consultation.