diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 000000000..e69de29bb diff --git a/404.html b/404.html new file mode 100644 index 000000000..d407465e2 --- /dev/null +++ b/404.html @@ -0,0 +1,1246 @@ + + + +
+ + + + + + + + + + + + + + +artifacts/adbserver-desktop.jar
artifacts/desktop_1_1_0.jar
is also available for use with older versions of Kaspresso.device.logcat
in your tests, you should call device.logcat.disableChatty
in the before
section of your test.
+ In previous version of Kaspresso, device.logcat.disableChatty
was called automatically during initialization. This resulted in the need to always run AdbServer before tests.io.github.kakaocup.kakao
package name. Replace all imports using command
+ find . -type f \( -name "*.kt" -o -name "*.java" \) -print0 | xargs -0 sed -i '' -e 's/com.agoda/io.github.kakaocup/g'
or using global replacement tool in IDE./sdcard/Documents
folder.
+ Video recording in the allure tests requires using new kaspresso builder: Kaspresso.Builder.withForcedAllureSupport() and replacing the test runner (io.qameta.allure.android.runners.AllureAndroidJUnitRunner) with com.kaspersky.kaspresso.runner.KaspressoRunner
+ Deprecated TestFailRule. Fixed fail test screenshotting
+ Fixed an automatic system dialogs closing. See this diff.issue-***/detailed_description. Example: issue-306/fix-padding-breaks-autoscroll-interceptor
+The commit message should begin with: "Issue #***: ...". Example: "Issue #306: Fixed padding-breaks autoscroll interceptor".
+ + + + + + +[RU] Евгений Мацюк — Kaspresso: фреймворк для автотестирования, который вы ждали
+[RU] Иван Федянин — Kaspresso tutorials. Часть 1. Запуск первого теста
+[EN] Eugene Matsyuk — Kaspresso: The autotest framework that you have been looking forward to. Part I
++ + + + + + +Do you want your article to be included in this list? Everything is simple! Write an article, send it to us and we will add it to this list! +
+
[RU] Дмитрий Мовчан, Евгений Мацюк — Как начать писать автотесты и не сойти с ума
+[RU] Егор Курников — Единственное, что вам нужно для UI-тестирования
+[RU] Воркшоп по автотестам. 19-12-2019
+[RU] Руслан Мингалиев - Live-coding: мобильные автотесты с нуля
+[RU] "Kaspresso" с Евгением Мацюком и Егором Курниковым
+[RU] Kaspresso: Q&A Session 9.04.20
+[EN] Eugene Matsyuk — How to start writing autotests and not go crazy
Info
+The problem described below is relevant for versions of Kaspresso below 1.5.0. Starting with this version, Kaspresso fully supports the new format of working with system storage.
+Kaspresso can use external storage to save various data about executed tests. The example of such data is screenshots, xml dumps, logs, video and anymore. +But, new Android OS provides absolutely new way to work with external storage - Scoped Storage. Currently, we are working on the support of Scoped Storage. +On versions of Kaspresso prior to 1.5.0, work with Scoped storage is supported only by requesting various permissions. +Here, it's a detailed instruction:
+# Please, add these permissions
+<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
+<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
+<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
+
+<application
+ # storage support for Android API 29
+ android:requestLegacyExternalStorage="true"
+ ...
+</application>
+
class SampleTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple( // simple/advanced - it doesn't matter
+ customize = {
+ // storage support for Android API 30+
+ if (isAndroidRuntime) {
+ UiDevice
+ .getInstance(instrumentation)
+ .executeShellCommand("appops set --uid ${InstrumentationRegistry.getInstrumentation().targetContext.packageName} MANAGE_EXTERNAL_STORAGE allow")
+ }
+ }
+ )
+) {
+
+ // storage support for Android API 29-
+ @get:Rule
+ val runtimePermissionRule: GrantPermissionRule = GrantPermissionRule.grant(
+ Manifest.permission.WRITE_EXTERNAL_STORAGE,
+ Manifest.permission.READ_EXTERNAL_STORAGE
+ )
+
+ //...
+}
+
This is a temporary solution. We recommend migrating to the latest version of Kaspresso (1.5.0 and above) to avoid these problems.
+ + + + + + +Kaspresso has a great community that helps make it better by suggesting new ideas, reporting bugs with detailed descriptions and making pull requests.
+In our Issues tab you can create a new one. There are two most popular types of issues: bug and enhancement.
+If you found a bug you can create new issue. Enter a title and provide a description (bug details) in the input fields. We will be very grateful if you use this template:
+Description:
+...
+Expected Behavior:
+...
+Actual Behavior:
+...
+Steps to Reproduce the Problem:
+...
+Specifications:
+...
+
For example: +
When using newer versions of the library, Gradle is unable to find and download the library sources (which allow you to read and debug the source code directly on the IDE).
+
+Expected Behavior
+Projects with the Kaspresso dependency should be able to download sources.
+
+Actual Behavior
+When trying do download sources, the following error appears:
+
+* What went wrong:
+Execution failed for task ':app:DownloadSources'.
+> Could not resolve all files for configuration ':app:downloadSources_10c6f7e9-408b-4f6a-8bd9-fe15e255981e'.
+ > Could not find com.kaspersky.android-components:kaspresso:1.4.1@aar.
+ Searched in the following locations:
+ - https://dl.google.com/dl/android/maven2/com/kaspersky/android-components/kaspresso/1.4.1@aar/kaspresso-1.4.1@aar.pom
+ - https://repo.maven.apache.org/maven2/com/kaspersky/android-components/kaspresso/1.4.1@aar/kaspresso-1.4.1@aar.pom
+ Required by:
+ project :app
+
+Steps to Reproduce the Problem
+Create an empty project;
+Add the dependency androidTestImplementation "com.kaspersky.android-components:kaspresso:1.4.1";
+Create a test using classes from Kaspresso;
+Try to access the source (on IntelliJ IDE, Ctrl+left click a Kaspresso class name/method call);
+You will only be able to see the decompiled bytecode.
+Specifications
+Library version: at least >= 1.4.1
+IDE used: Android Studio
+
+Observations
+I haven't tested on all versions, but sources were able to be downloaded at least up to version 1.2.1.
+
If you have an idea of a new enhancement you can create new issue. Enter a title and provide a description in the input fields. We will be very grateful if you use this template:
+Description:
+...
+How the new enhancement will help?:
+...
+Existing analogs (with links):
+...
+
If you have not only an issue, but also a ready implementation, you can always submit the pull request on Github.
+In this tutorial, we will learn how to work with permissions (Permissions).
+Often, in order to work correctly, an application needs access to certain functions of the mobile device: to the camera, voice recording, making calls, sending SMS messages, etc. The application can access and use them only if the user gives permission to do so.
+On older devices below the sixth version of Android (API level 23), such permissions were requested at the time the application was installed, and if the user installed it, it was considered that he agreed with all the permissions, and the application would be able to use all the necessary functions. This was unsafe, as it opened up the possibility for unscrupulous developers to gain access to the microphone, camera, calls and other important components without the user noticing and use it for their own purposes.
+For this reason, on newer versions, the so-called "dangerous" permissions began to be requested not at the time of installation, but while the application was running. Now the user will clearly see a dialog with a proposal to allow or deny a request to use some functionality.
+For example, run the tutorial
application on one of the latest versions of Android (API 23 and above) and press the Make Call Activity
button
You will see a screen on which there are two elements - an input field and a button. In the input field, you can specify some phone number and click on the Make Call
button to make a call
Making calls is one of the features that requires permission from the user to work. Therefore, you will see a dialog asking you to allow the application to control calls, which has "Allow" and "Reject" buttons.
+ +If we click “Allow”, then the call will begin to the subscriber at the number that you specified in the input field
+ +The next time you open the application, the permission will no longer be requested, it is saved on the device. If you want to revoke permission, you can do so in the settings. To do this, go to the application section, find the one you need and go to the Permissions
section
Here you can go to any permission and change the value from Allow
to Deny
or vice versa.
The second way to do this is with the adb shell command:
+adb shell pm revoke package_name permission_name
For our application, the command will look like this:
+adb shell pm revoke com.kaspersky.kaspresso.tutorial android.permission.CALL_PHONE
After executing the command, the application will ask for permission again the next time you try to make a call.
+When testing applications that require permissions, there are certain considerations. Let's write a test for this screen.
+First of all, let's create a Page Object of the screen with the Make Call
button
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+import com.kaspersky.kaspresso.tutorial.R
+import io.github.kakaocup.kakao.edit.KEditText
+import io.github.kakaocup.kakao.text.KButton
+
+object MakeCallActivityScreen : KScreen<MakeCallActivityScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val inputNumber = KEditText { withId(R.id.input_number) }
+ val makeCallButton = KButton { withId(R.id.make_call_btn) }
+}
+
To get to this screen, you will need to click on the corresponding button in MainActivity
, add this button to MainScreen
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+import com.kaspersky.kaspresso.tutorial.R
+import io.github.kakaocup.kakao.text.KButton
+
+object MainScreen : KScreen<MainScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val simpleActivityButton = KButton { withId(R.id.simple_activity_btn) }
+ val wifiActivityButton = KButton { withId(R.id.wifi_activity_btn) }
+ val loginActivityButton = KButton { withId(R.id.login_activity_btn) }
+ val notificationActivityButton = KButton { withId(R.id.notification_activity_btn) }
+ val makeCallActivityButton = KButton { withId(R.id.make_call_activity_btn) }
+}
+
We can create a test. For now, let's just open the screen for making a call, enter some number and click on the button
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.MakeCallActivityScreen
+import org.junit.Rule
+import org.junit.Test
+
+class MakeCallActivityTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun checkSuccessCall() = run {
+ step("Open make call activity") {
+ MainScreen {
+ makeCallActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check UI elements") {
+ MakeCallActivityScreen {
+ inputNumber.isVisible()
+ inputNumber.hasHint(R.string.phone_number_hint)
+ makeCallButton.isVisible()
+ makeCallButton.isClickable()
+ makeCallButton.hasText(R.string.make_call_btn)
+ }
+ }
+ step("Try to call number") {
+ MakeCallActivityScreen {
+ inputNumber.replaceText("111")
+ makeCallButton.click()
+ }
+ }
+ }
+}
+
Let's run the test. Test passed successfully.
+Depending on whether you have given permission or not, you may see a dialog asking permission to make calls.
+At this stage, we have checked the operation of our screen, that it is possible to enter a number and click on the button, but we have not checked in any way whether a call is being made to the entered number or not. To check if a call is currently in progress, you can use AudioManager
, this is done as follows:
val manager = device.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+Assert.assertTrue(manager.mode == AudioManager.MODE_IN_CALL)
+
We can add this check in a separate step:
+package com.kaspersky.kaspresso.tutorial
+
+import android.media.AudioManager
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.MakeCallActivityScreen
+import org.junit.Assert
+import org.junit.Rule
+import org.junit.Test
+
+class MakeCallActivityTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun checkSuccessCall() = run {
+ step("Open make call activity") {
+ MainScreen {
+ makeCallActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check UI elements") {
+ MakeCallActivityScreen {
+ inputNumber.isVisible()
+ inputNumber.hasHint(R.string.phone_number_hint)
+ makeCallButton.isVisible()
+ makeCallButton.isClickable()
+ makeCallButton.hasText(R.string.make_call_btn)
+ }
+ }
+ step("Try to call number") {
+ MakeCallActivityScreen {
+ inputNumber.replaceText("111")
+ makeCallButton.click()
+ }
+ }
+ step("Check phone is calling") {
+ val manager = device.context.getSystemService(AudioManager::class.java)
+ Assert.assertTrue(manager.mode == AudioManager.MODE_IN_CALL)
+ }
+ }
+}
+
Info
+Before running the test, remove the application from the device or revoke permissions using the adb shell command. Also make sure you are running the test on a device with API 23 and higher.
+Let's run the test. Test failed.
+This happened because after clicking on the button, the user was asked for permission. No one gave this permission, and the next screen was not opened.
+There are several options for solving the problem. The first option is to use GrantPermissionRule
. The essence of this method is that we create a list of permissions that will be automatically allowed on the device under test.
To do this, we add a new rule before the test method:
+@get:Rule
+val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(
+ android.Manifest.permission.CALL_PHONE
+)
+
In the grant
method, in parentheses, we list all the required permissions separated by commas, in this case there is only one, so we leave it as it is. Then the whole test code will look like this:
package com.kaspersky.kaspresso.tutorial
+
+import android.content.Context
+import android.media.AudioManager
+import androidx.test.ext.junit.rules.activityScenarioRule
+import androidx.test.rule.GrantPermissionRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.MakeCallActivityScreen
+import org.junit.Assert
+import org.junit.Rule
+import org.junit.Test
+
+class MakeCallActivityTest : TestCase() {
+
+ @get:Rule
+ val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(
+ android.Manifest.permission.CALL_PHONE
+ )
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun checkSuccessCall() = run {
+ step("Open make call activity") {
+ MainScreen {
+ makeCallActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check UI elements") {
+ MakeCallActivityScreen {
+ inputNumber.isVisible()
+ inputNumber.hasHint(R.string.phone_number_hint)
+ makeCallButton.isVisible()
+ makeCallButton.isClickable()
+ makeCallButton.hasText(R.string.make_call_btn)
+ }
+ }
+ step("Try to call number") {
+ MakeCallActivityScreen {
+ inputNumber.replaceText("111")
+ makeCallButton.click()
+ }
+ }
+ step("Check phone is calling") {
+ val manager = device.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ Assert.assertTrue(manager.mode == AudioManager.MODE_IN_CALL)
+ }
+ }
+}
+
Info
+Remember to revoke all permissions from the app or remove it from the device before running the test.
+Let's run the test. In some cases, this test will pass, and in others it will not. We will now analyze the reason.
+Remember the lesson about the flakySafely
method. There we talked about the fact that in case of failure, all checks in Kaspresso will be restarted within a certain timeout.
In our case, we start the call and the next step is to check that the phone is really ringing. We do this through the Assert.assertTrue(…)
method. Sometimes the device manages to dial the number before this check, and sometimes it does not. It seems that in such a situation the flakySafely
method should work and the check should be carried out again within ten seconds, but for some reason this does not happen.
The fact is that all checks of view-elements in Kaspresso (isVisible, isClickable ...) "under the hood" use the flakySafely
method, but if we ourselves call various checks through assert
, then flakySafely
will not be used and if the check fails, the test will immediately finished with failure.
Cases like this are another example of when you should explicitly call flakySafely
package com.kaspersky.kaspresso.tutorial
+
+import android.content.Context
+import android.media.AudioManager
+import androidx.test.ext.junit.rules.activityScenarioRule
+import androidx.test.rule.GrantPermissionRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.MakeCallActivityScreen
+import org.junit.Assert
+import org.junit.Rule
+import org.junit.Test
+
+class MakeCallActivityTest : TestCase() {
+
+ @get:Rule
+ val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(
+ android.Manifest.permission.CALL_PHONE
+ )
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun checkSuccessCall() = run {
+ step("Open make call activity") {
+ MainScreen {
+ makeCallActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check UI elements") {
+ MakeCallActivityScreen {
+ inputNumber.isVisible()
+ inputNumber.hasHint(R.string.phone_number_hint)
+ makeCallButton.isVisible()
+ makeCallButton.isClickable()
+ makeCallButton.hasText(R.string.make_call_btn)
+ }
+ }
+ step("Try to call number") {
+ MakeCallActivityScreen {
+ inputNumber.replaceText("111")
+ makeCallButton.click()
+ }
+ }
+ step("Check phone is calling") {
+ flakySafely {
+ val manager = device.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ Assert.assertTrue(manager.mode == AudioManager.MODE_IN_CALL)
+ }
+ }
+ }
+}
+
Firstly, after the end of the test, the call to the subscriber is still ongoing on the device. Let's add the before
and after
sections and in the section that runs after the test, complete the call. This can be done with the following code: device.phone.cancelCall("111")
. This method works through adb commands, so do not forget to start the adb server.
Theoretically, you could put the call reset in a separate step and run it as the last step without moving it to the after section. But this would be a bad decision, because if any step fails and the test fails, then the device will continue the call and never reset. The advantage of the after section is that the code inside this block will be executed regardless of the result of the test.
+In order not to duplicate the same number in two places, let's move it to a separate variable, then the test code will look like this:
+package com.kaspersky.kaspresso.tutorial
+
+import android.content.Context
+import android.media.AudioManager
+import androidx.test.ext.junit.rules.activityScenarioRule
+import androidx.test.rule.GrantPermissionRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.MakeCallActivityScreen
+import org.junit.Assert
+import org.junit.Rule
+import org.junit.Test
+
+class MakeCallActivityTest : TestCase() {
+
+ @get:Rule
+ val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(
+ android.Manifest.permission.CALL_PHONE
+ )
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ private val testNumber = "111"
+
+ @Test
+ fun checkSuccessCall() = before {
+ }.after {
+ device.phone.cancelCall(testNumber)
+ }.run {
+ step("Open make call activity") {
+ MainScreen {
+ makeCallActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check UI elements") {
+ MakeCallActivityScreen {
+ inputNumber.isVisible()
+ inputNumber.hasHint(R.string.phone_number_hint)
+ makeCallButton.isVisible()
+ makeCallButton.isClickable()
+ makeCallButton.hasText(R.string.make_call_btn)
+ }
+ }
+ step("Try to call number") {
+ MakeCallActivityScreen {
+ inputNumber.replaceText(testNumber)
+ makeCallButton.click()
+ }
+ }
+ step("Check phone is calling") {
+ flakySafely {
+ val manager = device.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ Assert.assertTrue(manager.mode == AudioManager.MODE_IN_CALL)
+ }
+ }
+ }
+}
+
Now, after the test is completed, the call ends.
+The second problem is that when using GrantPermissionRule
we can only check the application in the state where the user has given the permission. At the same time, it is possible that the developers did not foresee the option when the permission request was rejected, then the result may be unexpected up to the point that the application will crash. We need to check these scenarios too, but using GrantPermissionRule
for this will not work, because in this case the permission will always be approved, and in tests we will never know what the behavior will be if the request is denied.
One of the solutions to the problem is to interact with the dialog using KAutomator, having previously found all the necessary interface elements, but this is not very convenient, and a much more convenient way has been added to the Kaspresso - Device.Permissions
. It makes it very easy to check permission dialogs, as well as accept or reject them.
Therefore, instead of Rule
we will use the Permissions
object, which can be obtained from Device
. Let's do this in a separate class so that you can keep both test cases. The class in which we are currently working will be renamed to MakeCallActivityRuleTest
.
To do this, right-click on the file name and select Refactor
-> Rename
And enter a new class name:
+ +And create a new class MakeCallActivityDevicePermissionsTest
. Code can be copied from the current test, except for GrantPermissionRule
package com.kaspersky.kaspresso.tutorial
+
+import android.content.Context
+import android.media.AudioManager
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.MakeCallActivityScreen
+import org.junit.Assert
+import org.junit.Rule
+import org.junit.Test
+
+class MakeCallActivityDevicePermissionsTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ private val testNumber = "111"
+
+ @Test
+ fun checkSuccessCall() = before {
+ }.after {
+ device.phone.cancelCall(testNumber)
+ }.run {
+ step("Open make call activity") {
+ MainScreen {
+ makeCallActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check UI elements") {
+ MakeCallActivityScreen {
+ inputNumber.isVisible()
+ inputNumber.hasHint(R.string.phone_number_hint)
+ makeCallButton.isVisible()
+ makeCallButton.isClickable()
+ makeCallButton.hasText(R.string.make_call_btn)
+ }
+ }
+ step("Try to call number") {
+ MakeCallActivityScreen {
+ inputNumber.replaceText(testNumber)
+ makeCallButton.click()
+ }
+ }
+ step("Check phone is calling") {
+ flakySafely {
+ val manager = device.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ Assert.assertTrue(manager.mode == AudioManager.MODE_IN_CALL)
+ }
+ }
+ }
+}
+
If we run the test now, it will fail because we do not have needed permission to make calls. Let's add one more step in which we will give the appropriate permission through device.permissions
. After specifying an object, you can put a dot and see what methods it has:
It is possible to check if the dialog is displayed, as well as to reject or grant permission.
+step("Accept permission") {
+ Assert.assertTrue(device.permissions.isDialogVisible())
+ device.permissions.allowViaDialog()
+}
+
In this way, we will make sure that the dialog is displayed and agree to making calls.
+Info
+As a reminder, the dialog will be shown on Android API version 23 and above, how to run these tests on earlier versions, we will explain at the end of this tutorial.
+Here we have written device.permissions
twice, let's shorten the code a bit by using the apply function. And let's move the check through assert
to the flakySafely
method. Then the whole test code will look like this:
package com.kaspersky.kaspresso.tutorial
+
+import android.content.Context
+import android.media.AudioManager
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.MakeCallActivityScreen
+import org.junit.Assert
+import org.junit.Rule
+import org.junit.Test
+
+class MakeCallActivityDevicePermissionsTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ private val testNumber = "111"
+
+ @Test
+ fun checkSuccessCall() = before {
+ }.after {
+ device.phone.cancelCall(testNumber)
+ }.run {
+ step("Open make call activity") {
+ MainScreen {
+ makeCallActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check UI elements") {
+ MakeCallActivityScreen {
+ inputNumber.isVisible()
+ inputNumber.hasHint(R.string.phone_number_hint)
+ makeCallButton.isVisible()
+ makeCallButton.isClickable()
+ makeCallButton.hasText(R.string.make_call_btn)
+ }
+ }
+ step("Try to call number") {
+ MakeCallActivityScreen {
+ inputNumber.replaceText(testNumber)
+ makeCallButton.click()
+ }
+ }
+ step("Accept permission") {
+ device.permissions.apply {
+ flakySafely {
+ Assert.assertTrue(isDialogVisible())
+ allowViaDialog()
+ }
+ }
+ }
+ step("Check phone is calling") {
+ flakySafely {
+ val manager = device.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ Assert.assertTrue(manager.mode == AudioManager.MODE_IN_CALL)
+ }
+ }
+ }
+}
+
Let's run the test. Test passed successfully.
+Now we can easily write a test for the fact that the call is not made if permission was not given. To do this, instead of allowViaDialog
you need to specify denyViaDialog
.
You also need to change the checks in the test itself, and do not forget to remove the code from the after
function in the new method, since after the permission is denied, the call will not be made, and after the test, you no longer need to reset the call.
package com.kaspersky.kaspresso.tutorial
+
+import android.content.Context
+import android.media.AudioManager
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.MakeCallActivityScreen
+import org.junit.Assert
+import org.junit.Rule
+import org.junit.Test
+
+class MakeCallActivityDevicePermissionsTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ private val testNumber = "111"
+
+ @Test
+ fun checkSuccessCall() = before {
+ }.after {
+ device.phone.cancelCall(testNumber)
+ }.run {
+ step("Open make call activity") {
+ MainScreen {
+ makeCallActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check UI elements") {
+ MakeCallActivityScreen {
+ inputNumber.isVisible()
+ inputNumber.hasHint(R.string.phone_number_hint)
+ makeCallButton.isVisible()
+ makeCallButton.isClickable()
+ makeCallButton.hasText(R.string.make_call_btn)
+ }
+ }
+ step("Try to call number") {
+ MakeCallActivityScreen {
+ inputNumber.replaceText(testNumber)
+ makeCallButton.click()
+ }
+ }
+ step("Accept permission") {
+ device.permissions.apply {
+ flakySafely {
+ Assert.assertTrue(isDialogVisible())
+ allowViaDialog()
+ }
+ }
+ }
+ step("Check phone is calling") {
+ flakySafely {
+ val manager = device.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ Assert.assertTrue(manager.mode == AudioManager.MODE_IN_CALL)
+ }
+ }
+ }
+
+ @Test
+ fun checkCallIfPermissionDenied() = run {
+ step("Open make call activity") {
+ MainScreen {
+ makeCallActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check UI elements") {
+ MakeCallActivityScreen {
+ inputNumber.isVisible()
+ inputNumber.hasHint(R.string.phone_number_hint)
+ makeCallButton.isVisible()
+ makeCallButton.isClickable()
+ makeCallButton.hasText(R.string.make_call_btn)
+ }
+ }
+ step("Try to call number") {
+ MakeCallActivityScreen {
+ inputNumber.replaceText(testNumber)
+ makeCallButton.click()
+ }
+ }
+ step("Deny permission") {
+ device.permissions.apply {
+ flakySafely {
+ Assert.assertTrue(isDialogVisible())
+ denyViaDialog()
+ }
+ }
+ }
+ step("Check stay on the same screen") {
+ MakeCallActivityScreen {
+ inputNumber.isDisplayed()
+ makeCallButton.isDisplayed()
+ }
+ }
+ }
+}
+
On modern versions of the Android OS (API 23 and higher), permissions are requested from the user during the application through a dialog. But in earlier versions, they were requested at the time of installation of the application, and during operation it was considered that the user agreed with all the required permissions.
+Therefore, if you run the test on devices with API below version 23, then there will be no request for permissions, so the dialog check is not required.
+In the test using GrantPermissionRule
no changes are required, on older versions the permission is always there, so this annotation will not affect the test in any way. But in the test using device.permissions
, changes need to be made, because here we are explicitly checking the operation of the dialog.
There are several options here. Firstly, on such devices it makes no sense to test the application if the permission was denied, so this test should simply be skipped. To do this, you can use the @SuppressSdk
annotation. Then the code of the checkCallIfPermissionDenied
method will change to:
@SdkSuppress(minSdkVersion = 23)
+@Test
+fun checkCallIfPermissionDenied() = run {
+ step("Open make call activity") {
+ MainScreen {
+ makeCallActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check UI elements") {
+ MakeCallActivityScreen {
+ inputNumber.isVisible()
+ inputNumber.hasHint(R.string.phone_number_hint)
+ makeCallButton.isVisible()
+ makeCallButton.isClickable()
+ makeCallButton.hasText(R.string.make_call_btn)
+ }
+ }
+ step("Try to call number") {
+ MakeCallActivityScreen {
+ inputNumber.replaceText(testNumber)
+ makeCallButton.click()
+ }
+ }
+ step("Deny permission") {
+ device.permissions.apply {
+ flakySafely {
+ Assert.assertTrue(isDialogVisible())
+ denyViaDialog()
+ }
+ }
+ }
+ step("Check stay on the same screen") {
+ MakeCallActivityScreen {
+ inputNumber.isDisplayed()
+ makeCallButton.isDisplayed()
+ }
+ }
+}
+
Now this test will be performed only on new versions of the Android OS, and on older versions it will be skipped.
+The second solution for the problem is to skip certain steps or replace them with others, depending on the API level. For example, in the checkSuccessCall
method on old devices, we can skip the step with checking the dialog, for this use the following code:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ step("Accept permission") {
+ device.permissions.apply {
+ flakySafely {
+ Assert.assertTrue(isDialogVisible())
+ allowViaDialog()
+ }
+ }
+ }
+}
+
The rest of the code can be left untouched and the test will run successfully on both new and old devices, just in one case permission will be requested, in the other it won't.
+The final test code will now look like this:
+package com.kaspersky.kaspresso.tutorial
+
+import android.content.Context
+import android.media.AudioManager
+import android.os.Build
+import androidx.test.ext.junit.rules.activityScenarioRule
+import androidx.test.filters.SdkSuppress
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.MakeCallActivityScreen
+import org.junit.Assert
+import org.junit.Rule
+import org.junit.Test
+
+class MakeCallActivityDevicePermissionsTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ private val testNumber = "111"
+
+ @Test
+ fun checkSuccessCall() = before {
+ }.after {
+ device.phone.cancelCall(testNumber)
+ }.run {
+ step("Open make call activity") {
+ MainScreen {
+ makeCallActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check UI elements") {
+ MakeCallActivityScreen {
+ inputNumber.isVisible()
+ inputNumber.hasHint(R.string.phone_number_hint)
+ makeCallButton.isVisible()
+ makeCallButton.isClickable()
+ makeCallButton.hasText(R.string.make_call_btn)
+ }
+ }
+ step("Try to call number") {
+ MakeCallActivityScreen {
+ inputNumber.replaceText(testNumber)
+ makeCallButton.click()
+ }
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ step("Accept permission") {
+ device.permissions.apply {
+ flakySafely {
+ Assert.assertTrue(isDialogVisible())
+ allowViaDialog()
+ }
+ }
+ }
+ }
+ step("Check phone is calling") {
+ flakySafely {
+ val manager = device.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ Assert.assertTrue(manager.mode == AudioManager.MODE_IN_CALL)
+ }
+ }
+ }
+
+ @SdkSuppress(minSdkVersion = 23)
+ @Test
+ fun checkCallIfPermissionDenied() = run {
+ step("Open make call activity") {
+ MainScreen {
+ makeCallActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check UI elements") {
+ MakeCallActivityScreen {
+ inputNumber.isVisible()
+ inputNumber.hasHint(R.string.phone_number_hint)
+ makeCallButton.isVisible()
+ makeCallButton.isClickable()
+ makeCallButton.hasText(R.string.make_call_btn)
+ }
+ }
+ step("Try to call number") {
+ MakeCallActivityScreen {
+ inputNumber.replaceText(testNumber)
+ makeCallButton.click()
+ }
+ }
+ step("Deny permission") {
+ device.permissions.apply {
+ flakySafely {
+ Assert.assertTrue(isDialogVisible())
+ denyViaDialog()
+ }
+ }
+ }
+ step("Check stay on the same screen") {
+ MakeCallActivityScreen {
+ inputNumber.isDisplayed()
+ makeCallButton.isDisplayed()
+ }
+ }
+ }
+}
+
In this tutorial, we have looked at two options for working with Permissions: GrantPermissionRule
and device.permissions
.
We also learned that the second option is preferable for a number of reasons:
+In this lesson, we will download the Kaspresso project, install Android studio and set up the emulator.
+Android Studio is used for software development. We will need it to write and run autotests.
+
If you already have Android Studio installed, skip this step. If not, then follow the link and click Download Android Studio.
Run the downloaded file and go through all the steps of the initial setup of the studio. You can use the official manual or the official codelabs manual in case of problems.
+
After Android Studio is downloaded, run it.
To download a project, you must have the GIT version control system installed on your computer. You can download GIT and learn more about it here.
+Once GIT is installed, you will be able to download the project. To do this, follow the link.
+Click the Code button and copy the link to the repository
+ +Open Android Studio.
+If you have not previously opened any project in the studio, then you must select the Get From VCS item
+ +If a project has already been launched, then you can load a new one from GIT as follows: File
-> New
-> Project From Version Control
In the window that opens, enter the copied project URL, select the folder where Kaspresso will be placed and click clone.
+ +In the top menu of Android Studio, select 'Tools' -> 'Device Manager'
+ +The tab for managing emulators and real devices will appear on the screen. Click on 'Create Device':
+ +We will see the following screen:
+ +On this screen, you can set the characteristics of the hardware you want to emulate. In section "1" you can select phone, tablet, TV and so on. For the purposes of this tutorial we will be working with the "phone" type. In section "2" you can select a specific model. Within the scope of this guide, it makes no difference which one to choose. Choose 'Pixel 6'. Click 'Next' and get to the operating system image selection window:
+ +This screen is more important for regular work and lets you choose which version of Android to install on the emulator. Let's choose 'R'. Click on the download icon to the right of the letter 'R', go through the installation process and wait.
+ +When the installation process is completed, click the Finish button:
+ +Select the installed version ('R') and click 'Next':
+ +On the screen below, you can change the name of the created emulator so that it is easy to distinguish between them. The default value is fine for our purposes. Click 'Finish'.
+ +The device is set up and ready for work. We launch it by the 'Play' icon to the right of the device name:
+ +In some cases, Android Studio may recommend installing Hypervisor:
+ + +Android Studio is installed, emulator is configured, Kaspresso project is loaded. In the next lesson, we will run the first tests.
+ + + + + + +In this tutorial, we'll learn how to test screens that change state over time.
+So far, in all tests, the screens immediately had a final look, all elements were displayed when they were opened, and we could conduct tests. To change the status, we ourselves performed some actions - clicked on the button, entered text in the input field, and so on.
+But often there is a situation where the appearance of the screen changes over time. For example, at the start, data loading begins - a ProgressBar is displayed, after loading, a list of elements or an error dialog is displayed if something went wrong. In such cases, during the test, you need to check all intermediate states, while not changing them from the test method.
+Consider an example. Open the tutorial
application and click on the Flaky Activity
button
This screen displays several TextView
for which some data is being loaded
After one second, the text for the first element is loaded
+ +After another three seconds, text appears on the second element
+ +After 10 seconds, the rest of the data will be loaded and the texts will appear in all TextView
Let's write a test for this screen. As usual, let's start by creating a Page Object
+package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+import com.kaspersky.kaspresso.tutorial.R
+import io.github.kakaocup.kakao.progress.KProgressBar
+import io.github.kakaocup.kakao.text.KButton
+
+object FlakyScreen : KScreen<FlakyScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val text1 = KButton { withId(R.id.text_1) }
+ val text2 = KButton { withId(R.id.text_2) }
+ val text3 = KButton { withId(R.id.text_3) }
+ val text4 = KButton { withId(R.id.text_4) }
+ val text5 = KButton { withId(R.id.text_5) }
+
+ val progressBar1 = KProgressBar { withId(R.id.progress_bar_1) }
+ val progressBar2 = KProgressBar { withId(R.id.progress_bar_2) }
+ val progressBar3 = KProgressBar { withId(R.id.progress_bar_3) }
+ val progressBar4 = KProgressBar { withId(R.id.progress_bar_4) }
+ val progressBar5 = KProgressBar { withId(R.id.progress_bar_5) }
+}
+
FlakyActivity
you need to click the button on the main screen. Let's add it to PageObject MainScreen
+package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+import com.kaspersky.kaspresso.tutorial.R
+import io.github.kakaocup.kakao.text.KButton
+
+object MainScreen : KScreen<MainScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val simpleActivityButton = KButton { withId(R.id.simple_activity_btn) }
+ val wifiActivityButton = KButton { withId(R.id.wifi_activity_btn) }
+ val loginActivityButton = KButton { withId(R.id.login_activity_btn) }
+ val notificationActivityButton = KButton { withId(R.id.notification_activity_btn) }
+ val makeCallActivityButton = KButton { withId(R.id.make_call_activity_btn) }
+ val flakyActivityButton = KButton { withId(R.id.flaky_activity_btn) }
+}
+
ProgressBar
is displayed on them
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.FlakyScreen
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import org.junit.Rule
+import org.junit.Test
+
+class FlakyScreenTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun checkFlakyScreen() = run {
+ step("Open flaky screen") {
+ MainScreen {
+ flakyActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check initial elements") {
+ FlakyScreen {
+ text1.isVisible()
+ text2.isVisible()
+ text3.isVisible()
+ text4.isVisible()
+ text5.isVisible()
+ progressBar1.isVisible()
+ progressBar2.isVisible()
+ progressBar3.isVisible()
+ progressBar4.isVisible()
+ progressBar5.isVisible()
+ }
+ }
+ }
+}
+
The next action that happens on the screen is loading the text for the first element. We need to check that at this stage the first TextView
contains the text "TEXT 1". This check must be done after the download is complete.
It turns out that the next step is to add the necessary checks, and if they fail, then we need to perform them again for some time. In this case, loading the first text takes about one second after opening the screen, so we can add a timeout of 1-3 seconds, during which the checks will be repeated. If during this time the methods return the correct value, then the test will complete successfully, but if after the timeout the condition is not met, then the test will fail.
+In order to add a timeout, you must use the flakySafely
method, where the time in milliseconds is indicated in parentheses during which attempts to pass the test will occur. Then the test code will look like this:
package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.FlakyScreen
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import org.junit.Rule
+import org.junit.Test
+
+class FlakyScreenTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun checkFlakyScreen() = run {
+ step("Open flaky screen") {
+ MainScreen {
+ flakyActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check initial elements") {
+ FlakyScreen {
+ text1.isVisible()
+ text2.isVisible()
+ text3.isVisible()
+ text4.isVisible()
+ text5.isVisible()
+ progressBar1.isVisible()
+ progressBar2.isVisible()
+ progressBar3.isVisible()
+ progressBar4.isVisible()
+ progressBar5.isVisible()
+ }
+ }
+ step("Check first element after loading") {
+ FlakyScreen {
+ flakySafely(3000) {
+ text1.hasText(R.string.text_1)
+ progressBar1.isGone() // Проверяем, что ProgressBar невидимый
+ }
+ }
+ }
+ }
+}
+
Let's launch the test. It passed successfully.
+Our test completes successfully. Now let's check what happens if we remove the call to the flakySafely
method
package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.FlakyScreen
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import org.junit.Rule
+import org.junit.Test
+
+class FlakyScreenTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun checkFlakyScreen() = run {
+ step("Open flaky screen") {
+ MainScreen {
+ flakyActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check initial elements") {
+ FlakyScreen {
+ text1.isVisible()
+ text2.isVisible()
+ text3.isVisible()
+ text4.isVisible()
+ text5.isVisible()
+ progressBar1.isVisible()
+ progressBar2.isVisible()
+ progressBar3.isVisible()
+ progressBar4.isVisible()
+ progressBar5.isVisible()
+ }
+ }
+ step("Check first element after loading") {
+ FlakyScreen {
+ text1.hasText(R.string.text_1)
+ progressBar1.isGone() // Проверяем, что ProgressBar невидимый
+ }
+ }
+ }
+}
+
It would seem that we did not set any timeout, the check should have failed, but the test is green. The fact is that in Kaspresso all checks implicitly use the flakySafely
method with some kind of timeout (in the current version of Kaspresso, the timeout is 10 seconds).
You may have noticed that if a test runs successfully, the application closes immediately and Android Studio displays a message that the tests ran successfully. But if some check fails, then the error message does not appear immediately, but after a few seconds - the reason lies in the use of flakySafely. The test fails and restarts several more times within 10 seconds.
+Therefore, flakySafely
should be added only if the default timeout does not suit you for some reason, and you need to change it to another one. A good use case for the extended timeout is when the screen is loading data from the network. The server may take a long time to return a response, while the test should not fall due to a slow backend.
In the next step, after 3 seconds, the second text is loaded. Three seconds is within the default timeout, so explicitly using flakeSafely
with a different timeout doesn't make sense.
package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.FlakyScreen
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import org.junit.Rule
+import org.junit.Test
+
+class FlakyScreenTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun checkFlakyScreen() = run {
+ step("Open flaky screen") {
+ MainScreen {
+ flakyActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check initial elements") {
+ FlakyScreen {
+ text1.isVisible()
+ text2.isVisible()
+ text3.isVisible()
+ text4.isVisible()
+ text5.isVisible()
+ progressBar1.isVisible()
+ progressBar2.isVisible()
+ progressBar3.isVisible()
+ progressBar4.isVisible()
+ progressBar5.isVisible()
+ }
+ }
+ step("Check first element after loading") {
+ FlakyScreen {
+ text1.hasText(R.string.text_1)
+ progressBar1.isGone()
+ }
+ }
+ step("Check second element after loading") {
+ FlakyScreen {
+ text2.hasText(R.string.text_2)
+ progressBar2.isGone()
+ }
+ }
+ }
+}
+
TextView
. 10 seconds is an approximate data loading time, it can be more or less than this value, so the standard timeout will not work for us. In such cases, you need to explicitly call flakySafely
passing an extended timeout, let's pass 15 seconds
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.FlakyScreen
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import org.junit.Rule
+import org.junit.Test
+
+class FlakyScreenTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun checkFlakyScreen() = run {
+ step("Open flaky screen") {
+ MainScreen {
+ flakyActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check initial elements") {
+ FlakyScreen {
+ text1.isVisible()
+ text2.isVisible()
+ text3.isVisible()
+ text4.isVisible()
+ text5.isVisible()
+ progressBar1.isVisible()
+ progressBar2.isVisible()
+ progressBar3.isVisible()
+ progressBar4.isVisible()
+ progressBar5.isVisible()
+ }
+ }
+ step("Check first element after loading") {
+ FlakyScreen {
+ text1.hasText(R.string.text_1)
+ progressBar1.isGone()
+ }
+ }
+ step("Check second element after loading") {
+ FlakyScreen {
+ text2.hasText(R.string.text_2)
+ progressBar2.isGone()
+ }
+ }
+ step("Check left elements after loading") {
+ FlakyScreen {
+ flakySafely(15000) {
+ text3.hasText(R.string.text_3)
+ progressBar3.isGone()
+ text4.hasText(R.string.text_4)
+ progressBar4.isGone()
+ text5.hasText(R.string.text_5)
+ progressBar5.isGone()
+ }
+ }
+ }
+ }
+}
+
In some tests, you may see code like Thread.sleep(delay_in_millis)
used to solve timeout problems instead of flakySafely
. This code stops the thread for the time that was passed as a parameter. That is, the test in this place will stop its execution and will wait for some time, after the timeout is completed, the test will continue to work.
At first glance, it may seem that there is no difference in these methods, and they do the same thing. But in fact, they have a significant difference. If you use flakySafely
, then regardless of the timeout, the test will continue to run after a successful check. And when using Thread.sleep
in any case, the test will wait until the timeout is completed.
Normally, all checks in Kaspresso use flakySafely
with a timeout of 10 seconds, but despite this, the tests complete very quickly, because if the method returned the correct value, then there will be no waiting. If all these methods are replaced by Thread.sleep
, then each such check will take at least 10 seconds and the tests will run for a very long time.
Knowing the benefits of flakySafely
that we just discussed, you may want to specify a very large timeout for all tests, just to be on the safe side. But this should not be done for several reasons.
Firstly, if the application really does not work correctly, and some tests will fail, then their passage will be much longer than with a standard timeout.
+Secondly, there may be some bugs in the application that cause it to run much slower than expected. In this case, we could learn about the problem from autotests, but if the timeout is too long, it will go unnoticed.
+Therefore, in most cases, the standard timeout will suit you, and you do not need to explicitly specify it. Otherwise, specify a timeout that is acceptable to the user.
+You may have noticed that all the elements on the screen do not fit, because they take up quite a lot of space in height, so all the content was added to the ScrollView, so that the screen can be scrolled.
+We can add a check that when the screen is opened, the first element is displayed, but the last one is not. It would be wrong to use the isVisible
method in this case, because even if the object does not fit on the screen, but it is visible, the check will return true
. Instead, you can use the isDisplayed
and isNotDisplayed
methods, which are needed just in such cases - when you need to know that the element is actually visible on the screen.
Then the test code will look like this:
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.FlakyScreen
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import org.junit.Rule
+import org.junit.Test
+
+class FlakyScreenTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun checkFlakyScreen() = run {
+ step("Open flaky screen") {
+ MainScreen {
+ flakyActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check display of elements") {
+ FlakyScreen {
+ text1.isDisplayed()
+ text5.isNotDisplayed()
+ }
+ }
+ step("Check initial elements") {
+ FlakyScreen {
+ text1.isVisible()
+ text2.isVisible()
+ text3.isVisible()
+ text4.isVisible()
+ text5.isVisible()
+ progressBar1.isVisible()
+ progressBar2.isVisible()
+ progressBar3.isVisible()
+ progressBar4.isVisible()
+ progressBar5.isVisible()
+ }
+ }
+ step("Check first element after loading") {
+ FlakyScreen {
+ text1.hasText(R.string.text_1)
+ progressBar1.isGone()
+ }
+ }
+ step("Check second element after loading") {
+ FlakyScreen {
+ text2.hasText(R.string.text_2)
+ progressBar2.isGone()
+ }
+ }
+ step("Check left elements after loading") {
+ FlakyScreen {
+ flakySafely(15000) {
+ text3.hasText(R.string.text_3)
+ progressBar3.isGone()
+ text4.hasText(R.string.text_4)
+ progressBar4.isGone()
+ text5.hasText(R.string.text_5)
+ progressBar5.isGone()
+ }
+ }
+ }
+ }
+}
+
isNotDisplayed
method, we use isDisplayed
.
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.FlakyScreen
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import org.junit.Rule
+import org.junit.Test
+
+class FlakyScreenTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun checkFlakyScreen() = run {
+ step("Open flaky screen") {
+ MainScreen {
+ flakyActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check display of elements") {
+ FlakyScreen {
+ text1.isDisplayed()
+ text5.isDisplayed()
+ }
+ }
+ step("Check initial elements") {
+ FlakyScreen {
+ text1.isVisible()
+ text2.isVisible()
+ text3.isVisible()
+ text4.isVisible()
+ text5.isVisible()
+ progressBar1.isVisible()
+ progressBar2.isVisible()
+ progressBar3.isVisible()
+ progressBar4.isVisible()
+ progressBar5.isVisible()
+ }
+ }
+ step("Check first element after loading") {
+ FlakyScreen {
+ text1.hasText(R.string.text_1)
+ progressBar1.isGone()
+ }
+ }
+ step("Check second element after loading") {
+ FlakyScreen {
+ text2.hasText(R.string.text_2)
+ progressBar2.isGone()
+ }
+ }
+ step("Check left elements after loading") {
+ FlakyScreen {
+ flakySafely(15000) {
+ text3.hasText(R.string.text_3)
+ progressBar3.isGone()
+ text4.hasText(R.string.text_4)
+ progressBar4.isGone()
+ text5.hasText(R.string.text_5)
+ progressBar5.isGone()
+ }
+ }
+ }
+ }
+}
+
It seems that the test should fail, since initially the fifth element is not visible on the screen. We launch. Test passed successfully.
+The reason for this behavior is the implementation of checks in the Kaspresso library. If we test an element that is inside ScrollView and this test fails, then the test will automatically scroll to that element, and the test will will be executed again. Thus, the problem was solved when, during the normal behavior of the application, the tests crashed only because they could not check an element that is not currently visible on the screen.
+It turns out that the text5.isDisplayed
check was performed, it failed and the screen was scrolled down and the check started again. Now the element was actually visible on the screen, so the test succeeded.
When writing tests for screens that can be scrolled, consider the peculiarities of working with them in Kaspresso.
+In this tutorial, we covered the following points:
+In this tutorial, we will learn how to identify the causes of failing tests by adding additional logs and screenshots.
+Let's recall an example that was already used in one of the previous lessons. Opening the tutorial app
+ +and click on the Login Activity
button
On this screen, you can enter your login and password, and if they are correct, the screen after authorization will open. In this case, the following are considered correct: a login with a length of three characters, a password - from six.
+ +We have already written tests for this screen, they are in the class LoginActivityTest
package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.afterlogin.AfterLoginActivity
+import com.kaspersky.kaspresso.tutorial.login.LoginActivity
+import org.junit.Rule
+import org.junit.Test
+
+class LoginActivityTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun loginSuccessfulIfUsernameAndPasswordCorrect() {
+ run {
+ step("Try to login with correct username and password") {
+ scenario(
+ LoginScenario(
+ username = "123456",
+ password = "123456"
+ )
+ )
+ }
+ step("Check current screen") {
+ device.activities.isCurrent(AfterLoginActivity::class.java)
+ }
+ }
+ }
+
+ @Test
+ fun loginUnsuccessfulIfUsernameIncorrect() {
+ run {
+ step("Try to login with incorrect username") {
+ scenario(
+ LoginScenario(
+ username = "12",
+ password = "123456"
+ )
+ )
+ }
+ step("Check current screen") {
+ device.activities.isCurrent(LoginActivity::class.java)
+ }
+ }
+ }
+
+ @Test
+ fun loginUnsuccessfulIfPasswordIncorrect() {
+ run {
+ step("Try to login with incorrect password") {
+ scenario(
+ LoginScenario(
+ username = "123456",
+ password = "12345",
+ )
+ )
+ }
+ step("Check current screen") {
+ device.activities.isCurrent(LoginActivity::class.java)
+ }
+ }
+ }
+}
+
In this test, we ourselves create a username and password with which we will log in. But there are times when we get the data for the test from some external system. For example, a project may have some kind of service that generates a login and password for logging in, returns it to us, and we use them for testing.
+Let's simulate this situation. Let's create a class that returns login data - login and password.
+Let's create another package data
in the com.kaspersky.kaspresso.tutorial
package
In the created package, add the TestData
class, select the type Object
As we said earlier, here we will only simulate the situation when we receive data for the test from an external system. In the created class, we will have two methods: one of them returns the login, the other returns the password. In real projects, we would request this data from the server, and we would not have been able to change the internal implementation of the possibility. That is, now we ourselves will indicate which login and password the system will return, but we imagine that this is a “black box” for us, and we do not know what values will be received.
+We add two methods in this class and let them return the correct login and password:
+package com.kaspersky.kaspresso.tutorial.data
+
+object TestData {
+
+ fun generateUsername(): String = "Admin"
+
+ fun generatePassword(): String = "123456"
+}
+
TestData
class. Let's call the test class LoginActivityGeneratedDataTest
. We can copy the successful login test from the LoginActivityTest
class
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.afterlogin.AfterLoginActivity
+import org.junit.Rule
+import org.junit.Test
+
+class LoginActivityGeneratedDataTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun loginSuccessfulIfUsernameAndPasswordCorrect() {
+ run {
+ step("Try to login with correct username and password") {
+ scenario(
+ LoginScenario(
+ username = "123456",
+ password = "123456"
+ )
+ )
+ }
+ step("Check current screen") {
+ device.activities.isCurrent(AfterLoginActivity::class.java)
+ }
+ }
+ }
+}
+
Here we use a hardcoded username and password, let's get them from the TestData
class
package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.afterlogin.AfterLoginActivity
+import com.kaspersky.kaspresso.tutorial.data.TestData
+import org.junit.Rule
+import org.junit.Test
+
+class LoginActivityGeneratedDataTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun loginSuccessfulIfUsernameAndPasswordCorrect() {
+ run {
+ val username = TestData.generateUsername()
+ val password = TestData.generatePassword()
+ step("Try to login with correct username and password") {
+ scenario(
+ LoginScenario(
+ username = username,
+ password = password
+ )
+ )
+ }
+ step("Check current screen") {
+ device.activities.isCurrent(AfterLoginActivity::class.java)
+ }
+ }
+ }
+}
+
We checked that if the system returns correct data, then the test passes. Let's change the TestData
class so that it returns incorrect values
package com.kaspersky.kaspresso.tutorial.data
+
+object TestData {
+
+ fun generateUsername(): String = "Adm"
+
+ fun generatePassword(): String = "123"
+}
+
We have already said that in real projects we cannot influence the external system, and sometimes it can return incorrect data, which will cause the test to fail. If the test fails, then you need to analyze and determine what the problem was: in the tests, in a malfunctioning application, or in an external system. Let's try to determine this from the logs. Open Logcat and filter the log by tag KASPRESSO
What do we see from here? The attempt to log in was successful, but the check that the correct screen was opened after a successful login failed.
+At the same time, it is completely unclear from here why the problem arose. We do not see what data was used to log in, whether they are really correct, and it is not clear how to solve the problem that has arisen. The result would be more understandable if the logs contained information - which particular login and password were used during testing.
+If we need to add some of our information to the logs, we can use the testLogger
object, on which we need to call the i
method (from the word info)
, and pass the text to be logged as a parameter.
Our login and password are generated before the step step("Try to login with correct username and password")
we can display a message in the log at this point about what data was generated
package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.afterlogin.AfterLoginActivity
+import com.kaspersky.kaspresso.tutorial.data.TestData
+import org.junit.Rule
+import org.junit.Test
+
+class LoginActivityGeneratedDataTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun loginSuccessfulIfUsernameAndPasswordCorrect() {
+ run {
+ val username = TestData.generateUsername()
+ val password = TestData.generatePassword()
+
+ testLogger.i("Generated data. Username: $username, Password: $password")
+
+ step("Try to login with correct username and password") {
+ scenario(
+ LoginScenario(
+ username = username,
+ password = password
+ )
+ )
+ }
+ step("Check current screen") {
+ device.activities.isCurrent(AfterLoginActivity::class.java)
+ }
+ }
+ }
+}
+
In this line testLogger.i("Generated data. Username: $username, Password: $password")
we call the i
method on the testLogger
object, passing the string "Generated data. Username: $username, Password: $password")
as a parameter, where instead of $username
and $password
the values will be substituted login and password variables.
Info
+You can read more about how to form a string using variables and methods in documentation
+Let's run the test again and see the logs:
+ +After TEST SECTION
you can see our log, which is displayed with the KASPRESSO_TEST
tag. This log shows that the generated data is incorrect (the password is too short), which means that the test fails due to an external system, and the problem needs to be solved in it.
If you don't want to watch the entire log, and you are only interested in messages added by you, you can filter the log by the tag KASPRESSO_TEST
Logs are really very useful when analyzing tests and finding bugs, but there are times when it's much easier to find a problem from screenshots. If during the test a screenshot was saved at each step, and then we could look at them in some folder, then finding errors would be much easier.
+In Kaspresso, it is possible to take screenshots at any step during the test, for this it is enough to call the device.screenshots.take("file_name")
method. Instead of file_name
, you need to specify the name of the screenshot file, by which you can find it. Let's add screenshots to each LoginScenario
step so that we can analyze everything that happened on the screen later.
package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.scenario.Scenario
+import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext
+import com.kaspersky.kaspresso.tutorial.screen.LoginScreen
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+
+class LoginScenario(
+ private val username: String,
+ private val password: String
+) : Scenario() {
+
+ override val steps: TestContext<Unit>.() -> Unit = {
+ step("Open login screen") {
+ device.screenshots.take("before_open_login_screen")
+ MainScreen {
+ loginActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ device.screenshots.take("after_open_login_screen")
+ }
+ step("Check elements visibility") {
+ device.screenshots.take("check_elements_visibility")
+ LoginScreen {
+ inputUsername {
+ isVisible()
+ hasHint(R.string.login_activity_hint_username)
+ }
+ inputPassword {
+ isVisible()
+ hasHint(R.string.login_activity_hint_password)
+ }
+ loginButton {
+ isVisible()
+ isClickable()
+ }
+ }
+ }
+ step("Try to login") {
+ LoginScreen {
+ inputUsername {
+ replaceText(username)
+ device.screenshots.take("setup_username")
+ }
+ inputPassword {
+ replaceText(password)
+ device.screenshots.take("setup_password")
+ }
+ loginButton {
+ click()
+ device.screenshots.take("after_click_login")
+ }
+ }
+ }
+ }
+}
+
In order for screenshots to be saved on the device, the application must have permission to read and write to the smartphone's file system. Therefore, in the test class, we will give the appropriate permission through GrantPermissionRule
package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import androidx.test.rule.GrantPermissionRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.afterlogin.AfterLoginActivity
+import com.kaspersky.kaspresso.tutorial.data.TestData
+import org.junit.Rule
+import org.junit.Test
+
+class LoginActivityGeneratedDataTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @get:Rule
+ val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(
+ android.Manifest.permission.READ_EXTERNAL_STORAGE,
+ android.Manifest.permission.WRITE_EXTERNAL_STORAGE
+ )
+
+ @Test
+ fun loginSuccessfulIfUsernameAndPasswordCorrect() {
+ run {
+ val username = TestData.generateUsername()
+ val password = TestData.generatePassword()
+
+ testLogger.i("Generated data. Username: $username, Password: $password")
+
+ step("Try to login with correct username and password") {
+ scenario(
+ LoginScenario(
+ username = username,
+ password = password
+ )
+ )
+ }
+ step("Check current screen") {
+ device.activities.isCurrent(AfterLoginActivity::class.java)
+ }
+ }
+ }
+}
+
Let's run the test again.
+After running the test, go to Device File Explorer
and open the sdcard/Documents/screenshots
folder. If it is not displayed for you, then right-click on the sdcard
folder and click Synchronize
Here, from the screenshots, you can determine what the problem is - at the stage of setting the password, the number of characters entered is 3
+ +So, after analyzing the screenshots, you can determine which error occurred at the time of the tests.
+Info
+One way to take a screenshot is to call the device.uiDevice.takeScreenshot
method. This is a method from the uiautomator
library and should never be used directly.
Firstly, a screenshot taken with Kaspresso (device.screenshots.take
) will be in the correct folder, which is easy to find by the name of the test, and the files for each test and step will be in their own folders with understandable names, and in the case of uiautomator
, finding the right screenshots will be problematic.
Secondly, Kaspresso has made a lot of convenient improvements for working with screenshots, such as scaling, photo quality settings, full-screen screenshots (when all the content does not fit on the screen), and so on.
+Therefore, for screenshots, always use only the Kaspresso device.screenshots
objects.
Theoretically, all tests you write can fail. In such cases, I would like to always be able to look at screenshots to understand what went wrong. How to achieve this? As an option, add a method call that takes a screenshot to all steps of all tests, but this is not very convenient.
+Therefore, Kaspresso has added the ability to configure test parameters when creating a test class. To do this, you can pass the Kaspresso.Builder
object to the TestCase
constructor, which by default takes the value Kaspresso.Builder.simple()
.
Info
+To see the parameters a method or constructor takes, you can left-click inside the parentheses and press ctrl + P
(or cmd + P
on Mac)
We can add many different settings, you can read more about them in the Wiki.
+Now we are interested in adding screenshots if the tests have failed. The easiest way to do this is to use advanced
builder instead of simple
. This is done as follows:
class LoginActivityGeneratedDataTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.advanced()
+)
+
Info
+Please note that permissions to access the file system are required, without them screenshots will not be saved.
+package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.scenario.Scenario
+import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext
+import com.kaspersky.kaspresso.tutorial.screen.LoginScreen
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+
+class LoginScenario(
+ private val username: String,
+ private val password: String
+) : Scenario() {
+
+ override val steps: TestContext<Unit>.() -> Unit = {
+ step("Open login screen") {
+ MainScreen {
+ loginActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check elements visibility") {
+ LoginScreen {
+ inputUsername {
+ isVisible()
+ hasHint(R.string.login_activity_hint_username)
+ }
+ inputPassword {
+ isVisible()
+ hasHint(R.string.login_activity_hint_password)
+ }
+ loginButton {
+ isVisible()
+ isClickable()
+ }
+ }
+ }
+ step("Try to login") {
+ LoginScreen {
+ inputUsername {
+ replaceText(username)
+ }
+ inputPassword {
+ replaceText(password)
+ }
+ loginButton {
+ click()
+ }
+ }
+ }
+ }
+}
+
Let's start the test. Tests failed and screenshots appeared on the device (don't forget to press Synchronize
):
When using the advanced
builder, there are a few more changes. In addition to screenshots, files with logs, the View hierarchy, and more are also added.
If you do not need all these changes, then you can only change certain settings of a simple builder.
+Info
+If you're not a developer, customizing the default builder can be quite tricky. In case it was not possible to figure out the setting, use the advanced
builder to get screenshots
You should remember that in the previous tests, in addition to executing our methods, there were many additional actions “under the hood”: writing logs for each step, implicitly calling flakySafely, automatically scrolling to the element if the check was unsuccessful, and so on.
+All this worked thanks to Interceptors
. Interceptors
are classes that intercept the actions we call and add some functionality to them. There are a lot of such classes in Kaspresso, you can read more about them in documentation
We are interested in adding screenshots, the ScreenshotStepWatcherInterceptor
, ScreenshotFailStepWatcherInterceptor
and TestRunnerScreenshotWatcherInterceptor
classes are responsible for this.
If the test fails, it is convenient to look not only at the step at which the error occurred, but also at the previous ones - this way it is much easier to figure out the problem. Therefore, we will add the first Interceptor
option, which will screenshot all the steps, regardless of the result. This is done as follows:
class LoginActivityGeneratedDataTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple().apply {
+ stepWatcherInterceptors.add(ScreenshotStepWatcherInterceptor(screenshots))
+ }
+)
+
apply
method, and add all the necessary settings in curly braces. In this case, we get all the Interceptors
that intercept the step event (step
) and add a ScreenshotStepWatcherInterceptor
there, passing the screenshots
object to the constructor.
+Now that we have added this Interceptor
, after each test step, regardless of the result of its execution, screenshots will be saved on the device.
We launch. The test failed and screenshots were saved to the device
+ +Let's return the correct implementation of the TestData
class
package com.kaspersky.kaspresso.tutorial.data
+
+object TestData {
+
+ fun generateUsername(): String = "Admin"
+
+ fun generatePassword(): String = "123456"
+}
+
Let's run the test again. The test passed successfully and all screenshots are saved on the device.
+In this tutorial, we learned how to add logging and screenshots to our tests. We found out when standard logs are not enough, learned how to customize Kaspresso.Builder
by adding various Interceptors
to it.
+We also looked at ways to create screenshots manually, and how this process can be automated.
In practice, we often have to work with screens that contain lists of elements, and these lists are dynamic, and their size and content can change. When testing such screens, there are some peculiarities. We will talk about them in this lesson.
+Open the tutorial
application and click on the List Activity
button.
You will see the following screen:
+ +It displays the user's to-do list. Each element of the list has a serial number, text and color, which is set depending on the priority. If the priority is low, then the background color is green, if medium, then orange, if high, then red.
+It is also possible to delete list items with a swipe action.
+ + +Let's write tests for this screen. We need the IDs of the list elements, we will use the LayoutInspector to find them.
+ +Note that all list items are inside RecyclerView with id rv_notes. The recycler has three objects that have the same IDs: note_container
, tv_note_id
and tv_note_text
.
It turns out that we will not be able to test the screen in the usual way, since all elements have the same ID, instead we use a different approach. The PageObject of the screen with the list of notes will contain only one element - RecyclerView
, and the elements of the list will be separate PageObjects, whose content we will check.
Let's start writing a test. First of all let's add PageObject NoteListScreen
.
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+import com.kaspersky.kaspresso.tutorial.R
+import io.github.kakaocup.kakao.recycler.KRecyclerView
+
+object NoteListScreen : KScreen<NoteListScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val rvNotes = KRecyclerView { withId(R.id.rv_notes) }
+}
+
RecyclerView
, then it is assumed that you will be checking the elements of the list, and not the container with these elements. Therefore, when creating an instance of KRecyclerView
, it is not enough to pass only the matcher by which the object will be found, you must pass the second parameter, which is called itemTypeBuilder
.
+Info
+If you want to know what parameters to pass to a particular method or constructor, you can press the key combination ctrl + P
(cmd + P
on Mac OS), and you will see a tooltip that will indicate the necessary arguments.
We have already said earlier that we will need a Page Object for each list item, so we need to create an appropriate class, we will pass an instance of this class to itemTypeBuilder
.
In the same file, add the NoteItemScreen
class, this time we inherit not from KScreen
, but from KRecyclerViewItem
, since now it is not a regular Page Object, but a list item RecyclerView
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+import com.kaspersky.kaspresso.tutorial.R
+import io.github.kakaocup.kakao.recycler.KRecyclerItem
+import io.github.kakaocup.kakao.recycler.KRecyclerView
+
+object NoteListScreen : KScreen<NoteListScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val rvNotes = KRecyclerView { withId(R.id.rv_notes) }
+
+ class NoteItemScreen: KRecyclerItem<NoteItemScreen>() {
+
+ }
+}
+
Please note that earlier when creating the Page Object we wrote the object
keyword, but here we need to write class
. The reason is that all the tested screens so far have been in a single instance, and here we will have several list elements, each of which will be a Page Object, so we create a class, and for each element we will receive an instance of this class.
In the notes, we will need the root note_container
and two TextView
. If we try to find them on the screen by id, then an error will occur, since there are several such elements on the screen and it is not clear which one we need.
This problem is solved as follows - each note is a separate View instance and we will search for elements not on the entire screen, but only inside these same View (notes). To implement such logic, the matcher
object must be passed as a parameter to the KRecyclerViewItem
constructor. During testing, a matcher
will be passed for each object, in which we will find the necessary View elements.
Therefore, we pass matcher
as a parameter:
package com.kaspersky.kaspresso.tutorial.screen
+
+import android.view.View
+import com.kaspersky.kaspresso.screens.KScreen
+import com.kaspersky.kaspresso.tutorial.R
+import io.github.kakaocup.kakao.recycler.KRecyclerItem
+import io.github.kakaocup.kakao.recycler.KRecyclerView
+import org.hamcrest.Matcher
+
+object NoteListScreen : KScreen<NoteListScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val rvNotes = KRecyclerView { withId(R.id.rv_notes) }
+
+ class NoteItemScreen(matcher: Matcher<View>) : KRecyclerItem<NoteItemScreen>(matcher) {
+
+ }
+}
+
package com.kaspersky.kaspresso.tutorial.screen
+
+import android.view.View
+import com.kaspersky.kaspresso.screens.KScreen
+import com.kaspersky.kaspresso.tutorial.R
+import io.github.kakaocup.kakao.common.views.KView
+import io.github.kakaocup.kakao.recycler.KRecyclerItem
+import io.github.kakaocup.kakao.recycler.KRecyclerView
+import io.github.kakaocup.kakao.text.KTextView
+import org.hamcrest.Matcher
+
+object NoteListScreen : KScreen<NoteListScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val rvNotes = KRecyclerView { withId(R.id.rv_notes) }
+
+ class NoteItemScreen(matcher: Matcher<View>) : KRecyclerItem<NoteItemScreen>(matcher) {
+
+ val noteContainer = KView(matcher) { withId(R.id.note_container) }
+ val tvNoteId = KTextView(matcher) { withId(R.id.tv_note_id) }
+ val tvNoteText = KTextView(matcher) { withId(R.id.tv_note_text) }
+ }
+}
+
First, it is now necessary to pass a matcher to the View-element constructor, in which we will search for the required object. If this is not done, the test will fail.
+Secondly, if we check some specific behavior of the UI element, then we specify a specific inheritor of KView
(KTextView
, KEditText
, KButton
...). For example, if we want to check for text, we create a KTextView
that has the ability to get the text.
And if we are checking some common things that are available in all interface elements (background color, size, visibility, etc.), then we can use the parent KView
. In this case, we will check the texts of tvNoteId
and tvNoteText
, so we specified the type KTextView
. And the container in which these TextView
are located is an instance of CardView
, we will only check the background color for it, it does not need to check any specific things, so we specified the parent type as KView
When the PageObject of the list item is ready, you can create an instance of KRecyclerView
, for this we pass two parameters:
The first is builder
, in which we will find RecyclerView
by its id:
val rvNotes = KRecyclerView(
+ builder = { withId(R.id.rv_notes) },
+)
+
itemTypeBuilder
, here you need to call the itemType
function and to create an instance of NoteItemScreen
here:
+val rvNotes = KRecyclerView(
+ builder = { withId(R.id.rv_notes) },
+ itemTypeBuilder = {
+ itemType {
+ NoteItemScreen(it)
+ }
+ }
+)
+
Info
+You can read more about lambda expressions here.
+This entry can be shortened using Method Reference, then the final version of the class will look like this:
+package com.kaspersky.kaspresso.tutorial.screen
+
+import android.view.View
+import com.kaspersky.kaspresso.screens.KScreen
+import com.kaspersky.kaspresso.tutorial.R
+import io.github.kakaocup.kakao.common.views.KView
+import io.github.kakaocup.kakao.recycler.KRecyclerItem
+import io.github.kakaocup.kakao.recycler.KRecyclerView
+import io.github.kakaocup.kakao.text.KTextView
+import org.hamcrest.Matcher
+
+object NoteListScreen : KScreen<NoteListScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val rvNotes = KRecyclerView(
+ builder = { withId(R.id.rv_notes) },
+ itemTypeBuilder = { itemType(::NoteItemScreen) }
+ )
+
+ class NoteItemScreen(matcher: Matcher<View>) : KRecyclerItem<NoteItemScreen>(matcher) {
+
+ val noteContainer = KView(matcher) { withId(R.id.note_container) }
+ val tvNoteId = KTextView(matcher) { withId(R.id.tv_note_id) }
+ val tvNoteText = KTextView(matcher) { withId(R.id.tv_note_text) }
+ }
+}
+
Main Screen
+package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+import com.kaspersky.kaspresso.tutorial.R
+import io.github.kakaocup.kakao.text.KButton
+
+object MainScreen : KScreen<MainScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val simpleActivityButton = KButton { withId(R.id.simple_activity_btn) }
+ val wifiActivityButton = KButton { withId(R.id.wifi_activity_btn) }
+ val loginActivityButton = KButton { withId(R.id.login_activity_btn) }
+ val notificationActivityButton = KButton { withId(R.id.notification_activity_btn) }
+ val makeCallActivityButton = KButton { withId(R.id.make_call_activity_btn) }
+ val flakyActivityButton = KButton { withId(R.id.flaky_activity_btn) }
+ val listActivityButton = KButton { withId(R.id.list_activity_btn) }
+}
+
We create a class for testing, and, as usual, add a transition to this screen:
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import org.junit.Rule
+import org.junit.Test
+
+class NoteListTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun checkNotesScreen() = run {
+ step("Open note list screen") {
+ MainScreen {
+ listActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ }
+}
+
Now let's check that three items are displayed on the screen with the list of notes, for this we can call the getSize
method on KRecyclerView
:
package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.NoteListScreen
+import org.junit.Assert
+import org.junit.Rule
+import org.junit.Test
+
+class NoteListTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun checkNotesScreen() = run {
+ step("Open note list screen") {
+ MainScreen {
+ listActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check notes count") {
+ NoteListScreen {
+ Assert.assertEquals(3, rvNotes.getSize())
+ }
+ }
+ }
+}
+
KRecyclerView
has many useful methods, you can put a dot after the object name and see all the possibilities. For example, using firstChild
or lastChild
you can get the first or last element of NoteItemScreen
respectively. You can also find an element by its position, or perform checks on absolutely all notes using the children
method. To use them in angle brackets, you need to specify the type KRecyclerViewItem
, in our case it is NoteItemScreen
.
Let's check the visibility of all elements and that they all contain some text:
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.NoteListScreen
+import org.junit.Assert
+import org.junit.Rule
+import org.junit.Test
+
+class NoteListTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun checkNotesScreen() = run {
+ step("Open note list screen") {
+ MainScreen {
+ listActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check notes count") {
+ NoteListScreen {
+ Assert.assertEquals(3, rvNotes.getSize())
+ }
+ }
+ step("Check elements visibility") {
+ NoteListScreen {
+ rvNotes {
+ children<NoteListScreen.NoteItemScreen> {
+ tvNoteId.isVisible()
+ tvNoteText.isVisible()
+ noteContainer.isVisible()
+
+ tvNoteId.hasAnyText()
+ tvNoteText.hasAnyText()
+ }
+ }
+ }
+ }
+ }
+}
+
package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.NoteListScreen
+import org.junit.Assert
+import org.junit.Rule
+import org.junit.Test
+
+class NoteListTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun checkNotesScreen() = run {
+ step("Open note list screen") {
+ MainScreen {
+ listActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check notes count") {
+ NoteListScreen {
+ Assert.assertEquals(3, rvNotes.getSize())
+ }
+ }
+ step("Check elements visibility") {
+ NoteListScreen {
+ rvNotes {
+ children<NoteListScreen.NoteItemScreen> {
+ tvNoteId.isVisible()
+ tvNoteText.isVisible()
+ noteContainer.isVisible()
+
+ tvNoteId.hasAnyText()
+ tvNoteText.hasAnyText()
+ }
+ }
+ }
+ }
+ step("Check elements content") {
+ NoteListScreen {
+ rvNotes {
+ childAt<NoteListScreen.NoteItemScreen>(0) {
+ noteContainer.hasBackgroundColor(android.R.color.holo_green_light)
+ tvNoteId.hasText("0")
+ tvNoteText.hasText("Note number 0")
+ }
+ childAt<NoteListScreen.NoteItemScreen>(1) {
+ noteContainer.hasBackgroundColor(android.R.color.holo_orange_light)
+ tvNoteId.hasText("1")
+ tvNoteText.hasText("Note number 1")
+ }
+ childAt<NoteListScreen.NoteItemScreen>(2) {
+ noteContainer.hasBackgroundColor(android.R.color.holo_red_light)
+ tvNoteId.hasText("2")
+ tvNoteText.hasText("Note number 2")
+ }
+ }
+ }
+ }
+ }
+}
+
The application has the ability to delete notes with a swipe action. Let's check this point - remove the first note and make sure that two elements with the corresponding content remain on the screen.
+To perform some actions with View elements, we can get the view
object and call its perform
method as a parameter, passing the desired action. In this case, we swipe to the left, then the code will look like this:
package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.espresso.action.ViewActions
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.NoteListScreen
+import org.junit.Assert
+import org.junit.Rule
+import org.junit.Test
+
+class NoteListTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun checkNotesScreen() = run {
+ step("Open note list screen") {
+ MainScreen {
+ listActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check notes count") {
+ NoteListScreen {
+ Assert.assertEquals(3, rvNotes.getSize())
+ }
+ }
+ step("Check elements visibility") {
+ NoteListScreen {
+ rvNotes {
+ children<NoteListScreen.NoteItemScreen> {
+ tvNoteId.isVisible()
+ tvNoteText.isVisible()
+ noteContainer.isVisible()
+
+ tvNoteId.hasAnyText()
+ tvNoteText.hasAnyText()
+ }
+ }
+ }
+ }
+ step("Check elements content") {
+ NoteListScreen {
+ rvNotes {
+ childAt<NoteListScreen.NoteItemScreen>(0) {
+ noteContainer.hasBackgroundColor(android.R.color.holo_green_light)
+ tvNoteId.hasText("0")
+ tvNoteText.hasText("Note number 0")
+ }
+ childAt<NoteListScreen.NoteItemScreen>(1) {
+ noteContainer.hasBackgroundColor(android.R.color.holo_orange_light)
+ tvNoteId.hasText("1")
+ tvNoteText.hasText("Note number 1")
+ }
+ childAt<NoteListScreen.NoteItemScreen>(2) {
+ noteContainer.hasBackgroundColor(android.R.color.holo_red_light)
+ tvNoteId.hasText("2")
+ tvNoteText.hasText("Note number 2")
+ }
+ }
+ }
+ }
+ step("Check swipe to dismiss action") {
+ NoteListScreen {
+ rvNotes {
+ childAt<NoteListScreen.NoteItemScreen>(0) {
+ view.perform(ViewActions.swipeLeft())
+ }
+ childAt<NoteListScreen.NoteItemScreen>(0) {
+ noteContainer.hasBackgroundColor(android.R.color.holo_orange_light)
+ tvNoteId.hasText("1")
+ tvNoteText.hasText("Note number 1")
+ }
+ childAt<NoteListScreen.NoteItemScreen>(1) {
+ noteContainer.hasBackgroundColor(android.R.color.holo_red_light)
+ tvNoteId.hasText("2")
+ tvNoteText.hasText("Note number 2")
+ }
+ }
+ }
+ }
+ }
+}
+
In the last step, we remove the element at index 0 and check that “Note number 1” now lies at this index.
+You may have noticed that all checks are performed immediately after the swipe, without even waiting for the animation to complete. Now the test passes successfully, but sometimes it can lead to errors.
+Therefore, in cases where some action is performed with animation and it takes time to complete, you can call the device.uiDevice.waitForIdle
method. This method will stop the test execution until the screen enters the idle state - when no action is taking place and no animations are being performed.
We add this line to the test after the swipe, and check that the number of elements has become two:
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.espresso.action.ViewActions
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.NoteListScreen
+import org.junit.Assert
+import org.junit.Rule
+import org.junit.Test
+
+class NoteListTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun checkNotesScreen() = run {
+ step("Open note list screen") {
+ MainScreen {
+ listActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check notes count") {
+ NoteListScreen {
+ Assert.assertEquals(3, rvNotes.getSize())
+ }
+ }
+ step("Check elements visibility") {
+ NoteListScreen {
+ rvNotes {
+ children<NoteListScreen.NoteItemScreen> {
+ tvNoteId.isVisible()
+ tvNoteText.isVisible()
+ noteContainer.isVisible()
+
+ tvNoteId.hasAnyText()
+ tvNoteText.hasAnyText()
+ }
+ }
+ }
+ }
+ step("Check elements content") {
+ NoteListScreen {
+ rvNotes {
+ childAt<NoteListScreen.NoteItemScreen>(0) {
+ noteContainer.hasBackgroundColor(android.R.color.holo_green_light)
+ tvNoteId.hasText("0")
+ tvNoteText.hasText("Note number 0")
+ }
+ childAt<NoteListScreen.NoteItemScreen>(1) {
+ noteContainer.hasBackgroundColor(android.R.color.holo_orange_light)
+ tvNoteId.hasText("1")
+ tvNoteText.hasText("Note number 1")
+ }
+ childAt<NoteListScreen.NoteItemScreen>(2) {
+ noteContainer.hasBackgroundColor(android.R.color.holo_red_light)
+ tvNoteId.hasText("2")
+ tvNoteText.hasText("Note number 2")
+ }
+ }
+ }
+ }
+ step("Check swipe to dismiss action") {
+ NoteListScreen {
+ rvNotes {
+ childAt<NoteListScreen.NoteItemScreen>(0) {
+ view.perform(ViewActions.swipeLeft())
+ device.uiDevice.waitForIdle()
+ }
+
+ Assert.assertEquals(2, getSize())
+
+ childAt<NoteListScreen.NoteItemScreen>(0) {
+ noteContainer.hasBackgroundColor(android.R.color.holo_orange_light)
+ tvNoteId.hasText("1")
+ tvNoteText.hasText("Note number 1")
+ }
+
+ childAt<NoteListScreen.NoteItemScreen>(1) {
+ noteContainer.hasBackgroundColor(android.R.color.holo_red_light)
+ tvNoteId.hasText("2")
+ tvNoteText.hasText("Note number 2")
+ }
+ }
+ }
+ }
+ }
+}
+
There is one more point that we will consider in this lesson.
+There are times when you need to add some behavior to the Page Object. For example, now you can swipe through the elements of the list. In the test, this is done with this line of code view.perform(ViewActions.swipeLeft())
.
Every time we need to swipe, we will have to perform the same actions - get the view
object, call the method passing the parameter. Instead, we can add the necessary functionality in the Page Object class and then use it where necessary.
Add a method to the NoteItemScreen class, let's call it swipeLeft:
+class NoteItemScreen(matcher: Matcher<View>) : KRecyclerItem<NoteItemScreen>(matcher) {
+
+ val noteContainer = KView(matcher) { withId(R.id.note_container) }
+ val tvNoteId = KTextView(matcher) { withId(R.id.tv_note_id) }
+ val tvNoteText = KTextView(matcher) { withId(R.id.tv_note_text) }
+
+ fun swipeLeft() {
+ view.perform(ViewActions.swipeLeft())
+ }
+}
+
NoteItemScreen
object:
+childAt<NoteListScreen.NoteItemScreen>(0) {
+ swipeLeft()
+ device.uiDevice.waitForIdle()
+}
+
package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.NoteListScreen
+import org.junit.Assert
+import org.junit.Rule
+import org.junit.Test
+
+class NoteListTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun checkNotesScreen() = run {
+ step("Open note list screen") {
+ MainScreen {
+ listActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check notes count") {
+ NoteListScreen {
+ Assert.assertEquals(3, rvNotes.getSize())
+ }
+ }
+ step("Check elements visibility") {
+ NoteListScreen {
+ rvNotes {
+ children<NoteListScreen.NoteItemScreen> {
+ tvNoteId.isVisible()
+ tvNoteText.isVisible()
+ noteContainer.isVisible()
+
+ tvNoteId.hasAnyText()
+ tvNoteText.hasAnyText()
+ }
+ }
+ }
+ }
+ step("Check elements content") {
+ NoteListScreen {
+ rvNotes {
+ childAt<NoteListScreen.NoteItemScreen>(0) {
+ noteContainer.hasBackgroundColor(android.R.color.holo_green_light)
+ tvNoteId.hasText("0")
+ tvNoteText.hasText("Note number 0")
+ }
+ childAt<NoteListScreen.NoteItemScreen>(1) {
+ noteContainer.hasBackgroundColor(android.R.color.holo_orange_light)
+ tvNoteId.hasText("1")
+ tvNoteText.hasText("Note number 1")
+ }
+ childAt<NoteListScreen.NoteItemScreen>(2) {
+ noteContainer.hasBackgroundColor(android.R.color.holo_red_light)
+ tvNoteId.hasText("2")
+ tvNoteText.hasText("Note number 2")
+ }
+ }
+ }
+ }
+ step("Check swipe to dismiss action") {
+ NoteListScreen {
+ rvNotes {
+ childAt<NoteListScreen.NoteItemScreen>(0) {
+ swipeLeft()
+ device.uiDevice.waitForIdle()
+ }
+
+ Assert.assertEquals(2, getSize())
+
+ childAt<NoteListScreen.NoteItemScreen>(0) {
+ noteContainer.hasBackgroundColor(android.R.color.holo_orange_light)
+ tvNoteId.hasText("1")
+ tvNoteText.hasText("Note number 1")
+ }
+
+ childAt<NoteListScreen.NoteItemScreen>(1) {
+ noteContainer.hasBackgroundColor(android.R.color.holo_red_light)
+ tvNoteId.hasText("2")
+ tvNoteText.hasText("Note number 2")
+ }
+ }
+ }
+ }
+ }
+}
+
Info
+Note that no business logic needs to be added to the Page Object. You can give these objects certain properties, add functionality, but you should not add complex logic. The Page Object should remain a screen model with described interface elements and functions for interacting with these elements.
+In this tutorial, we learned how to test lists of items set in RecyclerView. We learned how to find elements, how to interact with them and check their behavior for compliance with the expected result.
+ + + + + + +In this lesson, we will learn what scenarios are (the Scenario
class from the Kaspresso library), find out what their purpose is, when they should be used, and when it is better to avoid them.
Open the tutorial application and click on the Login Acitivity
button.
We have an authorization screen where the user can enter a login and password and click on the Login
button
If the username
field contains less than three characters or the password
field contains less than six characters, then nothing will happen when the LOGIN
button is clicked.
If the data is filled in correctly, then the authorization is successful and the AfterLoginActivity
screen opens.
It turns out that in order to check the AfterLoginActivity
screen, the user must be authorized in the application. Therefore, let's first test the authorization screen LoginActivity
.
To check LoginActivity
it is necessary to declare one more button inside the PageObject of the main screen - a button to go to the authorization screen.
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+import com.kaspersky.kaspresso.tutorial.R
+import io.github.kakaocup.kakao.text.KButton
+
+object MainScreen : KScreen<MainScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val simpleActivityButton = KButton { withId(R.id.simple_activity_btn) }
+ val wifiActivityButton = KButton { withId(R.id.wifi_activity_btn) }
+ val loginActivityButton = KButton { withId(R.id.login_activity_btn) }
+}
+
Now create a PageObject for LoginActivity
, let's call it LoginScreen
:
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+import com.kaspersky.kaspresso.tutorial.R
+import io.github.kakaocup.kakao.edit.KEditText
+import io.github.kakaocup.kakao.text.KButton
+
+object LoginScreen : KScreen<LoginScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val inputUsername = KEditText { withId(R.id.input_username) }
+ val inputPassword = KEditText { withId(R.id.input_password) }
+ val loginButton = KButton { withId(R.id.login_btn) }
+}
+
We can create a LoginActivityTest
test. Let's add a step: opening the target screen LoginActivity
.
package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import org.junit.Test
+
+class LoginActivityTest : TestCase() {
+
+ @Test
+ fun test() {
+ run {
+ step("Open login screen") {
+ MainScreen {
+ loginActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ }
+ }
+}
+
When the target screen is open, we can test it. At the current stage, we will only add a check for a positive scenario when the user has successfully entered a login and password:
+In order to check which activity is currently open, you can use the method: device.activities.isCurrent(LoginActivity::class.java)
.
Then the general code of the test class will look like this:
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.afterlogin.AfterLoginActivity
+import com.kaspersky.kaspresso.tutorial.login.LoginActivity
+import com.kaspersky.kaspresso.tutorial.screen.LoginScreen
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import org.junit.Rule
+import org.junit.Test
+
+class LoginActivityTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ run {
+ val username = "123456"
+ val password = "123456"
+
+ step("Open login screen") {
+ MainScreen {
+ loginActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check elements visibility") {
+ LoginScreen {
+ inputUsername {
+ isVisible()
+ hasHint(R.string.login_activity_hint_username)
+ }
+ inputPassword {
+ isVisible()
+ hasHint(R.string.login_activity_hint_password)
+ }
+ loginButton {
+ isVisible()
+ isClickable()
+ }
+ }
+ }
+ step("Try to login") {
+ LoginScreen {
+ inputUsername {
+ replaceText(username)
+ }
+ inputPassword {
+ replaceText(password)
+ }
+ loginButton {
+ click()
+ }
+ }
+ }
+ step("Check current screen") {
+ device.activities.isCurrent(AfterLoginActivity::class.java)
+ }
+ }
+ }
+}
+
Let's start the test. Test passed successfully.
+Now let's add checks for a negative scenario when the user entered a login or password that is less than the allowed minimum length.
+Here you need to follow the rule: each test-case has its own test method. That is, we will not test entering both an incorrect login and incorrect password in the same method, but we will create separate ones in the same LoginActivityTest
class.
@Test
+fun loginUnsuccessfulIfUsernameIncorrect() {
+ run {
+ val username = "12"
+ val password = "123456"
+
+ step("Open login screen") {
+ MainScreen {
+ loginActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check elements visibility") {
+ LoginScreen {
+ inputUsername {
+ isVisible()
+ hasHint(R.string.login_activity_hint_username)
+ }
+ inputPassword {
+ isVisible()
+ hasHint(R.string.login_activity_hint_password)
+ }
+ loginButton {
+ isVisible()
+ isClickable()
+ }
+ }
+ }
+ step("Try to login") {
+ LoginScreen {
+ inputUsername {
+ replaceText(username)
+ }
+ inputPassword {
+ replaceText(password)
+ }
+ loginButton {
+ click()
+ }
+ }
+ }
+ step("Check current screen") {
+ device.activities.isCurrent(LoginActivity::class.java)
+ }
+ }
+}
+
Then we add a test for the case when the login is correct and the password is not.
+@Test
+fun loginUnsuccessfulIfPasswordIncorrect() {
+ run {
+ val username = "123456"
+ val password = "12345"
+
+ step("Open login screen") {
+ MainScreen {
+ loginActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check elements visibility") {
+ LoginScreen {
+ inputUsername {
+ isVisible()
+ hasHint(R.string.login_activity_hint_username)
+ }
+ inputPassword {
+ isVisible()
+ hasHint(R.string.login_activity_hint_password)
+ }
+ loginButton {
+ isVisible()
+ isClickable()
+ }
+ }
+ }
+ step("Try to login") {
+ LoginScreen {
+ inputUsername {
+ replaceText(username)
+ }
+ inputPassword {
+ replaceText(password)
+ }
+ loginButton {
+ click()
+ }
+ }
+ }
+ step("Check current screen") {
+ device.activities.isCurrent(LoginActivity::class.java)
+ }
+ }
+}
+
Let's rename the first test so that it is clear by its name that we are checking for successful authorization.
+@Test
+fun test()
+
Change to:
+@Test
+fun loginSuccessfulIfUsernameAndPasswordCorrect()
+
We run the tests. They all passed successfully.
+Take a look at the code we're using in these tests. For each test we do the following:
+Depending on what we check in each specific test, we have different first and last steps. In the first step we assign different values to the username
and password
variables, in the last step we make different checks to see if the screen is LoginActivity
or AfterLoginActivity
.
At the same time, steps from the second to the fourth are exactly the same for all tests. This is one of the cases where we can use the Scenario class.
+Scenarios are classes that allow you to combine several steps into one. For example, in this case, we can create an authorization script that will go through the entire process from starting the main screen to clicking on the Login
button after entering the login and password.
In the package with all tests com.kaspersky.kaspresso.tutorial
create a new class LoginScenario
and inherit from the class Scenario
from the package com.kaspersky.kaspresso.testcases.api.scenario
package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.scenario.Scenario
+
+class LoginScenario : Scenario() {
+
+}
+
There is an error here, because the Scenario class is abstract, and its child needs to override the steps
property, in which we must list all the steps of this scenario.
Press the key combination ctrl + i
, select the property you want to override and press OK
.
package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.scenario.Scenario
+import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext
+
+class LoginScenario : Scenario() {
+ override val steps: TestContext<Unit>.() -> Unit
+ get() = TODO("Not yet implemented")
+}
+
Now, after specifying the type TestContext<Unit>.() -> Unit
, delete the line get() = TODO("Not yet implemented")
, put the =
sign and open curly brackets, in which we list all the necessary steps.
Info
+The return type of steps
is a lambda expression, which is an extension function of the TestContext class. You can read more about lambda expressions and extension functions in the official Kotlin documentation .
Let's copy the steps that are repeated in each test.
+package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.scenario.Scenario
+import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext
+import com.kaspersky.kaspresso.tutorial.screen.LoginScreen
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+
+class LoginScenario : Scenario() {
+
+ override val steps: TestContext<Unit>.() -> Unit = {
+ step("Open login screen") {
+ MainScreen {
+ loginActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check elements visibility") {
+ LoginScreen {
+ inputUsername {
+ isVisible()
+ hasHint(R.string.login_activity_hint_username)
+ }
+ inputPassword {
+ isVisible()
+ hasHint(R.string.login_activity_hint_password)
+ }
+ loginButton {
+ isVisible()
+ isClickable()
+ }
+ }
+ }
+ step("Try to login") {
+ LoginScreen {
+ inputUsername {
+ replaceText(username)
+ }
+ inputPassword {
+ replaceText(password)
+ }
+ loginButton {
+ click()
+ }
+ }
+ }
+ }
+}
+
Now we have an authorization script in which we open the login screen, check the visibility of all elements, enter the login and password values and click on the Login
button.
But there is one problem: in this class there are no username
and password
variables that need to be entered in the input fields. We could declare them right here inside the test, as we did in the LoginActivityTest
class,
override val steps: TestContext<Unit>.() -> Unit = {
+ val username = "123456" // You can declare variables here
+ val password = "123456"
+
+ step("Open login screen") {
+ ...
+
but depending on the test being run, these values should be different, so we cannot assign a value inside the test.
+Therefore, instead of specifying the login and password directly inside the script, we can specify them as a parameter in the Scenario class inside the constructor. Then this piece of code:
+class LoginScenario : Scenario()
+
changes to:
+class LoginScenario(
+ private val username: String,
+ private val password: String
+) : Scenario()
+
Now, inside the test, we do not create a login and password, but use those that were passed to us as a constructor parameter:
+step("Try to login") {
+ LoginScreen {
+ inputUsername {
+ replaceText(username)
+ }
+ inputPassword {
+ replaceText(password)
+ }
+ loginButton {
+ click()
+ }
+ }
+}
+
Then the general Scenario code will look like this:
+package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.scenario.Scenario
+import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext
+import com.kaspersky.kaspresso.tutorial.screen.LoginScreen
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+
+class LoginScenario(
+ private val username: String,
+ private val password: String
+) : Scenario() {
+
+ override val steps: TestContext<Unit>.() -> Unit = {
+ step("Open login screen") {
+ MainScreen {
+ loginActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check elements visibility") {
+ LoginScreen {
+ inputUsername {
+ isVisible()
+ hasHint(R.string.login_activity_hint_username)
+ }
+ inputPassword {
+ isVisible()
+ hasHint(R.string.login_activity_hint_password)
+ }
+ loginButton {
+ isVisible()
+ isClickable()
+ }
+ }
+ }
+ step("Try to login") {
+ LoginScreen {
+ inputUsername {
+ replaceText(username)
+ }
+ inputPassword {
+ replaceText(password)
+ }
+ loginButton {
+ click()
+ }
+ }
+ }
+ }
+}
+
The Scenario is ready, we can use it in tests. Let's first use the Scenario in the first test method, and then we will do it in the rest the same way:
+@Test
+fun loginSuccessfulIfUsernameAndPasswordCorrect() {
+ run {
+ step("Try to login with correct username and password") {
+ scenario(
+ LoginScenario(
+ username = "123456",
+ password = "123456",
+ )
+ )
+ }
+ step("Check current screen") {
+ device.activities.isCurrent(AfterLoginActivity::class.java)
+ }
+ }
+}
+
For the rest of the tests, we modify them the same way:
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.afterlogin.AfterLoginActivity
+import com.kaspersky.kaspresso.tutorial.login.LoginActivity
+import org.junit.Rule
+import org.junit.Test
+
+class LoginActivityTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun loginSuccessfulIfUsernameAndPasswordCorrect() {
+ run {
+ step("Try to login with correct username and password") {
+ scenario(
+ LoginScenario(
+ username = "123456",
+ password = "123456",
+ )
+ )
+ }
+ step("Check current screen") {
+ device.activities.isCurrent(AfterLoginActivity::class.java)
+ }
+ }
+ }
+
+ @Test
+ fun loginUnsuccessfulIfUsernameIncorrect() {
+ run {
+ step("Try to login with incorrect username") {
+ scenario(
+ LoginScenario(
+ username = "12",
+ password = "123456",
+ )
+ )
+ }
+ step("Check current screen") {
+ device.activities.isCurrent(LoginActivity::class.java)
+ }
+ }
+ }
+
+ @Test
+ fun loginUnsuccessfulIfPasswordIncorrect() {
+ run {
+ step("Try to login with incorrect password") {
+ scenario(
+ LoginScenario(
+ username = "123456",
+ password = "12345",
+ )
+ )
+ }
+ step("Check current screen") {
+ device.activities.isCurrent(LoginActivity::class.java)
+ }
+ }
+ }
+}
+
We have considered one case when Scenarios are convenient to use: when the same steps are used in different tests within the framework of testing one screen. But this is not their only purpose.
+An application can have multiple screens that can only be accessed by being logged in. In this case, for each such screen, you will have to re-describe all the authorization steps. But when using Scenario, this becomes a very simple task.
+Now after logging in, we have the AfterLoginActivity
screen. Let's write a test for this screen.
First of all, we create a Page Object
+package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+import com.kaspersky.kaspresso.tutorial.R
+import io.github.kakaocup.kakao.edit.KEditText
+
+object AfterLoginScreen : KScreen<AfterLoginScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val title = KEditText { withId(R.id.title) }
+}
+
Add a test:
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import org.junit.Rule
+import org.junit.Test
+
+class AfterLoginActivityTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+
+ }
+}
+
In order to get to this screen, we need to go through the authorization process. Without the use of Scenario, we would have to repeat all the steps: launch the main screen, click on the button, then enter the username and password and click on the button again. But now this whole process comes down to using LoginScenario
:
package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.AfterLoginScreen
+import org.junit.Rule
+import org.junit.Test
+
+class AfterLoginActivityTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ run {
+ step("Open AfterLogin screen") {
+ scenario(
+ LoginScenario(
+ username = "123456",
+ password = "123456"
+ )
+ )
+ }
+ step("Check title") {
+ AfterLoginScreen {
+ title {
+ isVisible()
+ hasText(R.string.screen_after_login)
+ }
+ }
+ }
+ }
+ }
+}
+
Thus, through the use of Scenario, the code becomes clean, understandable and reusable. And to check the screens available only to authorized users, now you do not need to take many identical steps.
+Scenario is very handy if you use it correctly.
+In this lesson, we learned what Scenarios are, how to create them, use them, and pass parameters to their constructor. We also considered cases when their use benefits the project, and when, on the contrary, it worsens the readability of the code, increases its coupling and complicates reuse.
+В этом уроке мы научимся писать screenshot-тесты, узнаем, зачем они нужны, и как правильно разрабатывать приложение, чтобы его можно было покрыть тестами.
+Для успешного прохождения предыдущих уроков было достаточно базовых навыков программирования на Kotlin, знания Android разработки при этом не требовались, и успешно пройти все уроки могли как разработчики, так и тестировщики. Но для нашей сегодняшней темы, а также всех последующих, нужно понимание того, как разрабатываются приложения, чем отличаются архитектурные шаблоны MVVM и MVP, как применять Dependency Injection и другое.
+Поэтому предполагается, что все дальнейшие действия (или бОльшая их часть), которые мы будем проходить в курсе, находятся в зоне ответственности разработчиков, и эти уроки ориентированы на них. Если же с Android разработкой вы не знакомы, то можете все равно проходить эти уроки, чтобы иметь представление о возможностях Kaspresso, но учитывайте тот факт, что часть материала может быть непонятной.
+Чтобы узнать, зачем нужны скриншот-тесты, разберем небольшой пример. Представим, что наше приложение должно быть локализовано на французский язык. Для этого в проекте были добавлены переводы в файл strings.xml
в папку values-fr
.
Давайте установим на устройстве французский язык
+ +и запустим LoginActivityTest.
+ +Тест пройден успешно, значит теоретически это приложение рабочее, и его можно раскатывать на пользователей. Но давайте откроем LoginActivity
вручную (французский язык должен быть установлен на устройстве) и посмотрим, как выглядит этот экран.
Видим, что вместо корректных текстов здесь указано «TODO: add french locale». Похоже, что разработчики во время добавления строк оставили комментарий, чтобы добавить переводы в будущем, но забыли это сделать, поэтому приложение выглядит некорректно. Обнаружить эту ошибку тесты не могут, потому что они не знают, какой должен быть текст на французском языке. По этой причине приложение работает неправильно, но тесты проходят успешно.
+Возникшую проблему могут решить скриншот-тесты. Их суть заключается в том, что для всех экранов, где пользователю отображаются строки, создаются так называемые «скриншотилки» – классы, которые делают скриншоты экрана во всех необходимых состояниях и для всех поддерживаемых языков.
+После выполнения таких тестов скриншоты складываются в определенные папки. Тогда люди, ответственные за переводы и строки, смогут просмотреть снимки и убедиться, что для всех локалей и для всех состояний используются корректные значения.
+Screenshot-тесты будут отличаться от тестов, которые мы писали ранее:
+Во-первых, нас интересуют только строки на определенном экране, поэтому нет необходимости проходить весь процесс от старта приложения до открытия нужного экрана. Вместо этого, в тесте мы сразу будем открывать Activity или Fragment, скриншоты которого хотим получить.
+Во-вторых, мы хотим получить снимки всех возможных состояний экрана для каждой локали, поэтому добавлять проверки элементов или выполнять шаги, имитирующие действия пользователя, как мы делали ранее, мы не будем. Наша цель –
+Дальше нужно поменять локаль и повторить все перечисленные действия.
+Подробнее про состояния (или стейты, как их часто называют), и как их правильно устанавливать, мы поговорим позже, а сейчас напишем простой screenshot-тест, который откроет экран LoginActivity, сделает скриншот, затем сменит язык на устройстве на французский и снова сделает скриншот.
+Создание screenshot-теста начинается так же, как мы делали ранее – в папке тестов создаем новый класс. Классы для скриншотов обычно называются с окончанием Screenshots
. Давайте все скриншот-тесты будем хранить в отдельном пакете, назовем его screenshot_tests.
В этом пакете создаем класс LoginActivityScreenshots
У тестов, которые мы сейчас будем создавать, есть особенности: во-первых, они должны запускаться для разных локалей, во-вторых, полученные скриншоты должны быть размещены в удобной структуре папок – для каждого языка своя папка. По этим причинам тестовый класс мы унаследуем от класса DocLocScreenshotTestCase
, а не от TestCase
, как мы это делали ранее
package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+
+class LoginActivityScreenshots : DocLocScreenshotTestCase() {
+
+}
+
В качестве параметра конструктору нужно передать список локалей, для которых будут делаться скриншоты. В данном случае нас интересует английский и французский языки, устанавливаем их. Делается это следующим образом:
+package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+
+class LoginActivityScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+}
+
Как мы говорили ранее, здесь мы не будем проходить весь процесс от старта приложения до открытия необходимого экрана. Вместо этого мы сразу создадим Rule
, в котором укажем, что при старте теста должен быть открыт экран LoginActivity
package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+import com.kaspersky.kaspresso.tutorial.login.LoginActivity
+import org.junit.Rule
+
+class LoginActivityScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<LoginActivity>()
+}
+
В этом классе мы можем использовать такие же методы, какие использовали в других тестах. Давайте создадим один step, в котором проверим только исходное состояние экрана. Назовем метод takeScreenshots()
+package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+import com.kaspersky.kaspresso.tutorial.login.LoginActivity
+import org.junit.Rule
+import org.junit.Test
+
+class LoginActivityScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<LoginActivity>()
+
+ @Test
+ fun takeScreenshots() = run {
+ step("Take screenshots initial state") {
+
+ }
+ }
+}
+
Для того чтобы сделать скриншоты, и чтобы эти скриншоты были сохранены в правильные папки на устройстве, необходимо вызвать метод captureScreenshot
. В качестве параметра методу необходимо передать название файла, это может быть любая строка – по этому имени вы сможете найти скриншот на устройстве.
package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+import com.kaspersky.kaspresso.tutorial.login.LoginActivity
+import org.junit.Rule
+import org.junit.Test
+
+class LoginActivityScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<LoginActivity>()
+
+ @Test
+ fun takeScreenshots() = run {
+ step("Take screenshots initial state") {
+ captureScreenshot("Initial state")
+ }
+ }
+}
+
Разрешение на доступ к файлам здесь давать не нужно, это реализовано «под капотом». На данном этапе мы сделали все, что нужно, чтобы получить скриншоты экрана и посмотреть, как выглядит приложение на разных локалях, но желательно сделать еще одно изменение.
+Сейчас у нас открывается нужный экран, и сразу делается скриншот, поэтому есть вероятность, что какие-то данные на экране не успеют загрузиться, и снимок будет сделан до того, как мы увидим нужные нам элементы.
+Чтобы решить эту проблему, давайте в Page Object Login Screen
мы добавим метод, который дождется загрузки всех необходимых элементов интерфейса. В этом методе мы просто для всех объектов сделаем проверку на isVisible
. Это проверка в своей реализации использует flakySafely
, поэтому даже если данные мгновенно загружены не будут, то тест будет ждать, пока условие не выполнится в течение нескольких секунд.
Добавляем метод, назовем его waitForScreen
:
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+import com.kaspersky.kaspresso.tutorial.R
+import io.github.kakaocup.kakao.edit.KEditText
+import io.github.kakaocup.kakao.text.KButton
+
+object LoginScreen : KScreen<LoginScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val inputUsername = KEditText { withId(R.id.input_username) }
+ val inputPassword = KEditText { withId(R.id.input_password) }
+ val loginButton = KButton { withId(R.id.login_btn) }
+
+ fun waitForScreen() {
+ inputUsername.isVisible()
+ inputPassword.isVisible()
+ loginButton.isVisible()
+ }
+}
+
package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+import com.kaspersky.kaspresso.tutorial.login.LoginActivity
+import com.kaspersky.kaspresso.tutorial.screen.LoginScreen
+import org.junit.Rule
+import org.junit.Test
+
+class LoginActivityScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<LoginActivity>()
+
+ @Test
+ fun takeScreenshots() = run {
+ step("Take screenshots initial state") {
+ LoginScreen {
+ waitForScreen()
+ captureScreenshot("Initial state")
+ }
+ }
+ }
+}
+
Запускаем тест. Тест пройден успешно, и в Device File Explorer
в папке sdcard/Documents/screenshots
вы сможете найти все скриншоты, при этом для каждой локали была создана своя папка и вы сможете просмотреть, как выглядит ваше приложение на разных языках.
Теперь, просмотрев скриншоты, можно увидеть проблему в приложении, что не все строки были добавлены корректно, и разработчик может исправить ошибку, добавив необходимые значения в файл values-fr/strings.xml
.
Info
+Возможно, на некоторых устройствах при смене локали у вас возникнет проблема с заголовком экрана – весь контент на экране будет корректно переведен на необходимый язык, а заголовок останется прежним. Проблема связана с багом в библиотеке Google. Его уже пофиксили, как только опубликуют соответствующий релиз, внесем изменения в Kaspresso.
+В данном уроке мы рассмотрели: зачем нужны скриншот-тесты, как их писать и где смотреть результаты выполнения тестов.
+Тема screenshot-тестов довольно обширная, и для более комфортного освоения мы ее разбили на несколько частей. Для более углубленного изучения переходите к следующему уроку
+ + + + + + +Если в вашем приложении планируется использование screenshot-тестов, то этот момент нужно учитывать не только при написании тестов, но также при разработке приложения. В сегодняшнем уроке мы поближе познакомимся с установкой стейтов, внесем изменения в код приложения, чтобы его можно было покрыть тестами, и напишем первый скриншот тест, в котором будет работа с ViewModel.
+Если вы ранее не разрабатывали приложения под Android, то сегодняшний урок может быть сложным для понимания. Поэтому мы настоятельно рекомендуем перед прохождением данного урока ознакомиться со следующими темами:
+В приложении, которое мы сегодня будем покрывать тестами, имитируется загрузка данных. При клике на кнопку начинается загрузка, отображается прогресс бар, после окончания загрузки, в зависимости от результата, мы увидим либо загруженные данные, либо сообщение об ошибке.
+Откройте приложение tutorial и кликнете по кнопке «Load User Activity»
+ +Давайте сразу начнем разбираться, что такое стейты. После перехода по кнопке у вас откроется экран, на котором располагается одна кнопка и больше ничего нет.
+ +При клике на данную кнопку внешний вид экрана изменится, то есть изменится его состояние или, как его часто называют, стейт. Сейчас мы видим исходный стейт экрана, назовем его Initial
.
Кликнете по этой кнопке и обратите внимание, как изменится стейт экрана.
+ +Кнопка стала неактивной, то есть по ней больше нельзя кликать, и на экране появился Progress Bar, который показывает, что идет процесс загрузки. Этот стейт отличается от исходного, назовем этот стейт Progress
.
Через несколько секунд загрузка будет завершена, и вы увидите на экране загруженные данные пользователя (его имя и фамилию).
+ +Это третий стейт экрана. В таком состоянии кнопка снова становится активной, прогресс бар скрывается, и на экране отображаются имя и фамилия пользователя. Назовем такой стейт Content
.
В данном случае мы имитируем загрузку данных, в реальных приложениях работа с интернетом может завершиться с ошибкой. Такие ошибки нужно каким-то образом обрабатывать, к примеру, показывать уведомление пользователю. В нашем тестовом приложении мы сымитировали подобное поведение. Если вы попытаетесь загрузить пользователя несколько раз, то какая-то из этих попыток завершится с ошибкой и вы увидите следующее состояние экрана:
+ +Это четвертый и последний стейт экрана, который показывает состояние ошибки, назовем его Error
.
Перед тем, как мы изучим правильный способ покрытия тестами данного экрана, давайте напишем тесты тем способом, который мы уже знаем. Это нужно для того, чтобы вы понимали, почему так делать не стоит, и зачем нам вносить изменения в уже написанный код.
+В пакете screenshot_tests
создаем класс LoadUserScreenshots
Наследуемся от DocLocScreenshotTestCase
и передаем список языков в качестве параметра конструктору, сделаем скриншоты для английской и французской локалей
package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+
+class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+}
+
LoadUserActivity
, создаем соответствующее правило.
+package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+import com.kaspersky.kaspresso.tutorial.user.LoadUserActivity
+import org.junit.Rule
+
+class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<LoadUserActivity>()
+}
+
Для того чтобы получить все состояния экрана мы будем, как и раньше, имитировать действия пользователя – кликать по кнопке и ждать получения результата. Создаем PageObject
этого экрана. В пакете com.kaspersky.kaspresso.tutorial.screen
добавляем класс LoadUserScreen
, тип Object
Наследумся от KScreen
и добавляем все необходимые UI-элементы: кнопка загрузки, ProgressBar, TextView с именем пользователя и TextView с текстом ошибки
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+import com.kaspersky.kaspresso.tutorial.R
+import io.github.kakaocup.kakao.progress.KProgressBar
+import io.github.kakaocup.kakao.text.KButton
+import io.github.kakaocup.kakao.text.KTextView
+
+object LoadUserScreen : KScreen<LoadUserScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val loadingButton = KButton { withId(R.id.loading_button) }
+ val progressBarLoading = KProgressBar { withId(R.id.progress_bar_loading) }
+ val username = KTextView { withId(R.id.username) }
+ val error = KTextView { withId(R.id.error) }
+}
+
takeScreenshots
+package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+import com.kaspersky.kaspresso.tutorial.user.LoadUserActivity
+import org.junit.Rule
+import org.junit.Test
+
+class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<LoadUserActivity>()
+
+ @Test
+ fun takeScreenshots() {
+
+ }
+}
+
Первым делом давайте дождемся, пока кнопка отобразится на экране и сделаем первый скриншот исходного состояния экрана
+package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+import com.kaspersky.kaspresso.tutorial.screen.LoadUserScreen
+import com.kaspersky.kaspresso.tutorial.user.LoadUserActivity
+import org.junit.Rule
+import org.junit.Test
+
+class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<LoadUserActivity>()
+
+ @Test
+ fun takeScreenshots() {
+ LoadUserScreen {
+ loadingButton.isVisible()
+ captureScreenshot("Initial state")
+ }
+ }
+}
+
package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+import com.kaspersky.kaspresso.tutorial.screen.LoadUserScreen
+import com.kaspersky.kaspresso.tutorial.user.LoadUserActivity
+import org.junit.Rule
+import org.junit.Test
+
+class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<LoadUserActivity>()
+
+ @Test
+ fun takeScreenshots() {
+ LoadUserScreen {
+ loadingButton.isVisible()
+ captureScreenshot("Initial state")
+ loadingButton.click()
+ progressBarLoading.isVisible()
+ captureScreenshot("Progress state")
+ }
+ }
+}
+
Следующий этап – отображение данных о пользователе (стейт Content)
+package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+import com.kaspersky.kaspresso.tutorial.screen.LoadUserScreen
+import com.kaspersky.kaspresso.tutorial.user.LoadUserActivity
+import org.junit.Rule
+import org.junit.Test
+
+class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<LoadUserActivity>()
+
+ @Test
+ fun takeScreenshots() {
+ LoadUserScreen {
+ loadingButton.isVisible()
+ captureScreenshot("Initial state")
+ loadingButton.click()
+ progressBarLoading.isVisible()
+ captureScreenshot("Progress state")
+ username.isVisible()
+ captureScreenshot("Content state")
+ }
+ }
+}
+
package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+import com.kaspersky.kaspresso.tutorial.screen.LoadUserScreen
+import com.kaspersky.kaspresso.tutorial.user.LoadUserActivity
+import org.junit.Rule
+import org.junit.Test
+
+class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<LoadUserActivity>()
+
+ @Test
+ fun takeScreenshots() {
+ LoadUserScreen {
+ loadingButton.isVisible()
+ captureScreenshot("Initial state")
+ loadingButton.click()
+ progressBarLoading.isVisible()
+ captureScreenshot("Progress state")
+ username.isVisible()
+ captureScreenshot("Content state")
+ loadingButton.click()
+ progressBarLoading.isVisible()
+ username.isVisible()
+ loadingButton.click()
+ progressBarLoading.isVisible()
+ error.isVisible()
+ captureScreenshot("Error state")
+ }
+ }
+}
+
Таким образом, мы смогли написать скриншот тест, в котором получили все необходимые состояния экрана, имитируя действия пользователя – кликая по кнопке и ожидая результата выполнения запроса. Но давайте подумаем, насколько эта реализация подойдет для реальных приложений.
+Если мы работаем с реальным приложением, то после клика на кнопку тест будет ждать, пока запрос не вернет какой-то ответ с сервера. Если интернет будет медленным, или на сервере будут наблюдаться какие-то проблемы, то и время ожидания ответа может сильно увеличиться, соответственно будет увеличено время выполнения теста. При этом обратите внимание, что тест будет выполнен для каждой локали, которую мы передали в качестве параметра конструктора DocLocScreenshotTestCase
, и каждый из этих тестов будет зависеть от скорости интернет-соединения и от работоспособности сервера.
Также сервер может вернуть ошибку, когда мы ее не ожидали, в этом случае тест завершится неудачно.
+На определенном этапе теста мы хотим сделать скриншот состояния ошибки. В данном случае мы одинаково реагируем на любой тип ошибки, показывая строчку «Что-то пошло не так», но часто бывают случаи, когда пользователю нужно сообщать о том, что конкретно произошло. Например, при отсутствии интернета, показать уведомление об этом, при ошибке на сервере показать соответствующий диалог и так далее. Поэтому в каких-то случаях для воспроизведения стейта с ошибкой будет достаточно отключить интернет на устройстве, а в каких-то это может стать проблемой, которую не так просто решить.
+Так мы приходим к следующему выводу: получить все необходимые стейты, имитируя действия пользователя, для скриншот-тестов нецелесообразно.
+Во-первых, это может сильно замедлить выполнение теста.
+Во-вторых, такие тесты начинают зависеть от многих факторов, таких как скорость интернета и работоспособность сервера, следовательно вероятность того, что эти тесты завершатся неудачно, возрастает.
+В-третьих, некоторые состояния экрана получить очень сложно, и этот процесс может занять длительное время
+По причинам, рассмотренным выше, в скриншот-тестах стейты устанавливают другим способом. Имитировать действия пользователя мы не будем.
+На этом этапе важно понимать паттерн MVVM (Model-View-ViewModel). Если говорить кратко, то согласно этому паттерну в приложении логика отделяется от видимой части.
+Видимая часть, или можно сказать экраны (Activity и Fragments) отвечают за отображение элементов интерфейса и взаимодействия с пользователем. То есть они показывают вам какие-то элементы (кнопки, поля ввода и т.д.) и реагируют на действия пользователя (клики, свайпы и т.д). В паттерне MVVM эта часть называется View.
+ViewModel в этом паттерне отвечает за логику.
+Их взаимодействие выглядит следующим образом: ViewModel у себя хранит стейт экрана, она определяет, что следует показать пользователю. View получает этот стейт из ViewModel, и, в зависимости от полученного значения, отрисовывает нужные элементы. Если пользователь выполняет какие-то действия, то View вызывает соответствующий метод из ViewModel.
+Давайте посмотрим пример из нашего приложения. На экране есть кнопка загрузки, пользователь кликнул по ней, View вызывает метод загрузки данных из ViewModel.
+Откройте класс LoadUserFragment
из пакета com.kaspersky.kaspresso.tutorial.user
. Этот фрагмент в паттерне MVVM представляет собой View. В следующем фрагменте кода мы устанавливаем слушатель клика на кнопку и говорим, чтобы при клике на нее был вызван метод loadUser
из ViewModel
binding.loadingButton.setOnClickListener {
+ viewModel.loadUser()
+}
+
Логика загрузки реализована внутри ViewModel. Откройте класс LoadUserViewModel
из пакета com.kaspersky.kaspresso.tutorial.user
.
При вызове этого метода ViewModel меняет стейт экрана: при старте загрузки устанавливает стейт Progress, после окончания загрузки в зависимости от результата она устанавливает стейт Error или Content.
+fun loadUser() {
+ viewModelScope.launch {
+ _state.value = State.Progress
+ try {
+ val user = repository.loadUser()
+ _state.value = State.Content(user)
+ } catch (e: Exception) {
+ _state.value = State.Error
+ }
+ }
+}
+
LoadUserFragment
) подписывается на стейт из ViewModel и в зависимости от полученного значения меняет содержимое экрана. Происходит это в методе observeViewModel
+private fun observeViewModel() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.state.collect { state ->
+ when (state) {
+ is State.Content -> {
+ binding.progressBarLoading.isVisible = false
+ binding.loadingButton.isEnabled = true
+ binding.error.isVisible = false
+ binding.username.isVisible = true
+
+ val user = state.user
+ binding.username.text = "${user.name} ${user.lastName}"
+ }
+ State.Error -> {
+ binding.progressBarLoading.isVisible = false
+ binding.loadingButton.isEnabled = true
+ binding.error.isVisible = true
+ binding.username.isVisible = false
+ }
+ State.Progress -> {
+ binding.progressBarLoading.isVisible = true
+ binding.loadingButton.isEnabled = false
+ binding.error.isVisible = false
+ binding.username.isVisible = false
+ }
+ State.Initial -> {
+ binding.progressBarLoading.isVisible = false
+ binding.loadingButton.isEnabled = true
+ binding.error.isVisible = false
+ binding.username.isVisible = false
+ }
+ }
+ }
+ }
+ }
+}
+
Таким образом происходит взаимодействие View и ViewModel. ViewModel хранит стейт экрана, а View отображает его. При этом если пользователь совершил какие-то действия, то View сообщает об этом ViewModel и ViewModel меняет стейт экрана.
+Получается, что если мы хотим изменить состояние экрана, то можно изменить значение стейта внутри ViewModel, View отреагирует на это и отрисует то, что нам нужно. Именно этим мы и будем заниматься при написании скриншот-тестов.
+Давайте внутри тестового класса создадим объект ViewModel, у которого будем устанавливать стейт
+class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+ val viewModel = LoadUserViewModel()
+
+…
+}
+
state
.
+Info
+Далее мы будем работать с объектами StateFlow и MutableStateFlow, если вы не знаете, что это, и как с ними работать, обязательно прочитайте документацию
+package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+import com.kaspersky.kaspresso.tutorial.screen.LoadUserScreen
+import com.kaspersky.kaspresso.tutorial.user.LoadUserActivity
+import com.kaspersky.kaspresso.tutorial.user.LoadUserViewModel
+import com.kaspersky.kaspresso.tutorial.user.State
+import org.junit.Rule
+import org.junit.Test
+
+class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+ val viewModel = LoadUserViewModel()
+
+ @get:Rule
+ val activityRule = activityScenarioRule<LoadUserActivity>()
+
+ @Test
+ fun takeScreenshots() {
+ LoadUserScreen {
+ viewModel.state.value = State.Initial
+ …
+ }
+ }
+}
+
state
внутри ViewModel имеет тип StateFlow
, который является неизменяемым. То есть установить новый стейт в этот объект не получится. Если вы посмотрите в код LoadUserViewModel
, то увидите, что все новые значения стейта устанавливаются в переменную с нижним подчеркиванием _state
, у которой тип MutableStateFlow
+viewModelScope.launch {
+ _state.value = State.Progress
+ try {
+ val user = repository.loadUser()
+ _state.value = State.Content(user)
+ } catch (e: Exception) {
+ _state.value = State.Error
+ }
+}
+
private
, то есть снаружи обратиться к ней не получится.
+Как быть в этом случае? Нам необходимо добиться такого поведения, чтобы мы внутри тестового метода устанавливали новые значения стейта, а фрагмент на эти изменения реагировал. При этом фрагмент подписывается на viewModel.state
без нижнего подчеркивания.
Можно пойти другим путем – внутри тестового класса мы создадим свой объект state, который будет изменяемый, и в который мы сможем устанавливать любые значения.
+package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+import com.kaspersky.kaspresso.tutorial.screen.LoadUserScreen
+import com.kaspersky.kaspresso.tutorial.user.LoadUserActivity
+import com.kaspersky.kaspresso.tutorial.user.LoadUserViewModel
+import com.kaspersky.kaspresso.tutorial.user.State
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Rule
+import org.junit.Test
+
+class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+ val _state = MutableStateFlow<State>(State.Initial)
+ val viewModel = LoadUserViewModel()
+
+ @get:Rule
+ val activityRule = activityScenarioRule<LoadUserActivity>()
+
+ @Test
+ fun takeScreenshots() {
+ LoadUserScreen {
+ _state.value = State.Initial
+ …
+ }
+ }
+}
+
viewModel.state
вместо настоящей реализации подставлялся наш только что созданный объект. Сделать это можно при помощи библиотеки Mockk. Если вы не работали с этой библиотекой ранее, советуем почитать официальную документацию
+Для использования данной библиотеки необходимо добавить зависимости в файл build.gradle
+androidTestImplementation("io.mockk:mockk-android:1.13.3")
+
Info
+Если после синхронизации и запуска проекта у вас возникают ошибки, следуйте инструкциям в журнале ошибок. В случае, если разобраться не получилось, переключитесь на ветку TECH-tutorial-results
и сверьте файл build.gradle
из этой ветки с вашим
Теперь внутренняя реализация ViewModel нас не интересует. Все, что нам нужно – чтобы фрагмент подписывался на state
из ViewModel, а ему возвращался тот объект, который мы создали внутри тестового класса. Делается это следующим образом:
package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+import com.kaspersky.kaspresso.tutorial.screen.LoadUserScreen
+import com.kaspersky.kaspresso.tutorial.user.LoadUserActivity
+import com.kaspersky.kaspresso.tutorial.user.LoadUserViewModel
+import com.kaspersky.kaspresso.tutorial.user.State
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Rule
+import org.junit.Test
+
+class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+ val _state = MutableStateFlow<State>(State.Initial)
+ val viewModel = mockk<LoadUserViewModel>(relaxed = true){
+ every { state } returns _state
+ }
+
+ …
+}
+
То, что мы сделали, называется мокированием. Мы «замокали» ViewModel таким образом, что если кто-то будет работать с ней и обратится к ее полю state
, то ему вернется созданный нами объект _state
. Настоящая реализация LoadUserViewModel
в тестах использоваться не будет.
Теперь нам не нужно имитировать действия пользователя для получения различных состояний экрана. Вместо этого, мы будем устанавливать стейт в переменную _state
и затем делать скриншот.
package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+import com.kaspersky.kaspresso.tutorial.screen.LoadUserScreen
+import com.kaspersky.kaspresso.tutorial.user.LoadUserActivity
+import com.kaspersky.kaspresso.tutorial.user.LoadUserViewModel
+import com.kaspersky.kaspresso.tutorial.user.State
+import com.kaspersky.kaspresso.tutorial.user.User
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Rule
+import org.junit.Test
+
+class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+ val _state = MutableStateFlow<State>(State.Initial)
+ val viewModel = mockk<LoadUserViewModel>(relaxed = true) {
+ every { state } returns _state
+ }
+
+ @get:Rule
+ val activityRule = activityScenarioRule<LoadUserActivity>()
+
+ @Test
+ fun takeScreenshots() {
+ LoadUserScreen {
+ _state.value = State.Initial
+ captureScreenshot("Initial state")
+ _state.value = State.Progress
+ captureScreenshot("Progress state")
+ _state.value = State.Content(user = User(name = "Test", lastName = "Test"))
+ captureScreenshot("Content state")
+ _state.value = State.Error
+ captureScreenshot("Error state")
+ }
+ }
+}
+
Если мы запустим тест в таком виде, то работать он будет неправильно, никакой смены состояний экрана происходить не будет. Дело в том, что мы создали объект viewModel
, но нигде его не используем.
Давайте посмотрим, как происходит взаимодействие экрана и ViewModel, и какие изменения нужно внести в код, чтобы экран взаимодействовал не с настоящей ViewModel, а с замоканной.
+Для открытия экрана мы запускаем LoadUserActivity
package com.kaspersky.kaspresso.tutorial.user
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import com.kaspersky.kaspresso.tutorial.R
+
+class LoadUserActivity : AppCompatActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_load_user)
+ if (savedInstanceState == null) {
+ supportFragmentManager.beginTransaction()
+ .replace(R.id.fragment_container, LoadUserFragment.newInstance())
+ .commit()
+ }
+ }
+}
+
LoadUserFragment
, а LoadUserActivity
представляет собой контейнер, в который мы этот фрагмент установили. Следовательно, изменения нужно делать именно внутри фрагмента.
+Открываем LoadUserFragment
package com.kaspersky.kaspresso.tutorial.user
+
+class LoadUserFragment : Fragment() {
+
+…
+
+ private lateinit var viewModel: LoadUserViewModel
+
+…
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ viewModel = ViewModelProvider(this)[LoadUserViewModel::class.java]
+…
+}
+
viewModel
, а в методе onViewCreated
мы этой переменной присваиваем значение, создавая объект при помощи ViewModelProvider
. Нам необходимо добиться такого поведения, чтобы при обычном использовании фрагмента вьюмодель создавалась через ViewModelProvider
, а если этот фрагмент используется в screenshot-тестах, то должна быть возможность передать замоканную вьюмодель в качестве параметра.
+Для создания экземпляра фрагмента мы используем фабричный метод newInstance
companion object {
+
+ fun newInstance(): LoadUserFragment = LoadUserFragment()
+}
+
LoadUserFragment
. Давайте добавим еще один метод, который будет принимать в качестве параметра замоканную вьюмодель и устанавливать это значение в поле фрагмента. Этот метод мы будем использовать в тестах, поэтому давайте назовем его newTestInstance
+companion object {
+
+ fun newInstance(): LoadUserFragment = LoadUserFragment()
+
+ fun newTestInstance(
+ mockedViewModel: LoadUserViewModel
+ ): LoadUserFragment = LoadUserFragment().apply {
+ viewModel = mockedViewModel
+ }
+}
+
newInstance
, что мы сейчас и делаем
+if (savedInstanceState == null) {
+ supportFragmentManager.beginTransaction()
+ .replace(R.id.fragment_container, LoadUserFragment.newInstance())
+ .commit()
+}
+
newTestInstance
.
+На данном этапе в методе onViewCreated
мы присваиваем значение переменной viewModel
независимо от того, используется этот фрагмент для скриншот-тестов или нет. Давайте это исправим, добавим поле isForScreenshots
типа Boolean
, по умолчанию установим значение false
, а в методе newTestInstance
установим значение true
.
package com.kaspersky.kaspresso.tutorial.user
+
+…
+
+class LoadUserFragment : Fragment() {
+
+…
+
+ private lateinit var viewModel: LoadUserViewModel
+ private var isForScreenshots = false
+
+…
+ companion object {
+
+ fun newInstance(): LoadUserFragment = LoadUserFragment()
+
+ fun newTestInstance(
+ mockedViewModel: LoadUserViewModel
+ ): LoadUserFragment = LoadUserFragment().apply {
+ viewModel = mockedViewModel
+ isForScreenshots = true
+ }
+ }
+}
+
onViewCreated
мы будем создавать вьюмодель через ViewModelProvider
только в том случае, если isForScreenshots
равен false
+override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ if (!isForScreenshots) {
+ viewModel = ViewModelProvider(this)[LoadUserViewModel::class.java]
+ }
+ binding.loadingButton.setOnClickListener {
+ viewModel.loadUser()
+ }
+ observeViewModel()
+}
+
viewModel.loadUser()
приведет к падению теста, так как у нее данный метод не реализован. Поэтому вызов любых методов вьюмодели мы также будем выполнять только в том случае, если этот фрагмент используется не для скриншот-тестов:
+override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ if (!isForScreenshots) {
+ viewModel = ViewModelProvider(this)[LoadUserViewModel::class.java]
+ binding.loadingButton.setOnClickListener {
+ viewModel.loadUser()
+ }
+ }
+ observeViewModel()
+}
+
state
из вьюмодели
+val _state = MutableStateFlow<State>(State.Initial)
+val viewModel = mockk<LoadUserViewModel>(relaxed = true) {
+ every { state } returns _state
+}
+
viewModel.state
из фрагмента в методе observeViewModel
+viewModel.state.collect { state ->
+ when (state) {
+ is State.Content -> {
+ …
+
_state
, созданной внутри теста.
+Теперь, для того чтобы написать скриншот тест, нам нужно внутри этого теста создать экземпляр фрагмента, передав ему замоканную вьюмодель в качестве параметра. Но если вы посмотрите на текущий код, то увидите, что мы вообще не создаем здесь никаких фрагментов
+package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+import com.kaspersky.kaspresso.tutorial.screen.LoadUserScreen
+import com.kaspersky.kaspresso.tutorial.user.LoadUserActivity
+import com.kaspersky.kaspresso.tutorial.user.LoadUserViewModel
+import com.kaspersky.kaspresso.tutorial.user.State
+import com.kaspersky.kaspresso.tutorial.user.User
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Rule
+import org.junit.Test
+
+class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+ val _state = MutableStateFlow<State>(State.Initial)
+ val viewModel = mockk<LoadUserViewModel>(relaxed = true) {
+ every { state } returns _state
+ }
+
+ @get:Rule
+ val activityRule = activityScenarioRule<LoadUserActivity>()
+
+ @Test
+ fun takeScreenshots() {
+ LoadUserScreen {
+ _state.value = State.Initial
+ captureScreenshot("Initial state")
+ _state.value = State.Progress
+ captureScreenshot("Progress state")
+ _state.value = State.Content(user = User(name = "Test", lastName = "Test"))
+ captureScreenshot("Content state")
+ _state.value = State.Error
+ captureScreenshot("Error state")
+ }
+ }
+}
+
LoadUserActivity
, а внутри этой активити создается фрагмент, поэтому передать в тот фрагмент никакие параметры мы не можем.
+Если мы тестируем фрагменты, то запускать активити, в которой этот фрагмент лежит, не нужно. Мы можем напрямую тестировать фрагменты. Для того чтобы у нас была такая возможность, необходимо добавить следующие зависимости в build.gradle
+debugImplementation("androidx.fragment:fragment-testing-manifest:1.6.0"){
+ isTransitive = false
+}
+androidTestImplementation("androidx.fragment:fragment-testing:1.6.0")
+
После синхронизации проекта открываем класс LoadUserScreenshots
и удаляем из него activityRule
, запускать активити нам больше не нужно.
Для того чтобы запустить фрагмент, необходимо вызвать метод launchFragmentInContainer
и в фигурных скобках создать фрагмент, который нужно отобразить
+
package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import androidx.fragment.app.testing.launchFragmentInContainer
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+import com.kaspersky.kaspresso.tutorial.screen.LoadUserScreen
+import com.kaspersky.kaspresso.tutorial.user.LoadUserFragment
+import com.kaspersky.kaspresso.tutorial.user.LoadUserViewModel
+import com.kaspersky.kaspresso.tutorial.user.State
+import com.kaspersky.kaspresso.tutorial.user.User
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Test
+
+class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+ val _state = MutableStateFlow<State>(State.Initial)
+ val viewModel = mockk<LoadUserViewModel>(relaxed = true) {
+ every { state } returns _state
+ }
+
+ @Test
+ fun takeScreenshots() {
+ LoadUserScreen {
+ launchFragmentInContainer {
+ LoadUserFragment.newTestInstance(mockedViewModel = viewModel)
+ }
+ _state.value = State.Initial
+ captureScreenshot("Initial state")
+ _state.value = State.Progress
+ captureScreenshot("Progress state")
+ _state.value = State.Content(user = User(name = "Test", lastName = "Test"))
+ captureScreenshot("Content state")
+ _state.value = State.Error
+ captureScreenshot("Error state")
+ }
+ }
+}
+
Итак, давайте обсудим, что здесь происходит. Внутри метода takeScreenshots
мы запускаем фрагмент LoadUserFragment
. Для создания фрагмента мы воспользовались методом newTestInstance
, передавая созданный в тестовом классе вариант вьюмодели.
Теперь фрагмент работает не с настоящей вьюмоделью, а с замоканной. Фрагмент отрисовывает стейт, который лежит внутри вьюмодели, но так как мы подменили (замокали) объект state
, то фрагмент покажет то состояние, которое мы установим в тестовом классе.
С этого момента нам не нужно имитировать действия пользователя, мы просто устанавливаем необходимое состояние, фрагмент его отрисовывает, и мы делаем скриншот.
+Если вы сейчас запустите тест, то увидите, что скриншоты всех состояний успешно сохраняются в папку на устройстве, и это происходит гораздо быстрее, чем в предыдущем варианте теста.
+Вы могли обратить внимание, что внешний вид экрана в приложении отличается от того, который мы получили в результате выполнения теста. Проблема заключается в использовании стилей. Во время теста под капотом создается активити, которая является контейнером для нашего фрагмента. Стиль этой активити может отличаться от того, который используется у нас в приложении.
+Данная проблема решается очень просто – в качестве параметра в метод launchFragmentInContainer
можно передать стиль, который должен использоваться внутри фрагмента, его можно найти в манифесте приложения
Передать этот стиль в метод launchFragmentInContainer
можно следующим образом:
package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import androidx.fragment.app.testing.launchFragmentInContainer
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+import com.kaspersky.kaspresso.tutorial.R
+import com.kaspersky.kaspresso.tutorial.screen.LoadUserScreen
+import com.kaspersky.kaspresso.tutorial.user.LoadUserFragment
+import com.kaspersky.kaspresso.tutorial.user.LoadUserViewModel
+import com.kaspersky.kaspresso.tutorial.user.State
+import com.kaspersky.kaspresso.tutorial.user.User
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Test
+
+class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+ val _state = MutableStateFlow<State>(State.Initial)
+ val viewModel = mockk<LoadUserViewModel>(relaxed = true) {
+ every { state } returns _state
+ }
+
+ @Test
+ fun takeScreenshots() {
+ LoadUserScreen {
+ launchFragmentInContainer(
+ themeResId = R.style.Theme_Kaspresso
+ ) {
+ LoadUserFragment.newTestInstance(mockedViewModel = viewModel)
+ }
+ _state.value = State.Initial
+ captureScreenshot("Initial state")
+ _state.value = State.Progress
+ captureScreenshot("Progress state")
+ _state.value = State.Content(user = User(name = "Test", lastName = "Test"))
+ captureScreenshot("Content state")
+ _state.value = State.Error
+ captureScreenshot("Error state")
+ }
+ }
+}
+
Это был очень насыщенный урок, в котором мы научились правильно устанавливать стейты во вьюмодель, тестировать фрагменты, использовать бибилотеку Mockk для мокирования сущностей, а также дорабатывать код приложения, чтобы его можно было покрыть screenshot-тестами.
+ + + + + + +In the last lesson, we wrote a test for the Internet availability screen, the test class code looked like this:
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.device.exploit.Exploit
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.WifiScreen
+import org.junit.Rule
+import org.junit.Test
+
+class WifiSampleTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ MainScreen {
+ wifiActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ WifiScreen {
+ device.exploit.setOrientation(Exploit.DeviceOrientation.Portrait)
+ checkWifiButton.isVisible()
+ checkWifiButton.isClickable()
+ wifiStatus.hasEmptyText()
+ checkWifiButton.click()
+ wifiStatus.hasText(R.string.enabled_status)
+ device.network.toggleWiFi(false)
+ checkWifiButton.click()
+ wifiStatus.hasText(R.string.disabled_status)
+ device.exploit.rotate()
+ wifiStatus.hasText(R.string.disabled_status)
+ }
+ }
+}
+
And we talked about how one of the problems with this code is that it is difficult to read and maintain even at this stage, and if the functionality of the screen expands and we have to add more tests, then the code will become completely unreadable.
+In fact, usually any tests (including manual ones) are performed on test cases. That is, the tester has a sequence of steps that he performs to check the performance of the screen. In our case, we have this sequence of steps, but it is written in one block of code and it is not clear where one step ends and another begins. We can solve this problem with comments.
+Let's copy this WifiSampleTest
class and paste it into the same package, but with a different name WifiSampleWithStepsTest
. This is necessary so that you can then compare the new and old implementations of this test. We will not change the WifiSampleTest
code today. Now in the new class WifiSampleWithStepsTest
we add comments to each step.
package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.device.exploit.Exploit
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.WifiScreen
+import org.junit.Rule
+import org.junit.Test
+
+class WifiSampleWithStepsTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ // Step 1. Open target screen
+ MainScreen {
+ wifiActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ WifiScreen {
+ // Step 2. Check correct wifi status
+ device.exploit.setOrientation(Exploit.DeviceOrientation.Portrait)
+ checkWifiButton.isVisible()
+ checkWifiButton.isClickable()
+ wifiStatus.hasEmptyText()
+ checkWifiButton.click()
+ wifiStatus.hasText(R.string.enabled_status)
+ device.network.toggleWiFi(false)
+ checkWifiButton.click()
+ wifiStatus.hasText(R.string.disabled_status)
+
+ // Step 3. Rotate device and check wifi status
+ device.exploit.rotate()
+ wifiStatus.hasText(R.string.disabled_status)
+ }
+ }
+}
+
This slightly improved the readability of the code, but did not solve all the problems. For example, if your test fails, how do you know at what step it happened? You will have to examine the logs, trying to figure out what went wrong. It would be much better if the logs showed entries like Step 1 started -> ... -> Step 1 succeed
or Step 2 started -> ... -> Step 2 failed
. This will allow you to immediately determine by the notes in the log at what stage the problem arose.
To do this, we can manually add output to the log for each step before and after its execution and wrap it all in a try catch
block to make the test failure also recorded in logs. In this case, our test would look like this:
package com.kaspersky.kaspresso.tutorial
+
+import android.util.Log
+import androidx.test.core.app.takeScreenshot
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.device.exploit.Exploit
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.WifiScreen
+import org.junit.Rule
+import org.junit.Test
+
+class WifiSampleWithStepsTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ try {
+ Log.i("KASPRESSO", "Step 1. Open target screen -> started")
+ MainScreen {
+ wifiActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ Log.i("KASPRESSO", "Step 1. Open target screen -> succeed")
+ } catch (e: Throwable) {
+ Log.i("KASPRESSO", "Step 1. Open target screen -> failed")
+ takeScreenshot()
+ }
+ WifiScreen {
+ try {
+ Log.i("KASPRESSO", "Step 2. Check correct wifi status -> started")
+ device.exploit.setOrientation(Exploit.DeviceOrientation.Portrait)
+ checkWifiButton.isVisible()
+ checkWifiButton.isClickable()
+ wifiStatus.hasEmptyText()
+ checkWifiButton.click()
+ wifiStatus.hasText(R.string.enabled_status)
+ device.network.toggleWiFi(false)
+ checkWifiButton.click()
+ wifiStatus.hasText(R.string.disabled_status)
+ Log.i("KASPRESSO", "Step 2. Check correct wifi status -> succeed")
+ } catch (e: Throwable) {
+ Log.i("KASPRESSO", "Step 2. Check correct wifi status -> failed")
+ }
+
+ try {
+ Log.i("KASPRESSO", "Step 3. Rotate device and check wifi status -> started")
+ device.exploit.rotate()
+ wifiStatus.hasText(R.string.disabled_status)
+ Log.i("KASPRESSO", "Step 3. Rotate device and check wifi status -> succeed")
+ } catch (e: Throwable) {
+ Log.i("KASPRESSO", "Step 3. Rotate device and check wifi status -> failed")
+ takeScreenshot()
+ }
+ }
+ }
+}
+
Let's turn on the Internet on the device and check the operation of our test.
+Let's launch the test. It passed successfully.
+Now let's see the logs. To do this, open the Logcat
tab at the bottom of Android Studio
There are a lot of logs displayed here and finding ours is quite difficult. We can filter the logs by the tag we specified ("KASPRESSO"). To do this, click on the arrow at the top right of Logcat
window and select Edit Configuration
A filter creation window will open. Add the name of the filter and the tag that we are interested in:
+ +Now we can see only useful information. Let's clear the log
+ +and run the test again. Do not forget to turn on the Internet on the device before this. Read the logs:
+ +Here are the logs we added: step 1 is run, then checks are done, then step 1 succeeds.
+Looking further:
+ + +With the second and third steps, everything is also fine. We understand when and what step starts the execution, we can see the specific actions that the test is currently performing, and we can see the result of the test.
+Now let's turn off the Internet and run the test again. According to our logic, the test should fail.
+Even though the test should have failed, all tests are green. We look at the log - now we are interested in step 2, which should have failed due to the fact that the Internet was initially turned off on the device.
+ +Judging by the logs, step 2
really failed. The status of the header was checked, the text did not match, the program made several more attempts to check that the text on the header contains the text enabled
, but all these attempts were unsuccessful and the step ended with an error. Why do we have green tests in this case?
The fact is that if the test fails, then an exception is thrown, and if no one handled this exception in the try catch block, then the tests will be red. But we handle all exceptions in the code in order to make an entry in the log that the test ended with an error.
+try {
+ ...
+} catch (e: Throwable) {
+ /**
+ * Мы обработали исключение и дальше оно проброшено не будет, поэтому такой
+ * тест считается выполненным успешно
+ */
+ Log.i("KASPRESSO", "Step 2. Check correct wifi status -> failed")
+}
+
To solve this problem, it is necessary to throw this exception further after the error message is output to the log so that the test fails. This is done using the throw
keyword. Then the test code will look like this:
package com.kaspersky.kaspresso.tutorial
+
+import android.util.Log
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.device.exploit.Exploit
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.WifiScreen
+import org.junit.Rule
+import org.junit.Test
+
+class WifiSampleWithStepsTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ try {
+ Log.i("KASPRESSO", "Step 1. Open target screen -> started")
+ MainScreen {
+ wifiActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ Log.i("KASPRESSO", "Step 1. Open target screen -> succeed")
+ } catch (e: Throwable) {
+ Log.i("KASPRESSO", "Step 1. Open target screen -> failed")
+ throw e
+ }
+ WifiScreen {
+ try {
+ Log.i("KASPRESSO", "Step 2. Check correct wifi status -> started")
+ device.exploit.setOrientation(Exploit.DeviceOrientation.Portrait)
+ checkWifiButton.isVisible()
+ checkWifiButton.isClickable()
+ wifiStatus.hasEmptyText()
+ checkWifiButton.click()
+ wifiStatus.hasText(R.string.enabled_status)
+ device.network.toggleWiFi(false)
+ checkWifiButton.click()
+ wifiStatus.hasText(R.string.disabled_status)
+ Log.i("KASPRESSO", "Step 2. Check correct wifi status -> succeed")
+ } catch (e: Throwable) {
+ Log.i("KASPRESSO", "Step 2. Check correct wifi status -> failed")
+ throw e
+ }
+
+ try {
+ Log.i("KASPRESSO", "Step 3. Rotate device and check wifi status -> started")
+ device.exploit.rotate()
+ wifiStatus.hasText(R.string.disabled_status)
+ Log.i("KASPRESSO", "Step 3. Rotate device and check wifi status -> succeed")
+ } catch (e: Throwable) {
+ Log.i("KASPRESSO", "Step 3. Rotate device and check wifi status -> failed")
+ throw e
+ }
+ }
+ }
+}
+
Let's run the test again. Now it ends with an error and we have understandable logs, where you can immediately see at which step the error occurred. After step 2
there is nothing else in the logs.
The code that we wrote is working, but very cumbersome, and we have to write a whole canvas of the same code for each step (logs, try catch blocks, etc.).
+In order to simplify writing tests and make the code more readable and extendable, steps have been added to Kaspresso. They implement everything that we just wrote by hand "under the hood".
+To use steps, you need to call the run {}
method and list all the steps that will be performed during the test in curly brackets. Each step must be called inside the step function.
Let's write it in code. First, we remove all unnecessary logs and try catch blocks.
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.device.exploit.Exploit
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.WifiScreen
+import org.junit.Rule
+import org.junit.Test
+
+class WifiSampleWithStepsTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ MainScreen {
+ wifiActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+
+ WifiScreen {
+ device.exploit.setOrientation(Exploit.DeviceOrientation.Portrait)
+ checkWifiButton.isVisible()
+ checkWifiButton.isClickable()
+ wifiStatus.hasEmptyText()
+ checkWifiButton.click()
+ wifiStatus.hasText(R.string.enabled_status)
+ device.network.toggleWiFi(false)
+ checkWifiButton.click()
+ wifiStatus.hasText(R.string.disabled_status)
+
+ device.exploit.rotate()
+ wifiStatus.hasText(R.string.disabled_status)
+ }
+ }
+}
+
Now, at the beginning of the test, we call the run method, inside which we call the step
function for each step. We pass the name of the step as a parameter to this function.
@Test
+ fun test() {
+ run {
+ step("Open target screen") {
+ ...
+ }
+ step("Check correct wifi status") {
+ ...
+ }
+ step("Rotate device and check wifi status") {
+ ...
+ }
+ }
+ }
+
Within each step, we specify the actions that are required for that step. The actions stay the same as before. Then the test code will look like this:
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.device.exploit.Exploit
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.WifiScreen
+import org.junit.Rule
+import org.junit.Test
+
+class WifiSampleWithStepsTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ run {
+ step("Open target screen") {
+ MainScreen {
+ wifiActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check correct wifi status") {
+ WifiScreen {
+ device.exploit.setOrientation(Exploit.DeviceOrientation.Portrait)
+ checkWifiButton.isVisible()
+ checkWifiButton.isClickable()
+ wifiStatus.hasEmptyText()
+ checkWifiButton.click()
+ wifiStatus.hasText(R.string.enabled_status)
+ device.network.toggleWiFi(false)
+ checkWifiButton.click()
+ wifiStatus.hasText(R.string.disabled_status)
+ }
+ }
+ step("Rotate device and check wifi status") {
+ WifiScreen {
+ device.exploit.rotate()
+ wifiStatus.hasText(R.string.disabled_status)
+ }
+ }
+ }
+ }
+}
+
Turn on the Internet on the device and run the test. Test passed successfully. Let's look at the logs:
+ +Thus, thanks to the use of steps, not only our code has become more understandable and easy to understand, but also the logs have a clear structure and allow you to quickly determine which steps were performed and what the result of these operations is.
+Let's run this test again now with the internet off. The test falls. Let's look at the logs.
+ +Now it becomes much easier to find an error in the test, thanks to understandable logs.
+Our code has become much better, but one important problem remains. It is necessary to reset the device to a default state before each test: the Internet must be turned on and the portrait orientation must be set.
+Kaspresso has the ability to add before
and after
blocks. The code inside the before
block will be executed before the test, and this is where we can set the defaults. The code inside the after
block will be executed after the test. During the test, the state of the phone may change: we can turn off the Internet, change orientation, but after the test we need to return to the original state. We will do this inside the after
block.
Then the test code will look like this:
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.device.exploit.Exploit
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.WifiScreen
+import org.junit.Rule
+import org.junit.Test
+
+class WifiSampleWithStepsTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ before {
+ /**
+ * Set portrait orientation and enable Wifi before the test
+ */
+ device.exploit.setOrientation(Exploit.DeviceOrientation.Portrait)
+ device.network.toggleWiFi(true)
+ }.after {
+ /**
+ * Reset the default state after the test
+ */
+ device.exploit.setOrientation(Exploit.DeviceOrientation.Portrait)
+ device.network.toggleWiFi(true)
+ }.run {
+ step("Open target screen") {
+ MainScreen {
+ wifiActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check correct wifi status") {
+ WifiScreen {
+ checkWifiButton.isVisible()
+ checkWifiButton.isClickable()
+ wifiStatus.hasEmptyText()
+ checkWifiButton.click()
+ wifiStatus.hasText(R.string.enabled_status)
+ device.network.toggleWiFi(false)
+ checkWifiButton.click()
+ wifiStatus.hasText(R.string.disabled_status)
+ }
+ }
+ step("Rotate device and check wifi status") {
+ WifiScreen {
+ device.exploit.rotate()
+ wifiStatus.hasText(R.string.disabled_status)
+ }
+ }
+ }
+ }
+}
+
The test is almost ready, we can add one small improvement. Now after flipping the device, we check that the text is still the same, but we don't check that the orientation has actually changed. If it turns out that if the device.expoit.rotate()
method did not work for some reason, then the orientation will not change and the check for text will be useless. Let's add a check that the device's orientation is landscape.
Assert.assertTrue(device.context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE)
Now the complete test code looks like this:
+package com.kaspersky.kaspresso.tutorial
+
+import android.content.res.Configuration
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.device.exploit.Exploit
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.WifiScreen
+import org.junit.Assert
+import org.junit.Rule
+import org.junit.Test
+
+class WifiSampleWithStepsTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ before {
+ device.exploit.setOrientation(Exploit.DeviceOrientation.Portrait)
+ device.network.toggleWiFi(true)
+ }.after {
+ device.exploit.setOrientation(Exploit.DeviceOrientation.Portrait)
+ device.network.toggleWiFi(true)
+ }.run {
+ step("Open target screen") {
+ MainScreen {
+ wifiActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Check correct wifi status") {
+ WifiScreen {
+ checkWifiButton.isVisible()
+ checkWifiButton.isClickable()
+ wifiStatus.hasEmptyText()
+ checkWifiButton.click()
+ wifiStatus.hasText(R.string.enabled_status)
+ device.network.toggleWiFi(false)
+ checkWifiButton.click()
+ wifiStatus.hasText(R.string.disabled_status)
+ }
+ }
+ step("Rotate device and check wifi status") {
+ WifiScreen {
+ device.exploit.rotate()
+ Assert.assertTrue(device.context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE)
+ wifiStatus.hasText(R.string.disabled_status)
+ }
+ }
+ }
+ }
+}
+
In this lesson, we've significantly improved our code, making it cleaner, clearer, and easier to maintain. This is made possible by Kaspresso's step
, before
and after
functions. We also learned how to output messages to the log, as well as read the logs, filter and analyze them.
In previous lessons, we learned how to write tests for user interface elements that are located in our application. But there are often cases when this is not enough for full-fledged testing, and in addition to our application, we need to perform some actions outside of it.
+As an example, let's check the start screen of the Google Play app in an unauthorized state.
+Do not forget to log out before starting the test.
+Let's start writing a test - create a class GooglePlayTest
and inherit it from TestCase
:
package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+
+class GooglePlayTest : TestCase() {
+
+}
+
Add a test method:
+package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import org.junit.Test
+
+class GooglePlayTest : TestCase() {
+
+ @Test
+ fun testNotSignIn() = run {
+
+ }
+}
+
The first step we need to take is to launch the Google Play application, for this we need the name of its package. Google Play's package name is com.android.vending
, later we will show where you can find this information.
We will use this package name in the test several times, therefore, in order not to duplicate the code, we will create a constant where we will put this name:
+package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import org.junit.Test
+
+class GooglePlayTest : TestCase() {
+
+ @Test
+ fun testNotSignIn() = run {
+
+ }
+
+ companion object {
+
+ private const val GOOGLE_PLAY_PACKAGE = "com.android.vending"
+ }
+}
+
To launch any screen in Android, we need an Intent
object. To get the required Intent we will use the following code:
val intent = device.targetContext.packageManager.getLaunchIntentForPackage(GOOGLE_PLAY_PACKAGE)
+
Here several objects that may be unfamiliar to you are used at once: Context, PackageManager and Intent. You can read more about them in the documentation.
+In short, Context provides access to various application resources and allows you to perform many actions, including opening screens using Intents. The Intent contains information about the screen we want to open, and the PackageManager in this case allows you to get an Intent to open the start screen of a particular application by its package name.
+Info
+To get the Context
, you can use the targetContext
and context
methods of the device
object. They have one significant difference.
+When we want to check the operation of some application and run an autotest, in fact, two applications are installed on the device: the one that we are testing (in this case, the tutorial) and the second, which runs all the test scripts.
+When we call the targetContext
method, we refer to the application under test (tutorial), and if we call the context
method, then the call will be to the second application that runs the tests.
val intent = device.targetContext.packageManager.getLaunchIntentForPackage(GOOGLE_PLAY_PACKAGE)
+
In the above code we first get the targetContext
from the device
object, like we already did in one of the previous lessons. Then, from targetContext
we get packageManager
, from which we can get the Intent
to launch the Google Play screen using the getLaunchIntentForPackage
method.
This method returns an Intent
to launch the start screen of the application whose package was passed as a parameter. To do this, we pass the package name of the application we want to run, in this case, Google Play.
We got Intent
, now we use it to launch the screen. To do this, call the startActivity
method on the targetContext
object and pass intent as a parameter:
val intent = device.targetContext.packageManager.getLaunchIntentForPackage(GOOGLE_PLAY_PACKAGE)
+device.targetContext.startActivity(intent)
+
In this code, we get the targetContext
twice from the device
object. In order not to duplicate code, you can shorten this entry by using the with
function
Info
+You can read more about with
and other scope functions in documentation.
Then the test code will look like this:
+package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import org.junit.Test
+
+class GooglePlayTest : TestCase() {
+
+ @Test
+ fun testNotSignIn() = run {
+ step("Open Google Play") {
+ with(device.targetContext) {
+ val intent = packageManager.getLaunchIntentForPackage(GOOGLE_PLAY_PACKAGE)
+ startActivity(intent)
+ }
+ }
+ }
+
+ companion object {
+
+ private const val GOOGLE_PLAY_PACKAGE = "com.android.vending"
+ }
+}
+
If you are not familiar with the with
, apply
, and other scope functions, you can rewrite code without them, in which case the test code will look like this:
package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import org.junit.Test
+
+class GooglePlayTest : TestCase() {
+
+ @Test
+ fun testNotSignIn() = run {
+ step("Open Google Play") {
+ val intent = device.targetContext.packageManager.getLaunchIntentForPackage(GOOGLE_PLAY_PACKAGE)
+ device.targetContext.startActivity(intent)
+ }
+ }
+
+ companion object {
+
+ private const val GOOGLE_PLAY_PACKAGE = "com.android.vending"
+ }
+}
+
Let's launch the test. Test passed successfully, the Google Play app opens on the device.
+Now we need to check that there is a button with the text Sign in
on the opened screen. This is not our application, we do not have access to the source code, so getting the button id through the Layout Inspector will not work. You need to use other tools.
UI Automator is a library for finding components on the screen and emulating user actions (clicks, swipes, text input, etc.). It allows you to manage the application the way the user would do it, i.e. to interact with any of its elements.
+Thanks to this library, we can test any applications and perform various actions in them, despite the fact that we do not have access to their source code.
+Info
+You can read more about UiAutomator and its capabilities in documentation.
+The Android SDK also includes the Ui Automator Viewer. It allows us to find the IDs of the elements we want to interact with, their position and other useful attributes.
+In order to launch Ui Automator Viewer, you need to open a command line in the ../Android/sdk/tools/bin
folder and execute the command uiautomatorviewer
.
You should have a window like this:
+ +If this did not happen and some error was displayed in the console, then you should google the error text.
+The most common problem is that the Java version is not compatible with uiautomatorviewer. In this case, you should install Java 8 (use only released by Oracle) and set the path to it in environment variables. How to do this, we discussed in the lesson Executing adb commands.
+Let's get back to writing the test. We will check the Google Play application, and in order to interact with it from the Ui Automator Viewer, you need to run it on the emulator and click on the Device Screenshot
button:
On some OS versions, these icons are initially hidden, so if you don't see them, just stretch the program window.
+On the right side, you can see all the information about the user interface elements. Now we are interested in the Sign in
button. We click on this element and look at the information about the button:
Here you can see some useful information:
+If for some reason you are not comfortable using the Ui Automator Viewer, or you are unable to launch it, then you can use the Developer Assistant application. It can be downloaded on Google Play.
+After installing and launching Developer Assistant, you need to select it in the settings as the default assistant application. To do this, click on the Choose
button and follow the instructions:
Once configured, you can run application analysis. Open the Google Play app and long press the Home
button:
You will see a window with information about the application, which you can move or expand if necessary. The App
tab contains information about the application: package name, currently running Activity, etc.
The Element
tab allows you to explore the user interface elements.
The Sign in
button has all the same attributes that we saw in Ui Automator Viewer
.
In some cases, which we'll talk about later in this tutorial, you won't be able to use the Developer Assistant because it can't display information about the system UI (notifications, dialogs, etc.). If you find yourself in such a situation that the Developer Assistant capabilities are not enough, and the Ui Automator Viewer failed to start, then there is a third option: run the adb shell command uiautomator dump
.
To do this, on the emulator, open the screen that you need to get information about (in this case, Google Play). Open the console and run the command:
+adb shell uiautomator dump
+
A window_dump.xml
file should have appeared on your emulator, which can be found through the Device Explorer
. If it is not displayed for you, then select the sdcard
folder and click Synchronize
:
If after these steps the file still does not appear, then run one more command in the console:
+adb pull /sdcard/window_dump.xml
+
After that find the file on your computer via Device File Explorer
and open it in Android Studio:
This file is a description of the screen in xml format. Here you can also find all the necessary objects, their properties and IDs. If you have it displayed in one line, then you should auto-format the file to make it easier to read the code. To do this, press the key combination ctrl + alt + L
on Windows or cmd + option + L
on Mac.
You can find the login button and see all its attributes. To do this, press the key combination ctrl + F
(or cmd + F
on Mac) and enter the text that is set on the "Sign in" button.
We have found the interface elements we need, and now we can start testing. As usual, we'll start by creating a Page Object for the Google Play screen.
+package com.kaspersky.kaspresso.tutorial.screen
+
+object GooglePlayScreen {
+
+}
+
Previously, we inherited all Page Objects from the KScreen
class. In this case, we needed to override two properties: layoutId
and viewClass
override val layoutId: Int? = null
+override val viewClass: Class<*>? = null
+
We did this because we were testing the screen that is inside our application, we had access to the source code, the layout and the Activity we are working with. But now we want to test the screen from a third-party application, so it is impossible to search for some elements in it, click on buttons and perform any other actions with it the way that we did in previous lessons.
+For these purposes, Kaspresso has the Kautomator component - a wrapper over the well-known UiAutomator tool. Kautomator makes writing tests much easier, and also adds a number of advantages compared to UiAutomator, which you can read about in detail in the Wiki.
+Page objects for screens of third-party applications should not inherit from KScreen
, but from UiScreen
. Additionally, you need to override the packageName
property so that it returns the package name of the application under test:
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.components.kautomator.screen.UiScreen
+
+object GooglePlayScreen : UiScreen<GooglePlayScreen>() {
+
+ override val packageName: String = "com.android.vending"
+}
+
Further, all user interface elements will be instances of classes with the prefix Ui
(UiButton
, UiTextView
, UiEditText
...), and not K
(KButton
, KTextView
, KEditText
. ..) as it was before. The point is that we are currently testing another application and we need the functionality available in the Kautomator components.
On this screen, we are interested in the signIn
button, add it:
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.components.kautomator.component.text.UiButton
+import com.kaspersky.components.kautomator.screen.UiScreen
+
+object GooglePlayScreen : UiScreen<GooglePlayScreen>() {
+
+ override val packageName: String = "com.android.vending"
+
+ val signInButton = UiButton { }
+}
+
In curly brackets UiButton {...}
we need to use some kind of matcher, thanks to which we will find the element on the screen. Previously, we used only withId
, but now the id of the button is not available and we will have to use some other option.
To see all available matchers, you can go to the UiButton
definition (hold ctrl
and left-click on the class name). Inside it you will see the class UiViewBuilder
.
The UiViewBuilder
class contains many matchers that you can use. By going to its definition (holding ctrl
, left-clicking on the class name), you can see the full up-to-date list:
For example, you can use withText
to find the element containing specific text, or use withClassName
to find an instance of some class.
Let's find the button by the text that is displayed on it.
+package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.components.kautomator.component.text.UiButton
+import com.kaspersky.components.kautomator.screen.UiScreen
+
+object GooglePlayScreen : UiScreen<GooglePlayScreen>() {
+
+ override val packageName: String = "com.android.vending"
+
+ val signInButton = UiButton { withText("Sign in") }
+}
+
We can add a test. Let's check that the login button is displayed on the Google Play screen:
+package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.GooglePlayScreen
+import org.junit.Test
+
+class GooglePlayTest : TestCase() {
+
+ @Test
+ fun testNotSignIn() = run {
+ step("Open Google Play") {
+ with(device.targetContext) {
+ val intent = packageManager.getLaunchIntentForPackage(GOOGLE_PLAY_PACKAGE)
+ startActivity(intent)
+ }
+ }
+ step("Check sign in button visibility") {
+ GooglePlayScreen {
+ signInButton.isDisplayed()
+ }
+ }
+ }
+
+ companion object {
+
+ private const val GOOGLE_PLAY_PACKAGE = "com.android.vending"
+ }
+}
+
Let's launch the test. It passed successfully.
+We have considered one option when we need to use the UI automator for testing: if we are interacting with a third-party application. But this is not the only case when it should be used.
+Let's open our tutorial
application and go to the Notification Activity
screen:
Click on the “Show notification” button - a notification is displayed on top.
+Info
+You can read more about notifications in Android here.
+Let's try to test this screen.
+First, let's create a Page Object for the screen with the "Show Notification" button. This screen is in our application, so we can inherit from KScreen
. Button id can be found through the Layout Inspector:
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+import com.kaspersky.kaspresso.tutorial.R
+import io.github.kakaocup.kakao.text.KButton
+
+object NotificationActivityScreen : KScreen<NotificationActivityScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val showNotificationButton = KButton { withId(R.id.show_notification_button) }
+}
+
In the Page Object of the main screen, add a button to open NotificationActivity
:
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+import com.kaspersky.kaspresso.tutorial.R
+import io.github.kakaocup.kakao.text.KButton
+
+object MainScreen : KScreen<MainScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val simpleActivityButton = KButton { withId(R.id.simple_activity_btn) }
+ val wifiActivityButton = KButton { withId(R.id.wifi_activity_btn) }
+ val loginActivityButton = KButton { withId(R.id.login_activity_btn) }
+ val notificationActivityButton = KButton { withId(R.id.notification_activity_btn) }
+}
+
You can create a test, first just show a notification by clicking on the button on the main screen:
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.NotificationActivityScreen
+import org.junit.Rule
+import org.junit.Test
+
+class NotificationActivityTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun checkNotification() = run {
+ step("Open notification activity") {
+ MainScreen {
+ notificationActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Show notification") {
+ NotificationActivityScreen {
+ showNotificationButton.isVisible()
+ showNotificationButton.isClickable()
+ showNotificationButton.click()
+ }
+ }
+ }
+}
+
Let's launch the test. It passed successfully, notification is displayed.
+Now let's check that the title and content of the notification contain the required text.
+Finding the id of the elements using the Layout Inspector
or Developer Assistant
will not work, because display of notifications belongs to the system UI. In this case, we will have to use one of two options: launch the Ui Automator Viewer and look through it, or run the adb shell uiautomator dump
command.
Next, we will show the solution through the Ui Automator Viewer
, and also attach a screenshot of where to find the View elements in the window_dump.xml
file
Open the list of notifications and take a screenshot:
+ +Using the dump
command, the necessary elements can be found as follows
Here, by the package name, you can see that the notification drawer does not belong to our application, so for testing it is necessary to inherit from the UiScreen class and use Kautomator.
+Create a Page Object of the notification screen:
+package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.components.kautomator.screen.UiScreen
+
+object NotificationScreen : UiScreen<NotificationScreen>() {
+
+ override val packageName: String = "com.android.systemui"
+}
+
packageName
was set to the value obtained by dump
or Ui Automator Viewer
.
We declare the elements with which we will interact.
+package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.components.kautomator.component.text.UiTextView
+import com.kaspersky.components.kautomator.screen.UiScreen
+
+object NotificationScreen : UiScreen<NotificationScreen>() {
+
+ override val packageName: String = "com.android.systemui"
+
+ val title = UiTextView { }
+ val content = UiTextView { }
+}
+
You can find elements by different criteria, for example, by text or by id. Let's find an element by its id. Call matcher withId
:
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.components.kautomator.component.text.UiTextView
+import com.kaspersky.components.kautomator.screen.UiScreen
+
+object NotificationScreen : UiScreen<NotificationScreen>() {
+
+ override val packageName: String = "com.android.systemui"
+
+ val title = UiTextView { withId("", "") }
+ val content = UiTextView { withId("", "") }
+}
+
The first parameter to pass is the package name of the application in whose resources the element will be searched. We could pass the previously obtained packageName
and resource_id
values:
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.components.kautomator.component.text.UiTextView
+import com.kaspersky.components.kautomator.screen.UiScreen
+
+object NotificationScreen : UiScreen<NotificationScreen>() {
+
+ override val packageName: String = "com.android.systemui"
+
+ val title = UiTextView { withId(this@NotificationScreen.packageName, "android:id/title") }
+ val content = UiTextView { withId(this@NotificationScreen.packageName, "android:id/text") }
+}
+
But in this case, the elements will not be found. The id
scheme of the element we are looking for on the screen of another application looks like this: package_name:id/resource_id
. This string will be formed from the two parameters that we passed to the withId
method. Instead of package_name
the package name com.android.systemui
will be substituted, instead of resource_id
the identifier android:id/title
will be substituted. The resulting resource_id will look like this: com.android.systemui:id/android:id/title
. It turns out that the characters :id/
will be added for us, and we only need to pass what is to the right of the slash, which will be the correct identifier:
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.components.kautomator.component.text.UiTextView
+import com.kaspersky.components.kautomator.screen.UiScreen
+
+object NotificationScreen : UiScreen<NotificationScreen>() {
+
+ override val packageName: String = "com.android.systemui"
+
+ val title = UiTextView { withId(this@NotificationScreen.packageName, "title") }
+ val content = UiTextView { withId(this@NotificationScreen.packageName, "text") }
+}
+
Now the full resource_id
looks like this: com.android.systemui:id/title
and com.android.systemui:id/text
.
Please note that the first part (package_name
) is different from what is specified in the Ui Automator Viewer
, we specified the package name com.android.systemui
, and the program says android
.
The reason is that each application can have its own resources, in which case the first part of the resource identifier will contain the package name of the application where the resource was created, and the application can also use the resources of the Android system. They are shared between different applications and contain the package name android
.
This is exactly the case, so we specify android
as the first parameter.
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.components.kautomator.component.text.UiTextView
+import com.kaspersky.components.kautomator.screen.UiScreen
+
+object NotificationScreen : UiScreen<NotificationScreen>() {
+
+ override val packageName: String = "com.android.systemui"
+
+ val title = UiTextView { withId("android", "title") }
+ val content = UiTextView { withId("android", "text") }
+}
+
Now we can add checks to this screen. Let's make sure that the correct texts are set in the title and in the body of the notification:
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.NotificationActivityScreen
+import com.kaspersky.kaspresso.tutorial.screen.NotificationScreen
+import org.junit.Rule
+import org.junit.Test
+
+class NotificationActivityTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun checkNotification() = run {
+ step("Open notification activity") {
+ MainScreen {
+ notificationActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+ step("Show notification") {
+ NotificationActivityScreen {
+ showNotificationButton.isVisible()
+ showNotificationButton.isClickable()
+ showNotificationButton.click()
+ }
+ }
+ step("Check notification texts") {
+ NotificationScreen {
+ title.isDisplayed()
+ title.hasText("Notification Title")
+ content.isDisplayed()
+ content.hasText("Notification Content")
+ }
+ }
+ }
+}
+
Let's launch the test. It passed successfully.
+In this lesson, we learned how to run tests for third-party applications, and also learned how you can test the system UI using UiAutomator
, or rather its wrapper Kautomator
. In addition, we got to know the programs that allow us to analyze the UI of applications, even if we do not have access to their source code: these are Ui Automator Viewer
, Developer Assistant
and UiAutomator Dump
.
In this tutorial we'll create a test that tests the Internet Availability (WifiActivity
) screen.
Run our tutorial application and click on the Internet Availability
button
Let's manually test this screen first.
+Initially, we have a CHECK WIFI STATUS
button, there is no more text on the screen. Wifi is currently enabled on the device.
Let's click on the button.
+ +This button is clickable, after clicking, the correct Wifi state status is displayed - enabled. Disable WiFi.
+ +Click on the button again and check the Wifi status now:
+ +The state is determined correctly. One last check - let's flip the device over and make sure the text on the screen is preserved.
+ +The text is saved successfully, all tests passed. Now we need to achieve the same result with all the checks performed automatically.
+During the test, you will need to automatically turn the Internet on and off, as well as change the orientation of the device to landscape. This is beyond the responsibility of our application, which means that we will have to use adb commands for tests. This requires the ADB server to be running. We discussed this point in the previous lesson. If you forgot how to do it, you can review it again.
+Now in our test, you will need to click on the Internet Availability
button on the main screen. This means that it is necessary to modify the Page Object of the main screen by adding one more button there:
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+import com.kaspersky.kaspresso.tutorial.R
+import io.github.kakaocup.kakao.text.KButton
+
+object MainScreen : KScreen<MainScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val simpleActivityButton = KButton { withId(R.id.simple_activity_btn) }
+ val wifiActivityButton = KButton { withId(R.id.wifi_activity_btn) }
+}
+
Now we can add a new test class. In the same package where we have other tests, we add WifiSampleTest:
+package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+
+class WifiSampleTest: TestCase() {
+
+}
+
To check the Internet availability screen, you need to go to it. To do this, we will follow the same steps as in tutorial, in which we wrote our first autotest:
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import org.junit.Rule
+import org.junit.Test
+
+class WifiSampleTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ MainScreen {
+ wifiActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ }
+}
+
Let's launch the test. It passed successfully. The Wifi test screen starts. Now we can test it.
+To fully test this screen, we will need to change the Wifi connection state, as well as change the orientation of the device. To do this, in the BaseTestCase
class (from which our WifiSampleTest
class is inherited) there is an instance of the Device
class, which is called device
. We already encountered it in the previous lesson when we got the packageName of our application.
This object has many useful methods, which you can read about in detail here.
+First of all, we are interested in a method that enables / disables the Internet. The network
object, which is in the Device
class, is responsible for working with the network.
If we want to change the Wifi state, we can do it like this:
+/**
+* As a parameter, we pass the boolean type, false if we want to turn Wifi off, true if we want to turn it on
+*/
+device.network.toggleWiFi(false)
+
In addition to Wifi, we can also manage the mobile network, as well as the Internet connection on the device as a whole (Wifi + mobile network). In order to see all the available methods, you can go to the documentation above, but there is an easier way - put a dot after the name of the object and see which methods can be called on this object. It is usually clear what they do from their names.
+ +Let's write a test that performs all the necessary checks, except for flipping the device - we'll deal with flipping a bit later. The first step is to create a Page Object for the internet connection test screen WifiScreen
. Add it to the com.kaspersky.kaspresso.tutorial.screen
package
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+import com.kaspersky.kaspresso.tutorial.R
+import io.github.kakaocup.kakao.text.KButton
+import io.github.kakaocup.kakao.text.KTextView
+
+object WifiScreen : KScreen<WifiScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val checkWifiButton = KButton { withId(R.id.check_wifi_btn) }
+ val wifiStatus = KTextView { withId(R.id.wifi_status) }
+}
+
Now add steps:
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.WifiScreen
+import org.junit.Rule
+import org.junit.Test
+
+class WifiSampleTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ MainScreen {
+ wifiActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ WifiScreen {
+ checkWifiButton.isVisible()
+ checkWifiButton.isClickable()
+ wifiStatus.hasEmptyText()
+ device.network.toggleWiFi(true)
+ checkWifiButton.click()
+ wifiStatus.hasText("enabled")
+ device.network.toggleWiFi(false)
+ checkWifiButton.click()
+ wifiStatus.hasText("disabled")
+ }
+ }
+}
+
We remember that it is not recommended to use hardcoded strings, it is better to use string resources instead.
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.WifiScreen
+import org.junit.Rule
+import org.junit.Test
+
+class WifiSampleTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ MainScreen {
+ wifiActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ WifiScreen {
+ checkWifiButton.isVisible()
+ checkWifiButton.isClickable()
+ wifiStatus.hasEmptyText()
+ checkWifiButton.click()
+ wifiStatus.hasText(R.string.enabled_status)
+ device.network.toggleWiFi(false)
+ checkWifiButton.click()
+ wifiStatus.hasText(R.string.disabled_status)
+ }
+ }
+}
+
Info
+Do not forget to enable Wifi on the device before starting the test, because after each launch it will be turned off for you and the test will fail on the second run.
+Now we need to learn how to flip the device in order to perform the rest of the checks. The exploit
object from the Device
class is responsible for flipping the device, which you can also read more about in documentation.
The whole test process will now look like this:
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.device.exploit.Exploit
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.WifiScreen
+import org.junit.Rule
+import org.junit.Test
+
+class WifiSampleTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ MainScreen {
+ wifiActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ WifiScreen {
+ device.exploit.setOrientation(Exploit.DeviceOrientation.Portrait)
+ checkWifiButton.isVisible()
+ checkWifiButton.isClickable()
+ wifiStatus.hasEmptyText()
+ checkWifiButton.click()
+ wifiStatus.hasText(R.string.enabled_status)
+ device.network.toggleWiFi(false)
+ checkWifiButton.click()
+ wifiStatus.hasText(R.string.disabled_status)
+ device.exploit.rotate()
+ wifiStatus.hasText(R.string.disabled_status)
+ }
+ }
+}
+
In this lesson we practiced with the device
object, learned how to change the status of the Internet connection and the screen orientation from the test code. Test passed and all checks completed successfully, but there are several serious problems in our code:
In the following lessons, we will learn how we can improve this code and solve the problems that have arisen.
+In the last lesson, we wrote the first test on Kaspresso, and at this stage, our test can interact with the elements of the application interface, can somehow influence them (e.g. click on the button) and check their state (visibility, clickability and etc.).
+But often it is not enough to use only the capabilities of our application for testing. For example, during a test, we might want to test the operation of the application in various external states:
+In all of the above scenarios, the test must control the device and execute commands that are outside the responsibility of the application we are testing. In these cases, we can use the Android Debug Bridge
(ADB
) capabilities.
ADB
is a command line tool that allows you to interact with your device through various commands. They can help you perform actions such as installing and removing programs, getting a list of installed applications, starting a specific Activity, turning off your Internet connection, and much more.
We can execute all adb commands ourselves through the command line, but the Kaspresso library supports working with adb and can execute them automatically. Adb-server needs to be started so that tests that work with adb can run.
+The process of launching adb-server is very simple, if the paths to java and adb are correctly registered on your computer. But if the paths are not registered, then they will have to be registered. Therefore, the first thing we will do is check if any additional work is required or if you already have everything ready to start adb-server.
+Open a command prompt.
+On Windows the key combination is Win + R
, in the window that opens, enter cmd
and press Enter
.
First, we check that the path to java is correct. To do this, we write java -version
.
If everything is fine, then you will see the installed version of Java.
+ +If the paths are written incorrectly, you will see something similar to this:
+ +Now we do the same check for adb. We print in the console adb version
.
If everything is fine, then you will see your ADB version.
+ +Otherwise, you will see something like this error:
+ +If everything works for you on both points, then you can skip the next step.
+The solution to these problems may differ depending on your operating system and some other factors, so we will present here the most popular solution for OS Windows. If you have a different OS, or for some reason this solution does not help you, then search the Internet for information on how to do the steps below in your situation. Without solving these problems, you will not be able to start adb-server and the tests will not work.
+If you have reached this lesson, then you have successfully launched the application from Android Studio on the emulator, which means that java and adb are installed on your computer. The system simply does not know where to look for these programs. What needs to be done is to find the location of these programs and register the paths to them in the system.
+We are looking for the path to java, usually it is located in the jre\bin
folder (in some versions it will be located in jbr\bin
). It can often be found at C:\Program Files\Java\jre1.8.0\bin
.
If it is there, copy this path, if not, open Android Studio. Go to File
-> Settings
-> Build, Execution, Deployment
-> Build Tools
-> Gradle
.
The path to the desired folder will be written here, and you can copy it.
+Now it needs to be registered in the environment variables, for this press win + x
-> select System
-> Advanced System Settings
-> Advanced
-> Environment Variables
.
In the System Variables
section, select Path
and click Edit
-> New
-> Paste the copied path to the folder with java
-> Click OK
.
Restart the computer for the changes to take effect and check the java -version
command again.
It remains for us to do the same for adb. We are looking for the path to the platform-tools
folder, which contains adb
.
Open Android Studio
-> Tools
-> SDK Manager
. The Android SDK Location
field contains the path to the Sdk
folder, which contains platform-tools
.
Copy this path and add it to System Variables
as we did earlier with java
.
Restart the computer and check the adb version
command.
Now we can start running adb-server. If the java
and adb
commands still do not work for you, then google it, there are a lot of options for solving the problem. All you need to do is find the path to java and adb and set them to environment variables.
Before running the tests, let's see what adb can do and look at a few commands.
+First, we can see what devices are currently connected to adb. To do this, enter the command adb devices
.
So far we have not connected any devices to adb, so the list is empty. Let's run the application on the emulator and run the command again.
+ +Now our emulator is displayed in the list of devices.
+With adb commands we can:
+For practice, let's remove the tutorial app we just launched. This is done with the command adb uninstall package_name
.
The most interesting tasks can be performed by running the adb shell
command. It invokes the Android console (shell
) to execute Linux commands on the device.
Here are some examples of such commands.
+Getting a list of all installed applications pm list packages
.
Please note that we first started the shell-console, and then wrote commands, already being in it. Therefore, at the current stage, other adb commands will not work for you until you close the shell console through the exit command.
+ +At the same time, you can execute shell-commands without opening a shell-console. To do this, specify the full name of the command along with adb shell
. For example, let's try to take a screenshot and save it to the device. In Android Studio, you can open File Explorer, which displays all the files and folders on the device.
Screenshots are usually saved on sdcard, we will do the same.
+To create a screenshot, use the adb shell screencap /{pathToFile}/{name_of_image.png}
command. In our case, it will look like this: adb shell screencap /sdcard/my_screen.png
.
In Device File Explorer
, right-click and press Synchronize
, after which the screenshot we created will be displayed in the folder.
So, we've had a little practice with adb, now we need to learn how to work with it during the test run. That is, the test that we will create must be able to run adb commands and check the operation of the application after executing these commands.
+In order for the tests to be able to execute adb commands, we need to run adb-server on our computer. First you need to download the adbserver-desktop.jar
file on the official Kaspresso github and run the following command in the terminal:
java -jar <path/to/file>/adbserver-desktop.jar
+
In order for the path to the file to be correctly written in the console, it is enough to write the java -jar
command and simply drag the adbserver-desctop.jar
file to the console, the path to the file will be inserted automatically.
After entering the command, press Enter
. AdbServer will start. When running the test, the device will tell the desktop the necessary adb commands to run the test.
We can start creating an autotest.
+Create a new AdbTest
class in the com.kaspersky.kaspresso.tutorial
package and inherit from the TestCase
class.
package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+
+class AdbTest : TestCase() {
+}
+
Kaspresso has a special abstraction AdbServer
for working with adb. An instance of this class is available in BaseTestContext
and in BaseTestCase
, which our AdbTest
class inherits.
Earlier in the console, we ran the adb devices
command, which displayed a list of connected devices. Let's run the same command with a test. Create a test()
method and annotate it with @Test
.
package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import org.junit.Test
+
+class AdbTest : TestCase() {
+
+ @Test
+ fun test() {
+
+ }
+}
+
To execute an adb command, we can access the adbServer
field directly and call one of the methods - performAdb
, performCmd
or performShell
. The names of the methods should make it clear what they do.
Now we want to call the adb command devices
call the appropriate method adbServer.performAdb("devices")
.
package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import org.junit.Test
+
+class AdbTest : TestCase() {
+
+ @Test
+ fun test() {
+ adbServer.performAdb("devices")
+ }
+}
+
Run the test. Test completed successfully. Please note that in order to run this test, you must meet 2 conditions:
+We dealt with the first point earlier, now let's deal with the second. Every application that interacts with the Internet must contain a permission to use the Internet. It is written in the manifest.
+ +If you forget to specify this permission, the test will not work.
+Now the test runs the adb command, but does not check the result of its execution. This adb devices
command returns a list of resulting strings (type List<String>
). At the moment, this collection (list of strings) contains only one line like this: exitCode=0, message=List of devices attached emulator-5555 device
. Let's add a check that the first (and only) element of this collection contains the word "emulator", just to practice and make sure we get the output of the adb command correctly.
package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import org.junit.Assert // This class needs to be imported
+import org.junit.Test
+
+class AdbTest : TestCase() {
+
+ @Test
+ fun test() {
+ val result = adbServer.performAdb("devices")
+ Assert.assertTrue( // Method Assert.assertTrue() can be used to check if some condition is met, pay attention to the imports
+ Assert.assertTrue("emulator" in result.first()) // method 'in' checks that the first element of the result list contains the word "emulator"
+ )
+ }
+}
+
Let's launch the test. It passed successfully.
+Now let's try to execute a non-existent adb command. First, let's see how its execution looks in the terminal. Let's execute adb undefined_command
.
Info
+Please note that adb-server is currently running in the terminal, if we want to work with the command line while the server is running, we need to launch another terminal window and work in it
+When executing this command inside the test, it will throw an AdbServerException
exception and the message field will contain a string with the text that we saw in the console: unknown command undefined_command
. To prevent the test from failing, we need to handle this exception in a try catch
block, and inside the catch
block, we can add a check that the error message really contains the text specified above.
package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.internal.exceptions.AdbServerException
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import org.junit.Assert
+import org.junit.Test
+
+class AdbTest : TestCase() {
+
+ @Test
+ fun test() {
+ val result = adbServer.performAdb("devices")
+ Assert.assertTrue("emulator" in result.first())
+
+ val command = "undefined_command"
+ try {
+ adbServer.performAdb(command)
+ } catch (e: AdbServerException) {
+ Assert.assertTrue("unknown command $command" in e.message)
+ }
+ }
+}
+
Let's launch the test. It passed successfully.
+We learned how to run adb commands inside tests. Let's practice adb shell commands. Previously, we got a list of installed applications using a query like adb shell pm list packages
. Now we will execute it inside the test and check that our application is in the list of installed ones.
val packages = adbServer.performShell("pm list packages")
+Assert.assertTrue("com.kaspersky.kaspresso.tutorial" in packages.first())
+
Note that if we call a shell command with performShell
, then we don't need to write adb shell
.
Now we have hardcoded the name of the application package, but there is a much more convenient way. Inside the tests we can interact with the Device object, get some information about the device, the current application, and much more. From this object, we can get the package name of the current application. To do this, you need to access the targetContext
property of the device
object and get packageName
from the context
. The test code in this case will change to this:
...
+val packages = adbServer.performShell("pm list packages")
+Assert.assertTrue(device.targetContext.packageName in packages.first())
+...
+
Let's launch the test. It passed successfully.
+The last type of commands that we will look at in this lesson are [cmd commands]. These are the commands that we write in the console. For example, to run an adb command, we write adb command_name
in the console. Now, if we call performCmd
instead of performAdb
in the test, then we will need to write the entire command:
val result = adbServer.performCmd("adb devices")
+Assert.assertTrue("emulator" in result.first())
+
In this case, the result of the program will not change.
+For practice, we can execute some cmd-command. For example, hostname
prints the name of the host (your computer). If we run it in the console, the result will be something like this:
Let's execute the same command inside the test and check that the result is not empty.
+val hostname = adbServer.performCmd("hostname")
+Assert.assertTrue(hostname.isNotEmpty())
+
Let's launch the test. It passed successfully.
+One of the tests we have previously written checks if there is an emulator in the list of connected devices.
+val result = adbServer.performCmd("adb devices")
+Assert.assertTrue("emulator" in result.first())
+
We added it just for reference purposes, and to practice different commands. Real tests can be run both on emulators and on real devices, and tests should not crash because of this, so we will delete this test. The resulting AdbTest
code will look like this:
package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.internal.exceptions.AdbServerException
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import org.junit.Assert
+import org.junit.Test
+
+class AdbTest : TestCase() {
+
+ @Test
+ fun test() {
+ val command = "undefined_command"
+ try {
+ adbServer.performAdb(command)
+ } catch (e: AdbServerException) {
+ Assert.assertTrue("unknown command $command" in e.message)
+ }
+
+ val packages = adbServer.performShell("pm list packages")
+ Assert.assertTrue(device.targetContext.packageName in packages.first())
+
+ val hostname = adbServer.performCmd("hostname")
+ Assert.assertTrue(hostname.isNotEmpty())
+ }
+}
+
In this lesson, we learned what adb
is, set up adb-server
operation, learned how to execute various types of commands (cmd
, adb
, shell
) in the console and in autotests, and also learned about the Device
object, from which we can receive various information about the device and application we are testing.
+
In Android Studio you can switch between branches and thus see different versions of a project. Initially, after downloading Kaspresso, you will be in the master
branch.
This branch contains the source code of the application, which we will cover with tests. In the current and subsequent lessons, step-by-step instructions for writing autotests will be given in codelabs format. The final result with all written tests is available in the TECH-tutorial-results
branch, you can switch to it at any time and see the solution.
To do this, click on the name of the branch you are on, and in the search, enter the name of the branch you are interested in.
+ +Before we start writing a test, let's take a closer look at the functionality that we will cover with autotests. To do this, switch to the 'master' branch.
+Open configuration selection (1) and select tutorial (2):
+ +Check that the desired device is selected (1) and run the application (2):
+ +After successfully launching the application, we will see the main screen of the Tutorial application.
+ +Click on the button with the text "Simple test" and see the following screen:
+ +The screen consists of:
+Header TextView
EditText input fields
+Buttons
+Info
+A full list of widgets in Android with detailed information can be found here.
+When you click on the button, the text in the header changes to the one entered in the input field.
+We manually checked that the result of the application meets the expectations:
+Now we need to write all the same checks in the code so that they are performed automatically.
+To cover the application with Kaspresso tests, you need to start by including the Kaspresso library in the project dependencies.
+Switch the display of the project files to Project (1) and add the dependency to the existing dependencies
section in the build.gradle
file of the Tutorial
module:
dependencies {
+ androidTestImplementation("com.kaspersky.android-components:kaspresso:1.5.1")
+ androidTestUtil("androidx.test:orchestrator:1.4.2")
+}
+
We can start writing the code of our test. To do this, it is necessary to create a model (class) for each screen that participates in the test and, inside this model, declare all the interface elements (buttons, text fields, etc.) that make up the screen that the test will interact with. This approach is called Page Object
and you can read more about it in the documentation.
In the first four steps of the test, we are interacting with the main screen, so the first step is to create a Page Object for the main screen.
+We will work in the androidTest
folder of the tutorial module. If you do not have this folder, then you need to create it by right-clicking on the src
folder and selecting New
-> Directory
.
Select the item androidTest/kotlin
:
Inside the kotlin
folder, let's create a separate package in which we will store all Page Objects:
Creating a separate package does not affect the functionality, we do it just for convenience, so that all screen models are in one place. You can give the package any name (with a few exceptions), but it's common for tests to use the same name as the application itself. We can go to the MainActivity file and the package name will be listed at the top.
+ +Copy this name and paste it into the package name. Specifically, in this package we will store only screen models (Page Objects), so let's add .screen
at the end.
When we add other classes to the folder with tests, we will put them in other packages, but the first part of their name will be the same: com.kaspersky.kaspresso.tutorial
.
Now in the created package we add a screen model (class):
+ +Choose the type Object and name it MainScreen.
+ +MainScreen is a model of the main screen. In order for this model to be used in autotests, it is necessary to inherit from the KScreen class and specify the name of this class in angle brackets.
+Info
+Specifying the type in angle brackets in Java and Kotlin is called Generics. You can read more about this in Java and Kotlin documentation.
+package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+
+object MainScreen : KScreen<MainScreen>() {
+}
+
An error occurred because the KScreen class contains two members that need to be redefined when inheriting. In order to do this quickly in Android Studio, we can press the key combination ctrl + i
and select the elements that we want to override.
Holding ctrl
, select all items and press OK
.
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+
+object MainScreen : KScreen<MainScreen>() {
+
+ override val layoutId: Int?
+ get() = TODO("Not yet implemented")
+ override val viewClass: Class<*>?
+ get() = TODO("Not yet implemented")
+}
+
New lines of code appeared in the file. Instead of TODO
, you need to write the correct implementation: the id of the layout (layoutId
) that is set on the screen, and the name of the class (viewClass
). This is necessary to associate the test with a specific layout file and activity class. This binding will make further support and refinement of the test more convenient, but for now we are faced with the task of writing the first test, so we will leave the null
value.
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+
+object MainScreen : KScreen<MainScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+}
+
Now inside the MainScreen class we will declare all the user interface elements with which the test will interact. In our case, we are only interested in the SimpleTest
button on the main screen.
In order for the test to interact with it, you need to know the id by which this button can be found on the screen. These identifiers are assigned by a developer when writing the application.
+To find out what id has been assigned to some interface element, you can use the LayoutInspector
tool built into Android Studio.
Select an item on the screen and look for its id. This is the identifier that interests us.
+ +It is also important to understand what UI element we are working with. To do this, you can go to the layout where the element was declared and see all the information about it.
+ +In this case, it's a Button element with id simple_activity_btn
We can add this button to the MainScreen
. Usually the name of the variable matches the element's id, but is written without underscores and each word except the first one is capitalized (this is called camelCase)
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+
+object MainScreen : KScreen<MainScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val simpleActivityButton =
+}
+
The simpleActivityButton variable needs to be assigned a value. It represents a button that can be tested, and the class KButton is responsible for this. This is how setting the value to this variable will look like, now we will analyze in detail what this code does.
+package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+import com.kaspersky.kaspresso.tutorial.R
+import io.github.kakaocup.kakao.text.KButton
+
+object MainScreen : KScreen<MainScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val simpleActivityButton = KButton { withId(R.id.simple_activity_btn) }
+}
+
First, let's jump into the definition of KButton and see what it is. To do this, holding ctrl
, click on the name of the KButton class with the left mouse button.
We see that this is a class that inherits from KBaseView and implements the TextViewAssertions interface. We can go to the definition of KBaseView and see all the inheritors of this class, there are quite a lot of them.
+ +Why are they all needed?
+The reason is that each element of the user interface can be tested in different ways. For example, in a TextView we can check what text is currently set in it, we can set a new text, while the ProgressBar does not contain any text and it makes no sense to check what text is set in it.
+Therefore, depending on which interface element we are testing, we need to choose the correct implementation of KBaseView. Now we are testing a button, so we chose KButton. On the next screen, we will test the title (TextView) and input field (EditText) and select the appropriate KBaseView implementations.
+ +Next, the test should find this button on the screen according to some criterion. In this case, we will search for an element by id, so we use the withId
matcher, where we pass the button ID as a parameter, which we found thanks to the Layout Inpector
.
In order to specify this id, we used the R.id... syntax, where R
is the class with all the resources of the application. Thanks to it, you can find the id of interface elements, lines that are in the project, pictures, etc. When you enter the name of this class, Android Studio should import it automatically, but sometimes this does not happen, then you need to enter this import manually.
import com.kaspersky.kaspresso.tutorial.R
+
That's it, now we have a model of the main screen and this model contains a button that can be tested. We can start writing the test itself.
+In the folder androidTest
-> kotlin
, in the package we created, add the class SimpleActivityTest
.
The new class was placed in the screen
package, but we would like it to contain only screen models, so we will move the created test to the root of the com.kaspersky.kaspresso.tutorial
package. In order to do this, right-click on the class name and select Refactor
-> Move
And remove the last part .screen
from the package name.
The test class must be inherited from the TestCase
class. Pay attention to imports, the TestCase class must be imported from the import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
package.
package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+
+class SimpleActivityTest: TestCase() {
+}
+
Then we add the test()
method, in which we will check the operation of the application. It can have any name, not necessarily "test", but it needs to be annotated with @Test
(import org.junit.Test
).
package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import org.junit.Test
+
+class SimpleActivityTest : TestCase() {
+
+ @Test
+ fun test() {
+
+ }
+}
+
The SimpleActivityTest
test can be run. Information on how to run tests in Android Studio can be found in the previous tutorial.
For now this test does nothing, so it succeeds. Let's add logic to it and test the MainScreen.
+package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import org.junit.Test
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+
+class SimpleActivityTest : TestCase() {
+
+ @Test
+ fun test() {
+ MainScreen {
+ simpleActivityButton {
+ isVisible()
+ isClickable()
+ }
+ }
+ }
+}
+
Inside the test method, we get the MainScreen object, open the curly brackets and refer to the button that we will test, then open the curly brackets again and write all the checks here. Now, thanks to the isVisible()
and isClickable()
methods, we check that the button is visible and clickable. Let's launch the test. It falls.
The probleem is that Page Object MainScreen
refers to MainActivity
(this is the activity that the user sees when he launches the application) and, in order for the elements to be displayed on the screen, this activity must be launched before the test is executed. In order for some kind of activity to be launched before the test, you can add the following lines:
@get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
This test will launch the specified MainActivity
activity before running the test and close it after the test runs.
You can read more about activityScenarioRule
here.
Then the entire test code will look like this:
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.MainActivity
+import org.junit.Rule
+import org.junit.Test
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+
+class SimpleActivityTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ MainScreen {
+ simpleActivityButton {
+ isVisible()
+ isClickable()
+ }
+ }
+ }
+}
+
Launch it. Everything is fine, our test is successful, and you can see on the device that during the test the activity we need opens and closes after the run.
+ +It's good practice when writing tests to make sure that the test not only passes, but also fails if the condition is not met. This way you eliminate the situation when the tests are "green", but in reality, due to some error in the code, the tests were not performed at all. Let's do this by checking that the button contains incorrect text.
+class SimpleActivityTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ MainScreen {
+ simpleActivityButton {
+ isVisible()
+ isClickable()
+ containsText("Incorrect text")
+ }
+ }
+ }
+}
+
The test fails, let's change the text to the correct one.
+class SimpleActivityTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ MainScreen {
+ simpleActivityButton {
+ isVisible()
+ isClickable()
+ containsText("Simple test")
+ }
+ }
+ }
+}
+
The test is successful.
+Now we need to test the SimpleActivity
. We do it the same way as MainScreen
: first, create a Page Object.
object SimpleActivityScreen : KScreen<SimpleActivityScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+}
+
Then look for id elements through the Layout Inspector
:
Do not forget to specify correct View types: KTextView for the title, KEditText for the input field, and KButton for the button.
+object SimpleActivityScreen : KScreen<SimpleActivityScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+
+ val simpleTitle = KTextView { withId(R.id.simple_title) }
+ val inputText = KEditText { withId(R.id.input_text) }
+ val changeTitleButton = KButton { withId(R.id.change_title_btn) }
+}
+
And now we can test this screen. In order to go to it, on the main screen you need to click on the "Simple Test" button, so we call click()
in the code.
Add checks for this screen:
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.MainActivity
+import org.junit.Rule
+import org.junit.Test
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.SimpleActivityScreen
+
+class SimpleActivityTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ MainScreen {
+ simpleActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ SimpleActivityScreen {
+ simpleTitle.isVisible()
+ changeTitleButton.isClickable()
+ simpleTitle.hasText("Default title")
+ inputText.replaceText("new title")
+ changeTitleButton.click()
+ simpleTitle.hasText("new title")
+
+ }
+ }
+}
+
Our first test is almost ready. The only change worth making is that we're using the hardcoded "Default title" text here. For now, the test passes successfully, but if the application is suddenly localized into different languages, then when the test is launched with the English locale, the test can pass successfully, but if we run it on a device with the Russian locale, the test will fail.
+So instead of hardcoding the string, we'll take it from the application's resources. In the activity's layout, we can see which line was used in this TextView.
+ +Go to string resources (file values/strings.xml
) and copy the string id.
Now in the hasText method, instead of using the "Default title" string, we use its id R.string.simple_activity_default_title
.
Don't forget to import the R resource class import com.kaspersky.kaspresso.tutorial.R
.
The final test code looks like this:
+package com.kaspersky.kaspresso.tutorial
+
+import androidx.test.ext.junit.rules.activityScenarioRule
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import com.kaspersky.kaspresso.tutorial.MainActivity
+import com.kaspersky.kaspresso.tutorial.R
+import org.junit.Rule
+import org.junit.Test
+import com.kaspersky.kaspresso.tutorial.screen.MainScreen
+import com.kaspersky.kaspresso.tutorial.screen.SimpleActivityScreen
+
+class SimpleActivityTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ MainScreen {
+ simpleActivityButton {
+ isVisible()
+ isClickable()
+ click()
+ }
+ }
+ SimpleActivityScreen {
+ simpleTitle.isVisible()
+ changeTitleButton.isClickable()
+ simpleTitle.hasText(R.string.simple_activity_default_title)
+ inputText.replaceText("new title")
+ changeTitleButton.click()
+ simpleTitle.hasText("new title")
+
+ }
+ }
+}
+
In this tutorial, we have written our first Kaspresso test. In practice, we got acquainted with the PageObject approach. We learned how to get interface element IDs using the Layout inspector
.
Hi everyone!
+
If you're here, it means you're interested in Android autotests. Kaspresso is a great solution that can help you. You can find more information about our framework here.
+
The Kaspresso team prepared Tutorial in codelabs format. This Tutorial is designed to help you get started with Kaspresso and familiarize yourself with its main features.
The Tutorial is divided into steps (lessons). Each lesson begins with a brief overview and ends with summary and conclusions.
+We strive to make the lessons independent from each other, but this is not always possible. For a better understanding of Kaspresso, we recommend starting with the first lesson and moving sequentially to the next.
+
The codelab format assumes that you will combine theory and practice, repeating the instructions from the lessons step by step. In the Kaspresso project, in the 'tutorial' folder, there is an example of the application code for which tests will be written. The first lesson will tell you how to download it. In the tutorial_results
branch, you can see the final implementation of all tutorial tests.
We are not trying to teach you autotests from scratch. At the same time, we do not set any restrictions on knowledge and experience for passing the tutorial and try to keep the story in such a way that it is understandable to beginners in autotests and Android. It is almost impossible to talk about Kaspresso without terms from the Java and Kotlin programming languages, the Espresso, Kakao, UiAutomator and other frameworks, the Android operating system and testing itself as an IT area. Nevertheless, the main focus is on the explanation of Kaspresso itself, and in all places where various terms are mentioned, we share links to official sources for detailed information and better understanding.
+If you find a typo, error or inaccuracy in the material, want to suggest an improvement or add new lessons to the Tutorial, you can create an Issue in the Kaspresso project or open a Pull request (materials from the Tutorial are in the public domain in the docs folder).
+
If the Tutorial did not answer your question, you can search the Wiki section or the Kaspresso in articles and Kaspresso in video.
+
You can also join our Telegram channels ru and en and ask your question there.
If you like our framework, you can give our project a star on Github.
+ + + + + + +Kaspresso is based on Google testing framework Espresso (if you're not familiar with Espresso, check out the official docs)
+
Espresso allows you to work with the elements of your application as a white box (white box testing). You can find the desired element on the screen using matchers, perform different actions or checks.
This framework has a lot of drawbacks and not all things in Android autotesting can be done with Espresso alone.
+Kaspresso is based on Kakao - Android framework for UI autotests. It is also based on Espresso. Kakao provides a simple Kotlin DSL. This makes the tests more readable. You no longer need to put long constructors with matchers for finding elements on the screen in the code of your test. The result of calling the onView()
Espresso method is cached. You can then get the required view as a property.
+
Kakao also provides an implementation of Page object pattern with a Screen
object. You can describe all the interface elements that your test will interact with in one place (in one Screen object).
Kaspresso has wrapped some Espresso calls into a more stable implementation. For example you can find flakySafely()
method in the Kaspresso.
Kaspresso has wrapped some Espresso calls not only for higher stability. We have also implemented an interceptor that prints more logs.
+We have created the Device interface as a facade for all devices to work with. UiAutomator can only help you in some cases, but more often you need the ability to execute various commands (adb, shell). For example, with the adb emu command, you can emulate various actions or events.
+
Espresso tests are run directly on the android device, so we need some kind of external server to send the commands. In Kaspresso you can use AdbServer
.
Having described above implementations of Page object pattern, you can make your code in your test files more readable, maintainable, reusable, and understandable. Kaspresso also provides various methods and abstractions to improve the architecture (such as step
, Scenario
, test sections and more).
As you remember from the previous part devoted to Device interface, Device interface contains the following things under the hood:
+An attentive reader could notice that ADB is not available in Espresso tests. But using some other frameworks, like Appium, you can execute ADB commands. So we decided to add this important functionality too.
+We've developed a special Autotest's AdbServer to compensate lack of this feature.
+The main idea of the tool is similar to the idea in Appium. We just built a simple client-server system which contains two parts:
The algorithm how to use Autotest AdbServer:
+java -jar <path/to/kaspresso>/artifacts/adbserver-desktop.jar
in the terminalFor example, type shell input text abc
in the app's EditText and click Execute button. As result you will get shell input text abcabc
+in the EditText because ADB command has been executed and abc
symbols has been added into the focused EditText.
+You can notice that the app uses AdbTerminal
class to execute ADB commands.
In Kaspresso, we wrap AdbTerminal
into a special interface AdbServer
.
+AdbServer
's instance is available in BaseTestContext
scope and BaseTestCase
with adbServer
property:
+
@Test
+fun test() =
+ run {
+ step("Open Simple Screen") {
+ activityTestRule.launchActivity(null)
+ ======> adbServer.performShell("input text 1") <======
+
+ MainScreen {
+ simpleButton {
+ isVisible()
+ click()
+ }
+ }
+ }
+ // ....
+}
+
<uses-permission android:name="android.permission.INTERNET" />
+
You can also use a few special flags when he starts adbserver-desktop.jar
.
+For example, java -jar adbserver-desktop.jar -e emulator-5554,emulator-5556 -p 5041 -l VERBOSE
.
+Flags:
e
, --emulators
- the list of emulators that can be captured by adbserver-desktop.jar
(by default, adbserver-desktop.jar
captures all available emulators)p
, --port
- the adb server port number (the default value is 5037)l
, --logs
- what type of logs show (the default value is INFO).a
, --adb_path
- path to custom adb instance (by default, adbserver-desktop.jar
uses adb
from environment).
+For more information, you can run java -jar adbserver-desktop.jar --help
Consider available types of logs:
+1. ERROR
+ You will see only error messages in the output. For example,
+
ERROR 10/09/2020 11:37:19.893 desktop=Desktop-25920 device=emulator-5554 message: Incorrect type of the message...
+
WARN
+ Prints error and warning messages.
INFO
+ Default value, provides all the base events. For example,
+
INFO 10/09/2020 11:37:04.822 desktop=Desktop-25920 message: Desktop started with arguments: emulators=[], adbServerPort=null
+INFO 10/09/2020 11:37:19.859 desktop=Desktop-25920 message: New device has been found: emulator-5554. Initialize connection to the device...
+INFO 10/09/2020 11:37:19.892 desktop=Desktop-25920 device=emulator-5554 message: The connection establishment to device started
+INFO 10/09/2020 11:37:19.893 desktop=Desktop-25920 device=emulator-5554 message: WatchdogThread is started from Desktop to Device
+INFO 10/09/2020 11:37:19.893 desktop=Desktop-25920 device=emulator-5554 message: Desktop tries to connect to the Device.
+ It may take time because the device can be not ready. Possible reason: a kaspresso test has not been started
+INFO 10/09/2020 11:37:20.185 desktop=Desktop-25920 device=emulator-5554 message: The attempt to connect to Device was success
+INFO 10/09/2020 11:44:47.810 desktop=Desktop-25920 device=emulator-5554 message: The received command to execute: AdbCommand(body=shell input text abc)
+INFO 10/09/2020 11:44:49.115 desktop=Desktop-25920 device=emulator-5554 message: The executed command: AdbCommand(body=shell input text abc). The result: CommandResult(status=SUCCESS, description=exitCode=0, message=, serviceInfo=The command was executed on desktop=Desktop-25920)
+
serviceInfo
at the end:
+INFO 10/09/2020 11:44:49.115 desktop=Desktop-25920 device=emulator-5554 message: The executed command: AdbCommand(body=shell input text abc). The result: CommandResult(status=SUCCESS, description=exitCode=0, message=, serviceInfo=The command was executed on desktop=Desktop-25920)
+
VERBOSE
+ There are cases when you might to debug Desktop part of AdbServer. That's why there is a special very detailed format — VERBOSE.
+ Have a glance at logs reflecting similar events presented above (initialization, device connection and execution of a command):
+
INFO 10/09/2020 11:48:16.850 desktop=Desktop-27398 tag=MainKt method=main message: Desktop started with arguments: emulators=[], adbServerPort=null
+DEBUG 10/09/2020 11:48:16.853 desktop=Desktop-27398 tag=Desktop method=startDevicesObserving message: start
+INFO 10/09/2020 11:48:16.913 desktop=Desktop-27398 tag=Desktop method=startDevicesObserving message: New device has been found: emulator-5554. Initialize connection to the device...
+DEBUG 10/09/2020 11:48:16.918 desktop=Desktop-27398 device=emulator-5554 tag=DesktopDeviceSocketConnectionForwardImpl method=getDesktopSocketLoad message: calculated desktop client port=21234
+DEBUG 10/09/2020 11:48:16.918 desktop=Desktop-27398 device=emulator-5554 tag=DesktopDeviceSocketConnectionForwardImpl method=forwardPorts message: fromPort=21234, toPort=8500 started
+DEBUG 10/09/2020 11:48:16.919 desktop=Desktop-27398 device=emulator-5554 tag=CommandExecutorImpl method=execute message: The created adbCommand=adb -s emulator-5554 forward tcp:21234 tcp:8500
+DEBUG 10/09/2020 11:48:16.925 desktop=Desktop-27398 device=emulator-5554 tag=DesktopDeviceSocketConnectionForwardImpl method=forwardPorts message: fromPort=21234, toPort=8500) finished with result=CommandResult(status=SUCCESS, description=exitCode=0, message=21234
+, serviceInfo=The command was executed on desktop=Desktop-27398)
+DEBUG 10/09/2020 11:48:16.925 desktop=Desktop-27398 device=emulator-5554 tag=DesktopDeviceSocketConnectionForwardImpl method=getDesktopSocketLoad message: desktop client port=21234 is forwarding with device server port=8500
+INFO 10/09/2020 11:48:16.927 desktop=Desktop-27398 device=emulator-5554 tag=DeviceMirror method=startConnectionToDevice message: The connection establishment to device started
+INFO 10/09/2020 11:48:16.928 desktop=Desktop-27398 device=emulator-5554 tag=DeviceMirror$WatchdogThread method=run message: WatchdogThread is started from Desktop to Device
+DEBUG 10/09/2020 11:48:16.928 desktop=Desktop-27398 device=emulator-5554 tag=DeviceMirror$WatchdogThread method=run message: The attempt to connect to Device. It may take time because the device can be not ready (for example, a kaspresso test was not started).
+INFO 10/09/2020 11:48:16.928 desktop=Desktop-27398 device=emulator-5554 tag=DeviceMirror$WatchdogThread method=run message: Desktop tries to connect to the Device.
+ It may take time because the device can be not ready. Possible reason: a kaspresso test has not been started
+DEBUG 10/09/2020 11:48:16.929 desktop=Desktop-27398 device=emulator-5554 tag=ConnectionServerImplBySocket method=tryConnect message: Start the process
+DEBUG 10/09/2020 11:48:16.929 desktop=Desktop-27398 device=emulator-5554 tag=ConnectionMaker method=connect message: Start a connection establishment. The current state=DISCONNECTED
+DEBUG 10/09/2020 11:48:16.929 desktop=Desktop-27398 device=emulator-5554 tag=ConnectionMaker method=connect message: The current state=CONNECTING
+DEBUG 10/09/2020 11:48:16.930 desktop=Desktop-27398 device=emulator-5554 tag=DesktopDeviceSocketConnectionForwardImpl$getDesktopSocketLoad$1 method=invoke message: started with ip=127.0.0.1, port=21234
+DEBUG 10/09/2020 11:48:16.938 desktop=Desktop-27398 device=emulator-5554 tag=DesktopDeviceSocketConnectionForwardImpl$getDesktopSocketLoad$1 method=invoke message: completed with ip=127.0.0.1, port=21234
+DEBUG 10/09/2020 11:48:16.941 desktop=Desktop-27398 device=emulator-5554 tag=SocketMessagesTransferring method=prepareListening message: Start
+DEBUG 10/09/2020 11:48:16.948 desktop=Desktop-27398 device=emulator-5554 tag=SocketMessagesTransferring method=prepareListening message: IO Streams were created
+DEBUG 10/09/2020 11:48:16.948 desktop=Desktop-27398 device=emulator-5554 tag=ConnectionMaker method=connect message: The connection is established. The current state=CONNECTED
+DEBUG 10/09/2020 11:48:16.948 desktop=Desktop-27398 device=emulator-5554 tag=ConnectionServerImplBySocket$tryConnect$2 method=invoke message: The connection is ready. Start messages listening
+DEBUG 10/09/2020 11:48:16.949 desktop=Desktop-27398 device=emulator-5554 tag=SocketMessagesTransferring method=startListening message: Started
+INFO 10/09/2020 11:48:16.949 desktop=Desktop-27398 device=emulator-5554 tag=DeviceMirror$WatchdogThread method=run message: The attempt to connect to Device was success
+DEBUG 10/09/2020 11:48:16.949 desktop=Desktop-27398 device=emulator-5554 tag=SocketMessagesTransferring$MessagesListeningThread method=run message: Start listening
+DEBUG 10/09/2020 11:48:24.132 desktop=Desktop-27398 device=emulator-5554 tag=SocketMessagesTransferring method=peekNextMessage message: The message=TaskMessage(command=AdbCommand(body=shell input text abc))
+INFO 10/09/2020 11:48:24.132 desktop=Desktop-27398 device=emulator-5554 tag=DeviceMirror$Companion$create$connectionServerLifecycle$1 method=onReceivedTask message: The received command to execute: AdbCommand(body=shell input text abc)
+DEBUG 10/09/2020 11:48:24.132 desktop=Desktop-27398 device=emulator-5554 tag=ConnectionServerImplBySocket$handleMessages$1 method=invoke message: Received taskMessage=TaskMessage(command=AdbCommand(body=shell input text abc))
+DEBUG 10/09/2020 11:48:24.133 desktop=Desktop-27398 device=emulator-5554 tag=CommandExecutorImpl method=execute message: The created adbCommand=adb -s emulator-5554 shell input text abc
+INFO 10/09/2020 11:48:24.389 desktop=Desktop-27398 device=emulator-5554 tag=DeviceMirror$Companion$create$connectionServerLifecycle$1 method=onExecutedTask message: The executed command: AdbCommand(body=shell input text abc). The result: CommandResult(status=SUCCESS, description=exitCode=0, message=, serviceInfo=The command was executed on desktop=Desktop-27398)
+DEBUG 10/09/2020 11:48:24.389 desktop=Desktop-27398 device=emulator-5554 tag=ConnectionServerImplBySocket$handleMessages$1$1 method=run message: Result of taskMessage=TaskMessage(command=AdbCommand(body=shell input text abc)) => result=CommandResult(status=SUCCESS, description=exitCode=0, message=, serviceInfo=The command was executed on desktop=Desktop-27398)
+DEBUG 10/09/2020 11:48:24.389 desktop=Desktop-27398 device=emulator-5554 tag=SocketMessagesTransferring method=sendMessage message: Input sendModel=ResultMessage(command=AdbCommand(body=shell input text abc), data=CommandResult(status=SUCCESS, description=exitCode=0, message=, serviceInfo=The command was executed on desktop=Desktop-27398))
+
tag
and method
. Both fields are autogenerated using Throwable().stacktrace
method.
+DEBUG
+ Unlike a VERBOSE type, DEBUG packs repeating pieces of logs. For example,
+
DEBUG 10/09/2020 12:11:37.006 desktop=Desktop-30548 device=emulator-5554 tag=DeviceMirror$WatchdogThread method=run message: The attempt to connect to Device. It may take time because the device can be not ready (for example, a kaspresso test was not started).
+DEBUG 10/09/2020 12:11:44.063 desktop=Desktop-30548 device=emulator-5554 tag=ServiceInfo method=Start message: ////////////////////////////////////////FRAGMENT IS REPEATED 7 TIMES////////////////////////////////////////
+DEBUG 10/09/2020 12:11:44.064 desktop=Desktop-30548 device=emulator-5554 tag=ConnectionServerImplBySocket method=tryConnect message: Start the process
+DEBUG 10/09/2020 12:11:44.064 desktop=Desktop-30548 device=emulator-5554 tag=ConnectionMaker method=connect message: Start a connection establishment. The current state=DISCONNECTED
+DEBUG 10/09/2020 12:11:44.064 desktop=Desktop-30548 device=emulator-5554 tag=ConnectionMaker method=connect message: The current state=CONNECTING
+DEBUG 10/09/2020 12:11:44.064 desktop=Desktop-30548 device=emulator-5554 tag=DesktopDeviceSocketConnectionForwardImpl$getDesktopSocketLoad$1 method=invoke message: started with ip=127.0.0.1, port=37110
+DEBUG 10/09/2020 12:11:44.064 desktop=Desktop-30548 device=emulator-5554 tag=DesktopDeviceSocketConnectionForwardImpl$getDesktopSocketLoad$1 method=invoke message: completed with ip=127.0.0.1, port=37110
+DEBUG 10/09/2020 12:11:44.064 desktop=Desktop-30548 device=emulator-5554 tag=SocketMessagesTransferring method=prepareListening message: Start
+DEBUG 10/09/2020 12:11:44.064 desktop=Desktop-30548 device=emulator-5554 tag=ConnectionMaker method=connect message: The connection establishment process failed. The current state=DISCONNECTED
+DEBUG 10/09/2020 12:11:44.064 desktop=Desktop-30548 device=emulator-5554 tag=ConnectionServerImplBySocket$tryConnect$3 method=invoke message: The connection establishment attempt failed. The most possible reason is the opposite socket is not ready yet
+DEBUG 10/09/2020 12:11:44.064 desktop=Desktop-30548 device=emulator-5554 tag=DeviceMirror$WatchdogThread method=run message: The attempt to connect to Device. It may take time because the device can be not ready (for example, a kaspresso test was not started).
+DEBUG 10/09/2020 12:11:44.064 desktop=Desktop-30548 device=emulator-5554 tag=ServiceInfo method=End message: ////////////////////////////////////////////////////////////////////////////////////////////////////
+DEBUG 10/09/2020 12:11:44.064 desktop=Desktop-30548 device=emulator-5554 tag=ConnectionServerImplBySocket method=tryConnect message: Start the process
+DEBUG 10/09/2020 12:11:44.064 desktop=Desktop-30548 device=emulator-5554 tag=ConnectionMaker method=connect message: Start a connection establishment. The current state=DISCONNECTED
+
In Kaspresso, the AdbServer
interface has a default implementation AdbServerImpl
. This implementation sets WARN
log level for AdbServer.
+So, you can see such logs in LogCat:
+
2020-09-10 12:24:27.240 10349-10378/com.kaspersky.kaspressample I/KASPRESSO: ___________________________________________________________________________
+2020-09-10 12:24:27.240 10349-10378/com.kaspersky.kaspressample I/KASPRESSO: TEST STEP: "1. Disable network" in DeviceNetworkSampleTest
+2020-09-10 12:24:27.240 10349-10378/com.kaspersky.kaspressample I/KASPRESSO: AdbServer. The command to execute=su 0 svc data disable
+2020-09-10 12:24:27.240 10349-10378/com.kaspersky.kaspressample W/KASPRESSO_ADBSERVER: Something went wrong (fake message)
+
KASPRESSO_ADBSERVER
tag with WARN
log level. VERBOSE
log level:
+class DeviceNetworkSampleTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple {
+ libLogger = UiTestLoggerImpl(Kaspresso.DEFAULT_LIB_LOGGER_TAG)
+ adbServer = AdbServerImpl(LogLevel.VERBOSE, libLogger)
+ }
+) {...}
+
2020-09-10 12:24:27.240 10349-10378/com.kaspersky.kaspressample I/KASPRESSO: TEST STEP: "1. Disable network" in DeviceNetworkSampleTest
+2020-09-10 12:24:27.240 10349-10378/com.kaspersky.kaspressample I/KASPRESSO: AdbServer. The command to execute=su 0 svc data disable
+2020-09-10 12:24:27.240 10349-10378/com.kaspersky.kaspressample I/KASPRESSO_ADBSERVER: Start to execute the command=AdbCommand(body=shell su 0 svc data disable)
+2020-09-10 12:24:27.240 10349-10378/com.kaspersky.kaspressample D/KASPRESSO_ADBSERVER: class=ConnectionClientImplBySocket method=executeCommand message: Started command=AdbCommand(body=shell su 0 svc data disable)
+2020-09-10 12:24:27.241 10349-10378/com.kaspersky.kaspressample D/KASPRESSO_ADBSERVER: class=SocketMessagesTransferring method=sendMessage message: Input sendModel=TaskMessage(command=AdbCommand(body=shell su 0 svc data disable))
+2020-09-10 12:24:27.427 10349-10406/com.kaspersky.kaspressample D/KASPRESSO_ADBSERVER: class=SocketMessagesTransferring method=peekNextMessage message: The message=ResultMessage(command=AdbCommand(body=shell su 0 svc data disable), data=CommandResult(status=SUCCESS, description=exitCode=0, message=, serviceInfo=The command was executed on desktop=Desktop-30548))
+2020-09-10 12:24:27.427 10349-10406/com.kaspersky.kaspressample D/KASPRESSO_ADBSERVER: class=ConnectionClientImplBySocket$handleMessages$1 method=invoke message: Received resultMessage=ResultMessage(command=AdbCommand(body=shell su 0 svc data disable), data=CommandResult(status=SUCCESS, description=exitCode=0, message=, serviceInfo=The command was executed on desktop=Desktop-30548))
+2020-09-10 12:24:27.427 10349-10378/com.kaspersky.kaspressample D/KASPRESSO_ADBSERVER: class=ConnectionClientImplBySocket method=executeCommand message: Command=AdbCommand(body=shell su 0 svc data disable) completed with commandResult=CommandResult(status=SUCCESS, description=exitCode=0, message=, serviceInfo=The command was executed on desktop=Desktop-30548)
+2020-09-10 12:24:27.427 10349-10378/com.kaspersky.kaspressample I/KASPRESSO_ADBSERVER: The result of command=AdbCommand(body=shell su 0 svc data disable) => CommandResult(status=SUCCESS, description=exitCode=0, message=, serviceInfo=The command was executed on desktop=Desktop-30548)
+
The source code of AdbServer is available in adb-server module.
+If you want to build adbserver-desktop.jar
manually, just execute ./gradlew :adb-server:adbserver-desktop:assemble
.
Jetpack Compose support consists of two parts: Kakao Compose library and Kaspresso Interceptors mechanism.
+All detailed information is available in the README of the library.
+Jetpack Compose support is provided by a separate module to not force developers to up their minSDK version to 21.
+So, first of all, add a dependency to build.gradle
:
+
dependencies {
+ androidTestImplementation "com.kaspersky.android-components:kaspresso-compose-support:<latest_version>"
+}
+
In a nutshell, let's see at how Kakao Compose DSL looks like: +
// Screen class
+class ComposeMainScreen(semanticsProvider: SemanticsNodeInteractionsProvider) :
+ ComposeScreen<ComposeMainScreen>(
+ semanticsProvider = semanticsProvider,
+ // Screen in Kakao Compose can be a Node too due to 'viewBuilderAction' param.
+ // 'viewBuilderAction' param is nullable.
+ viewBuilderAction = { hasTestTag("ComposeMainScreen") }
+) {
+
+ // You can set clear parent-child relationship due to 'child' extension
+ // Here, 'simpleFlakyButton' is a child of 'ComposeMainScreen' (that is Node too)
+ val simpleFlakyButton: KNode = child {
+ hasTestTag("main_screen_simple_flaky_button")
+ }
+}
+
+// This annotation is here to make the test is appropriate for JVM environment (with Robolectric)
+@RunWith(AndroidJUnit4::class)
+// Test class declaration
+class ComposeSimpleFlakyTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.withComposeSupport()
+) {
+
+ // Special rule for Compose tests
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule<JetpackComposeActivity>()
+
+ // Test DSL. It's so similar to Kakao or Kautomator DSL
+ @Test
+ fun test() = run {
+ step("Open Flaky screen") {
+ onComposeScreen<ComposeMainScreen>(composeTestRule) {
+ simpleFlakyButton {
+ assertIsDisplayed()
+ performClick()
+ }
+ }
+ }
+
+ step("Click on the First button") {
+ onComposeScreen<ComposeSimpleFlakyScreen>(composeTestRule) {
+ firstButton {
+ assertIsDisplayed()
+ performClick()
+ }
+ }
+ }
+
+ // ...
+ }
+}
+
Interceptors are one of the main advantages and powers of Kaspresso library.
+How interceptors work is described
+at the article (look the chapter "Flaky tests and logging").
The same principles are using in Kaspresso for Jetpack Compose. +Let's enumerate default interceptors that work under the hood by default when you write tests with Kaspresso.
+FailureLoggingSemanticsBehaviorInterceptor
FlakySafeSemanticsBehaviorInterceptor
FlakySafetyParams
.SystemDialogSafetySemanticsBehaviorInterceptor
AutoScrollSemanticsBehaviorInterceptor
ElementLoaderSemanticsBehaviorInterceptor
SemanticNodeInteraction
using saved Matcher
when the element is not found.LoggingSemanticsWatcherInterceptor
. The Interceptor produces human-readable logs. The example:
+
I/KASPRESSO: TEST STEP: "1. Open Flaky screen" in ComposeSimpleFlakyTest SUCCEED. It took 0 minutes, 0 seconds and 212 millis.
+I/KASPRESSO: ___________________________________________________________________________
+I/KASPRESSO: ___________________________________________________________________________
+I/KASPRESSO: TEST STEP: "2. Click on the First button" in ComposeSimpleFlakyTest
+I/KASPRESSO: Operation: Check=IS_DISPLAYED(description={null}).
+ ComposeInteraction: matcher: (hasParentThat(TestTag = 'simple_flaky_screen_container')) && (TestTag = 'simple_flaky_screen_simple_first_button'); position: 0; useUnmergedTree: false.
+I/KASPRESSO: Reloading of the element is started
+I/KASPRESSO: Reloading of the element is finished
+I/KASPRESSO: Repeat action again with the reloaded element
+I/KASPRESSO: Operation: Check=IS_DISPLAYED(description={null}).
+ ComposeInteraction: matcher: (hasParentThat(TestTag = 'simple_flaky_screen_container')) && (TestTag = 'simple_flaky_screen_simple_first_button'); position: 0; useUnmergedTree: false.
+I/KASPRESSO: SemanticsNodeInteraction autoscroll successfully performed.
+I/KASPRESSO: Operation: Check=IS_DISPLAYED(description={null}).
+ ComposeInteraction: matcher: (hasParentThat(TestTag = 'simple_flaky_screen_container')) && (TestTag = 'simple_flaky_screen_simple_first_button'); position: 0; useUnmergedTree: false.
+I/KASPRESSO: Operation: Perform=PERFORM_CLICK(description={null}).
+ ComposeInteraction: matcher: (hasParentThat(TestTag = 'simple_flaky_screen_container')) && (TestTag = 'simple_flaky_screen_simple_first_button'); position: 0; useUnmergedTree: false.
+I/KASPRESSO: TEST STEP: "2. Click on the First button" in ComposeSimpleFlakyTest SUCCEED. It took 0 minutes, 0 seconds and 123 millis.
+I/KASPRESSO: ___________________________________________________________________________
+I/KASPRESSO: ___________________________________________________________________________
+I/KASPRESSO: TEST STEP: "3. Click on the Second button" in ComposeSimpleFlakyTest
+
Remember, that Jetpack Compose and all relative tools are developing. +It means Jetpack Compose is not learned very well and some things can be unexpected after "Old fashioned View World" experience. +Let me show the interesting case.
+For example, this code +
composeSimpleFlakyScreen(composeTestRule) {
+ firstButton {
+ performClick()
+ }
+}
+
firstButton
is located in non visible for a user area
+(you just need to scroll to see the element).
+But, this code will always work stably: +
composeSimpleFlakyScreen(composeTestRule) {
+ firstButton {
+ assertIsDisplayed()
+ performClick()
+ }
+}
+
The explanation is in the nature of SemanticsNode Tree and Jetpack Compose. firstButton
is a Node and presented in the Tree.
+It means that performClick()
may work and nothing bad doesn't happen. But, firstButton
is not visible physically and a real click doesn't occur.
+Such behavior causes the crash of a test a little bit later.
+But, assertIsDisplayed()
check doesn't pass on the first try (we don't see the element on the screen) and
+launches work of all Interceptors including Autoscroll interceptor which scrolls the Screen to the desired element.
Please, share your experience to help other developers.
+Jetpack Compose support is fully configurable. Have a look at various options to configure: +
// We edit only semanticsBehaviorInterceptors
+// Now, semanticsBehaviorInterceptors contains only FailureLoggingSemanticsBehaviorInterceptor
+class ComposeCustomizeTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.withComposeSupport { composeBuilder ->
+ composeBuilder.semanticsBehaviorInterceptors = composeBuilder.semanticsBehaviorInterceptors.filter {
+ it is FailureLoggingSemanticsBehaviorInterceptor
+ }.toMutableList()
+ }
+)
+
+// We edit flakySafetyParams and semanticsBehaviorInterceptors
+// Also, we change semanticsBehaviorInterceptors where we exclude SystemDialogSafetySemanticsBehaviorInterceptor
+class ComposeCustomizeTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.withComposeSupport(
+ // It's very important to change flakySafetyParams in customize section
+ // Otherwise, all interceptors will use a default version of flakySafetyParams
+ customize = {
+ flakySafetyParams = FlakySafetyParams.custom(timeoutMs = 5000, intervalMs = 1000)
+ },
+ lateComposeCustomize = { composeBuilder ->
+ composeBuilder.semanticsBehaviorInterceptors = composeBuilder.semanticsBehaviorInterceptors.filter {
+ it !is SystemDialogSafetySemanticsBehaviorInterceptor
+ }.toMutableList()
+ }
+ ).apply {
+ // Remember, It's better to customize ComposeSupport only after Kaspresso customizing
+ // Because ComposeSupport interceptors can be dependent on some Kaspresso entities
+ // For example, changing flakySafetyParams in this section will not affect ComposeSupport interceptors
+ }
+)
+
+// There is another way to do exactly the same
+class ComposeCustomizeTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple {
+ flakySafetyParams = FlakySafetyParams.custom(timeoutMs = 5000, intervalMs = 1000)
+ }.apply {
+ addComposeSupport { composeBuilder ->
+ composeBuilder.semanticsBehaviorInterceptors = composeBuilder.semanticsBehaviorInterceptors.filter {
+ it !is SystemDialogSafetySemanticsBehaviorInterceptor
+ }.toMutableList()
+ }
+ }
+)
+
You can run your Compose tests on the JVM environment with Robolectric.
+Run ComposeSimpleFlakyTest
(from "kaspresso-sample" module) on the JVM right now:
+
./gradlew :samples:kaspresso-compose-support-sample:testDebugUnitTest --info --tests "com.kaspersky.kaspresso.composesupport.sample.test.ComposeSimpleFlakyTest"
+
Sweet Kaspresso extensions means using of the such constructions as:
+flakySafely
continuously
The support of some constructions is in progress: issue-317.
+ + + + + + +In the 1.3.0 Kaspresso release the allure-framework support was added. Now it is very easy to generate pretty test reports using both Kaspresso and Allure frameworks.
+In this release, the file-managing classes family that is responsible for providing files for screenshots and logs has been refactored for better usage and extensibility. This change has affected the old classes that are deprecated now (see package com.kaspersky.kaspresso.files). Usage example: CustomizedSimpleTest.
+Also, the following interceptors were added:
+In the package com.kaspersky.components.alluresupport.interceptors, there are special Kaspresso interceptors helping to link and process files for Allure-report.
+First of all, add the following Gradle dependency and Allure runner to your project's gradle file to include allure-support Kaspresso module: +
android {
+ defaultConfig {
+ //...
+ testInstrumentationRunner "com.kaspersky.kaspresso.runner.KaspressoRunner"
+ }
+ //...
+}
+
+dependencies {
+ //...
+ androidTestImplementation "com.kaspersky.android-components:kaspresso-allure-support:<latest_version>"
+}
+
class AllureSupportTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.withForcedAllureSupport()
+) {
+
+}
+
class AllureSupportCustomizeTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple(
+ customize = {
+ videoParams = VideoParams(bitRate = 10_000_000)
+ screenshotParams = ScreenshotParams(quality = 1)
+ }
+ ).addAllureSupport().apply {
+ testRunWatcherInterceptors.apply {
+ add(object : TestRunWatcherInterceptor {
+ override fun onTestFinished(testInfo: TestInfo, success: Boolean) {
+ viewHierarchyDumper.dumpAndApply("ViewHierarchy") { attachViewHierarchyToAllureReport() }
+ }
+ })
+ }
+ }
+) {
+...
+}
+
class AllureSupportCustomizeTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple().apply {
+ stepWatcherInterceptors.addAll(
+ listOf(
+ ScreenshotStepInterceptor(screenshots),
+ AllureMapperStepInterceptor()
+ )
+ )
+ testRunWatcherInterceptors.addAll(
+ listOf(
+ DumpLogcatTestInterceptor(logcatDumper),
+ ScreenshotTestInterceptor(screenshots),
+ )
+ )
+ }
+) {
+...
+}
+
So you added the list of needed Allure-supporting interceptors to your Kaspresso configuration and launched the test. After the test finishes there will be sdcard/allure-results dir created on the device with all the files processed to be included to Allure-report.
+This dir should be moved from the device to the host machine which will do generate the report.
+For example, you can use adb pull command on your host for this. Let say you want to locate the data for the report at /Users/username/Desktop/allure-results, so you call: +
adb pull /sdcard/allure-results /Users/username/Desktop
+
adb devices
+
List of devices attached
+CLCDU18508004769 device
+emulator-5554 device
+
adb -s emulator-5554 pull /sdcard/allure-results /Users/username/Desktop
+
Now, we want to generate and watch the report. The Allure server must be installed on our machine for this. To find out how to do it with all the details please follow the Allure docs.
+For example to install Allure server on MacOS we can use the following command: +
brew install allure
+
allure serve /Users/username/Desktop/allure-results
+
If you want to save the generated html-report to a specific dir for future use you can just call: +
allure generate -o ~/kaspresso-allure-report /Users/username/Desktop/allure-results
+
allure open ~/kaspresso-allure-report
+
Details for succeeded test: +
+Details for failed test: +
+By default, Kaspresso-Allure introduces additional timeouts to assure the correctness of a Video recording as much as possible. To summarize, these timeouts increase a test execution time by 5 seconds.
+You are free to change these values by customizing videoParams
in Kaspresso.Builder
. See the example above.
Since Robolectric 4.0, we can also run Espresso-like tests also on the JVM with Robolectric. +That is part of the Project nitrogen from Google (which became Unified Test Platform), where they want to allow developers to write UI test once, and run them everywhere.
+However, before Kaspresso 1.3.0, if you tried to run Kaspresso-like test extending TestCase on the JVM with Robolectric, you got the following error: +
java.lang.NullPointerException
+ at androidx.test.uiautomator.QueryController.<init>(QueryController.java:95)
+ at androidx.test.uiautomator.UiDevice.<init>(UiDevice.java:109)
+ at androidx.test.uiautomator.UiDevice.getInstance(UiDevice.java:261)
+ at com.kaspersky.kaspresso.kaspresso.Kaspresso$Builder.<init>(Kaspresso.kt:297)
+ at com.kaspersky.kaspresso.kaspresso.Kaspresso$Builder$Companion.simple(Kaspresso.kt:215)
+ ...
+
Now, all Kaspresso tests are allowed to be executed correctly on the JVM with Robolectric with the following restrictions:
+UiDevice
and UiAutomation
classes. That's why a lot of (not all!) implementations in Device
will crash on the JVM with Robolectric with NotSupportedInstrumentalTestException
.UiDevice
and UiAutomation
classes affect the entire Kautomator. So, tests using Kautomator will crash on the JVM with Robolectric with KautomatorInUnitTestException
.UiDevice
, UiAutomation
or adb-server are turning off on the JVM with Robolectric automatically.DocLocScreenshotTestCase
will crash on the JVM with Robolectric with DocLocInUnitTestException
.To create a test that can run on a device/emulator and on the JVM, we recommend to create a sharedTest
folder, and configure sourceSets
in gradle.
sourceSets {
+ ...
+ //configure shared test folder
+ val sharedTestFolder = "src/sharedTest/kotlin"
+ val androidTest by getting {
+ java.srcDirs("src/androidTest/java", sharedTestFolder)
+ }
+ val test by getting {
+ java.srcDirs("src/test/java", sharedTestFolder)
+ }
+}
+
It is also important that such tests use @RunWith(AndroidJUnit4::class)
, since it is required by Robolectric.
In order to run your shared tests as Unit Tests on the JVM, you need to run a command looking like this: +
./gradlew :MODULE:testVARIANTUnitTest --info --tests "PACKAGE.CLASS"
+
For example, to run the sample RobolectricTest on the JVM you need to run: +
./gradlew :samples:kaspresso-sample:testDebugUnitTest --info --tests "com.kaspersky.kaspressample.sharedtest.SharedSimpleFlakyTest"
+
To run them on a device/emulator, the command to run would look like this: +
./gradlew :MODULE:connectedVARIANTAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=PACKAGE.CLASS
+
For instance, to run the sample SharedTest on a device/emulator, you need to run: +
./gradlew :samples:kaspresso-sample:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.kaspersky.kaspressample.sharedtest.SharedSimpleFlakyTest
+
We've prepared a bunch of tools and advices to accommodate your tests for the JVM (with Robolectric) environment.
+Let's consider the most popular problem when a test uses a class containing calls to UiDevice
/UiAutomation
/AdbServer
or other not working in JVM environment things.
For example, your test looks like below: +
@RunWith(AndroidJUnit4::class)
+class FailingSharedTest : TestCase() {
+
+ @get:Rule
+ val runtimePermissionRule: GrantPermissionRule = GrantPermissionRule.grant(
+ Manifest.permission.WRITE_EXTERNAL_STORAGE,
+ Manifest.permission.READ_EXTERNAL_STORAGE
+ )
+
+ @get:Rule
+ val activityTestRule = ActivityTestRule(DeviceSampleActivity::class.java, false, true)
+
+ @Test
+ fun exploitSampleTest() =
+ run {
+ step("Press Home button") {
+ device.exploit.pressHome()
+ }
+ //...
+ }
+}
+
device.exploit.pressHome()
calls UiDevice
under the hood and it leads to a crash the JVM environment.
There is following possible solution: +
// change an implementation of Exploit class
+@RunWith(AndroidJUnit4::class)
+class FailingSharedTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple {
+ exploit =
+ if (isAndroidRuntime) ExploitImpl() // old implementation
+ else ExploitUnit() // new implementation without UiDevice
+ }
+) { ... }
+
+// isAndroidRuntime property is available in Kaspresso.Builder.
+
Also, if your custom Interceptor uses UiDevice
/UiAutomation
/AdbServer
then you can turn off this Interceptor for JVM. The example:
+
class KaspressoConfiguringTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple {
+ viewBehaviorInterceptors = if (isAndroidRuntime) mutableListOf(
+ YourCustomInterceptor(),
+ FlakySafeViewBehaviorInterceptor(flakySafetyParams, libLogger)
+ ) else mutableListOf(
+ FlakySafeViewBehaviorInterceptor(flakySafetyParams, libLogger)
+ )
+ }
+) { ... }
+
Of course, there is a very obvious last option. Just don't include the test in a set of Unit tests.
+Further remarks
+As of Robolectric 4.8.1, there are some limitations to sharedTest: those tests run flawless on an emulator/device, but fail on the JVM
+Depending on your test configuration, useful artifacts may remain on the device after test finish: screenshots, reports, videos, etc.
+In order to pull them off the device special scripts are programmed, which are executed after the completion of the test run on CI. With Kaspresso,
+you can simplify this process. To do this, you need to configure the artifactsPullParams
variable in the Kaspresso Builder. Example:
class SomeTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple {
+ artifactsPullParams = ArtifactsPullParams(enabled = true, destinationPath = "artifacts/", artifactsRegex = Regex("(screenshots)|(videos)"))
+ }
+) {
+ ...
+}
+
For this mechanism to work, you need to start the ADB server before running the test. After the test is completed, the artifacts will be located by the path specified in the destinationPath
+argument relative to the working directory from which the ADB server was launched.
Kaspresso class - is a single point to set Kaspresso parameters.
+A developer can customize Kaspresso by setting Kaspresso.Builder
at constructors of TestCase
, BaseTestCase
, TestCaseRule
, BaseTestCaseRule
.
+The example:
+
class SomeTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple {
+ beforeEachTest {
+ testLogger.i("The beginning")
+ }
+ afterEachTest {
+ testLogger.i("The end")
+ }
+ }
+) {
+ // your test
+}
+
Kaspresso configuration contains:
+Kaspresso provides the possibility to override Espresso custom clicks. +Kakao library provides a set of prepared custom clicks which improves the stability of the tests especially on the devices under high load.
+All details about the problem and solutions are described in Kakao documentation.
+The example of how to apply the custom clicks in your test is presented in CustomClickTest. +
class ClickTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple(
+ customize = {
+ clickParams = ClickParams.kakaoVisual()
+ }
+ )
+) {
+ // your test
+}
+
Kaspresso provides the next prepared options to customise clicks:
+1. ClickParams.kakaoVisual()' - Kakao clicks with visualisation.
+2.
ClickParams.kakao()' - Kakao clicks.
+3. `ClickParams.default()' - Espresso clicks. Using by default.
Kaspresso provides two loggers: libLogger
and testLogger
.
+libLogger
- inner Kaspresso logger
+testLogger
- logger that is available for developers in tests.
+The last one is accessible by testLogger
property in test sections (before, after, init, transform, run
) in the test DSL (by TestContext
class).
+Also, it is available while setting Kaspresso.Builder
if you want to add it to your custom interceptors, for example.
These interceptors were introduced to simplify and uniform using of Kakao interceptors and Kautomator interceptors.
+Important moment about a mixing of Kaspresso interceptors and Kakao/Kautomator interceptors.
+Kaspresso interceptors will not work if you set your custom Kakao interceptors by calling of Kakao.intercept
method in the test or set your custom Kautomator interceptors by calling of Kautomator.intercept
in the test.
+If you set your custom Kakao interceptors for concrete Screen
or KView
and set argument isOverride
in true then Kaspresso interceptors will not work for concrete Screen
or KView
fully. The same statement is right for Kautomator where a developer interacts with UiScreen
and UiBaseView
.
Kaspresso interceptors can be divided into two types:
+Behavior Interceptors
- are intercepting calls to ViewInteraction
, DataInteraction
, WebInteraction
, UiObjectInteraction
, UiDeviceInteraction
and do some stuff. Behavior Interceptors
at the end of this document.Watcher Interceptors
- are intercepting calls to ViewAction
, ViewAssertion
, Atom
, WebAssertion
, UiObjectAssertion
, UiObjectAction
, UiDeviceAssertion
, UiDeviceAction
and do some stuff.Let's expand mentioned Kaspresso interceptors types:
+Behavior Interceptors
viewBehaviorInterceptors
- intercept calls to ViewInteraction#perform
and ViewInteraction#check
dataBehaviorInterceptors
- intercept calls to DataInteraction#check
webBehaviorInterceptors
- intercept calls to Web.WebInteraction<R>#perform
and Web.WebInteraction<R>#check
objectBehaviorInterceptors
- intercept calls to UiObjectInteraction#perform
and UiObjectInteraction#check
deviceBehaviorInterceptors
- intercept calls to UiDeviceInteraction#perform
and UiDeviceInteraction#check
Watcher Interceptors
viewActionWatcherInterceptors
- do some stuff before android.support.test.espresso.ViewAction.perform
is actually calledviewAssertionWatcherInterceptors
- do some stuff before android.support.test.espresso.ViewAssertion.check
is actually calledatomWatcherInterceptors
- do some stuff before android.support.test.espresso.web.model.Atom.transform
is actually calledwebAssertionWatcherInterceptors
- do some stuff before android.support.test.espresso.web.assertion.WebAssertion.checkResult
is actually calledobjectWatcherInterceptors
- do some stuff before UiObjectInteraction.perform
or UiObjectInteraction.check
is actually calleddeviceWatcherInterceptors
- do some stuff before UiDeviceInteraction.perform
or UiDeviceInteraction.check
is actually calledPlease, remember! Behavior and watcher interceptors work under the hood in every action and assertion of every View of Kakao and Kautomator by default in Kaspresso.
+These interceptors are not based on some lib. Short description:
+stepWatcherInterceptors
- an interceptor of Step lifecycle actionstestRunWatcherInterceptors
- an interceptor of entire Test lifecycle actionsAs you noticed these interceptors are a part of Watcher Interceptors
, also.
This watcher interceptor
by default is included into Kaspresso configurator
to collect your tests steps information for further processing in tests orchestrator.
+By default this interceptor is based on AllureReportWriter
(if you don't know what Allure is you should really check on it).
+This report writer works with each TestInfo
after test finishing, converts its steps information into Allure's steps info JSON, and then prints JSON into LogCat in the following format:
I/KASPRESSO: ---------------------------------------------------------------------------
+I/KASPRESSO: TEST PASSED
+I/KASPRESSO: ---------------------------------------------------------------------------
+I/KASPRESSO: #AllureStepsInfoJson#: [{"attachments":[],"name":"My step 1","parameters":[],"stage":"finished","start":1568790287246,"status":"passed", "steps":[],"stop":1568790288184}]
+
This logs should be processed by your test orchestrator (e.g. Marathon). +If you use Marathon you should know that the it requires +some additional modifications to support processing this logs and doesn't work as expected at the current moment. But we are working hard on it.
+Sometimes, a developer wishes to put some actions repeating in all tests before/after into a single place to simplify the maintenance of tests.
+You can make a remark that there are @beforeTest/@afterTest
annotations to resolve mentioned tasks. But the developer doesn't have an access to BaseTestContext
in those methods.
+That's why we have introduced special default actions that you can set in constructor by Kaspresso.Builder
.
+The example how to implement default actions in Kaspresso.Builder
is:
+
open class YourTestCase : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple {
+ beforeEachTest {
+ testLogger.i("beforeTestFirstAction")
+ }
+ afterEachTest {
+ testLogger.i("afterTestFirstAction")
+ }
+ }
+)
+
beforeEachTest
is:
+beforeEachTest(override = true, action = {
+ testLogger.i("beforeTestFirstAction")
+})
+
afterEachTest
is similar to beforeEachTest
. override
in false
then the final beforeAction will be beforeAction of the parent TestCase plus current action
. Otherwise, final beforeAction will be only current action
.
+How it's work and how to override (or just extend) default action, please,
+observe the example.
+Device
instance. Detailed info is at Device wiki.
AdbServer
instance. Detailed info is at AdbServer wiki.
The example of how to configure Kaspresso and how to use Kaspresso interceptors is in here.
+BaseTestCase
, TestCase
, BaseTestCaseRule
, TestCaseRule
are using default customized Kaspresso (Kaspresso.Builder.simple
builder).
+Most valuable features of default customized Kaspresso are below.
Just start SimpleTest. Next, you will see those logs: +
I/KASPRESSO: ---------------------------------------------------------------------------
+I/KASPRESSO: BEFORE TEST SECTION
+I/KASPRESSO: ---------------------------------------------------------------------------
+I/KASPRESSO: ---------------------------------------------------------------------------
+I/KASPRESSO: TEST SECTION
+I/KASPRESSO: ---------------------------------------------------------------------------
+I/KASPRESSO: ___________________________________________________________________________
+I/KASPRESSO: TEST STEP: "1. Open Simple Screen" in SimpleTest
+I/KASPRESSO_SPECIAL: I am kLogger
+I/ViewInteraction: Checking 'com.kaspersky.kaspresso.proxy.ViewAssertionProxy@95afab5' assertion on view (with id: com.kaspersky.kaspressample:id/activity_main_button_next)
+I/KASPRESSO: Check view has effective visibility=VISIBLE on AppCompatButton(id=activity_main_button_next;text=Next;)
+I/KASPRESSO: single click on AppCompatButton(id=activity_main_button_next;text=Next;)
+I/KASPRESSO: TEST STEP: "1. Open Simple Screen" in SimpleTest SUCCEED. It took 0 minutes, 0 seconds and 618 millis.
+I/KASPRESSO: ___________________________________________________________________________
+I/KASPRESSO: ___________________________________________________________________________
+I/KASPRESSO: TEST STEP: "2. Click button_1 and check button_2" in SimpleTest
+I/KASPRESSO: single click on AppCompatButton(id=button_1;text=Button 1;)
+I/ViewInteraction: Checking 'com.kaspersky.kaspresso.proxy.ViewAssertionProxy@9f38781' assertion on view (with id: com.kaspersky.kaspressample:id/button_2)
+I/KASPRESSO: Check view has effective visibility=VISIBLE on AppCompatButton(id=button_2;text=Button 2;)
+I/KASPRESSO: TEST STEP: "2. Click button_1 and check button_2" in SimpleTest SUCCEED. It took 0 minutes, 0 seconds and 301 millis.
+I/KASPRESSO: ___________________________________________________________________________
+I/KASPRESSO: ___________________________________________________________________________
+I/KASPRESSO: TEST STEP: "3. Click button_2 and check edit" in SimpleTest
+I/KASPRESSO: single click on AppCompatButton(id=button_2;text=Button 2;)
+I/ViewInteraction: Checking 'com.kaspersky.kaspresso.proxy.ViewAssertionProxy@ad01abd' assertion on view (with id: com.kaspersky.kaspressample:id/edit)
+I/KASPRESSO: Check view has effective visibility=VISIBLE on AppCompatEditText(id=edit;text=Some text;)
+E/KASPRESSO: Failed to interact with view matching: (with id: com.kaspersky.kaspressample:id/edit) because of AssertionFailedError
+I/ViewInteraction: Checking 'com.kaspersky.kaspresso.proxy.ViewAssertionProxy@d0f1c0a' assertion on view (with id: com.kaspersky.kaspressample:id/edit)
+I/KASPRESSO: Check view has effective visibility=VISIBLE on AppCompatEditText(id=edit;text=Some text;)
+I/ViewInteraction: Checking 'com.kaspersky.kaspresso.proxy.ViewAssertionProxy@3b62c7b' assertion on view (with id: com.kaspersky.kaspressample:id/edit)
+I/KASPRESSO: Check with string from resource id: <2131558461> on AppCompatEditText(id=edit;text=Some text;)
+I/KASPRESSO: TEST STEP: "3. Click button_2 and check edit" in SimpleTest SUCCEED. It took 0 minutes, 2 seconds and 138 millis.
+I/KASPRESSO: ___________________________________________________________________________
+I/KASPRESSO: ___________________________________________________________________________
+I/KASPRESSO: TEST STEP: "4. Check all possibilities of edit" in SimpleTest
+I/KASPRESSO: ___________________________________________________________________________
+I/KASPRESSO: TEST STEP: "4.1. Change the text in edit and check it" in SimpleTest
+I/KASPRESSO: replace text on AppCompatEditText(id=edit;text=Some text;)
+I/KASPRESSO: type text(111) on AppCompatEditText(id=edit;)
+I/ViewInteraction: Checking 'com.kaspersky.kaspresso.proxy.ViewAssertionProxy@dbd9c8' assertion on view (with id: com.kaspersky.kaspressample:id/edit)
+I/KASPRESSO: Check with text: is "111" on AppCompatEditText(id=edit;text=111;)
+I/KASPRESSO: TEST STEP: "4.1. Change the text in edit and check it" in SimpleTest SUCCEED. It took 0 minutes, 0 seconds and 621 millis.
+I/KASPRESSO: ___________________________________________________________________________
+I/KASPRESSO: ___________________________________________________________________________
+I/KASPRESSO: TEST STEP: "4.2. Change the text in edit and check it. Second check" in SimpleTest
+I/KASPRESSO: replace text on AppCompatEditText(id=edit;text=111;)
+I/KASPRESSO: type text(222) on AppCompatEditText(id=edit;)
+I/ViewInteraction: Checking 'com.kaspersky.kaspresso.proxy.ViewAssertionProxy@b8ca74' assertion on view (with id: com.kaspersky.kaspressample:id/edit)
+I/KASPRESSO: Check with text: is "222" on AppCompatEditText(id=edit;text=222;)
+I/KASPRESSO: TEST STEP: "4.2. Change the text in edit and check it. Second check" in SimpleTest SUCCEED. It took 0 minutes, 0 seconds and 403 millis.
+I/KASPRESSO: ___________________________________________________________________________
+I/KASPRESSO: TEST STEP: "4. Check all possibilities of edit" in SimpleTest SUCCEED. It took 0 minutes, 1 seconds and 488 millis.
+I/KASPRESSO: ___________________________________________________________________________
+I/KASPRESSO: ---------------------------------------------------------------------------
+I/KASPRESSO: AFTER TEST SECTION
+I/KASPRESSO: ---------------------------------------------------------------------------
+I/KASPRESSO: ---------------------------------------------------------------------------
+I/KASPRESSO: TEST PASSED
+I/KASPRESSO: ---------------------------------------------------------------------------
+I/KASPRESSO: #AllureStepsInfoJson#: [{"attachments":[],"name":"My step 1","parameters":[],"stage":"finished","start":1568790287246,"status":"passed", "steps":[],"stop":1568790288184}]
+I/KASPRESSO: ---------------------------------------------------------------------------
+
If a failure occurs then Kaspresso tries to fix it using a big set of diverse ways.
+This defense works for every action and assertion of each View of Kakao and Kautomator! You just need to extend your test class from TestCase
(BaseTestCase
) or to set TestCaseRule
(BaseTestCaseRule
) in your test.
+More detailed info about some ways of defense is below
Interceptors turned by default:
+So, all features described above are available thanks to these interceptors.
+Any lib for ui-tests is flaky. It's a hard truth of life. Any action/assert in your test may fail for some undefined reason.
+What general kinds of flaky errors exist:
+These handlings are possible thanks to BehaviorInterceptors
. Also, you can set your custom processing by Kaspresso.Builder
. But remember, the order of BehaviorInterceptors
is significant: the first item will be at the lowest level of intercepting chain, and the last item will be at the highest level.
Let's consider the work principle of BehaviorInterceptors
over Kakao interceptors. The first item actually wraps the androidx.test.espresso.ViewInteraction.perform
call, the second item wraps the first item, and so on.
+Have a glance at the order of BehaviorInterceptors
enabled by default in Kaspresso over Kakao. It's:
AutoScrollViewBehaviorInterceptor
SystemDialogSafetyViewBehaviorInterceptor
FlakySafeViewBehaviorInterceptor
Under the hood, all Kakao actions and assertions first of all call FlakySafeViewBehaviorInterceptor
that calls SystemDialogSafetyViewBehaviorInterceptor
and that calls AutoScrollViewBehaviorInterceptor
.
+If a result of AutoScrollViewBehaviorInterceptor
handling is an error then SystemDialogSafetyViewBehaviorInterceptor
attempts to handle received error. If a result of SystemDialogSafetyViewBehaviorInterceptor
handling is an error too then FlakySafeViewBehaviorInterceptor
attempts to handle received the error.
+To simplify the discussed topic we have drawn a picture:
Developer also can extends parametrized tests functionality by providing MainSectionEnricher
in BaseTestCase
or BaseTestCaseRule
.
+The main idea of enrichers - allow adding additional test case's steps before and after the main section's run
block.
All you need to do is:
+MainSectionEnricher
interface;class LoggingMainSectionEnricher : MainSectionEnricher<TestCaseData> {
+ ...
+
+}
+
Here, TestCaseData
is the same data type as in your BaseTestCase
implementation.
beforeMainSectionRun
or/and afterMainSectionRun
methods to add your before/after actions;class LoggingMainSectionEnricher : MainSectionEnricher<TestCaseData> {
+
+ override fun TestContext<TestCaseData>.beforeMainSectionRun(testInfo: TestInfo) {
+ testLogger.d("Before main section run... | ${testInfo.testName}")
+ step("Check users count...") {
+ testLogger.d("Check users count: ${data.users.size}")
+ }
+ }
+
+ override fun TestContext<TestCaseData>.afterMainSectionRun(testInfo: TestInfo) {
+ testLogger.d("After main section run... | ${testInfo.testName}")
+ step("Check posts count...") {
+ testLogger.d("Check posts count: ${data.posts.size}")
+ }
+ }
+
+}
+
In beforeMainSectionRun
and afterMainSectionRun
methods you have full access to TestContext<TestCaseData
properties and methods,
+so you can use logger, add test case's steps and so on. Also, this methods received TestInfo
parameter.
BaseTestCase
implementation.class EnricherBaseTestCase : BaseTestCase<TestCaseDsl, TestCaseData>(
+ kaspresso = Kaspresso.Builder.default(),
+ dataProducer = { action -> TestCaseDataCreator.initData(action) },
+ mainSectionEnrichers = listOf(
+ LoggingMainSectionEnricher(),
+ AnalyticsMainSectionEnricher()
+ )
+)
+
After this manipulations your described actions will be executed before or after main section's run
block.
Kautomator - Nice and simple DSL for UI Automator in Kotlin that allows to accelerate UI Automator to amazing.
+Inspired by Kakao and russian talk about UI Automator (thanks to Svetlana Smelchakova).
Tests written with UI Automator are so complex, non-readble and hard to maintain especially for testers. +Have a look at a typical piece of code written with UI Automator: +
val instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation()
+val uiDevice = UiDevice.getInstance(instrumentation)
+
+val uiObject = uiDevice.wait(
+ Until.findObject(
+ By.res(
+ "com.kaspersky.kaspresso.sample_kautomator",
+ "editText"
+ )
+ ),
+ 2_000
+)
+
+uiObject.text = "Kaspresso"
+assertEquals(uiObject.text, "Kaspresso")
+
MainScreen {
+ simpleEditText {
+ replaceText("Kaspresso")
+ hasText("Kaspresso")
+ }
+}
+
Another big advantage of Kautomator is a possibility to accelerate UI Automator.
+Have a glance at video below:
+The left video is boosted UI Automator, the right video is default UI Automator.
Why is it possible? The details are available a little bit later.
+Create your entity UiScreen
where you will add the views involved in the interactions of the tests:
+
class FormScreen : UiScreen<FormScreen>()
+
UiScreen
can represent the whole user interface or a portion of UI.
+If you are using Page Object pattern you can put the interactions of Kautomator inside the Page Objects.
+UiScreen
contains UiView
, these are the Android Framework views where you want to do the interactions:
+
class FormScreen : UiScreen<FormScreen>() {
+ val phone = UiView { withId(this@FormScreen.packageName, "phone") }
+ val email = UiEditText { withId(this@FormScreen.packageName, "email_edit") }
+ val submit = UiButton { withId(this@FormScreen.packageName, "submit_button") }
+}
+
UiView
UiEditText
UiTextView
UiButton
UiCheckbox
UiChipGroup
UiSwitchView
UiScrollView
Every UiView
contains matchers to retrieve the view involved in the ViewInteraction
. Some examples of matchers provided
+by Kakao:
withId
withText
withPackage
withContentDescription
textStartsWith
Like in Ui Automator you can combine different matchers: +
val email = UiEditText {
+ withId(this@FormScreen.packageName, "email")
+ withText(this@FormScreen.packageName, "matsyuk@kaspresso.com")
+}
+
The syntax of the test with Kautomator is very easy, once you have the UiScreen
and the UiView
defined, you only have to apply
+the actions or assertions like in UI Automator:
+
FormScreen {
+ phone {
+ hasText("971201771")
+ }
+ button {
+ click()
+ }
+}
+
In Espresso, all interaction with a View
is processing through ViewInteraction
that has two main methods:
+onCheck
and onPerform
which take ViewAction
and ViewAssertion
as arguments. Kakao was written based on this architecture.
So, we have set a goal to write Kautomator which would be like Kakao as much as possible. That's why we have introduced an additional layer over UiObject2 and UiDevice and that is so similar to ViewInteraction
. This layer is represented by UiObjectInteraction
and UiDeviceInteraction
that have two methods: onCheck
and onPerform
taking UiObjectAssertion and UiObjectAction or UiDeviceAssertion and UiDeviceAction as arguments.
UiObjectInteraction
is designed to work with concrete View
like ViewInteraction
. UiDeviceInteraction
has been created because UI Automator has a featureallowing you to do some system things like a click on Home button or on hard Back button, open Quick Setttings, open Notifications and so on. All such things are hidden by UiSystem
class.
So, enjoy it =)
+If you have custom Views in your tests and you want to create your own UiView
, we have UiBaseView
. Just extend
+this class and implement as much additional Action/Assertion interfaces as you want.
+You also need to override constructors that you need.
class UiMyView : UiBaseView<KView>, UiMyActions, UiMyAssertions {
+ constructor(selector: UiViewSelector) : super(selector)
+ constructor(builder: UiViewBuilder.() -> Unit) : super(builder)
+}
+
If you need to add custom logic during the Kautomator -> UI Automator
call chain (for example, logging) or
+if you need to completely change the UiAssertion
or UiAction
that are being sent to UI Automator
+during runtime in some cases, you can use the intercepting mechanism.
Interceptors are lambdas that you pass to a configuration DSL that will be invoked before real calls
+inside UiObject2
and UiDevice
classes in UI Automator.
You have the ability to provide interceptors at 3 different levels: Kautomator runtime, your UiScreen
classes
+and any individual UiView
instance.
On each invocation of UI Automator function that can be intercepted, Kautomator will aggregate all available interceptors
+for this particular call and invoke them in descending order: UiView interceptor -> Active Screens interceptors ->
+Kautomator interceptor
.
Each of the interceptors in the chain can break the chain call by setting isOverride
to true during configuration.
+In that case Kautomator will not only stop invoking remaining interceptors in the chain, but will not perform the UI Automator
+call. It means that in such case, the responsibility to actually invoke Kautomator lies on the shoulders
+of the developer.
Here's the examples of intercepting configurations: +
class SomeTest {
+ @Before
+ fun setup() {
+ KautomatorConfigurator { // Kautomator runtime
+ intercept {
+ onUiInteraction { // Intercepting calls on UiInteraction classes across whole runtime
+ onPerform { uiInteraction, uiAction -> // Intercept perform() call
+ testLogger.i("KautomatorIntercept", "interaction=$uiInteraction, action=$uiAction")
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ fun test() {
+ MyScreen {
+ intercept {
+ onUiInteraction { // Intercepting calls on UiInteraction classes while in the context of MyScreen
+ onCheck { uiInteraction, uiAssert -> // Intercept check() call
+ testLogger.i("KautomatorIntercept", "interaction=$uiInteraction, assert=$uiAssert")
+ }
+ }
+ }
+
+ myView {
+ intercept { // Intercepting ViewInteraction calls on this individual view
+ onPerform(true) { uiInteraction, uiAction -> // Intercept perform() call and overriding the chain
+ // When performing actions on this view, Kautomator level interceptor will not be called
+ // and we have to manually call UI Automator now.
+ Log.d("KAUTOMATOR_VIEW", "$uiInteraction is performing $uiAction")
+ uiInteraction.perform(uiAction)
+ }
+ }
+ }
+ }
+ }
+}
+
As you remember we told about the possible acceleration of UI Automator. How does it become a reality?
+UI Automator has an inner mechanism to prevent potential flakiness. Under the hood, the library listens and gives commands through AccessibilityManagerService. AccessibilityManagerService is a single point for all accessibility events in the system. At one moment, creators of UI Automator faced with the flakiness problem. One of the most popular reasons for such undetermined behavior is a big count of events processing in the System at the current moment. But UI Automator has a connection with AccessibilityManagerService. Such a connection gives an opportunity to listen to all accessibility events in the System and to wait for a calm state when there are no actions. The calm state leads to determined system behavior and decreases the possibility of flakiness.
+All of this pushed UI Automator authors to introduce the following algorithm: UI Automator waits 500ms (waitForIdleTimeout
and waitForSelectorTimeout
in androidx.test.uiautomator.Configurator
) window during 10 seconds for each action. EACH ACTION.
Perhaps, described solution made UI Automator more stable. But, the speed crashed, no doubts.
+Kautomator is a DSL over UI Automator that provides a mechanism of interceptors. Kaspresso offers a big set of default interceptors which eliminates any potential flaky action. So, Kaspresso + Kautomator helps UI Automator to struggle with flakiness.
+After some time, we thought why we need to save artificial timeouts inside UI Automator while Kaspresso + Kautomator does the same work. Have a look at the measure example: +
@RunWith(AndroidJUnit4::class)
+class KautomatorMeasureTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple {
+ kautomatorWaitForIdleSettings = KautomatorWaitForIdleSettings.boost()
+ }
+) {
+
+ companion object {
+ private val RANGE = 0..20
+ }
+
+ @get:Rule
+ val runtimePermissionRule: GrantPermissionRule = GrantPermissionRule.grant(
+ Manifest.permission.WRITE_EXTERNAL_STORAGE,
+ Manifest.permission.READ_EXTERNAL_STORAGE
+ )
+
+ @get:Rule
+ val activityTestRule = ActivityTestRule(MainActivity::class.java, true, false)
+
+ @Test
+ fun test() =
+ before {
+ activityTestRule.launchActivity(null)
+ }.after { }.run {
+
+ ======> UI Automator: 0 minutes, 1 seconds and 252 millis
+ ======> UI Automator boost: 0 minutes, 0 seconds and 310 millis
+ step("MainScreen. Click on `measure fragment` button") {
+ UiMainScreen {
+ measureButton {
+ isDisplayed()
+ click()
+ }
+ }
+ }
+
+ ======> UI Automator: 0 minutes, 11 seconds and 725 millis
+ ======> UI Automator boost: 0 minutes, 1 seconds and 50 millis
+ step("Measure screen. Button_1 clicks comparing") {
+ UiMeasureScreen {
+ RANGE.forEach { _ ->
+ button1 {
+ click()
+ hasText(device.targetContext.getString(R.string.measure_fragment_text_button_1).toUpperCase())
+ }
+ }
+ }
+ }
+
+ ======> UI Automator: 0 minutes, 11 seconds and 789 millis
+ ======> UI Automator boost: 0 minutes, 1 seconds and 482 millis
+ step("Measure screen. Button_2 clicks and TextView changes comparing") {
+ UiMeasureScreen {
+ RANGE.forEach { index ->
+ button2 {
+ click()
+ hasText(device.targetContext.getString(R.string.measure_fragment_text_button_2).toUpperCase())
+ }
+ textView {
+ hasText(
+ "${device.targetContext.getString(R.string.measure_fragment_text_textview)}${index + 1}"
+ )
+ }
+ }
+ }
+ }
+
+ ======> UI Automator: 0 minutes, 45 seconds and 903 millis
+ ======> UI Automator boost: 0 minutes, 2 seconds and 967 millis
+ step("Measure fragment. EditText updates comparing") {
+ UiMeasureScreen {
+ edit {
+ isDisplayed()
+ hasText(device.targetContext.getString(R.string.measure_fragment_text_edittext))
+ RANGE.forEach { _ ->
+ clearText()
+ typeText("bla-bla-bla")
+ hasText("bla-bla-bla")
+ clearText()
+ typeText("mo-mo-mo")
+ hasText("mo-mo-mo")
+ clearText()
+ }
+ }
+ }
+ }
+
+ ======> UI Automator: 0 minutes, 10 seconds and 901 millis
+ ======> UI Automator boost: 0 minutes, 1 seconds and 23 millis
+ step("Measure fragment. Checkbox clicks comparing") {
+ UiMeasureScreen {
+ RANGE.forEach { index ->
+ checkBox {
+ if (index % 2 == 0) {
+ setChecked(true)
+ isChecked()
+ } else {
+ setChecked(false)
+ isNotChecked()
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
Also, there are cases when UI Automator can't catch 500ms window. For example, when one element is updating too fast (one update in 100 ms). Just have a look at this test. Only KautomatorWaitForIdleSettings.boost()
allows to pass the test.
As you see, we have introduced a special kautomatorWaitForIdleSettings
property in Kaspresso configurator. By default, this property is not boost. Why? Because:
+1. You can have tests where you use UI Automator directly. But mentioned timeouts are global parameters. Resetting of these timeouts can lead to an undetermined state.
+2. We want to take time collecting data from the world and then to analyze potential problems of our solutions (but, we believe it's a stable and brilliant solution).
Another important remark is about kaspressoBuilder = Kaspresso.Builder.simple
configuration. This configuration is faster than advanced
because of each step's screenshots interceptor absence. If you need, add them manually.
Anyway, it's a small change for a developer, but it's a big step for the world =)
+ + + + + + +As you all know Kaspresso is based on Espresso (if you're not familiar with Espresso, check out the official docs).
+
According to official docs the main components of Espresso include the following:
onView()
and onData()
). Also exposes APIs that are not necessarily tied to any view, such as pressBack()
.Matcher<? super View>
interface. You can pass one or more of these to the onView()
method to locate a view within the current view hierarchy.ViewInteraction.perform()
method, such as click()
.ViewInteraction.check()
method. Most of the time, you will use the matches assertion, which uses a View matcher to assert the state of the currently selected view.// withId(R.id.my_view) is a ViewMatcher
+// click() is a ViewAction
+// matches(isDisplayed()) is a ViewAssertion
+onView(withId(R.id.my_view))
+ .perform(click())
+ .check(matches(isDisplayed()))
+
Most available instances of Matcher, ViewActions and ViewAssertions can be found in the Google cheat-sheet. +
+The results of calling onView()
methods (ViewInteractors
) can be cashed. In Kakao you can get references to ViewInteractors and reuse them in your code. This makes your code in tests more readable and understandable.
+
This framework also allows you to separate the search for an element and actions on it. Kakao has introduced KView and various implementations for the most available Android widgets. This KView implements the BaseAssertions and BaseActions interfaces with some additional methods. Every inheritor of KView implements its own interfaces for assertions and actions for some widget-specific methods.
+
As a result, you can get a reference to specific views from your test code and make the necessary assertions and actions on it in the view block.
Since Kasresso inherits all the best from these two frameworks, everything described above is available to you.
Page object pattern is explained well by Martin Fowler in this article. Long in short this is a test abstraction that describes the screen with some view elements. These view items can be interacted with during tests. As a result the description of the screen elements will be in a separate class. You no longer need to constantly look for the desired UI element with several matchers in tests. This can be done once by saving a link to the screen.
Kaspresso provides KScreen
and UiScreen
as implementations for Page object pattern.
Kaspresso is based on Kakao and UiAutomator.
+
When we have all info about the application code(white-box testing
cases) we should use KScreen to describe the structure of PageObject as Kakao does. This is a class in Kaspresso - extension for Kakao Screen class.
+
When we don't have access to a source code of an application (it can be some system dialogs, windows or apps) we should use UiScreen.
+
Here are two samples:
+
object SimpleScreen : KScreen<SimpleScreen>() {
+
+ override val layoutId: Int? = R.layout.activity_simple
+ override val viewClass: Class<*>? = SimpleActivity::class.java
+
+ val button1 = KButton { withId(R.id.button_1) }
+
+ val button2 = KButton { withId(R.id.button_2) }
+
+ val edit = KEditText { withId(R.id.edit) }
+}
+
+object MainScreen : UiScreen<MainScreen>() {
+
+ override val packageName: String = "com.kaspersky.kaspresso.kautomatorsample"
+
+ val simpleEditText = UiEditText { withId(this@MainScreen.packageName, "editText") }
+ val simpleButton = UiButton { withId(this@MainScreen.packageName, "button") }
+ val checkBox = UiCheckBox { withId(this@MainScreen.packageName, "checkBox") }
+}
+
layoutId
(layout file of a screen) and viewClass
(screen activity class name) fields. But this is optional. These fields will help in cases of code refactoring not to forget about the associated tests screens
+packageName
field (the full name of the application's package).
+
Page object pattern allows you to exclude the description of the screen in a separate file and to reuse Screens and views in different tests. When you have some changes in the UI of the application you can only change the code in the Screen file without the need for a lot of refactoring of the tests.
In some teams autotests are written only by developers, in others by QA engineers. In some cases autotests are written by someone who does not know details of the code (source code is available, but is bad understandable). In this case developers can write Screens for additional autotests. Having Screens helps another person to write tests using Kotlin DSL.
Sometimes when developing new features, there is a need to check if the application works properly in all supported languages. Manual locale setting changes could take a long time and require the efforts of developers, QA engineers, and etc. Also, it could increase the duration of the localization process.
+In order to avoid that, Kaspresso provides DocLocScreenshotTestCase
+which allows taking screenshots in all locales you specified. DocLocScreenshotTestCase
extends
+default Kaspresso TestCase
and offers the opportunity to make screenshots out the box by
+calling DocLocScreenshotTestCase#captureScreenshot(String)
method.
To create a single test, you should extend DocLocScreenshotTestCase
class as shown below:
@RunWith(AndroidJUnit4::class)
+class ScreenshotSampleTest : DocLocScreenshotTestCase(
+ locales = "en,ru"
+) {
+
+ @ScreenShooterTest
+ @Test
+ fun test() {
+ before{
+ }.after {
+ }.run {
+
+ step("1. Do the first step") {
+ // ...
+ captureScreenshot("First step")
+ }
+
+ step("2. Do the second step") {
+ // ...
+ captureScreenshot("Second step")
+ }
+ }
+ }
+}
+
There is one parameter passed in the base constructor: +- locales - comma-separated string with locales to run test with. + Captured screenshots will be available in the device's storage at the path "/sdcard/screenshots/".
+For full example, check the ScreenshotSampleTest.
+Notice, that the test is marked with @ScreenShooterTest
annotation. This is intended to filter only screenshooter tests to be run. For example, you could pass the
+annotation to default AndroidJUnitRunner
with command:
adb shell am instrument -w -e annotation com.kaspersky.kaspresso.annotations.ScreenShooterTest your.package.name/android.support.test.runner.AndroidJUnitRunner
+
Screenshot files location
+All screenshot files are stored in "screenshots" directory by default. +They are sorted by locale and test name:
+<base directory>/<test class canonical name>/<locale>/<your tag>.png
For the sample test case, the files tree should be like:
+- screenshots
+ - com.kaspersky.kaspressample.tests.docloc.ScreenshotSampleTest
+ - en
+ // screenshot files
+ - ru
+ // screenshot files
+
+So, in order to save screenshots at external storage, the test application requires
+android.permission.WRITE_EXTERNAL_STORAGE
permission.
Screenshot's additional meta-info
+When a developer calls captureScreenshot("la-la-la")
method then Kaspresso creates not only a screenshot but also a special xml file. This xml file contains data about all ui elements with their id located on the screen. Example:
+
<Metadata>
+ <Window Left="0" Top="0" Width="1440" Height="2560">
+ <LocalizedString Text="Simple Fragment" LocValueDescription="com.kaspersky.kaspressample.test:id/text_view_title" Top="140" Left="307" Width="825" Height="146"/>
+ <LocalizedString Text="Button 1" LocValueDescription="com.kaspersky.kaspressample.test:id/button_1" Top="370" Left="84" Width="1272" Height="168"/>
+ <LocalizedString Text="Button 2" LocValueDescription="com.kaspersky.kaspressample.test:id/button_2" Top="622" Left="84" Width="1272" Height="168"/>
+ <LocalizedString Text="Kaspresso" LocValueDescription="com.kaspersky.kaspressample.test:id/edit" Top="874" Left="84" Width="1272" Height="158"/>
+ <LocalizedString Text="Simple screen" LocValueDescription="com.kaspersky.kaspressample.test:id/[id:ffffffff]" Top="51" Left="56" Width="446" Height="93"/>
+ </Window>
+</Metadata>
+
Screenshots of system dialogs/windows
+Sometimes you want to take screenshots of Android system dialogs or windows. That's why you have to change the language for the entire system. For this purpose, there is additional param in DocLocScreenshotTestCase
constructor - changeSystemLocale
. Pay your attention to the fact that changeSystemLocale
defined in true demands Manifest.permission.CHANGE_CONFIGURATION
.
+Have a look at the code below:
+
@RunWith(AndroidJUnit4::class)
+class ChangeSysLanguageTestCase : DocLocScreenshotTestCase(
+ screenshotsDirectory = File("screenshots"),
+ locales = "en,ru",
+ changeSystemLocale = true
+) {
+
+ @ScreenShooterTest
+ @Test
+ fun test() {
+ before{
+ }.after {
+ }.run {
+
+ step("1. Do the first step") {
+ // ...
+ captureScreenshot("First step")
+ }
+
+ step("2. Do the second step") {
+ // ...
+ captureScreenshot("Second step")
+ }
+ }
+ }
+}
+
Please keep the strategy "one docloc test == one screen". If you will seek to capture screenshots from more than one screen during one test consequences may be unpredictable. Be aware.
+In most cases, there is no need to launch certain activity, do a lot of steps before reaching necessary functionality. Often showing fragments will be sufficient to make required screenshots. +Also, when you use Model-View-Presenter architectural pattern, you are able to control UI state +directly through the View interface. So, there is no need to interact with the application interface and wait for changes.
+First create a base test activity with setFragment(Fragment)
method in your application:
class FragmentTestActivity : AppCompatActivity() {
+
+ fun setFragment(fragment: Fragment) = with(supportFragmentManager.beginTransaction()) {
+ replace(android.R.id.content, fragment)
+ commit()
+ }
+}
+
Then add a base product screenshot test case:
+```kotlin +open class ProductDocLocScreenshotTestCase : DocLocScreenshotTestCase( + locales = "en,ru" +) {
+@get:Rule
+val activityTestRule = ActivityTestRule(FragmentTestActivity::class.java, false, true)
+
+protected val activity: FragmentTestActivity
+ get() = activityTestRule.activity
+
+} +
This test case would run your `FragmentTestActivity` on startup. Now you are able to write your screenshooter tests.
+For example, create a new test class which extends `ProductDocLocScreenshotTestCase`:
+
+```kotlin
+@RunWith(AndroidJUnit4::class)
+class AdvancedScreenshotSampleTest : ProductDocLocScreenshotTestCase() {
+
+ private lateinit var fragment: FeatureFragment
+ private lateinit var view: FeatureView
+
+ @ScreenShooterTest
+ @Test
+ fun test() {
+ before {
+ fragment = FeatureFragment()
+ view = getUiSafeProxy(fragment as FeatureView)
+ activity.setFragment(fragment)
+ }.after {
+ }.run {
+
+ step("1. Step 1") {
+ // ... [view] calls
+ captureScreenshot("Step 1")
+ }
+
+ step("2. Step 2") {
+ // ... [view] calls
+ captureScreenshot("Step 2")
+ }
+
+ step("3. Step 3") {
+ // ... [view] calls
+ captureScreenshot("Step 3")
+ }
+
+ // ... other steps
+ }
+ }
+}
+
As you might notice, the getUiSafeProxy
method called to get an instance of FeatureView
.
+This method wraps your View interface and returns a proxy on it.
+The proxy guarantees that all the methods of the View interface you called, will be invoked on the main thread.
+There is also getUiSafeProxyFromImplementation
which wraps an implementation rather than an interface.
For full example, check AdvancedScreenshotSampleTest class.
+By default, all screenshots are stored at:
+/sdcard/screenshots/<locale>/<full qualified test class name>/<method name>.
+You can change this behavior by providing custom
+ResourcesRootDirsProvider,
+ResourcesDirsProvider,
+ResourceFileNamesProvider and
+ResourcesDirNameProvider implementations.
Find out details here.
+We have been forced to redesign our resource providing system to support Allure.
+That's why we changed the primary constructor of DocLocScreenshotTestCase.
+But, we've kept the old option of using DocLocScreenshotTestCase
with old resource providing system as a secondary constructor.
+You can view the secondary constructor as an example of migration from old system to new system.
+Also, we've retained tests using old resource providing system in samples to ensure that nothing is broken.
All the supported Android UI widgets in Kakao can be found as inheritors of the KBaseView
class.
+
Here are some of them:
+
KBottomNavigationView
+
KCheckBox
+
KChipGroup
+
KSwipeView
+
KView
+
KAlertDialog
+
KDrawerView
+
KEditText
+
KTextInputLayout
+
KImageView
+
KNavigationView
+
KViewPager
+
KDatePicker
+
KDatePickerDialog
+
KTimePicker
+
KTimePickerDialog
+
KProgressBar
+
KSeekBar
+
KRatingBar
+
KScrollView
+
KSearchView
+
KSlider
+
KSwipeRefreshLayout
+
KSwitch
+
KTabLayout
+
KButton
+
KSnackbar
+
KTextView
+
KToolbar
If you extend the UiScreen
abstract class then the following views are available for you:
+
UiView
+
UiEditText
+
UiTextView
+
UiButton
+
UiCheckbox
+
UiChipGroup
+
UiSwitchView
+
UiScrollView
+
UiBottomNavigationView
Device
abstraction.Device is a provider of managers for all off-screen work.
+All examples are located in device_tests. +Device provides these managers:
+apps
allows to install or uninstall applications. Uses adb install
and adb uninstall
commands. See the example DeviceAppSampleTest.activities
is an interface to work with currently resumed Activities. AdbServer not required. See the example DeviceActivitiesSampleTest.files
provides the possibility of pushing or removing files from the device. Uses adb push
and adb rm
commands and does not require android.permission.WRITE_EXTERNAL_STORAGE
permission. See the example DeviceFilesSampleTest.internet
allows toggling WiFi and network data transfer settings. Be careful of using this interface, WiFi settings changes could not work with some Android versions. See the example DeviceNetworkSampleTest.keyboard
is an interface to send key events via adb. Use it only when Espresso or UiAutomator are not appropriate (e.g. screen is locked). See the example DeviceKeyboardSampleTest.location
emulates fake location and allows to toggle GPS setting. See the example DeviceLocationSampleTest.phone
allows to emulate incoming calls and receive SMS messages. Works only on emulators since uses adb emu
commands. See the example DevicePhoneSampleTest.screenshots
is an interface screenshots of currently resumed activity. Requires android.permission.WRITE_EXTERNAL_STORAGE permission
. See the example DeviceScreenshotSampleTest.accessibility
allows to enable or disable accessibility services. Available since api 24. See the example DeviceAccessibilitySampleTest.permissions
provides the possibility of allowing or denying permission requests via default Android permission dialog. See the example DevicePermissionsSampleTest.hackPermissions
provides the possibility of allowing any permission requests without default Android permission dialog. See the example DeviceHackPermissionsSampleTest.exploit
allows to rotate device or press system buttons. See the example DeviceExploitSampleTest.language
allows to switch language. See the example DeviceLanguageSampleTest.logcat
provides access to adb logcat. See the example DeviceLogcatSampleTest. logcat
: Logcat
class providing a wide variety of ways to check logcat.uiDevice
returns an instance of android.support.test.uiautomator.UiDevice
. We don't recommend to use it directly because there is Kautomator that offers a more readable, predictable and stable API to work outside your application.Also Device provides application and test contexts - targetContext
and context
.
Device instance is available in BaseTestContext
scope and BaseTestCase
via device
property.
+
@Test
+fun test() =
+ run {
+ step("Open Simple Screen") {
+ activityTestRule.launchActivity(null)
+ ======> device.screenshots.take("Additional_screenshot") <======
+
+ MainScreen {
+ simpleButton {
+ isVisible()
+ click()
+ }
+ }
+ }
+ // ....
+}
+
Most of the features that Device provides use of adb commands and requires AdbServer to be run.
+Some of them, such as call emulation or SMS receiving, could be executed only on emulator. All such methods are marked by annotation @RequiresAdbServer
.
All the methods which use ADB commands require android.permission.INTERNET
permission.
+For more information, see AdbServer documentation.
Anyone who starts to write UI-tests is facing with a problem of how to write UI-tests correctly.
+At the beginning of our great way, we had three absolutely different UI-test code styles from four developers. It was amazing.
+At that moment, we decided to do something to prevent it.
+That's why we have created rules on how to write UI-tests and we have tried to make Kaspresso helping to follow these rules. All rules are divided into two groups: abstractions and structure. Also, we have added a third part containing convenient things resolving the most common problems.
Only one! It's a page object (PO), the term explained well by Martin Fowler in this article.
+In Kakao a Screen
class (in Kautomator a UiScreen
) is the implementation of PO. Each screen visible by the user even a simple dialog is a separate PO.
+Yes, there are cases when you need new abstraction and it's ok. But our advice is to think well before you introduce new abstraction.
Screen
?In a big project with a lot of UI-tests, it's not an easy challenge.
+That's why we have implemented an extended version of the Kakao Screen
- KScreen
(KScreen). In KScreen
you have to implement two properties: layoutId
and viewClass
. So your search if the View has its description in some Kakao Screen
becomes easier.
+In Kautomator, there is general UiScreen
(UiScreen) that has an obligatory field - packageName
.
If these methods help to understand what the test is doing then it's ok.
+For example, compare two parts of code:
+
MainScreen {
+ shieldView {
+ click()
+ }
+}
+
MainScreen {
+ navigateToTasksScreen()
+}
+
+object MainScreen : KScreen<MainScreen>() {
+ //...
+ fun navigateToTasksScreen() {
+ shieldView {
+ click()
+ }
+ }
+ //...
+}
+
navigateToTasksScreen()
is more "talking" than the simple click on some shieldView
. Screen
contain inner state or logic?No! PO doesn't have any inner state or logic. It's only a description of the UI of concrete View.
+We think it's ok because it simplifies the code and puts all info that is about Screen into one class.
+The chosen approach doesn't lead to an uncontrolled grow of class size because even a dialog is a separate Screen
, so we don't have a huge Screen
describing half of all UI in the app.
+Just compare three parts of code executing the same thing:
+
ReportsScreen {
+ assertQuarantinedDetectsCountAfterScan(0)
+}
+
ReportsScreen {
+ reportsListView {
+ childAt<ReportsScreen.ReportsItem>(1) {
+ body {
+ containsText("Detected: 0")
+ containsText("Quarantined: 0")
+ containsText("Deleted: 0")
+ }
+ }
+ }
+}
+
ReportsScreen {
+ val detectsCount = getDetectsCountAfterScan()
+ ReportsScreenAssertions.assertQuarantinedDetectsCountAfterScan(
+ detectsCount
+ )
+}
+
assert<YourCheckName>
.
+First of all, let's consider the above-mentioned terms.
+Test-case is a scenario written in human language by a tester to check some feature.
+Test is an implementation of Test-case written in program language by developer/autotester.
+Terms were learned. Let's observe some test:
+
@Test
+fun test() {
+ MainScreen {
+ nextButton {
+ isVisible()
+ click()
+ }
+ }
+ SimpleScreen {
+ button1 {
+ click()
+ }
+ button2 {
+ isVisible()
+ }
+ }
+ SimpleScreen {
+ button2 {
+ click()
+ }
+ edit {
+ attempt(timeoutMs = 7000) { isVisible() }
+ hasText(R.string.text_edit_text)
+ }
+ }
+}
+
Sometimes you have to change the state of a device (edit contacts, phones, put files into storage and more) while you are running a test.
+What to do with a changed state? There are two variants:
+1. Create a universal method that sets a device to a consistent state.
+2. Clean the state after each test.
The first approach doesn't look like a safe case because you need to remember about all the tests in one huge method.
+That's why we prefer the second approach. But it would be nice if the structure of a test forced us to remember about a state.
All of the above mentioned inspired us to create the test's structure like below: +
@Test
+fun shouldPassOnNoInternetScanTest() =
+ before {
+ activityTestRule.launchActivity(null)
+ // some things with the state
+ }.after {
+ // some things with the state
+ }.run {
+ step("Open Simple Screen") {
+ MainScreen {
+ nextButton {
+ isVisible()
+ click()
+ }
+ }
+ }
+
+ step("Click button_1 and check button_2") {
+ SimpleScreen {
+ button1 {
+ click()
+ }
+ button2 {
+ isVisible()
+ }
+ }
+ }
+
+ step("Click button_2 and check edit") {
+ SimpleScreen {
+ button2 {
+ click()
+ }
+ edit {
+ attempt(timeoutMs = 7000) { isVisible() }
+ hasText(R.string.text_edit_text)
+ }
+ }
+ }
+
+ step("Check all possibilities of edit") {
+ scenario(
+ CheckEditScenario()
+ )
+ }
+ }
+
before - after - run
step
step
in the test is similar to step in the test-case. That's why test reading is easier and understandable.
+3. scenario
scenario
where you can replace your sequences of steps.
+How is this API enabled?
+Let's look at SimpleTest and
+SimpleTestWithRule.
+In the first example we inherit SimpleTest
from TestCase
. In the second example we use TestCaseRule
field.
+Also you can use BaseTestCase
and BaseTestCaseRule
.
A developer, while he is writing a test, needs to prepare some data for the test. It's a common case. Where do you locate test data preparing?
+Usually, it's the beginning of the test.
+But, first, we want to divide test data preparing and test data usage. Second, we want to guarantee that test data were prepared before the test.
+That's why we decided to introduce a special DSL to help and to highlight the work with test data preparing.
+Please look at the example - InitTransformDataTest.
+Updated DSL looks like:
+
before {
+ // ...
+}.after {
+ // ...
+}.init {
+ company {
+ name = "Microsoft"
+ city = "Redmond"
+ country = "USA"
+ }
+ company {
+ name = "Google"
+ city = "Mountain View"
+ country = "USA"
+ }
+ owner {
+ firstName = "Satya"
+ secondName = "Nadella"
+ country = "India"
+ }
+ owner {
+ firstName = "Sundar"
+ secondName = "Pichai"
+ country = "India"
+ }
+}.transform {
+ makeOwner(ownerSurname = "Nadella", companyName = "Microsoft")
+ makeOwner(ownerSurname = "Pichai", companyName = "Google")
+}.run {
+ // ...
+}
+
init
transform
init
block.
+Alexander Blinov wrote a good article about init-transform DSL in russian article where he explains all DSL details very well. You are welcome!
+Finally, let's look at all available Test DSL in Kaspresso:
+1. before-after-init-transform-run
+1. before-after-init-transform-transform-run
. It's possible to add multiple transform blocks.
+2. before-after-init-run
+3. before-after-run
+4. init-transform-run
+5. init-transform-transform-run
. It's possible to add multiple transform blocks.
+6. init-run
+7. run
You can have a look at examples of how to use and configure Kaspresso +and how to use different forms of DSL.
+You can notice an existing of some BaseTestContext
in before
, after
and run
methods. BaseTestContext
gives you access to all Kaspresso's entities that a developer can need during the test. Also, BaseTestContext
gives you insurance that all of these entities were created correctly for the current session and with actual Kaspresso configurator.
+So, let's consider what BaseTestContext
offers.
It's a method that receives a lambda and invokes it in the same manner as FlakySafeInterceptors group.
+If you disabled this interceptor or if you want to set some special flaky safety params for any view, you can use this method. The most common case is when the default timeout (10 seconds) for flakySafety is not enough, because, for example, the appearance of a view is blocked by long background operation.
+
step("Check tv6's text") {
+ CommonFlakyScreen {
+ tv6 {
+ flakySafely(timeoutMs = 16_000) {
+ hasText(R.string.common_flaky_final_textview)
+ }
+ }
+ }
+}
+
This function is similar to what flakySafely
does, but for negative scenarios, where you need all the time to check that something does not happen.
+
ContinuouslyDialogScreen {
+ continuously() {
+ dialogTitle {
+ doesNotExist()
+ }
+ }
+}
+
This is a method to make a composed action from multiple actions or assertions, and this action succeeds if at least one of its components succeeds.
+compose
is useful in cases when we don't know an accurate sequence of events and can't influence it. Such cases are possible when a test is performed outside the application.
+When a test is performed inside the application we strongly recommend to make your test linear and don't put any conditions in tests that are possible thanks to compose
.
+It is available as an extension function for any KView
, UiBaseView
and as just a regular method (in this case it can take actions on different views as well).
The key words using in compose:
+- compose
- marks the beginning of "compose", turn on all needed logic
+- or
- marks the possible branches. The lambda after or
has a context of concrete element. Just have a look at the simple below.
+- thenContinue
- is an action that will be executed if a branch (the code into lambda of or
) is completed successfully. The context of a lambda after thenContinue
is a context of concrete element described in or
section.
+- then
- is almost the same construction as thenContinue
excepting the context after then
. The context after then
is not restricted.
Have a glance at the example below: +
step("Handle potential unexpected behavior") {
+ // simple compose
+ CommonFlakyScreen {
+ btn5.compose {
+ or {
+ // the context of this lambda is `btn5`
+ hasText("Something wrong")
+ } thenContinue {
+ // here, the context of this lambda is a context of KButton(btn5),
+ // that's why we can call KButton's methods inside the lambda directly
+ click()
+ }
+ or {
+ // the context of this lambda is `btn5`
+ hasText(R.string.common_flaky_final_button)
+ } then {
+ // here, there is not any special context of this lambda
+ // that's why we can't call KButton's methods inside the lambda directly
+ btn5.click()
+ }
+ }
+ }
+ // complex compose
+ compose {
+ // the first potential branch when ComplexComposeScreen.stage1Button is visible
+ or(ComplexComposeScreen.stage1Button) {
+ // the context of this lambda is `ComplexComposeScreen.stage1Button`
+ isVisible()
+ } then {
+ // if the first branch was succeed then we execute some special flow
+ step("Flow is over the product") {
+ ComplexComposeScreen {
+ stage1Button {
+ click()
+ }
+ stage2Button {
+ isVisible()
+ click()
+ }
+ }
+ }
+ }
+ // the second potential branch when UiComposeDialog1.title is visible
+ // just imagine that is some unexpected system or product behavior and we cannot fix it now
+ or(UiComposeDialog1.title) {
+ // the context of this lambda is `UiComposeDialog1.title`
+ isDisplayed()
+ } then {
+ // if the second branch was succeed then we execute some special flow
+ step("Flow is over dialogs") {
+ UiComposeDialog1 {
+ okButton {
+ isDisplayed()
+ click()
+ }
+ }
+ UiComposeDialog2 {
+ title {
+ isDisplayed()
+ }
+ okButton {
+ isDisplayed()
+ click()
+ }
+ }
+ }
+ }
+ }
+}
+
If you set your test data by init-transform
methods then this test data is available by a data
field.
Special assistants to write tests. Pay attention to the fact that these assistants are available in BaseTestCase
also.
+1. testLogger
+ It's a logger for tests allowed to output logs by a more appropriate and readable form.
+2. device
+ An instance of Device
class is available in this context. It's a special interface given beautiful possibilities to do a lot of useful things at the test.
+ More detailed info about Device
is here.
+3. adbServer
+ You have access to AdbServer instance used in Device
's interfaces via adbServer
property.
+ More detailed info about AdbServer
is here.
+4. params
+ Params
is the facade class for all Kaspresso parameters.
+ Please, observe the source code.
Here you can find detailed information about all the Kaspresso features.
+ + + + + + +{"use strict";/*!
+ * escape-html
+ * Copyright(c) 2012-2013 TJ Holowaychuk
+ * Copyright(c) 2015 Andreas Lubbe
+ * Copyright(c) 2015 Tiancheng "Timothy" Gu
+ * MIT Licensed
+ */var _a=/["'&<>]/;Pn.exports=Aa;function Aa(e){var t=""+e,r=_a.exec(t);if(!r)return t;var o,n="",i=0,s=0;for(i=r.index;i