Mighty Practices

Architecting for Testability

How to design software that is easy to test

If you're finding it difficult to write automated tests for your code, it is often a symptom of the architecture, not the testing framework.

Good architecture enables easy testing; it's often worth redesigning your code to simplify your tests. As an added bonus, refactoring for testing often results in better code architecture in general.

Here's some (but definitely not all!) strategies one can use to make testing easier.

Smaller Functions

The less logic there is to test, the easier.

Imagine you've got this function:

fun changeOwner(itemId: String, newOwnerId: String) {
  // ...Fetch item from database...
  // ...Check that the new owner has authorization to own the item...
  // ...Change owner in the database...
}

Testing changeOwner() involves verifying three different moving parts - tricky!

Instead, let's divide changeOwner() into three separate functions:

fun getItem(itemId: String): Item { ... }

fun authorizeNewOwner(item: Item, newOwnerId: String) { ... }

fun changeOwner(item: Item, newOwnerId: String) { ... }

Testing each of these smaller functions will be much easier than testing one large one.

Constrained Inputs/Outputs

Limiting possible inputs/outputs means you have fewer cases to test.

Let's say we're testing a function that takes in user feedback over time and outputs whether they seem happy or not:

fun isUserHappy(feedback: List<Int>): Boolean { ... }

The feedback is just a plus, minus, or neutral (represented by -1, 0, and 1). But because you're using an Int, you have to test what happens if bad input is provided as well (such as a 2, or -100).

If we used an enum instead, we greatly constrain our input space:

enum class Feedback { PLUS, NEUTRAL, MINUS }

fun isUserHappy(feedback: List<Feedback>): Boolean { ... }

Now there are fewer cases to test (and as an added bonus, callers of this function are forced to provide good input).

There are plenty of ways to constrain inputs besides enums. Another common example is, instead of passing in a large data class (with many unrelated variables), make the input just the parts of that data class you need.

Dependency Injection

Dependency injection sounds fancy, but it simply means providing explicit dependencies instead of global dependencies. For example, instead of Foo calling MyApp.getDatabase(), instead the constructor should be Foo(val db: Database).

By setting up your code this way, you can swap test implementations, enabling complex testing that otherwise feels impossible.

For example, suppose you want to test that documents auto-expire after one hour:

@Test
fun expirationPolicy() {
  val docStore = DocumentStore()
  docStore.add(Document())

  // TODO: Advance time by 2 hours

  assertTrue(docStore.isEmpty())
}

Internally, DocumentStore is using System.currentTimeMillis(). How am I supposed to get the test to simulate an hour passing?

Let's inject a test clock into the document store instead:

@Test
fun expirationPolicy() {
  val clock = FakeClock()
  val docStore = DocumentStore(clock)
  docStore.add(Document())

  clock.advanceTime(hours=2)

  assertTrue(docStore.isEmpty())
}

Our fake clock lets us control time itself! Now we can manipulate test dependencies instead of being beholden to them.

Avoid Mixing Glue & Logic

Here's an excellent article that points out that there are essentially three types of components: value objects (dumb, holds data), service objects (logic), and glue (pieces things together).

It's worth reading the article in full (it's not long!) but the point it makes is that it's much easier to test logic than it is to test glue. Therefore, it's best to avoid mixing glue with logic when it comes to testing.

Help us improve

Your feedback helps us create better resources for teams like yours.

Was this page helpful?

Last updated on