How to Build and Test Decision Tree UX

If you’ve never heard of the term “Decision Tree UX”, don’t worry, you’re not alone. In fact, no one has ever heard of it, because I made it up! While the term is new, the concept is not, and something most of you might already be familiar with.

Consider a typical enrollment flow as an example. A user begins the flow at a screen with several options for either signing up or logging in. While each choice might lead the user down a different path, all the paths share the same broader goal of authentication. And that folks, is Decision Tree UX. Or put in the form of a formal definition…

Decision Tree UX refers to the flow through a series of decisions and their possible consequences to achieve a single, broader goal.

Isn’t every part of an app a leaf on a decision tree? ๐Ÿ๐ŸŒฒ

Not exactly. The key to this concept is the “achieving a single goal” part. For example, if you are just navigating around an app, you don’t really have a single goal. Another way of thinking about the “single goal” might be to think of it as “single state” - where all your choices and all the visuals you see are all related to a single state. To better understand this concept, let’s look at this, very scientific, decision tree:

But since were talking about apps here, let’s convert this tree into a prototype.

๐Ÿ”ฎ rubs magic ball ๐Ÿ”ฎ

Tada ๐ŸŽ‰ We have “Decision Tree UX”! While there are several different screens, they are all related to the same “goal” or “state” of a cat’s single decision.

Why Is It Hard to Code This? ๐Ÿค”

I don’t have a great answer to this question, other than to say, when I was recently presented with the challenge of having to code a Decision Tree UX, I was overwhelmed with the options for how to build it and sometimes having too many options makes things hard.

Some of the things I considered and why I didn’t choose them ๐Ÿ™…

Option: Passing around the single model object from Activity to Activity via Intents.

Why I Said “No Way Jose”: For my app the model object was fairly large and passing it via an Intent wasn’t really an option. Also, passing around an object doesn’t allow for a higher level understanding of what is happening.

Option: Inserting some data store into each Activity so they might access the same object and pass the particular models’ id via Intents.

Why I said “No Way Jose”: This was my runner up. What I didn’t totally love about this option was how many switch statements I was going to have to put into each Activity. Depending on where the user was coming from, there were several options for where to send them next, and I was going to have to put a lot of, what I will call, “higher level logic” about the broader flow in each individual Activity.

Ingredients for a solution ๐Ÿ“‹

With my list of “what I don’t like about other options” in hand, I set forth to find a solution with the following features:

  • Testable! Decisions trees are complicated, so testing was super important
  • Types! I knew the more types I had the more explicit I could be in my testing
  • All traversal logic (where to go when a button is tapped) in a single place, to make testing simple and clear

As I thought about these requirements and how to solve for them, I realized I was really describing a parent / child relationship - where the parent held the state and acted like a puppeteer moving its children around. That thinking led me to choose Fragments as my means for implementation, because the relationship between Activitys and Fragments can mimic that of a parent / child.

Da Code ๐Ÿ’ป

Now that we understand the requirements and the things were trying to avoid, let’s start coding.

Using the above cat decision app as an example, let’s begin with the Fragments. The above app has four distinct screens, so let’s make four Fragments.

  1. StartFragment
  2. QuestionFragment
  3. AnswerAFragment
  4. AnswerBFragment

Now we know the parent Activity, let’s call it DecisionActivity, will initially begin by attaching StartFragment but what happens when a user taps the start button, and how can we test that?

The answer? Interfaces! Let’s give StartFragment an interface, called StartDelegate, to call whenever the start button is clicked.

interface StartDelegate {
  fun onStartClick()
}
class StartFragment : Fragment() {

  lateinit var delegate: StartDelegate

  override fun onViewCreated(view: View?, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    val startButton = view?.findViewById(R.id.start_button)
    startButton?.setOnClickListener { delegate.onStartClick() }
  }
}

