Writing Clean Code with Jetpack Compose using Test-Driven Development (Part 2)

Photo by Alison Marras on Unsplash

We have now run a test case to verify that the components on the CalculatorScreen Composable are all visible as required. Now, we're ready to delve more into TDD. If you've not already read the introductory article, kindly do so.

In this article, we shall continue the test-driven development by testing the UI state. The UI state is usually provided by the ViewModel class and it is dependent on the input events from the UI. We want to ascertain that the UI state responds as expected, to the given input events.


Testing the UI state

First, go to the test source set of your project and create a class CalculatorViewModelTest.

class CalculatorViewModelTest {

}

Next, move over to the main project and create a CalculatorViewModel class under the ui package.

class CalculatorViewModel: ViewModel() {

}

Let's define a class to represent the UI state of the CalculatorScreen

data class CalculatorState(
  val number1: String = "",
  val number2: String = "",
  val op: Operation? = null,
)

In the above data class, we added three fields, number1, number2, and op. Since it's a calculator app, we carry out computations on two distinct numbers: number1 and number2. The op field represents the arithmetic operation we intend to carry out between the two given numbers. It is currently unresolved, yeah.

Next, create a sealed class called Operation to represent the arithmetic operations that could be carried out.

sealed class Operation(val symbol: String) {
  object Add: Operation("+")
  object Subtract: Operation("-")
  object Multiply: Operation("X")
  object Divide: Operation("/")
}

Next, under the CalculatorViewModel class, initialize the UI state.

class CalculatorViewModel : ViewModel() {
  var state by mutableStateOf(CalculatorState())

}

Next, we want to declare the input events that would cause the UI state to change. We will declare a sealed class as follows:

sealed class UiEvent {
  data class Number(val number: Int): UiEvent() 
  object Clear : UiEvent() 
  object Delete: UiEvent() 
  data class Op(val operation: Operation): UiEvent() 
  object Calculate: UiEvent() 
  object Decimal: UiEvent()
}

Next, create an EventHandler contract as follows:

interface EventHandler {
    fun onEvent(event: UiEvent)
}

Now, let's update the CalculatorViewModel class

class CalculatorViewModel: ViewModel(), EventHandler {
    var state by mutableStateOf(CalculatorState())
  
     override fun onEvent(event: UiEvent) {

     }
}

Back to our CalculatorViewModelTest class, set the class up as follows:

class CalculatorViewModelTest {
    lateinit var viewModel: CalculatorViewModel

    @Before
    fun setup() {
        viewModel = CalculatorViewModel()
    }


}

Our first test case would be to ensure that the calculator only accepts a number having a maximum length of eight digits. Trying to add more digits yields no result since this is a small calculator app.


 @Test
 fun enterNumber_whenLengthOfNumberTooLong_doesNothing() {
    //when number1 engaged
    viewModel.onEvent(UiEvent.Number(23344445))
    viewModel.onEvent(UiEvent.Number(4242))
    assertEquals("23344445", viewModel.state.number1)
    assertNotEquals("4242", viewModel.state.number2)
    
 }

Run the test and it should fail. Now, let's fix the code. Go to your CalculatorViewModel class and update the code as follows. Don't forget to update the onEvent(UiEvent) function.

class CalculatorViewModel: ViewModel(), EventHandler {
    var state by mutableStateOf(CalculatorState())
  
     override fun onEvent(event: UiEvent) {
        when(event) {
          is UiEvent.Number -> enterNumber(event.number)
          else -> Unit 
        }
     }

    private fun enterNumber(number: Int) {
       if(state.number1.length >= 8) return 
           state = state.copy(
            number1 = state.number1 + number  
           )
    }
}

Yes, now you can run the test again, this time, it should pass. Hurray!


Next, we run a test to verify that when there is no arithmetic operation entered, only number1 is engaged.

    @Test
    fun enterNumber_whenNoOperation_updateNumber1State() {
        viewModel.onEvent(UiEvent.Number(34))
        assertEquals("34", viewModel.state.number1)
        assertEquals("", viewModel.state.number2)

        viewModel.onEvent(UiEvent.Number(68))
        assertEquals("3468", viewModel.state.number1)
        assertEquals("", viewModel.state.number2)
    }

Run this test, and it should pass. The test passes because, from our enterNumber(Int) function, the input operation occurs only on number1

