The struggle with breaking tests and hard to change code: Test API to the rescue!

Putting tests on production code can make it very hard to work with the code: the tests can be difficult to understand and the production code can become more challenging to change because you have to fix tests alongside – it’s bad for a variety of reasons.

by Marc Neureiter

TL;DR:

  • Create a test API: by comparatively small effort you can create a test API that abstracts away technical implementation details so your tests are easier to understand and change. As the coupling between the test and production code becomes less, you’ll notice that production code becomes way easier to change.
  • This is a boost for your and your team’s efficiency and developer experience!
  • Coding example and explanatory video included!

 

Omg the code is so hard to change!

 

„If the structure of the tests follows the structure of the production code, then the tests are inextricably coupled to the production code“
Uncle Bob

 

As software developers, we naturally take our work seriously, and this includes adding tests with our production code from the very start. However, as we delve deeper into writing test code, meticulously covering every conceivable test case, you may have noticed that not all tests are created equal. Over the years, it feels like some tests become so intertwined with our production code that working with them is like wading through molasses. Weren’t we told that tests are supposed to boost our efficiency overall?

 

That’s true. Tests should be fast to read, write and modify – same as our production code. The problem lies within the depths of the code’s architecture, as is very often the case when development speed and experience suffer. So let’s examine!

 

Why, oh why?

 

Architecturally speaking, tests sit on top of our production code. They are independently deployed, but they depend on our production code. And whenever we change something in production code it’s not necessarily the changing part that causes the biggest problem but the stuff that depends on it (in this case: the tests), as it doesn’t work with the new behavior or structure. I’m sure you have experienced that a ton of times.

 

Consequently, you need to fix your tests so they work with your production code again. This is very bad as tests can only serve their purpose if they are as stable as possible. If you want them to protect you against mistakes in the production code, you surely don’t want to introduce another source of mistakes by having to rewrite big parts of your test code. So the goal must be to keep them as stable as possible.

 

Something stable (tests) depending on something volatile (much of the production code) is a basic problem in software architecture. And usually you use abstraction or dependency inversion to solve those. In this case we can go with abstraction: a test API.

 

Don’t back off here thinking “why should I create a dedicated API and a layer of complexity only for testing?”. I’d say, only if it decreases overall complexity perceived during ongoing development of your system. We are using architectural means for solving challenges all day. It’s just a matter of getting used to it. I still remember the times when the MVVM pattern felt like a waste of time for me. That was, back then, because I hadn’t spent so much time maintaining UIs yet. Now, I know that mashing multiple concerns into one class makes me only faster up-front. It sounds silly now, but I know we’ve all been there some way or the other.

 

Saying that introducing a test API grants you superpowers might be an overstatement, but if you are trying to implement a new feature and struggling with tests a third of the time: give it a try! By utilizing it in the right way you’ll be able to notice that revamping a whole production class can be a thing that doesn’t need a rewrite of your tests anymore: on the contrary, the tests still secure the production code from regressions while you are carving out responsibilities into various new classes and methods and move code around between them, essentially giving you strong confidence in your implementation throughout the process. But let’s come back to examining the impact of this approach in more detail after the example below so it becomes less words and more tangible results.

 

Introducing: test API – an example

 

In order to make the topic as easy as possible to understand I prepared a video here and don’t forget to continue with the conclusion below. So feel free to follow that one through, or in case you have no audio, below there is a summary for you.

 

 

Let’s say we have that little piece of production code:

 

class UserViewModel(
    private val userRepository: UserRepository,
    private val sessionManager: SessionManager,
    private val dateProvider: DateProvider,
) {
   var viewData: ViewUserData = ViewUserData.EMPTY
   var errorMessage: String? = null
   var isLoading: Boolean = false
}

 

It takes some dependencies in the constructor and exposes its state through three properties. The first part of A test of this class might look like this:

 

class UserViewTest {
    private val mockHttp = MockHttp()
    private val userService = mockHttp.getUserService()
    private val userRepository = UserRepository(userService)
    private val sessionManager = SessionManager()
    private val dateProvider = DateProvider { LocalDate.parse("2024-02-21") }


    private val testUser = User(
        id = "123123",
        name = "Marc",
        birthday = LocalDate.parse("1986-10-12")
    )