But who will listen for that callback? The DecisionActivity is not a great candidate since unit testing with Activitys is not always easy, so let’s make a class to manage all the coordination logic that we can more easily mock and test. Say hello ๐Ÿ‘‹toDecisionCoordinator.

class DecisionCoordinator() {

  fun startFlow() {
    val fragment = StartFragment.newInstance()
    fragment.delegate = ??
  }
}

Before we implement StartDelegate let’s consider our options for who should be the one conforming to it. One option would be to have DecisionCoordinator conform. But that could quickly get messy since onStartClick isn’t terribly specific and theoretically other Fragments could also have a StartDelegate, which puts us back in a situation of having lots of switch statements in our code. Another option, and the option I chose, is to use inner classes. Remember earlier when I said I liked types? Well here they are folks! By having some inner class confom to StartDelegate we have a single class whose sole responsibility is handling the feedback from the StartFragment. Single Responsibility FTW ๐Ÿ’ช

Now let’s see the updated DecisionCoordinator with that inner class:

class DecisionCoordinator() {

  fun startFlow() {
    val fragment = StartFragment.newInstance()
    fragment.delegate = StartCoordinator()
  }

  inner class StartCoordinator : StartDelegate {

    override fun onStartClick() {
      //handle where to go when start is clicked
    }
  }
}

You might notice that we are not actually showing any Fragments yet, and there are two reasons for that. First, we are not “in” an Activity we don’t have access to a FragmentManager which can show a Fragment. Second, we want to be able to test everything remember? So we want a solution that will allow us to test that the correct Fragments are being shown at the right times. Say hello ๐Ÿ‘‹ to FragmentRouter.

interface FragmentRouter {
  fun showFragment(fragment: Fragment)
}

Here’s what happens when we inject FragmentRouter into the DecisionCoordinator.

+ class DecisionCoordinator(val fragmentRouter: FragmentRouter) {

  fun startFlow() {
    val fragment = StartFragment.newInstance()
    fragment.delegate = StartCoordinator()
+   fragmentRouter.showFragment(fragment)
  }

  inner class StartCoordinator : StartDelegate {

    override fun onStartClick() {
      //handle where to go when start is clicked
    }
  }

Now when we test our DecisionCoordinator we can mock the fragmentRouter to assert we are showing the correct Fragments๐Ÿ’ƒ But before we move onto the tests, let’s fill out the rest of the DecisionCoordinator with the remaining logic:

class DecisionCoordinator(val fragmentRouter: FragmentRouter) {

  fun startDecision() {
    val fragment = StartFragment.newInstance()
    fragment.delegate = StartCoordinator()
    fragmentRouter.showFragment(fragment)
  }

  inner class StartCoordinator : StartDelegate {

    override fun onStartClick() {
      val fragment = QuestionFragment.newInstance()
      fragment.delegate = QuestionCoordinator()
      fragmentRouter.showFragment(fragment)
    }
  }

  inner class QuestionCoordinator : QuestionDelegate {

    override fun onYesClick() {
      val fragment = AnswerA.newInstance()
      fragmentRouter.showFragment(fragment)
    }

    override fun onNoClick() {
      val fragment = AnswerB.newInstance()
      fragmentRouter.showFragment(fragment)
    }
  }
}

And finally, let’s take a look at those tests ๐Ÿ‘€

class DecisionCoordinatorTest {

  val fragmentRouter = mock<FragmentRouter>()

  @Test
  fun testStartCoordinator_onStartClick() {
    val decisionCoordinator = DecisionCoordinator(fragmentRouter)

    decisionCoordinator.StartCoordinator().onStartClick()

    verify(fragmentRouter).showFragment(isA<QuestionFragment>())
    verify(fragmentRouter).showFragment(check {
      assertTrue { (it as QuestionFragment).delegate is QuestionCoordinator }
    })
  }
}

A sight for sore eyes, isn’t it? Our test is readable and clear and the DecisionCoordinator fulfills all of our previous requirements. And that is a solid day’s work folks! Till next time ๐Ÿ‘‹