In addition to this test, we should verify the second case, the case when the op is not null.

 @Test
    fun enterNumber_whenOperation_updateNumber2State() {
        viewModel.onEvent(UiEvent.Number(12))
        viewModel.onEvent(UiEvent.Op(Operation.Add))
        viewModel.onEvent(UiEvent.Number(23))
        assertEquals("12", viewModel.state.number1)
        assertEquals("23", viewModel.state.number2)

        viewModel.onEvent(UiEvent.Number(900))
        assertEquals("12", viewModel.state.number1)
        assertEquals("23900", viewModel.state.number2)
    }

This test should fail as we're yet to implement the arithmetic operation. Speaking about the arithmetic operation, let's go ahead to create a new function enterOperation(Operation) under the CalculatorViewModel class. Don't forget to update the onEvent(UiEvent) function.

class CalculatorViewModel: ViewModel(), EventHandler {
    var state by mutableStateOf(CalculatorState())
  
     override fun onEvent(event: UiEvent) {
        when(event) {
          is UiEvent.Number -> enterNumber(event.number)
          is UiEvent.Op -> enterOperation(event.operation)
          else -> Unit 
        }
     }

    private fun enterNumber(number: Int) {
       if(state.number1.length >= 8) return 
           state = state.copy(
            number1 = state.number1 + number  
           )
    }

    private fun enterOperation(op: Operation) {
        
    }
}

As you just observed, there's some kind of diversion here. Yes, this is because it so happens that the enterOperation(Operation) function needs to be up and running before we can proceed with number2. On this note, let's formulate test cases for enterOperation(Operation).

    @Test
    fun enterOperation_whenOpEnteredAndNumber1IsBlank_opIsNull() {
        viewModel.onEvent(UiEvent.Op(Operation.Add))
        assertEquals(null, viewModel.state.op)
        assertEquals("", viewModel.state.number1)
    }

    @Test
    fun enterOperation_whenOpEnteredAndNumber1IsNotBlank_updateOp() {
        viewModel.onEvent(UiEvent.Number(458))
        viewModel.onEvent(UiEvent.Op(Operation.Add))

        assertEquals(Operation.Add, viewModel.state.op)
        assertEquals("458", viewModel.state.number1)
    }

The above test cases should fail. Now, let's add the code to the function.

class CalculatorViewModel: ViewModel(), EventHandler {
    var state by mutableStateOf(CalculatorState())
  
     override fun onEvent(event: UiEvent) {
        when(event) {
          is UiEvent.Number -> enterNumber(event.number)
          is UiEvent.Op -> enterOperation(event.operation)
          else -> Unit 
        }
     }

    private fun enterNumber(number: Int) {
       if(state.number1.length >= 8) return 
           state = state.copy(
            number1 = state.number1 + number  
           )
    }

    private fun enterOperation(op: Operation) {
        if (state.number1.isBlank()) return
        state = state.copy(op = op)
    }
}

Run the tests again, this time around they should both pass. Welcome once again to TDD!


Now, let's continue our test for number2

    @Test
    fun enterNumber_whenOperation_updateNumber2State() {
        viewModel.onEvent(UiEvent.Number(12))
        viewModel.onEvent(UiEvent.Op(Operation.Add))
        viewModel.onEvent(UiEvent.Number(23))
        assertEquals("12", viewModel.state.number1)
        assertEquals("23", viewModel.state.number2)

        viewModel.onEvent(UiEvent.Number(900))
        assertEquals("12", viewModel.state.number1)
        assertEquals("23900", viewModel.state.number2)
    }

Run this test, and it should fail. Let's proceed to refactor the enterNumber(Int) function.

class CalculatorViewModel: ViewModel(), EventHandler {
    var state by mutableStateOf(CalculatorState())
  
     override fun onEvent(event: UiEvent) {
        when(event) {
          is UiEvent.Number -> enterNumber(event.number)
          is UiEvent.Op -> enterOperation(event.operation)
          else -> Unit 
        }
     }

    private fun enterNumber(number: Int) {
       if (state.op == null) {
            if (state.number1.length >= 8) return
            state = state.copy(
                number1 = state.number1 + number
            )
            return
        }

        if (state.number2.length >= 8) return
        state = state.copy(
            number2 = state.number2 + number
        )       

    }

    private fun enterOperation(op: Operation) {
        if (state.number1.isBlank()) return
        state = state.copy(op = op)
    }
}

Run the test again and it should pass. I believe this is interesting. TDD actually makes software development much easier as well as putting the engineer fully in charge of the stability and robustness of the system. This is as far as we come in this article.

In the next article, we take a look at decimals and other special operations. Thank you for your time.

19/07/2023