For all of you that are just new to the topic, I'll try to make a concise introduction to the idea of unit testing.
Unit testing allows us to test our functions (This is most often the case, but we can also unit test other things as well - e.g class instance state, etc) in isolation, so that we can verify implementation correctness.
In general, we should plan our test method implementation to be divided in three parts, like shown below:
Arrange phase
In this phase we "emulate" execution environment of the function we are testing. What does that mean? Let's use a code snippet (Code is written in Kotlin
, but pretty much any object oriented language could be used as well) of
the function we want to unit test:
class ProductService(
val shippingCostService: ShippingCostService,
val taxCalculationService: TaxCalculationService,
val productPriceService: PriceService) {
fun getPrice(productID: String): Long {
val basePrice: Long = priceService.getPrice(productID)
val shippingCosts: Long = shippingCostService.getShippingCosts(productID)
val taxes: Long = taxCalculationService.calculateTax(productID)
return basePrice + shippingCosts + taxes
}
}
Here we have imaginary ProductService
with getPrice
method, which we'd like to test. Unfortunately -
there are already some complications: this method can't be tested in isolation! Why? Simply because our class
depends on other classes to fulfill it's responsibility: ShippingCostService
, TaxCalculationService
and PriceService
. We can call them collaborators.
Luckily - all modern programming languages support some kind of support for emulation of our collaborators. Using these tools, we can give instructions to our test engine to emulate their particular behavior during test method execution.
In our imaginary case - we could give such an instruction:
class TestClass {
@Test
fun getPrice_when_shipping_cost_service_returns_proper_number_returns_positive_number() {
// arrange phase
val productID = UUID.randomUUID().toString()
when(priceService.getPrice(productID)).thenReturn(5L)
...
}
}
Act phase
In this phase we actually call the function we want to test:
class ProductServiceTest {
@Test
fun myTestMethod() {
// act phase
val calculatedPrice = productService.getPrice("XY-123")
// assert phase
...
}
}
Usually - as a consequence of invoking function we're testing - now we have to figure out how do we know if our function is correct or not. In our case, the function returns some value, which we can inspect and make a conclusion if the value is expected or not. Also, even in case that function we test returns no value, some state might have changed in the application, and we could inspect these state changes to verify our function correctness.
Assert phase
In this phase, we may want to do some of the following things:
- verify return value from the function we tested
...
assertThat(calculatedPrice).isEqualTo(5)
...
- verify that we had proper interactions with our collaborators during our test execution
...
verify(taxCalculationService, times(1)).calculateTax(productID)
...
Naming tests methods
One of the "patterns" I use when naming my test methods looks something like this:
fun getPrice_whenShippingCostServiceReturnsProperNumber_returnsPositiveNumber() { }
It consists of 3 parts, delimited with _
:
- the first part is the method name we test
- the middle part is the short description of emulated execution environment
- the last portion describes expected outcome
Couple of other examples of test methods:
@Test
fun getUserDetails_whenDatabaseDown_throwsException() {}
@Test
fun getNumberOfRegisteredUsers_whenNetworkError_returnsNull() {}
Things to remember
- Each test method should be composed out of three code blocks - Arrange, Act, and Assert
- We should name our test methods so that it's enough to understand the test just be reading test method name
- We should have multiple scenarios where we execute our method, with various emulated environments which our function can exhibit during regular application usage
That was all for today! Hope you liked it!