You know how when you sweep a room, you're gathering dust into one corner of the room, so you can manage it easily?
Like there's nothing stopping you from iteratively moving dust into the dustpan for each square foot of the room, if you're so inclined? It’s just a lot, lot harder to do with a broom.
It's the same way we manage unpredictability in software code where unpredictability is the dust we are trying to sweep, and making dependencies explicit is the act of sweeping unpredictability into a corner so it can be easily managed.
Unpredictability and chaos has the bitter enemy of humanity since man encountered nature and every surprise usually meant doom unless it could be foreseen. Because software is ultimately built for humans, predictability is indeed an important aspect of software quality. You want your software to be predictable, so if a function returns “1”
today, it should continue to return "1"
on a leap year, blue moon, or during the 13th lunar month.
Writing testable code helps ensure that the behavior of your software is consistent and predictable, and it is the goal of this article, to show how understanding dependency trees and making dependencies explicit, can be harnessed to write testable software. We explore the concepts of explicit and implicit dependencies with examples, and show the problems caused by implicit dependencies.
When writing testable software, it helps to think of all functions/classes as a dependency tree. Every function may have from zero to many dependencies, with each dependency potentially having their own sub-dependencies, and so on.
Here is a getFullName
function, showing its dependency tree.
function getFullName(
firstName: string, // dependency 1
lastName: string // dependency 2
);
getFullName
relies on both firstName
and lastName
to do its work, so they are its dependencies.
In the above example, both dependencies of getFullName
are explicitly defined as its parameters. This is ideal because we can pass arguments for these parameters into our function when we want to test it. Knowing that getFullName
has no other dependencies means we will always get the same result. For example:
getFullName("John", "Doe") // will always return "John Doe"
getFullName("Sarah", "Doe") // will always return "Sarah Doe"
When dependencies are explicitly defined as parameters of a function, they are known as explicit dependencies. Such a function has no side effects, and is known as a pure function.
To explain what side effects are, we will consider the alternative in the example of getFullName(userId)
.
async function getFullName(
userId: string
) {
const user = await getUserDetails(
userId
)
return `${user.firstName} ${user.lastName}`
}
Here, getFullName
has userId
as an explicit dependency, but it also depends on getUserDetails
which is not explicitly named.
This kind of dependency is called an implicit dependency, and it introduces unpredictability and side effects in your functions, leading to untestable software. E.g.
getFullName("user-001") // returns "John Doe" today
// but may return "Sarah Doe", when `getUserDetails` resolves with a different user
// this is a side effect of using `getFullName`
To manage this unpredictability when testing, we can explicitly name getUserDetails
as a dependency of getFullName
.
async function getFullName(
userId: string,
getUserDetails: (userId: string) => Promise<{ firstName: string, lastName: string }>
) {
const user = await getUserDetails()
return `${user.firstName} ${user.lastName}`
}
Now, when testing, we can have:
getFullName("user-001", async () => ({ firstName: "John", lastName: "Doe" }))
// will always return "John Doe"
getFullName("user-002", async () => ({ firstName: "Sarah", lastName: "Doe" }))
// will always return "Sarah Doe"