Legacy code exists in the codebases of many companies. Often within, there are anti-patterns that can cause roadblocks during development.

I recently added my first analytics event as part of some feature work that I had just finished up. I stumbled upon some unexpected issues that were consequences of incorrect patterns. In this article, I will show you how I overcame them using abstractions and improved testability.

The Windup

We have a feature or flow specific class called SessionMetrics. It contains all the necessary pieces to send analytics events related to our user sessions. Most of its methods take data as input and then create and send an event that is specific to our analytics library. It also defines common values that we send with our events.  

object SessionMetrics : BaseMetrics() {
    const val CONVO_STATUS = "conversation status"

    fun fireSelectedMsg(
        /* lots of params */
    ) {
        // create event
        fireEvent(event)
    }
} 

The SessionMetrics above extends from a parent class BaseMetrics. The parent is very minimal and is a wrapper for invoking our analytics library directly. It also contains a convenience method for creating an event specific to our analytics library.

Below is an example of what BaseMetrics looks like.

open class BaseMetrics {
    fun fireEvent(event: Event) {
        val instance = AnalyticsTool.getInstance()
    }
}

The Problem

As part of our work we needed to add in a new analytics event from a ViewModel. Naively, we conformed to the pattern above. The code ran as expected, but we were soon greeted with a broken unit test. Our static constructs were calling our analytics library directly and as a result our ViewModel test was failing.

The line val instance = AnalyticsTool.getInstance() from the example above was causing the issue. Our AnalyticsTool was never meant to run in the JVM and as a result has broken our unit tests. What could we do? We couldn't leave it like this.

What are we aiming for?

At the very least, our change should not break a test. At the very best, we would want to validate our change through a test in addition to not breaking existing tests. Let's aim high and see what we can can come up with!

Start with an Interface

Let's start by creating an interface that will define the values we want to send to our analytics client.

interface SessionMetrics {

    fun fireSelectedMsg(
        inboxSizeProperty: Int?,
        targetUserId: String?,
        matchedUser: User?,
        initialNWays: Int?
    )
}

We are calling our interface SessionMetrics and it will have our event method fireSelectedMsg() that takes in all the required data for our analytics event.

Create an Implementation

class AnalyticsLibSessionMetrics : SessionMetrics, BaseMetrics() {

    override fun fireSelectedMsg(
        inboxSizeProperty: Int?,
        targetUserId: String?,
        matchedUser: User?,
        initialNWays: Int?
    ) {
        // create our analytics library specific event
        // from our passed in data
        fireEvent(analyticsLibEvent)
    }
}

We then create our concrete implementation like above and name it AnalyticsLibSessionMetrics. The name should reflect what analytics library this specific implementation is working with. In this case it is AnalyticsLib. It implements our SessionMetrics interface above in addition to the utility BaseMetrics.  If you were using Firebase for example it would be FirebaseSessionMetrics

We can now pass our newly created class to our ViewModel like:

MessageThreadViewModel(AnalyticsLibSessionMetrics())

Fix the Test using a Fake

Let's get the test up and running again by creating a fake that represents our declared interface.

class FakeSessionMetrics : SessionMetrics {

    override fun fireSelectedMsg(
        inboxSizeProperty: Int?,
        targetUserId: String?,
        matchedUser: User?,
        initialNWays: Int?
    ) {
        // do nothing
    }
}

With our fake declared, we can create our ViewModel from the test like:

val fakeSessionMetrics = FakeSessionMetrics()
MessageThreadViewModel(fakeSessionMetrics)

Now our tests are working again because we aren't trying to actually call our analytics library. It feels like we can do better! Let's verify that the event was actually called.

Verify using a Fake

Let's add an instance variable to our FakeSessionMetrics.

class FakeSessionMetrics : SessionMetrics {
    var selectedMsgEventFired = false
    override fun fireSelectedMsg(
        // lots of params
    ) {
        selectedMsgEventFired = true
    }
}

The instance variable selectedMsgEventFired is set to true when our fake event method is called. We can then from a test validate that our fireSelectedMsg() method was invoked as we desired.

assertThat(fakeSessionMetrics.selectedMsgEventFired).isTrue()

Something Is Still Not Right

The parameters that our fireSelectedMsg() method is taking aren't the values that we pass to our analytics client. We do some processing of the data in order to derive some values that we then send to our analytics client. The current pattern in the code base would place this processing logic in the ViewModel. My current design had it in our newly created SessionMetrics It didn't really seem to belong in either.  

How Come It Doesn't Belong In Either?

Our ViewModel really shouldn't be responsible for processing data to send to our analytics layer. It increases the ViewModel's responsibility and makes it harder for us to validate the logic in this processing.  

If we put this processing logic in our concrete implementation of our helper SessionMetrics, we wouldn't be able to test it. We would have our original problem of a failing test because we are calling our analytics library directly. Does the processing logic have anything to do with our concrete implementation?

Data as a Type

Let's see exactly what we will be sending to our analytics client.  

    val hasRead: Boolean,
    val targetUserId: String?,
    val ageInDays: Int,
    val inboxSize: Int

Next, we can create a data class to represent these values.

data class SelectedMessageEvent(
    val hasRead: Boolean,
    val targetUserId: String?,
    val ageInDays: Int,
    val inboxSize: Int
)

Our SessionMetrics interface can now be changed.

interface SessionMetrics {
    fun fireSelectedMsg(
        selectedMessageEvent: SelectedMessageEvent
    )
}

This simplifies the interface and decouples our analytics library from our processing logic. We are now upfront about exactly what our analytics library is expecting for this event.

Processing Logic

We could associate our processing logic with our data class since they logically go together. We can attach a static method from() onto our data class like:


data class SelectedMessageEvent(
    val hasRead: Boolean,
    val targetUserId: String?,
    val ageInDays: Int,
    val inboxSize: Int
)
{
companion object {
        fun from(
            inboxSizeProperty: Int?,
            targetUserId: String?,
            matchedUser: User?,
            initialNWays: Int?
        ): SelectedMessageEvent { 
            // processing logic
        }
    }
}

Our invocation from our ViewModel would look like:

val event = SelectedMessageEvent.from(inSize, userId, matchedUser, nWays)
sessionMetrics.fireSelectedMsg(event)

What This All Means

Our ViewModel now has reduced responsibility. It creates an event, though it doesn't know how. It sends the event to an interface of which it does not know the concrete implementation. This all leads to improved testing. We can now assert at test time that our analytics layer was invoked.  

The analytics layer is now using our custom data class with derived values that require no processing. We are now explicit about what this layer needs. If, in the future, we wanted to replace our analytics library or add another implementation, it would need only to consume our new data class.

The processing logic used to create the event has now been isolated and can be tested. We can provide a wide array of inputs and validate that the event data class is generated as we expect.

Our test now works, we can validate correct behavior in our ViewModel, and we can validate that our event data class was created correctly.

I hope you enjoyed this article, please feel free to reach out to me on Twitter.

Interested in working for OkCupid? We're hiring!