    ...

So to make the test work, the view model will need a mock HTTP service that is able to receive some user data via the userService. That one is used by a repository. And that repository alongside the sessionManager will thereafter be used by the view model.

 

Let’s continue:

 

@Test
fun `Simple success case`() {
    mockHttp.mock("getUser") { testUser } // (1) initialize the mock behavior
    sessionManager.logIn(testUser.id) // (1) set the session state

    ...

    val sut = UserViewModel( // (2) instantiation of the production class 
        userRepository,
        sessionManager,
        dateProvider
    )

    assertEquals( // (3) assertion
        UserViewModel.ViewUserData("Marc", "37 years"),
        sut.viewData
    )

    ...
}

 

So here I am (1) setting some preconditions, (2) instantiating the view model with all its dependencies above and (3) checking whether the correct user data is put out.

 

So, what should we do if we were to introduce a test API? Let’s extract those calls in the test function into smaller function pieces:

 

@Test
fun `Simple success case`() {
   givenUser(testUser) // (1)
   givenLoggedIn(testUser) // (1)

   ...

   whenInitializing() // (2)

   thenDataIsDisplayed( // (3)
       name = "Marc",
       age = "37 years"
   )
}

 

That’s not only easier in our eyes, it also conveniently abstracts the implementation details from the business of the test. Speaking of technical implementation:

 

private fun givenUser(testUser: User = testUser) {
    mockHttp.mock("getUser") { testUser }
}

private fun givenLoggedIn(testUser: User = testUser) {
    sessionManager.logIn(testUser.id)
}

private fun thenDataIsDisplayed(name: String, age: String) {
    assertEquals(
        UserViewModel.ViewUserData(name, age),
        sut.viewData
    )
}

 

As you see, I added parameters to all of the functions above. So whenever we want to test with different users we might simply pass different user instances to givenUser, even changing it during runtime. And to verify different view states we can pass varying data to thenDataIsDisplayed accordingly.

 

private fun whenInitialized() {
   sut = UserViewModel( // sut becomes class member!
       userRepository,
       sessionManager,
       dateProvider,
       dispatcherProvider
   )
}

 

This function prefixed by “when” is the place where we finally interact with the production class. But it could be any other subsequent interaction as well, like calling a function to simulate a click on the user interface.

The use of functions, parameter names and arguments like this serves the purpose of readability and reusability pretty effectively. Let’s showcase that in another test case:

 

@Test
fun `Is first loading and empty, then done loading and data is present`() {

    ...

    whenInitializing()

    runCurrent()

    thenIsLoading()

    thenDataIsDisplayed( // empty stare first
        name = "",
        age = ""
    )

    advanceTimeBy(processingTime + 1)

    thenIsNotLoading() // finished loading

    thenDataIsDisplayed( // filled state
        name = "Marc",
        age = "37 years"
    )

    ...

}

 

So we reused most of the functions created above; we only needed to introduce two extra functions to accommodate the loading state (thenIsLoading(), thenIsNotLoading()).

 

That’s already nice, but the true powers of the test API over time can only be revealed if we do changes as we would expect throughout the development of a project and with the scale of our test class.

 

What if we decide that testing the view model with all its adjacent dependencies in place is too complex and we want to get rid of mockHttp? That would break our original test in many places – in each and every test case or function:

 

mockHttp.mock("getUser") { testUser } // <-- it breaks here

 

Or let’s consider changing the way the production class communicates through its API and encapsulate the three properties in a single state data structure:

 

// Before:
var viewData: ViewUserData = ViewUserData.EMPTY
var errorMessage: String? = null
var isLoading: Boolean = false

// After:
var state = State() // <-- single state property

data class State(
    var viewData: ViewUserData = ViewUserData.EMPTY,
    var errorMessage: String? = null,
    var isLoading: Boolean = false,
)

 

This is a very common transition if we’re coping with increasing complexity. Here again test code breaks at many places:

 

assertEquals(
   UserViewModel.ViewUserData(name, age),
   sut.viewData // <-- is breaks here
)

 

We would need to go through all of those test cases where the original state is mentioned, try to understand them and fix the broken calls one-by-one. But with a test API in place, we reduce those places to only the code behind the test API. It might seem negligible in this example, but just imagine three times the dependencies, ten times the test cases or a multitude of times the complexity. It scales fast!

 

If you want to check out the example project and its progression steps in whole, feel free to have a look at that GitHub project, alongside the commit history as well as the production and test code. I designed it with minimal library dependencies and with naive implementation so you don’t really need to know the Kotlin programming language or ecosystem in detail at all.

 

Conclusion

 

What you’ve seen in this code example was one way to implement a test API. It follows the very common Given-When-Then structure (another variant of this is AAA = Arrange-Act-Assert). Note that test APIs can exist on all levels, so not primarily only on unit level but also up to UI or system tests (also see Page Object). And putting the test API right in the test class where it acts on production code is only one way to do it. In fact, it’s only the simplest.

 

To progress the design even further you may also consider moving the test API into a dedicated class that you can instantiate in your test class (composition) or into a base class of your test class (inheritance), but consider composition over inheritance! So the simple approach is fine for most cases, but if you’re interested in the composition approach, feel free to have a look at that other blog article of mine.

 

Let’s recap once again what this approach gives us:

  1. Comprehensibility: we might have added complexity to the test code structure, but it is easier to understand; the test case is broken down into a comprehensible picture of business-facing functionality; each function makes the meaning, expectation and variation of its technical implementation clear through its name and parameter list.
  2. Separation of concerns: test cases mainly tackle the what of the tests (the business), the code behind the test API is concerned with the how (technical implementation details). If the behavior of the system changes, usually only the business part in the test cases need to be changed and if the technical implementation details change, only the code behind the test API needs to be changed.
  3. Maintainability: the test code might be a bit harder to write once, but the production code (including the test code) becomes way easier to change for the next at least ten times ahead.

It turns out that even usually very hard heavy lifting in the code like changing dependencies or the API of the production code doesn’t really need to be held up by complicated broken test code. On the contrary: the tests in place can still survive and be stable in big parts to accompany the refactoring process, giving us confidence we haven’t broken the production code.

 

As always with good architectural choices, the implementation might be harder up-front, but the more we progress through time, modify and grow our project, the more the approach pays off. By going that route, instead of making code unnecessarily hard to change, we can even incentivize ongoing evolution (because, as you know, change is the only constant).

 

But nonetheless, this is no recipe to be followed blindly (as always): I think trying it out, giving it a chance and seeing how hard or easy it is to introduce changes over time is the best way to get a grasp of where and when it’s most useful to be put in place.

 

I hope you have a lot of fun trying out that approach altogether with your IDE’s refactoring tools – see you another time!