Unit Test method naming convention should be as follow with enclosed backticks: given<Preconditions>, When<MethodIsExecuted>, Then<ShouldReturnExpectedResult>
fun `Given visibility GONE as parameter, When checkQuickSellTabItem method called,
Then dolapliteActivityViewStateLiveData update with false`
Given visibility GONE as parameter, When checkQuickSellTabItem method called, Then dolapliteActivityViewStateLiveData update with false
Every test method should start with @Test
Annotation
The test method names should not contain the following characters .:/\[]<>
Unit Test method names have character limitations. If the name of the test method is written too long, an error may be received as follows: error while writing …..$1.class (Permission denied...)
Code blocks within the test method should be split with Given-When-Then
comments to make it more readable and understandable.
Every code block written to satisfy the preconditions is written under the given comment. (every block, creating test observers, building expected response/result model, etc.)
The method to be tested is called under the When
comment.
All lines of code related to the validation phase must be placed under the Then
comment. ( assertThat, verify blocks, etc.)
@Test
fun `Given visibility GONE as parameter, When checkQuickSellTabItem method called, Then dolapliteActivityViewStateLiveData update with false`() {
//Given
val visibility = View.GONE
val observer = dolapLiteViewModel.getDolapliteActivityViewStateLiveData().test()
val expectedViewState = DolapliteActivityViewState(displayQuickSell = false, needInflateMenu = false)
//When
dolapLiteViewModel.checkQuickSellTabItem(visibility)
//Then
Truth.assertThat(observer.getValues().last()).isEqualTo(expectedViewState)
}
Test extension is used for capturing the changes in live data.
It creates a test observer using spyk.
If there is a change in the live data, it will be added to the test observer’s values list.
Live data changes can then be verified with the values in this list.
Since the values list holds the values of the captured live data, the first or last state of these captured values can be verified by looking at their respective elements such as first, last, or any specific item.
In the project, live data changes can be captured by observers created by using spyk in some old test methods. The test extension does the same thing. This extension should be used in test methods as it simplifies the code and has a more understandable structure.
val viewStateObserver = addressDetailViewModel.getViewState().test()
addressDetailViewModel.setAddress(address, true)
// Then
assertThat(viewStateObserver.getValues().last()).isEqualTo(expectedResult)
Some view state test methods require context usage.
getApplicationContext method is used to access context for the application under test.
@RunWith(AndroidJUnit4::class)
class WalletCardListNewCardViewStateTest {
private val context: Context = ApplicationProvider.getApplicationContext()
AndroidJunit runner should be specified to use ApplicationProvider.getContext(). Otherwise, the following error will occur.
🚫 java.lang.IllegalStateException: No instrumentation registered! Must run under a registering instrumentation.
Also, to prevent the error received below; Robolectric should be implemented as TestDependencies and the following plugin should be applied to build.gradle file
plugins {
id("base.android-conventions")
}
🚫 Resource ID #0x7f0600eb android.content.res.Resources$NotFoundException: Resource ID #0x7f0600eb at org.robolectric.shadows.ShadowLegacyAssetManager.getResName(ShadowLegacyAssetManager.java:1283) at org.robolectric.shadows.ShadowLegacyAssetManager.resolveResourceValue(ShadowLegacyAssetManager.java:1066) at org.robolectric.shadows.ShadowLegacyAssetManager.resolve(ShadowLegacyAssetManager.java:1026) at org.robolectric.shadows.ShadowLegacyAssetManager.getAndResolve(ShadowLegacyAssetManager.java:1020) at org.robolectric.shadows.ShadowLegacyAssetManager.getResourceValue(ShadowLegacyAssetManager.java:319)
In some cases, different drawable files could be returned from methods of the ViewState classes. In these cases, it should be verified whether the conditionally returning drawable file is correct.
To check if the returned drawable file satisfies the expected result; It is checked whether the constantState value of the returned and expected drawable file is equal.
@Test
fun `given isPriceBadgeFilterSelected is false, when getPriceBadgeBackground called, then should return expectedValue`() {
// Given
val isPriceBadgeFilterSelected = false
val viewState = FavoriteSpecialFiltersViewState(
isPriceBadgeFilterSelected = isPriceBadgeFilterSelected
)
// When
val actualValue = viewState.getPriceBadgeBackground(context)?.constantState
// Then
val expectedValue = context.drawable(R.drawable.shape_favorite_oval_white_with_with_gray_border)?.constantState
Truth.assertThat(actualValue).isEqualTo(expectedValue)
}
If the expected drawable file is tried to be verified without constantState value, the following error will occur.
🚫 expected:android.graphics.drawable.BitmapDrawable@1fead1b2 but was :android.graphics.drawable.BitmapDrawable@3823d69f
There are three test rules used in the project. These are respectively;
InstantTaskExecuterRule:
JUnit Test Rule that swaps the background executor used by the Architecture Components with a different one that executes each task synchronously. It is used for live data of architecture components used in ViewModel and useCase classes.
@Rule
@JvmField
val instantExecutorRule = InstantTaskExecutorRule()
RxSchedularTestRule:
Using for testing methods using RxJava.
@Rule
@JvmField
var testSchedulerRule = RxSchedulerTestRule()
TestCoroutineRule:
Using for testing methods using coroutine. To call suspend function testCoroutineRule.runBlockingTest is used. testCoroutineRule.testDispatcher is used for injected dispatcher.
@get:Rule
val testCoroutineRule = TestCoroutineRule()
@Test
fun `given order response order list is empty and banner list is not, when mapFrom called, then it should return empty list`() = testCoroutineRule.runBlockingTest {
val bannerResponse = OrderBannerResponse(
imageUrl = "",
deeplink = ""
)
val emptyOrderListWithBannersResponse = OrdersResponse(
orders = emptyList(),
banners = null,
pagination = null,
orderBannersForInstantChannel = listOf(bannerResponse)
)
val actualOrderList = mapper.mapFrom(emptyOrderListWithBannersResponse)
Truth.assertThat(actualOrderList.orderList).isEmpty()
}
The purpose of mock classes is to isolate the ViewModel or useCase classes being tested. Therefore, each class used in the constructors of the ViewModel or useCase classes should be mocked, as seen in the following example.
class InstantDeliveryOnboardingUseCase @Inject constructor(
private val repository: InstantDeliveryOnboardingRepository,
private val configurationUseCase: ConfigurationUseCase
) : IOnboardingUseCase {
class InstantDeliveryOnboardingUseCaseTest {
@MockK
private lateinit var repository: InstantDeliveryOnboardingRepository
@MockK
private lateinit var configurationUseCase: ConfigurationUseCase
private lateinit var useCase: InstantDeliveryOnboardingUseCase
@Before
fun setUp() {
MockKAnnotations.init(this)
useCase = InstantDeliveryOnboardingUseCase(
repository = repository,
configurationUseCase = configurationUseCase
)
}
If mockK annotation is used in the test class, it should be initialized before use.
@Before
fun setUp() {
MockKAnnotations.init(this)
If a method of a mocked class is called in the test flow or test method, every
block is used to specify what that method will return. Otherwise, a no answer found
error will occur.
every { repository.isSupportMenuOnboardingShowed() } returns true
🚫 io.mockk.MockKException: no answer found for:
If a method returns a unit, just Run can be used.
every { updateConfigurationUseCase.updateConfigurationIfRequired(version, any(), any()) } just runs
If the parameters sent to the method called in every block are not the same as the parameter values of the method called in the flow, no answers found
error may occur again.
no answers found
error will be occurred due to a similar reason.
Some cases need to be tested that scenario calls the correct method. In such cases, the verify
block can be used. The exactly
parameter can be used to verify that a method is called a certain number. In addition, the parameters atLeast
or atMost
can be used to verify the minimum or the maximum number of times the method is called.
Here, it has been verified that the extractOtpCode
method is called exactly once with the message
parameter.
verify(exactly = 1) { otpCodeExtractorUseCase.extractOtpCode(message) }
every
block, when checking the method called with verify
, the test will fail if the parameters passed to the method do not match the parameter values of the method called in the scenario. Therefore, when checking whether the method is called with validation, attention should be paid to whether the parameter sent to the method is correct.
Truth
library is used to perform assertions in test methods. With the assertThat
method, it can be compared whether the returned value of the tested method is the same as the expected value.
Truth.assertThat(expectedValue).isEqualTo(actualValue)
If the compared values are not an instance from a data class, then assertion can be failed. This problem can be solved by converting the class to a data class or by asserting the properties of these instances.
Parameterized test class can be created when testing a method with all possible inputs. @RunWith(ParameterizedRobolectricTestRunner::class)
annotation can be used to create a parameterized test class.
As in the following example, Parameters annotation is used for method (provideParameters) to provide parameters to be injected into the test class constructor by Parameterized.
The test method is created according to these parameters. When the test method is run, it will run consecutively for each set of parameters provided and there will be no need to create a separate test method for each scenario.
@RunWith(ParameterizedRobolectricTestRunner::class)
@Config(sdk = [28])
class InstantDeliveryCartProductPriceComparisonDeciderTest constructor(
val marketPrice: Double?,
val salesPrice: Double?,
val expectedResult: Boolean
){
@Test
fun `given marketPrice and salesPrice, when decideIsMarketPriceBiggerThanSalesPrice, then should return expectedResult`() {
//Given
val decider = InstantDeliveryCartProductPriceComparisonDecider()
//When
val actualResult = decider.decideIsMarketPriceBiggerThanSalesPrice(marketPrice, salesPrice)
//Then
Truth.assertThat(actualResult).isEqualTo(expectedResult)
}
companion object {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "given marketPrice {0} and salesPrice {1} should satisfies expectedResult {2}")
fun provideParameters(): List<Array<out Any?>> {
return listOf(
arrayOf(null, null, false),
arrayOf(null, 1.2, false),
arrayOf(1.2, null, false),
arrayOf(2.1, 1.2, true),
arrayOf(1.2, 2.1, false),
arrayOf(1.2, 1.2, false)
)
}
}
}
Adding the name parameter to the Parameters annotation provides an optional pattern to derive the test's name from the parameters. Numbers in braces are used to refer to the parameters. According to the example above, the name of the test cases would be:
✅ given marketPrice null and salesPrice null should satisfies expectedResult false
✅ given marketPrice null and salesPrice 1.2 should satisfies expectedResult false
✅ given marketPrice 1.2 and salesPrice null should satisfies expectedResult false
✅ given marketPrice 2.1 and salesPrice 1.2 should satisfies expectedResult true
✅ given marketPrice 1.2 and salesPrice 2.1 should satisfies expectedResult false
✅ given marketPrice 1.2 and salesPrice 1.2 should satisfies expectedResult false
To manage settings about the configuration is test classes, @Config
annotation can be used. This annotation can be used for a test method or an entire test class. The most used configuration setting in the test classes of the project is the android SDK level. The level to be emulated can be determined as shown below.
@Config(sdk = [Build.VERSION_CODES.O_MR1])
In the project, some classes create models to use in test methods called Factory
. The purpose of these classes is to create the model according to the given parameters and use it in the required method. Because some model classes have too many attributes, recreating these models in every test class needed makes it difficult to write the test. It is aimed to eliminate this difficulty with Factory classes.
//example of Factory object
object InstantDeliveryCartFactory {
fun createInstantDeliveryCart(
itemCount: Int = 4,
groups: List<InstantDeliveryGroup> = listOf(createInstantDeliveryGroup("1"), createInstantDeliveryGroup("2")),
totalProductPrice: Double = 100.0,
totalProductPriceDiscounted: Double = 100.0,
deciExceedText: String? = null,
bestSellersDeepLink: String? = null,
bestSellersTitle: String = ""
): InstantDeliveryCart =
InstantDeliveryCart(
itemCount = itemCount,
groups = groups,
totalProductPrice = totalProductPrice,
totalProductPriceDiscounted = totalProductPriceDiscounted,
campaignParameters = listOf(createCampaignParameter()),
cartSummaries = listOf(createSummary()),
deciExceedText = deciExceedText,
channelCartSummaries = listOf(createSummary()),
bestSellersTitle = bestSellersTitle,
bestSellersDeepLink = bestSellersDeepLink,
walletOffer = createInstantDeliveryWalletOffer(),
discountLimitInfoTexts = emptyList()
)
//Usage in test methods
@Test
fun `initializeViewModel should call pageUseCase getCart and update liveData objects`() {
// Given
val instantDeliveryCart = InstantDeliveryCartFactory.createInstantDeliveryCart()
https://medium.com/trendyol-tech/trendyol-android-team-unit-test-practice-907cf8d0346 https://medium.com/trendyol-tech/guide-to-trendyol-android-app-unit-testing-b4beebb5665b