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..591d199be --- /dev/null +++ b/404.html @@ -0,0 +1,997 @@ + + + +
+ + + + + + + + + + + + + +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
+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.
+ + + + + + +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
+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.
+ + + + + + +Kaspresso is a framework for Android UI testing. Based on Espresso and UI +Automator, Kaspresso provides a wide range of additional features, such as:
+And many more!
+ +To integrate Kaspresso into your project:
+1. If the mavenCentral
repository does not exist, include it to your root build.gradle
file:
allprojects {
+ repositories {
+ mavenCentral()
+ }
+}
+
build.gradle
:dependencies {
+ androidTestImplementation 'com.kaspersky.android-components:kaspresso:<latest_version>'
+ // Allure support
+ androidTestImplementation "com.kaspersky.android-components:kaspresso-allure-support:<latest_version>"
+ // Jetpack Compose support
+ androidTestImplementation "com.kaspersky.android-components:kaspresso-compose-support:<latest_version>"
+}
+
To try out the cutting edge kaspresso updates before an oficial release add a "-SNAPHOT" postfix to the latest Kaspresso version e.g. +
dependencies {
+ androidTestImplementation 'com.kaspersky.android-components:kaspresso:<latest_version>-SNAPSHOT'
+}
+
If you are still using the old Android Support libraries, we strongly recommend to migrate to AndroidX.
+The last version with Android Support libraries is:
+dependencies {
+ androidTestImplementation 'com.kaspersky.android-components:kaspresso:1.0.1-support'
+}
+
To make it easier to learn the framework, a step-by-step tutorial is available on our website.
+We like the syntax that Kakao applies to write UI tests. This wrapper over Espresso uses the Kotlin DSL approach, that makes the code +significantly shorter and more readable. See the difference:
+Espresso: +
@Test
+fun testFirstFeature() {
+ onView(withId(R.id.toFirstFeature))
+ .check(ViewAssertions.matches(
+ ViewMatchers.withEffectiveVisibility(
+ ViewMatchers.Visibility.VISIBLE)))
+ onView(withId(R.id.toFirstFeature)).perform(click())
+}
+
@Test
+fun testFirstFeature() {
+ mainScreen {
+ toFirstFeatureButton {
+ isVisible()
+ click()
+ }
+ }
+}
+
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")
+ }
+}
+
However, Kakao and Kautomator themselves don't help you to see the relation between the test and the corresponding test case. Also, a long test often becomes a giant piece of code that is impossible to split into smaller parts. +That's why we have created an additional Kotlin DSL that allows you to read your test more easily.
+See the example below:
+@Test
+fun shouldPassOnNoInternetScanTest() =
+ beforeTest {
+ activityTestRule.launchActivity(null)
+ // some things with the state
+ }.afterTest {
+ // 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 {
+ flakySafely(timeoutMs = 7000) { isVisible() }
+ hasText(R.string.text_edit_text)
+ }
+ }
+ }
+ step("Check all possibilities of edit") {
+ scenario(
+ CheckEditScenario()
+ )
+ }
+ }
+
Sometimes your UI test passes ten times, then breaks on the eleventh attempt for some mysterious reason. It’s called flakiness.
+The most popular reason for flakiness is the instability of the UI tests libraries, such as Espresso and UI Automator. To eliminate this instability, Kaspresso uses DSL wrappers and interceptors.
+Let’s watch some short video that shows the difference between the original UI Automator (on the right) and the accelerated one (on the left).
+ +Here is a short explanation of why it is possible.
+We developed Kaspresso behavior interceptors on the base of Kakao/Kautomator +Interceptors to catch failures.
+Thanks to interceptors, you can do a lot of useful things, such as:
+and many more (see the manual).
+Kaspresso writes its own logs, detailed and readable:
++
+Espresso and UI Automator don't allow to call ADB commands from inside a test. To fix this problem, we developed AdbServer (see the wiki).
+You can use Kaspresso classes to work with Android System.
+For example, with the Device
class you can:
(see more about the Device class).
+If you develop an application that is available across the world, you have to localize it into different languages. When UI is localized, it’s important for the translator to see the context of a word or a phrase, that is the specific screen.
+With Kaspresso, translators can automatically take a screenshot of any screen. It’s incredibly fast, even for legacy screens, and you don't have to refactor or mock anything (see the manual).
+You can tune any part of Kaspresso (read more).
+You can run your UI-tests on the JVM environment. Additionally, almost all interceptors improving stability, readability and other will work. +Read more.
+Kaspresso can generate very detailed Allure-reports for each test: + +More information is available here.
+Now, you can write your Kaspresso tests for Jetpack Compose screens! DSL and all principles are the same. +So, you will not see any difference between tests for View screens and for Compose screens. +More information is available here.
+Keep in mind it's early access that may contain bugs. Also, API can be changed, but we are going to avoid it. Be free to create relative issues if you've encountered with +any kind of problem.
+The tool itself, even the perfect one, can not solve all the problems in writing UI tests. It’s important to know how to write tests and how to organize the entire process. +Our team has great experience in introducing autotests in different companies. We shared our knowledge on Wiki.
+For all information check Kaspresso wiki
+All samples are available in the samples folder.
+Most of the samples require AdbServer. To start AdbServer you should do the following steps:
+Kaspresso
folder
+cd ~/Workspace/Kaspresso
+
adbserver-desktop.jar
+java -jar artifacts/adbserver-desktop.jar
+
All existing issues in Kaspresso can be found here.
+Breaking changes can be found here
+Kaspresso is an open source project, so you are welcome to contribute (see the Contribution Guidelines).
+Kaspresso is available under the Apache License, Version 2.0.
+ + + + + + +Kaspresso is a framework for Android UI testing. Based on Espresso and UI +Automator, Kaspresso provides a wide range of additional features, such as:
+And many more!
+ +To integrate Kaspresso into your project:
+1. If the mavenCentral
repository does not exist, include it to your root build.gradle
file:
allprojects {
+ repositories {
+ mavenCentral()
+ }
+}
+
build.gradle
:dependencies {
+ androidTestImplementation 'com.kaspersky.android-components:kaspresso:<latest_version>'
+ // Allure support
+ androidTestImplementation "com.kaspersky.android-components:kaspresso-allure-support:<latest_version>"
+ // Jetpack Compose support
+ androidTestImplementation "com.kaspersky.android-components:kaspresso-compose-support:<latest_version>"
+}
+
To try out the cutting edge kaspresso updates before an oficial release add a "-SNAPHOT" postfix to the latest Kaspresso version e.g. +
dependencies {
+ androidTestImplementation 'com.kaspersky.android-components:kaspresso:<latest_version>-SNAPSHOT'
+}
+
If you are still using the old Android Support libraries, we strongly recommend to migrate to AndroidX.
+The last version with Android Support libraries is:
+dependencies {
+ androidTestImplementation 'com.kaspersky.android-components:kaspresso:1.0.1-support'
+}
+
To make it easier to learn the framework, a step-by-step tutorial is available on our website.
+We like the syntax that Kakao applies to write UI tests. This wrapper over Espresso uses the Kotlin DSL approach, that makes the code +significantly shorter and more readable. See the difference:
+Espresso: +
@Test
+fun testFirstFeature() {
+ onView(withId(R.id.toFirstFeature))
+ .check(ViewAssertions.matches(
+ ViewMatchers.withEffectiveVisibility(
+ ViewMatchers.Visibility.VISIBLE)))
+ onView(withId(R.id.toFirstFeature)).perform(click())
+}
+
@Test
+fun testFirstFeature() {
+ mainScreen {
+ toFirstFeatureButton {
+ isVisible()
+ click()
+ }
+ }
+}
+
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")
+ }
+}
+
However, Kakao and Kautomator themselves don't help you to see the relation between the test and the corresponding test case. Also, a long test often becomes a giant piece of code that is impossible to split into smaller parts. +That's why we have created an additional Kotlin DSL that allows you to read your test more easily.
+See the example below:
+@Test
+fun shouldPassOnNoInternetScanTest() =
+ beforeTest {
+ activityTestRule.launchActivity(null)
+ // some things with the state
+ }.afterTest {
+ // 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 {
+ flakySafely(timeoutMs = 7000) { isVisible() }
+ hasText(R.string.text_edit_text)
+ }
+ }
+ }
+ step("Check all possibilities of edit") {
+ scenario(
+ CheckEditScenario()
+ )
+ }
+ }
+
Sometimes your UI test passes ten times, then breaks on the eleventh attempt for some mysterious reason. It’s called flakiness.
+The most popular reason for flakiness is the instability of the UI tests libraries, such as Espresso and UI Automator. To eliminate this instability, Kaspresso uses DSL wrappers and interceptors.
+Let’s watch some short video that shows the difference between the original UI Automator (on the right) and the accelerated one (on the left).
+ +Here is a short explanation of why it is possible.
+We developed Kaspresso behavior interceptors on the base of Kakao/Kautomator +Interceptors to catch failures.
+Thanks to interceptors, you can do a lot of useful things, such as:
+and many more (see the manual).
+Kaspresso writes its own logs, detailed and readable:
++
+Espresso and UI Automator don't allow to call ADB commands from inside a test. To fix this problem, we developed AdbServer (see the wiki).
+You can use Kaspresso classes to work with Android System.
+For example, with the Device
class you can:
(see more about the Device class).
+If you develop an application that is available across the world, you have to localize it into different languages. When UI is localized, it’s important for the translator to see the context of a word or a phrase, that is the specific screen.
+With Kaspresso, translators can automatically take a screenshot of any screen. It’s incredibly fast, even for legacy screens, and you don't have to refactor or mock anything (see the manual).
+You can tune any part of Kaspresso (read more).
+You can run your UI-tests on the JVM environment. Additionally, almost all interceptors improving stability, readability and other will work. +Read more.
+Kaspresso can generate very detailed Allure-reports for each test: + +More information is available here.
+Now, you can write your Kaspresso tests for Jetpack Compose screens! DSL and all principles are the same. +So, you will not see any difference between tests for View screens and for Compose screens. +More information is available here.
+Keep in mind it's early access that may contain bugs. Also, API can be changed, but we are going to avoid it. Be free to create relative issues if you've encountered with +any kind of problem.
+The tool itself, even the perfect one, can not solve all the problems in writing UI tests. It’s important to know how to write tests and how to organize the entire process. +Our team has great experience in introducing autotests in different companies. We shared our knowledge on Wiki.
+For all information check Kaspresso wiki
+All samples are available in the samples folder.
+Most of the samples require AdbServer. To start AdbServer you should do the following steps:
+Kaspresso
folder
+cd ~/Workspace/Kaspresso
+
adbserver-desktop.jar
+java -jar artifacts/adbserver-desktop.jar
+
All existing issues in Kaspresso can be found here.
+Breaking changes can be found here
+Kaspresso is an open source project, so you are welcome to contribute (see the Contribution Guidelines).
+Kaspresso is available under the Apache License, Version 2.0.
+ + + + + + +artifacts/adbserver-desktop.jar
artifacts/desktop_1_1_0.jar
также доступна для ранних версий Kaspresso.device.logcat
в ваших тестах, вам следует использовать метод device.logcat.disableChatty
в секции before
.
+ В предыдущей версии Kaspresso device.logcat.disableChatty
вызывался автоматически во время инициализации. Как результат, всегда приходилось перезапускать AdbServer перед каждым тестом.io.github.kakaocup.kakao
. Замените все импорты с помощью комманды
+ find . -type f \( -name "*.kt" -o -name "*.java" \) -print0 | xargs -0 sed -i '' -e 's/com.agoda/io.github.kakaocup/g'
или с помощью утилиты среды разработки для глобальной замены импортов./sdcard/Documents
.
+ Для записи видео необходимо использовать новый Kaspresso builder: Kaspresso.Builder.withForcedAllureSupport()
и заменить test runner (io.qameta.allure.android.runners.AllureAndroidJUnitRunner
) на com.kaspersky.kaspresso.runner.KaspressoRunner
.
+ TestFailRule устарел. Поправили падающие скриншот-тесты.
+ Улучшено автоматическое закрытие системных окон. Посмотреть изменения можно здесь.issue-***/detailed_description. Пример: issue-306/fix-padding-breaks-autoscroll-interceptor
+Сообщения к коммитам должны начинаться с: "Issue #***: ...". Пример: "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
++ + + + + + +Хочешь попасть в этот список? Все просто! Напиши статью про Kaspresso, пришли нам ссылку, и мы добавим её в этот список! +
+
[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
+Описанная ниже проблема актуальна для версий Kaspresso ниже 1.5.0. Начиная с этой версии Kaspresso полноценно поддерживает новый формат работы с системной памятью.
+Kaspresso может использовать память девайса для сохранения различных артефактов выполняемых тестов. Например, это могут быть скриншоты, дампы xml, журналы событий, видео и многое другое. +Однако, новые версии Android предполагают абсолютно новый способ взаимодействия с памятью - Scoped storage. +На версиях Kaspresso до 1.5.0 поддерживается работа с Scoped storage только через запрос различных разрешений. +Ниже предоставлена инструкция:
+# Пожалуйста, добавьте эти разрешения
+<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
+ )
+
+ //...
+}
+
Это временное решение. Мы рекомендуем мигрировать на свежую версию Kaspresso (1.5.0 и выше) для избежания этих проблем.
+ + + + + + +Kaspresso объединило вокруг себя большое сообщество, которое позволяет улучшить фреймворк, предлагая новые идеи, сообщая о найденных ошибках с детальным описанием и оформляя pull request-ы, предоставляя готовую реализации доработок.
+В нашей вкладке Issue вы можете создать новую issue. Чаще всего используются два типа issue: ошибки и доработки.
+Если вы нашли ошибку, вы можете создать новую issue. Введите заголовок и описание (детали ошибки) в поля ввода. Мы будем благодарны, если вы будете использовать этот готовый шаблон:
+Description:
+...
+Expected Behavior:
+...
+Actual Behavior:
+...
+Steps to Reproduce the Problem:
+...
+Specifications:
+...
+
Например: +
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.
+
Если у вас есть идея для доработки вы можете создать новую issue. Введите заголовок и описание в поля ввода. Мы будем благодарны, если вы будете использовать этот готовый шаблон:
+Description:
+...
+How the new enhancement will help?:
+...
+Existing analogs (with links):
+...
+
Если у вас есть не просто запрос на доработку, но и готовая реализация, вы можете отправить ее на Github, оформив Pull request.
+В этом уроке мы научимся работать с разрешениями (Permissions).
+Часто для корректной работы приложению нужен доступ к определенным функциям мобильного устройства: к камере, записи голоса, совершению звонков, отправке SMS-сообщений и т.д. Приложение может получить доступ к ним и использовать только в том случае, если пользователь даст на это разрешение.
+На старых устройствах ниже шестой версии Android (API level 23) такие разрешения запрашивались в момент установки приложения и, если пользователь установил его, то считалось, что он согласен со всеми разрешениями, и приложение будет иметь возможность использовать все необходимые функции. Это было небезопасно, так как открывало возможность недобросовестным разработчикам незаметно для пользователя получать доступ к микрофону, камере, звонкам и другим важным компонентам и использовать в своих целях.
+По этой причине на более новых версиях так называемые «опасные» разрешения стали запрашиваться не в момент установки, а во время работы приложения. Теперь пользователь явно будет видеть диалог с предложением разрешить или отклонить запрос на использование какой-то функциональности.
+Для примера запустите приложение tutorial
на одной из последних версий Android (API 23 и выше) и нажмите кнопку Make Call Activity
У вас откроется экран, на котором есть два элемента – поле ввода и кнопка. В поле ввода можно указать какой-то номер телефона и кликнуть на кнопку Make Call
для осуществления вызова
Совершение звонков – одна из функций, для работы которой требуется запросить разрешение у пользователя. Поэтому у вас отобразится диалог с предложением позволить приложению управлять звонками, на котором есть кнопки «Разрешить» и «Отклонить»
+ +Если мы нажмем “Allow”, то начнется вызов абонента по тому номеру, который вы указали в поле ввода
+ +При следующем открытии приложения разрешение больше не будет запрашиваться, оно сохраняется на устройстве. Если вы хотите отозвать разрешение, то можно это сделать в настройках. Для этого перейдите в раздел приложения, найдите нужное вам и заходите в раздел Permissions
Здесь вы сможете зайти в любое разрешение и изменить значение с Allow
на Deny
или наоборот.
Второй способ, как это можно сделать – при помощи adb shell команды:
+adb shell pm revoke package_name permission_name
Для нашего приложения команда будет выглядеть так:
+adb shell pm revoke com.kaspersky.kaspresso.tutorial android.permission.CALL_PHONE
После выполнения команды приложение снова запросит разрешение при следующей попытке совершить звонок.
+При тестировании приложений, которое требует разрешений, есть определенные особенности. Давайте напишем тест на данный экран.
+Первым делом создадим Page Object экрана с кнопкой Make Call
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) }
+}
+
MainActivity
кликнуть по соответствующей кнопке, добавляем эту кнопку в 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) }
+}
+
Можем создавать тест. Давайте пока просто откроем экран совершения звонка, введем какой-то номер и кликнем по кнопке
+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()
+ }
+ }
+ }
+}
+
Запускаем тест. Тест пройден успешно.
+В зависимости от того, дали вы разрешение или нет, у вас может отобразиться диалог с запросом разрешения на совершение звонков.
+На данном этапе мы проверили работу нашего экрана, что есть возможность ввести номер и кликнуть на кнопку, но никак не проверили, происходит вызов по введенному номеру или нет. Для того чтобы проверить, происходит ли в данный момент вызов можно использовать AudioManager
, делается это следующим образом:
val manager = device.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+Assert.assertTrue(manager.mode == AudioManager.MODE_IN_CALL)
+
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
+Перед запуском теста удалите приложение с устройства или отзовите разрешения при помощи adb shell команды. Также убедитесь, что вы запускаете тест на устройстве с API 23 и выше
+Запускаем тест. Тест провален.
+Это произошло, потому что после клика по кнопке у пользователя было запрошено разрешение. Никто этого разрешения не дал, и следующий экран открыт не был.
+Есть несколько вариантов решения проблемы. Первый вариант – использовать GrantPermissionRule
. Суть этого способа заключается в том, что мы создаем список разрешений, которые будут автоматически разрешены на тестируемом устройстве.
Для этого перед тестовым методом мы добавляем новое правило:
+@get:Rule
+val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(
+ android.Manifest.permission.CALL_PHONE
+)
+
В методе grant
в круглых скобках мы через запятую перечисляем все требуемые разрешения, в данном случае оно всего одно, поэтому оставляем в таком виде. Тогда весь код теста будет выглядеть так:
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
+Перед запуском теста не забудьте отозвать все разрешения у приложения или удалить его с устройства.
+Запускаем. В некоторых случаях этот тест будет пройдет успешно, а в некоторых – нет. Причину мы сейчас разберем.
+Вспомните урок про метод flakySafely
. Там мы говорили о том, что в случае неудачи все проверки в Kaspresso будут запускаться заново в течение определенного таймаута.
В нашем случае мы стартуем звонок и следующим шагом проверяем, что телефон действительно звонит. Делаем это мы через метод Assert.assertTrue(…)
. Иногда устройство успевает осуществить набор номера до этой проверки, а иногда нет. Кажется, что в такой ситуации должен отрабатывать метод flakySafely
и проверка должна быть проведена заново в течение десяти секунд, но почему-то этого не происходит.
Дело в том, что все проверки view-элементов в Kaspresso (isVisible, isClickable…) «под капотом» используют метод flakySafely
, но если мы сами вызываем различные проверки через assert
, то flakySafely
использован не будет и, если проверка выполнится неудачно, то тест сразу завершится с ошибкой.
Такие случаи – это еще один пример, когда стоит явно вызывать 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)
+ }
+ }
+ }
+}
+
Во-первых, после окончания теста на устройстве все еще продолжается вызов абонента. Давайте добавим секции before
и after
и в секции, которая выполняется после теста, завершим вызов. Это можно сделать при помощи следующего кода: device.phone.cancelCall("111")
. Работает этот метод посредством adb-команд, поэтому не забывайте запускать adb-сервер.
Теоретически, вы могли бы сброс звонка вынести в отдельный step и запускать его последним шагом, не вынося в секцию after. Но это было бы плохим решением, поскольку в случае, если какой-то шаг завершится с ошибкой, и тест будет провален, то на устройстве будет продолжен вызов и никогда не сбросится. Преимущество секции after в том, что код внутри этого блока выполнится независимо от результата теста.
+Чтобы не дублировать один и тот же номер в двух местах, давайте вынесем его в отдельную переменную, тогда код теста будет выглядеть так:
+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)
+ }
+ }
+ }
+}
+
Теперь после выполнения теста звонок завершается.
+Вторая проблема – при использовании GrantPermissionRule
мы можем проверить приложение только в состоянии, когда пользователь дал разрешение. При этом есть вероятность, что разработчики не предусмотрели вариант, когда запрос разрешения был отклонен, тогда результат может быть неожиданным вплоть до того, что приложение будет крашиться. Необходимо проверять и такие сценарии, но использовать для этого GrantPermissionRule
не получится, так как в этом случае разрешение всегда будет одобрено, и в тестах мы никогда не узнаем, какое будет поведение, если запрос отклонить.
Один из вариантов решения проблемы - взаимодействовать с диалогом при помощи KAutomator, предварительно найдя все необходимые элементы интерфейса, но это не слишком удобно, и в Kaspresso был добавлен намного более удобный способ - Device.Permissions
. Он позволяет очень просто проверять диалоги разрешений, а также соглашаться с ними или отклонять.
Поэтому вместо Rule
мы будем использовать объект Permissions
, который можно получить у Device
. Давайте сделаем это в отдельном классе, чтобы у вас сохранились оба варианта тестов. Класс, в котором мы сейчас работаем, переименуем в MakeCallActivityRuleTest
.
Чтобы это сделать, кликните правой кнопкой на название файла и выберите Refactor
-> Rename
И введите новое название класса:
+ +И создаем новый класс MakeCallActivityDevicePermissionsTest
. Код можно скопировать из текущего теста, за исключением 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)
+ }
+ }
+ }
+}
+
Если мы запустим тест сейчас, то он завершится неудачно, т.к. мы не дали разрешений на совершение звонков. Давайте добавим еще один step, в котором дадим соответствующее разрешение через device.permissions
. После указания объекта можно поставить точку и посмотреть, какие у него есть методы:
Есть возможность проверить, отображается ли диалог, а также отклонить или дать разрешение.
+step("Accept permission") {
+ Assert.assertTrue(device.permissions.isDialogVisible())
+ device.permissions.allowViaDialog()
+}
+
Таким образом мы убедимся, что диалог отображается и дадим согласие на осуществление звонков.
+Info
+Напоминаем, что диалог будет показан на версии Android API 23 и выше, как выполнять эти тесты на более ранних версиях, мы разберем в конце этого урока
+Тут мы дважды написали device.permissions
, давайте немного сократим код, применив функцию apply. А также проверку через assert
давайте вынесем в метод flakySafely
. Тогда весь код теста будет выглядеть так:
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)
+ }
+ }
+ }
+}
+
Запускаем. Тест пройден успешно.
+Теперь мы можем с легкостью написать тест на то, что звонок не осуществляется, если разрешение дано не было. Для этого вместо allowViaDialog
нужно указать denyViaDialog
.
Также нужно изменить проверки в самом тесте, и не забудьте в новом методе удалить код из функции after
, так как после отклонения разрешения звонок осуществлен не будет, и после теста сбрасывать звонок больше не нужно.
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()
+ }
+ }
+ }
+}
+
На современных версиях ОС Android (API 23 и выше) разрешения у пользователя запрашиваются во время работы приложения посредством диалога. Но в более ранних версиях они запрашивались в момент установки приложения, а во время работы считалось, что пользователь согласился со всеми требуемыми разрешениями.
+Поэтому, если вы запускаете тест на устройствах с API ниже 23-ой версии, то никакого запроса разрешений не будет, соответственно проверка диалога не требуется.
+В тесте с использованием GrantPermissionRule
никаких изменений не требуется, на старых версиях разрешение всегда есть, поэтому данная аннотация на работе теста никак не скажется. Но в тесте с использованием device.permissions
изменения сделать необходимо, так как здесь мы явно проверяем работу диалога.
Вариантов здесь несколько. Во-первых, на таких устройствах нет смысла проверять работу приложения, если разрешение было отклонено, поэтому данный тест нужно просто пропускать. Для этого можно воспользоваться аннотацией @SuppressSdk
. Тогда код метода checkCallIfPermissionDenied
изменится на:
@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()
+ }
+ }
+}
+
Второй вариант решения проблемы – пропускать какие-то определенные шаги или заменять их другими в зависимости от уровня API. Например, в методе checkSuccessCall
на старых девайсах мы можем пропустить шаг с проверкой диалога, для этого использовать такой код:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ step("Accept permission") {
+ device.permissions.apply {
+ flakySafely {
+ Assert.assertTrue(isDialogVisible())
+ allowViaDialog()
+ }
+ }
+ }
+}
+
Финальный код теста теперь будет выглядеть так:
+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()
+ }
+ }
+ }
+}
+
В этом уроке мы рассмотрели два варианта работы с Permissions: GrantPermissionRule
и device.permissions
.
Также мы узнали, что второй вариант предпочтительнее по ряду причин:
+В этом уроке мы скачаем проект Kaspresso, установим Android studio и настроим эмулятор.
+Android Studio используется для разработки программ. Нам она понадобится для написания и запуска автотестов.
+
Если у вас уже установлена Android Studio, то пропустите этот шаг. Если нет, то переходим по ссылке и нажимаем Download Android Studio.
Запускаем скачанный файл и проходим все шаги первичной настройки студии. Можно воспользоваться официальной инструкцией или официальной инструкцией в формате codelabs в случае возникновения проблем.
+
После того как Android Studio будет скачана, запускаем ее.
Для загрузки проекта необходимо, чтобы на вашем компьютера была установлена система контроля версий GIT. Загрузить GIT и узнать о нем подробнее вы можете здесь.
+Когда GIT будет установлен, то вы сможете скачать проект. Для этого переходим по ссылке.
+Нажимаем кнопку Code и копируем ссылку на репозиторий
+ +Открываем Android Studio.
+Если у вас ранее не был открыт никакой проект в студии, то необходимо выбрать пункт Get From VCS
+ +Если какой-то проект уже был запущен, то загрузить новый с GIT можно следующим образом: File
-> New
-> Project From Version Control
В открывшемся окне введите скопированный URL проекта, выберите папку, в которой будет размещен Kaspresso, и нажмите clone.
+ +В верхнем меню Android Studio выбираем 'Tools' -> 'Device Manager'
+ +На экране появится вкладка управления эмуляторами и реальными устройствами. Нажимаем 'Create Device':
+ +Увидим следующий экран:
+ +На этом экране можно задать характеристики "железа", эмуляцию которого хотим получить. В секции "1" можно выбрать телефон, планшет, телевизор и так далее. Нас интересует Телефон. В секции "2" - конкретную модель. В рамках туториала нет разницы, что выбрать. Выбираем 'Pixel 6'. Нажимаем 'Next' и попадаем на окно выбора образа операционной системы:
+ +Этот экран более важен в регулярной работе - здесь выбираем, какую версию Android установить на эмулятор. Давайте выберем 'R'. Нажимаем на иконку скачать справа от буквы 'R', проходим процесс установки и ожидаем.
+ +Когда процесс установки будет окончен, нажимаем кнопку 'Finish':
+ +Выбираем установленную версию 'R' и нажимаем 'Next':
+ +На экране ниже можно сменить название создаваемого эмулятора, чтоб их было легко отличать между собой. Дефолтное значение для наших целей подходит. Нажимаем 'Finish'.
+ +Устройство настроено и готово к работе. Запускаем его по иконке 'Play' справа от названия девайса:
+ +В некоторых случаях Android Studio может порекомендовать установить Hypervisor:
+ + +Android Studio установлена, эмулятор настроен, проект Kaspresso загружен. В следующем уроке запустим первые тесты.
+ + + + + + +В данном уроке мы научимся тестировать экраны, состояние которых меняется с течением времени.
+До сих пор во всех тестах экраны сразу имели финальный вид, все элементы отображались при их открытии, и мы могли проводить тесты. Для изменения стейта мы сами производили какие-то действия – кликали по кнопке, вводили текст в поле ввода и так далее.
+Но часто возникает ситуация, когда внешний вид экрана меняется с течением времени. Например, на старте начинается загрузка данных – отображается ProgressBar, после загрузки отображается список элементов или диалог с сообщением об ошибке, если что-то пошло не так. В таких случаях во время теста нужно проверить все промежуточные состояния, при этом не меняя их из тестового метода.
+Рассмотрим пример. Откройте приложение tutorial
и кликните по кнопке Flaky Activity
На этом экране отображаются несколько TextView
, для которых загружаются какие-то данные
Через одну секунду загружается текст для первого элемента
+ +Еще через три секунды появляется текст у второго элемента
+ +Спустя 10 секунд произойдет загрузка остальных данных и тексты появятся у всех TextView
Давайте напишем тест на этот экран. Как обычно начнем с создания 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
нужно кликнуть кнопку на главном экране. Добавляем ее в 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
+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()
+ }
+ }
+ }
+}
+
Следующее действие, происходящее на экране – загрузка текста для первого элемента. Нам нужно проверить, что на данном этапе первый TextView
содержит текст “TEXT 1”. Эту проверку нужно сделать после того, как загрузка будет завершена.
Получается, что следующим шагом мы должны добавить необходимые проверки, и, если они завершатся неудачно, то нужно выполнять их снова в течение какого-то времени. В данном случае загрузка первого текста занимаете около одной секунды после открытия экрана, поэтому мы можем добавить таймаут в 1-3 секунды, в течение которых проверки будут повторяться. Если в течение этого времени методы вернут корректное значение, то тест завершится успешно, если же по истечении таймаута условие так и не будет выполнено, то тест будет «красным».
+Для того, чтобы добавить таймаут, необходимо использовать метод flakySafely
, где в круглых скобках указывается время в миллисекундах, в течение которого будут происходить попытки пройти тест. Тогда код теста будет выглядеть следующим образом:
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 невидимый
+ }
+ }
+ }
+ }
+}
+
Наш тест завершается успешно. Теперь давайте проверим, что будет, если мы уберем вызов метода flakySafely
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 невидимый
+ }
+ }
+ }
+}
+
Казалось бы, мы не установили никакой таймаут, проверка должна была завершиться неудачей, но тест «зеленый». Дело в том, что в Kaspresso все проверки неявно используют метод flakySafely
с каким-то таймаутом (в текущей версии Kaspresso таймаут составляет 10 секунд).
Вы могли обратить внимание, что если какой-то тест выполняется успешно, то приложение сразу закрывается, и Android Studio выводит сообщение об успешном прогоне тестов. Но если какая-то проверка завершается неудачей, то сообщение об ошибке появляется не сразу, а через несколько секунд – причина заключается в использовании flakySafely. Тест завершился неудачно и в течение 10 секунд еще несколько раз перезапускается.
+Поэтому flakySafely
добавлять нужно только в том случае, если дефолтный таймаут вам по каким-то причинам не подходит, и его нужно изменить на другой. Хороший случай использования увеличенного таймаута – когда на экране происходит загрузка данных из сети. Сервер может долго возвращать ответ, при этом тест не должен падать из-за медленно работающего backend-а.
На следующем шаге, через 3 секунды загружается второй текст. Три секунды укладывается в дефолтный таймаут, значит явно использовать flakeSafely
с другим таймаутом не имеет смысла
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 секунд – приблизительное время загрузки данных, оно может быть больше или меньше этого значения, поэтому стандартный таймаут нам не подойдет. В таких случаях нужно явно вызывать flakySafely
, передавая увеличенный таймаут, давайте передадим 15 секунд
+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()
+ }
+ }
+ }
+ }
+}
+
В некоторых тестах можно увидеть такой код Thread.sleep(delay_in_millis)
, который используется для решения проблем с таймаутом вместо flakySafely
. Этот код останавливает поток на то время, которое было передано в качестве параметра. То есть тест в этом месте прекратит свое выполнение и будет ждать в течение какого-то времени, после завершения таймаута тест продолжит работу.
На первый взгляд может показаться, что в этих способах нет разницы, и делают они одно и то же. Но на самом деле в них есть существенное отличие. Если вы используете flakySafely
, то независимо от таймаута после успешного прохождения проверки тест продолжит выполняться. А при использовании Thread.sleep
в любом случае тест будет ждать, пока таймаут не завершится.
В обычном случае все проверки в Kaspresso используют flakySafely
с таймаутом 10 секунд, но, несмотря на это, тесты завершаются очень быстро, потому что, если метод вернул корректное значение, то никакого ожидания не будет. Если же все эти методы заменить на Thread.sleep
, то каждая такая проверка будет занимать минимум 10 секунд и тесты будут прогоняться очень длительное время.
Зная о преимуществах flakySafely
, которые мы только что обсудили, может возникнуть желание для всех тестов указывать очень большой таймаут просто на всякий случай. Но так делать не стоит по нескольким причинам.
Во-первых, если приложение действительно работает некорректно, и какие-то тесты будут падать, то их прохождение будет значительно дольше, чем при стандартном таймауте.
+Во-вторых, в приложении могут быть какие-то ошибки, которые приводят к тому, что оно работает значительно медленнее, чем ожидается. В таком случае мы могли бы узнать о проблеме из автотестов, но при слишком большом таймауте она останется незамеченной.
+Поэтому в большинстве случаев вам подойдет стандартный таймаут, и явно указывать его не придется. В остальных случаях указывайте таймаут, который будет приемлемым для пользователя.
+Вы могли обратить внимание, что все элементы на экране не помещаются, поскольку занимают довольно много места по высоте, поэтому весь контент был добавлен в ScrollView, чтобы экран можно было скроллить.
+Мы можем добавить проверку на то, что при открытии экрана первый элемент отображается, а последний – нет. Использовать метод isVisible
в данном случае будет неправильно, поскольку даже если на экране объект не поместился, но он видимый, то проверка вернет true
. Вместо этого можно использовать методы isDisplayed
и isNotDisplayed
, которые нужны как раз в таких случаях – когда нужно узнать, что элемент действительно видно на экране.
Тогда код теста будет выглядеть так:
+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
мы используем 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()
+ }
+ }
+ }
+ }
+}
+
Причина такого поведения в реализации проверок в библиотеке Kaspresso. Если мы проверяем элемент, который находится внутри ScrollView, и эта проверка завершается неудачно, то внутри теста автоматически будет осуществлен скролл до данного элемента, и проверка выполнится снова. Таким образом была решена проблема, когда при нормальном поведении приложения тесты падали, только потому что не смогли проверить элемент, который в данный момент не виден на экране.
+Получается, что была выполнена проверка text5.isDisplayed
, она завершилась неудачно и экран был прокручен вниз и проверка запустилась снова. Теперь элемент действительно был виден на экране, поэтому тест завершился успешно.
При написании тестов на экраны, которые можно скроллить, учитывайте особенности работы с ними в Kaspresso.
+В этом уроке мы рассмотрели следующие моменты:
+В этом уроке мы научимся выявлять причины падающих тестов путем добавления дополнительных логов и скриншотов.
+Вспомним пример, который уже использовался в одном из предыдущих уроков. Открываем приложение tutorial
+ +и кликаем на кнопку Login Activity
На этом экране можно ввести логин и пароль, и, если они будут корректные, то откроется экран после авторизации. Корректными в данном случае считаются: логин длиной от трех символов, пароль – от шести.
+ +Мы уже писали тесты для этого экрана, они находятся в классе 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)
+ }
+ }
+ }
+}
+
В этом тесте мы сами создаем логин и пароль, с которыми будем авторизоваться. Но довольно распространенной является ситуация, когда данные для теста мы получаем из какой-то внешней системы. Например, для целей тестирования в проекте может быть поднят REST-API сервис, который генерирует данные для авторизациии, которые мы будем использовать.
+Давайте смоделируем эту ситуацию. Создадим класс, который возвращает данные для входа – логин и пароль.
+В пакете com.kaspersky.kaspresso.tutorial
создадим еще один пакет data
В созданном пакете добавляем класс TestData
, тип выбираем Object
Как мы уже говорили ранее – здесь мы будем только моделировать ситуацию, когда данные для теста получаем из внешней системы. В созданном классе у нас будет два метода: один из них возвращает логин, другой – пароль. В реальных проектах эти данные мы бы запрашивали с сервера. Сейчас мы сами укажем, какие логин и пароль вернет система, но представляем, что для нас это «черный ящик», и мы не знаем, какие значения будут получены.
+Добавляем в этом классе два метода. Пусть они возвращают корректные логин и пароль:
+package com.kaspersky.kaspresso.tutorial.data
+
+object TestData {
+
+ fun generateUsername(): String = "Admin"
+
+ fun generatePassword(): String = "123456"
+}
+
TestData
. Тестовый класс назовем LoginActivityGeneratedDataTest
. Можем скопировать проверку успешного логина из класса 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 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)
+ }
+ }
+ }
+}
+
Здесь мы используем захардкоженные логин и пароль, давайте получим их из класса TestData
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)
+ }
+ }
+ }
+}
+
Мы проверили, что, если система возвращает корректные данные, то тест проходит успешно. Давайте внесем изменения в класс TestData
, чтобы он возвращал неверные значения
package com.kaspersky.kaspresso.tutorial.data
+
+object TestData {
+
+ fun generateUsername(): String = "Adm"
+
+ fun generatePassword(): String = "123"
+}
+
Мы уже говорили о том, что в реальных проектах влиять на внешнюю систему мы не можем, и иногда она может возвращать некорректные данные, из-за чего тест будет падать. Если тест упал, то нужно провести анализ и определить, в чем была проблема: в тестах, в неправильно работающем приложении или во внешней системе. Давайте попробуем определить это из логов. Открываем Logcat и фильтруем лог по тэгу KASPRESSO
Что мы отсюда видим? Первый шаг теста - авторизация (LoginScenario
) выполнен успешно, а проверка на то, что после успешного логина открыт нужный экран – провалилась.
При этом, отсюда совершенно неясно, почему проблема возникла. Мы не видим, с какими данными была попытка залогиниться, действительно ли они корректные, и непонятно, как решать возникшую проблему. Результат был бы более понятный, если бы в логах содержалась информация – какие конкретно логин и пароль были использованы во время тестирования.
+Для того чтобы выводить различную информацию в Logcat, мы можем воспользоваться классом Log
из пакета android.util
. Для этого у класса Log
необходимо вызвать один из публичных статических методов: i
(info), d
(debug), w
(warning), e
(error). Все эти методы по сути делают одно и то же - выводят сообщение в журнал, но среди них есть отличие. Для того чтобы упростить поиск и чтение логов, их делят на несколько уровней:
В зависимости от типа сообщения, которое вы хотите вывести в журнал, необходимо вызвать метод с соответствующим уровнем логирования.
+Info
+Более подробную информацию про уровни логирования и вывод сообщений в Logcat можно почитать в официальной документации
+Например, в нашем случае мы хотим в журнале показать данные, которые использовались при авторизации - это простое информационное сообщение, которое не говорит об ошибках в работе программы или каких-то предупреждениях, а также не используется для отладки, поэтому нам подойдет уровень логирования info
- метод Log.i()
.
В качестве параметра этому методу нужно передать два аргумента типа String - две строчки:
+Раньше необходимые сообщения в журнале мы искали по тэгу "KASPRESSO", можем указать его в качестве тэга, а в качестве сообщения выведем данные, использованные при авторизации.
+Логин и пароль у нас генерируются перед шагом step("Try to login with correct username and password")
можем в этом месте вывести в лог сообщение о том, какие именно данные были сгенерированы
package com.kaspersky.kaspresso.tutorial
+
+import android.util.Log
+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()
+
+ Log.i("KASPRESSO","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)
+ }
+ }
+ }
+}
+
В этой строчке Log.i("Generated data. Username: $username, Password: $password")
мы вызываем метод i
(уровень логирования info) у класса Log
, в качестве тэга передаем "KASPRESSO", а в качестве сообщения передаем строку "Generated data. Username: $username, Password: $password")
, где вместо $username
и $password
будут подставлены значения переменных логин и пароль.
Info
+Подробнее о том, как формировать строку с использованием переменных и методов, можно почитать в документации
+Давайте запустим тест еще раз и посмотрим логи:
+ +После TEST SECTION
видно наш лог, который выводится с тэгом KASPRESSO
. В этом логе видно, что сгенерированные данные некорректные (пароль слишком короткий), а значит тест падает из-за внешней системы, и решать проблему нужно именно в ней.
Если вы не хотите смотреть полностью весь лог, и вас интересуют только сообщения, добавленные вами, то вы можете использовать любой другой тэг. Для таких ситуаций удобно использовать тэг "KASPRESSO_TEST", тогда ваши логи будут отображаться в общем журнале вместе с другими сообщениями, если отфильтровать их по тэгу "KASPRESSO", при этом вы в любой момент сможете оставить только ваши сообщения, отфильтровав их по тэгу "KASPRESSO_TEST"
+package com.kaspersky.kaspresso.tutorial
+
+import android.util.Log
+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()
+
+ Log.i("KASPRESSO_TEST","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)
+ }
+ }
+ }
+}
+
Добавление собственных логов используется очень часто на практике, поэтому для удобства в Kaspresso был добавлен класс UiTestLogger
, в котором вывод сообщений в лог с тэгом "KASPRESSO_TEST" реализован под капотом. В самих тестах вам достаточно обратиться к объекту testLogger
, вызвав метод с необходимым уровнем логирования. При использовании этого метода больше не нужно передавать тэг, достаточно указать только текст сообщения.
В нашем случае логирование выглядело бы следующим образом:
+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)
+ }
+ }
+ }
+}
+
Теперь указывать тэг вручную не нужно, по умолчанию будет использован "KASPRESSO_TEST".
+Логи действительно очень полезны при анализе тестов и поиске ошибок, но бывают случаи, когда одних логов недостаточно. Например, во время выполнения теста на экране мог отобразиться системный диалог, который помешал дальнейшему выполнению теста и привел к ошибке, или тест по какой-то причине не нашел нужного текста на экране. В таких ситуациях определить проблему по одним логам бывает невозможно. Если бы во время теста на каждом шаге сохранялся скриншот, и потом мы могли бы посмотреть их в какой-то папке, то поиск ошибок был бы намного проще.
+В Kaspresso есть возможность во время теста делать скриншоты на любом шаге, для этого достаточно вызвать метод device.screenshots.take("file_name")
. Вместо file_name
нужно указать название файла скриншота, по которому вы сможете его найти. Давайте в каждый шаг LoginScenario
мы добавим скриншоты, чтобы потом проанализировать все, что происходило на экране.
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")
+ }
+ }
+ }
+ }
+}
+
Для того чтобы скриншоты сохранились на устройстве, у приложения должно быть дано разрешение на чтение и запись в файловую систему смартфона. Поэтому в тестовом классе мы дадим соответствующее разрешение через 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)
+ }
+ }
+ }
+}
+
Запускаем тест еще раз.
+После выполнения теста перейдите в Device File Explorer
и откройте папку sdcard/Documents/screenshots
. Если она у вас не отображается, то кликните правой кнопкой по папке sdcard
и нажмите Synchronize
Здесь по скриншотам можно определить, в чем проблема – на этапе установки пароля количество введенных символов – 3
+ +Так, проанализировав скриншоты, можно определить, какая ошибка возникла в момент проведения тестов.
+Info
+Один из способов сделать скриншот – вызвать метод device.uiDevice.takeScreenshot
. Это метод из библиотеки uiautomator
и использовать его напрямую никогда не следует.
Во-первых, скриншот, сделанный при помощи Kaspresso (device.screenshots.take
), будет лежать в нужной папке, которую легко найти по названию теста, и файлы для каждого теста и шага будут находиться в своих папках с понятными названиями, а в случае с uiautomator
находить нужные скриншоты будет проблематично.
Во-вторых, в Kaspresso сделано множество удобных доработок по работе со скриншотами таких как: масштабирование, настройка качества фото, полноэкранные скрины (когда весь контент не помещается на экране) и так далее.
+Поэтому для скриншотов всегда используйте только объекты Kaspresso device.screenshots
.
Теоретически, все тесты, которые вы пишете, могут упасть. В таких случаях хотелось бы всегда иметь возможность посмотреть скриншоты, чтобы понять, что пошло не так. Как этого добиться? Как вариант – во все шаги всех тестов добавлять вызов метода, который делает скриншот, но это не слишком удобно.
+Поэтому в Kaspresso была добавлена возможность настройки параметров теста при создании тестового класса. Для этого в конструктор TestCase
можно передать объект Kaspresso.Builder
, у которого можно указать различные настройки.
Info
+Чтобы посмотреть параметры, которые принимает метод или конструктор, можно кликнуть левой кнопкой мыши внутри круглых скобок и нажать комбинацию клавиш ctrl + P
(или cmd + P
на Mac)
Если этот параметр не передавать, оставив конструктор пустым, то будет использоваться значение по умолчанию Kaspresso.Builder.simple()
. В этом варианте билдера автоматическое сохранение скриншотов не реализовано. Мы можем добавить множество разных настроек, подробнее о которых можно почитать в Wiki.
Сейчас нас интересует добавление скриншотов, если тесты упали. Самый простой вариант сделать это – использовать advanced
builder вместо simple
. Делается это следующим образом:
class LoginActivityGeneratedDataTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.advanced()
+)
+
Info
+Обратите внимание, что разрешения на доступ к файловой системе нужны обязательно, без них скриншоты сохранены не будут
+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()
+ }
+ }
+ }
+ }
+}
+
Запускаем тест. Он завершился неудачно, и на устройстве появились скриншоты (не забывайте нажимать Synchronize
):
При использовании advanced
builder-а появляется еще несколько изменений. Кроме скриншотов добавляются также файлы с логами, иерархией View и другое.
Если вам не нужны все эти артефакты, то можно изменить только определенные настройки простого builder-а
+Info
+Если вы испытываете сложности с кастомизацией builder-ов, то используйте advanced
builder для получения скриншотов
Следует помнить, что в предыдущих тестах кроме выполнения наших методов «под капотом» происходило много дополнительных действий: запись логов для каждого шага, неявный вызов flakySafely, автоматический скролл до элемента, если проверка выполнилась неуспешно, и так далее.
+Все это работало благодаря Interceptor
-ам. Interceptor
— это класс, который перехватывает вызываемые нами действия и добавляет в них какую-то функциональность. Таких классов в Kaspresso достаточно много, подробнее о них вы можете почитать в документации
Нас интересует добавление скриншотов, за это отвечают классы ScreenshotStepWatcherInterceptor
, ScreenshotFailStepWatcherInterceptor
и TestRunnerScreenshotWatcherInterceptor
.
before
или after
+Если тест падает, то удобно смотреть не только шаг, на котором произошла ошибка, но и предыдущие – таким образом разобраться в проблеме бывает гораздо проще. Поэтому мы добавим первый вариант Interceptor
-а, который скриншотит все шаги, независимо от результата. Делается это следующим образом:
class LoginActivityGeneratedDataTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple().apply {
+ stepWatcherInterceptors.add(ScreenshotStepWatcherInterceptor(screenshots))
+ }
+)
+
Kaspresso.Builder.simple()
, вызываем у него метод apply
+ kaspressoBuilder = Kaspresso.Builder.simple().apply {
+
+ }
+
и в фигурных скобках добавляем все необходимые настройки.
+В данном случае мы получаем все Interceptor
-ы, которые перехватывают событие выполнения шагов (step
)
stepWatcherInterceptors
+
и добавляем туда ScreenshotStepWatcherInterceptor
.
stepWatcherInterceptors.add(ScreenshotStepWatcherInterceptor(...))
+
Этому interseptor
-у в качестве параметра конструктора нужно передать реализацию интерфейса Screenshots
, то есть экземпляр класса, который реализует данный интерфейс и, соответственно, умеет делать скриншоты. Такой объект уже есть в Kaspresso.Builder
, называется он screenshots
. Мы вызвали функцию apply
у Kaspresso.Builder
, поэтому, находясь внутри этой функции, мы можем напрямую обращаться к переменным и методам данного builder
-а. Обращаемся к переменной screenshots
, передавая ее в качестве параметра.
class LoginActivityGeneratedDataTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple().apply {
+ stepWatcherInterceptors.add(ScreenshotStepWatcherInterceptor(screenshots))
+ }
+)
+
Теперь, когда мы добавили данный Interceptor
, после каждого шага теста, независимо от результата его выполнения, на устройстве будут сохранены скриншоты.
Запускаем. Тест завершился неудачно, и на устройстве были сохранены скриншоты
+ +Давайте вернем корректную реализацию класса TestData
package com.kaspersky.kaspresso.tutorial.data
+
+object TestData {
+
+ fun generateUsername(): String = "Admin"
+
+ fun generatePassword(): String = "123456"
+}
+
Запустим тест еще раз. Тест пройден успешно, и все скриншоты сохранены на устройстве.
+Info
+Обратите внимание на то, что скриншоты сохраняются на тестируемом устройстве. Поэтому, если вы делаете скриншоты для каждого шага независимо от результата, то размер артефактов после прогона тестов может быть очень большим. Это может стать проблемой, особенно если ваши тесты запускаются на CI. Поэтому злоупотреблять скриншотами не следует, используйте их сохранение только в случае необходимости.
+В этом уроке мы узнали, как в наши тесты добавить логирование и скриншоты. Узнали, в каких случаях стандартных логов бывает недостаточно, научились настраивать Kaspresso.Builder
, добавляя в него различные Interceptor
-ы.
На практике часто приходится работать с экранами, которые содержат списки элементов, причем эти списки динамические, и их размер и содержимое могут изменяться. При тестировании таких экранов есть свои особенности. О них мы поговорим в этом уроке.
+Откройте приложение tutorial
и кликните по кнопке List Activity
.
У вас откроется следующий экран:
+ +На нем отображается список дел пользователя. У каждого элемента списка есть порядковый номер, текст и цвет, который устанавливается в зависимости от приоритета. Если приоритет низкий, то цвет фона зеленый, если средний, то оранжевый, если высокий, то красный.
+Также имеется возможность удалять элементы списка при помощи свайпа.
+ + +Давайте напишем тесты на этот экран. Нам понадобится id элементов списка, для их поиска воспользуемся LayoutInspector
+ +Обратите внимание, что все элементы списка лежат внутри RecyclerView
, у которого id rv_notes
. Внутри него лежит три объекта, у которых одинаковые идентификаторы: note_container
, содержащий tv_note_id
и tv_note_text
.
Получается, что протестировать экран обычным способом у нас не получится, так как у всех элементов один и тот же id, вместо этого мы используем другой подход. PageObject экрана со списком заметок будет содержать всего один элемент – RecyclerView
, а элементы списка будут представлять собой отдельные PageObject-ы, данные которых мы будем проверять.
Начинаем создавать тест. Первым делом добавляем 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
, то предполагается, что проверять вы будете элементы списка, а не контейнер с этими элементами. Поэтому при создании экземпляра KRecyclerView
недостаточно передать только matcher, по которому объект будет найден, необходимо передать второй параметр, который называется itemTypeBuilder
.
Info
+Если вы хотите узнать, какие параметры нужно передать в определенный метод или конструктор, то можно нажать комбинацию клавиш ctrl + P
(cmd + P
на Mac OS), и у вас отобразится подсказка, в которой будут указаны необходимые аргументы
Мы уже говорили ранее о том, что Page Object нам понадобится для каждого элемента списка, поэтому необходимо создать соответствующий класс, экземпляр этого класса мы будем передавать в itemTypeBuilder
.
В этом же файле добавляем класс NoteItemScreen
, в этот раз наследуемся не от KScreen
, а от KRecyclerViewItem
, так как сейчас это не обычный Page Object, а элемент списка 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>() {
+
+ }
+}
+
Обратите внимание на то, что раньше при создании Page Object мы писали ключевое слово object
, а здесь нужно написать class
. Причина в том, что все тестируемые экраны до сих пор были в единственном экземпляре, а здесь у нас будет несколько элементов списка, каждый из которых будет Page Object-ом, поэтому мы создаем класс, и для каждого элемента будем получать экземпляр этого класса.
В заметках нам нужны будут элементы - корневой note_container
и два TextView
. Если мы попытаемся найти их на экране по id, то возникнет ошибка, так как на экране таких элементов несколько и непонятно, какой конкретно нам нужен.
Эта проблема решается следующим образом - каждая заметка представляет собой отдельный экземпляр View и поиск элементов мы будем осуществлять не на всем экране, а только внутри этих самых View (заметок). Для реализации такой логики в качестве параметра конструктора KRecyclerViewItem
необходимо передать объект matcher
. Во время тестирования для каждого объекта будет передан свой matcher
, в котором мы найдем необходимые View-элементы.
Поэтому в качестве параметра передаем 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.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) }
+ }
+}
+
Первое - в конструктор View-элементов теперь необходимо передать matcher, в котором будем произведен поиск необходимого объекта. Если этого не сделать, тест завершится неудачно
+Второе - если мы проверяем какое-то специфичное поведение элемента UI, то указываем конкретного наследника KView
(KTextView
, KEditText
, KButton
...). Например, если мы хотим проверить наличие текста, то создаем KTextView
, у которого есть возможность получить текст.
А если мы проверяем какие-то общие вещи, которые доступны во всех элементах интерфейса (цвет фона, размеры, видимость и т.д.), то можно использовать родительский KView. В данном случае мы будем проверять тексты у tvNoteId
и tvNoteText
, поэтому указали тип KTextView
. А контейнер, в котором лежат эти TextView
является экземпляром CardView
, у него мы будем проверять только цвет фона, каких-то специфичных вещей проверять у него нет необходимости, поэтому в качестве типа мы указали родительский - KView
Когда PageObject элемента списка готов, можно создавать экземпляр KRecyclerView
, для этого передаем два параметра:
Первый – builder
, в котором найдем RecyclerView
по его id:
val rvNotes = KRecyclerView(
+ builder = { withId(R.id.rv_notes) },
+)
+
itemTypeBuilder
, здесь необходимо вызвать функцию itemType
, где создать экземпляр NoteItemScreen
:
+val rvNotes = KRecyclerView(
+ builder = { withId(R.id.rv_notes) },
+ itemTypeBuilder = {
+ itemType {
+ NoteItemScreen(it)
+ }
+ }
+)
+
Info
+Подробнее про лямбда-выражения можно почитать здесь.
+Эту запись можно сократить, используя Method Reference, тогда финальная версия класса будет выглядеть так:
+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) }
+}
+
Создаем класс для тестирования, и, как обычно, добавляем переход на данный экран:
+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()
+ }
+ }
+ }
+ }
+}
+
Теперь давайте проверим, что на экране со списком заметок отображается три элемента, для этого у KRecyclerView
можно вызвать метод getSize
:
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
есть множество полезных методов, можете поставить точку после названия объекта и посмотреть все возможности. Например, при помощи firstChild
или lastChild
можно получить соответственно первый или последний элемент NoteItemScreen
. Также можно найти элемент по его позиции или выполнить проверки для абсолютно всех заметок при помощи метода children
. Для их использования в угловых скобках нужно указать тип KRecyclerViewItem
, в нашем случае это NoteItemScreen
.
Давайте проверим видимость всех элементов и что все они содержат какой-то текст:
+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")
+ }
+ }
+ }
+ }
+ }
+}
+
В приложении есть возможность удалять заметки при помощи свайпа. Давайте проверим этот момент – удалим первую заметку и убедимся, что на экране осталось два элемента с соответствующим контентом.
+Чтобы выполнять какие-то действия с View-элементами, мы можем получить объект view
и вызвать у него метод perform
в качестве параметра передав нужное действие. В данном случае выполняем swipe влево, тогда код будет выглядеть следующим образом:
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")
+ }
+ }
+ }
+ }
+ }
+}
+
В последнем шаге мы удаляем элемент по индексу 0 и проверяем, что теперь по этому индексу лежит “Note number 1”.
+Вы могли обратить внимание, что все проверки выполняются сразу после свайпа, даже не дожидаясь завершения анимации. Сейчас тест проходит успешно, но иногда это может привести к ошибкам.
+Поэтому в случаях, когда какое-то действие выполняется с анимацией и для его завершения требуется время, можно вызвать метод device.uiDevice.waitForIdle
. Этот метод остановит выполнения теста до тех пор, пока экран не перейдет в состояние idle (бездействующее) – когда не происходит никаких действий и не выполняются анимации.
Добавляем эту строчку в тест после свайпа, и проверим, что количество элементов стало равно двум:
+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")
+ }
+ }
+ }
+ }
+ }
+}
+
Остался еще один момент, который мы рассмотрим в этом уроке.
+Бывают случаи, когда в Page Object нужно добавить какое-то поведение. Например, сейчас можно делать свайп по элементам списка. В тесте это делается при помощи этой строчки кода view.perform(ViewActions.swipeLeft())
.
Каждый раз, когда нам понадобится сделать свайп, придется выполнять те же действия – получать объект view
, вызывать метод передавая параметр. Вместо этого мы можем в классе Page Object добавить необходимую функциональность и затем использовать ее, где необходимо.
Добавляем метод в класс NoteItemScreen, назовем 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
вызовем созданный нами метод:
+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
+Обратите внимание, что никакой бизнес-логики добавлять в Page Object не нужно. Вы можете наделить эти объекты определенными свойствами, добавить функциональность, но добавлять сложную логику не следует. Page Object должен оставаться моделью экрана с описанными элементами интерфейса и функциями по взаимодействию с этими элементами.
+В этом уроке мы научились тестировать списки элементов, установленные в RecyclerView. Узнали, как можно найти элементы, как взаимодействовать с ними и проверять их поведение на соответствие ожидаемому результату.
+В этом уроке мы познакомимся со сценариями (класс Scenario
из библиотеки Kaspresso), узнаем, что это, для чего они нужны, когда их стоит использовать, а когда лучше избегать.
Открываем приложение tutorial и кликаем по кнопке Login Acitivity
.
У нас открывается экран авторизации, где пользователь может ввести логин и пароль и нажать на кнопку Login
Если поле username
будет содержать менее трех символов или поле password
- менее шести символов, то при клике на кнопку LOGIN
ничего не произойдет.
Если же данные заполнены корректно, то авторизация проходит успешно и у нас открывается экран AfterLoginActivity
.
Получается, что для проверки экрана AfterLoginActivity
пользователь должен быть авторизован в приложении. Поэтому давайте первым делом протестируем авторизацию - LoginActivity
.
Для проверки LoginActivity
необходимо внутри PageObject главного экрана объявить еще одну кнопку для перехода в экран авторизации.
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) }
+}
+
Теперь создаем PageObject для LoginActivity
, назовем 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) }
+}
+
Можем создавать тест LoginActivityTest
. Добавляем шаг – открытие целевого экрана 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()
+ }
+ }
+ }
+ }
+ }
+}
+
Теперь, когда целевой экран открыт, можем тестировать его. На текущем этапе добавим только проверку позитивного сценария, когда пользователь успешно ввел логин и пароль:
+Для того, чтобы проверить, какая активити сейчас открыта, можно воспользоваться методом: device.activities.isCurrent(LoginActivity::class.java)
.
Тогда общий код тестового класса будет выглядеть так:
+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)
+ }
+ }
+ }
+}
+
Запускаем тест. Тест пройден успешно.
+Теперь давайте добавим проверки негативного сценария - если пользователь ввел логин или пароль меньше допустимой длины.
+Здесь нужно придерживаться правила – на каждый test-case свой тестовый метод. То есть проверку на поведение при вводе некорректного логина и пароля мы будем делать не в этом же методе, а создадим отдельные в том же классе LoginActivityTest
.
@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)
+ }
+ }
+}
+
И такой же тест на то, что логин введен верно, а пароль неверно.
+@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)
+ }
+ }
+}
+
Давайте переименуем первый тест, чтобы по его названию было понятно, что мы проверяем именно успешную авторизацию.
+@Test
+fun test()
+
Меняем на:
+@Test
+fun loginSuccessfulIfUsernameAndPasswordCorrect()
+
Запускаем тесты – они все пройдены успешно.
+Обратите внимание на код, который мы используем внутри этих тестов. Для каждого теста мы делаем следующее:
+В зависимости от того, что мы проверяем в каждом конкретном тесте, у нас отличаются первый и последний шаги. На первом шаге мы присваиваем разные значения переменным username
и password
, на последнем шаге мы делаем разные проверки на то, какой экран открыт - LoginActivity
или AfterLoginActivity
.
При этом шаги со второго по четвертый абсолютно одинаковые для всех тестов. Это один из случаев, когда мы можем использовать класс Scenario.
+Сценарии – это классы, которые позволяют объединить в себе несколько step-ов. Например, в данном случае мы можем создать сценарий авторизации, в котором будет пройден весь процесс от старта главного экрана до клика по кнопке Login
после ввода логина и пароля.
В пакете со всеми тестами com.kaspersky.kaspresso.tutorial
создаем новый класс LoginScenario
и наследуемся от класса Scenario
из пакета com.kaspersky.kaspresso.testcases.api.scenario
.
package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.scenario.Scenario
+
+class LoginScenario : Scenario() {
+
+}
+
Здесь возникает ошибка, поскольку класс Scenario является абстрактным, и у него нужно переопределить свойство steps
, в котором мы должны перечислить все шаги данного сценария.
Нажимаем комбинацию клавиш ctrl + i
, выбираем свойство, которое нужно переопределить, и нажимаем 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")
+}
+
Теперь после указания типа TestContext<Unit>.() -> Unit
удаляем строчку get() = TODO("Not yet implemented")
, ставим знак =
и открываем фигурные скобки, в которых перечислим все необходимые шаги.
Info
+В качестве возвращаемого типа у steps
указано лямбда-выражение, которое является extension-функцией класса TestContext. Подробнее про лямбда-выражения и extension-функции вы можете почитать в официальной документации Kotlin.
Скопируем шаги, которые повторяются в каждом тесте.
+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()
+ }
+ }
+ }
+ }
+}
+
Теперь у нас есть сценарий авторизации, в котором мы открываем экран логина, проверяем видимость всех элементов, вводим значения логина и пароля и кликаем на кнопку Login
.
Но возникает одна проблема - в этом классе нет переменных username
и password
, которые нужно ввести в поля ввода. Мы могли бы объявить их прямо здесь внутри теста, как делали в классе LoginActivityTest
,
override val steps: TestContext<Unit>.() -> Unit = {
+ val username = "123456" // Можно объявить переменные здесь
+ val password = "123456"
+
+ step("Open login screen") {
+ ...
+
но в зависимости от проводимого теста эти значения должны отличаться, поэтому присвоить значение внутри теста мы не можем.
+Поэтому вместо того, чтобы указывать логин и пароль прямо внутри сценария, мы можем их указать в качестве параметра в классе Scenario внутри конструктора. Тогда эта часть кода:
+class LoginScenario : Scenario()
+
меняется на:
+class LoginScenario(
+ private val username: String,
+ private val password: String
+) : Scenario()
+
Теперь внутри теста мы не создаем логин и пароль, а используем те, что были переданы нам в качестве параметра в конструктор:
+step("Try to login") {
+ LoginScreen {
+ inputUsername {
+ replaceText(username)
+ }
+ inputPassword {
+ replaceText(password)
+ }
+ loginButton {
+ click()
+ }
+ }
+}
+
Тогда общий код сценария будет выглядеть так:
+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()
+ }
+ }
+ }
+ }
+}
+
Сценарий готов, можем его использовать в тестах. Давайте сначала используем сценарий в первом тестовом методе, а потом по аналогии сделаем и в остальных:
+@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)
+ }
+ }
+}
+
Для остальных тестов делаем по аналогии:
+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)
+ }
+ }
+ }
+}
+
Мы рассмотрели один случай, когда сценариями удобно пользоваться – когда одни и те же шаги используются в разных тестах в рамках тестирования одного экрана. Но это не единственное их предназначение.
+В приложении может быть множество экранов, попасть на которые можно только будучи авторизованным. В этом случае для каждого такого экрана придется заново описывать все шаги авторизации. Но при использовании сценариев это становится очень простой задачей.
+Сейчас после входа у нас открывается экран AfterLoginActivity
. Давайте напишем тест для этого экрана.
Первым делом создаем 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) }
+}
+
Добавляем тест:
+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() {
+
+ }
+}
+
Для того чтобы попасть на этот экран, нам нужно пройти процесс авторизации. Без использования сценариев нам бы пришлось заново выполнять все шаги – запускать главный экран, кликать на кнопку, затем вводить логин и пароль и снова кликать на кнопку. Но сейчас весь этот процесс сводится к использованию 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)
+ }
+ }
+ }
+ }
+ }
+}
+
Таким образом, благодаря использованию сценариев, код становится чистым, понятным и переиспользуемым. А для проверки экранов, доступных только авторизованным пользователям, теперь не нужно делать множество одинаковых шагов.
+Сценарии очень удобная вещь, если ими правильно пользоваться.
+В сегодняшнем уроке мы узнали, что такое сценарии, научились их создавать, использовать и передавать параметры в их конструктор. Также мы рассмотрели случаи, когда их использование приносит пользу проекту, а когда наоборот – ухудшает читаемость кода, увеличивает его связность и усложняет переиспользование.
+В этом уроке мы научимся писать screenshot-тесты, узнаем, зачем они нужны, и как правильно разрабатывать приложение, чтобы его можно было покрыть тестами.
+Ранее для успешного прохождения уроков было достаточно базовых навыков программирования на Kotlin, знания Android-разработки не требовались. Однако сегодня мы начинаем углубленное изучение фреймворка Kaspresso, и для последующих тем потребуется более глубокое понимание устройства приложений, архитектурного шаблона MVVM, применения Dependency Injection и других концепций.
+Если у вас возникают трудности с пониманием этих тем, вы все равно можете приступить к прохождению уроков, чтобы иметь представление о возможностях Kaspresso. Однако имейте в виду, что часть материала может быть непонятной на данном этапе.
+Чтобы узнать, зачем нужны скриншот-тесты, разберем небольшой пример. Представим, что наше приложение должно быть локализовано на французский язык. Для этого в проекте были добавлены переводы в файл strings.xml
в папку values-fr
.
Давайте установим на устройстве французский язык
+ +и запустим LoginActivityTest.
+ +Тест пройден успешно, значит теоретически это приложение рабочее, и его можно раскатывать на пользователей. Но давайте откроем LoginActivity
вручную (французский язык должен быть установлен на устройстве) и посмотрим, как выглядит этот экран.
Видим, что вместо корректных текстов здесь указано «TODO: add french locale». Похоже, что разработчики во время добавления строк оставили комментарий, чтобы добавить переводы в будущем, но забыли это сделать, поэтому приложение выглядит некорректно. Обнаружить эту ошибку тесты не могут, потому что они не знают, какой должен быть текст на французском языке. По этой причине приложение работает неправильно, но тесты проходят успешно.
+Возникшую проблему могут решить скриншот-тесты. Их суть заключается в том, что для всех экранов, где пользователю отображаются строки, создаются так называемые «скриншотилки» – классы, которые делают скриншоты экрана во всех необходимых состояниях и для всех поддерживаемых языков.
+После выполнения таких тестов скриншоты складываются в определенные папки. Затем их можно посмотреть и убедиться, что для всех локалей и для всех состояний используются корректные значения.
+Для создания screenshot-тестов можно воспользоваться уже написанными ранее тестами, внеся в них несколько изменений. В таком случае будут выполняться те же проверки, что и раньше, но также добавится сохранение скриншотов на определенных этапах. Так можно сделать, но это не считается хорошей практикой.
+Дело в том, что screenshot-тесты предназначены для того, чтобы предоставить снимки определенного экрана во всех возможных состояниях и для всех локалей. В некоторых случаях получение всех возможных состояний экрана может занять длительное время.
+К примеру, вам нужно узнать, как будет выглядеть экран, если пользователь только что прошел процесс регистрации. Тогда, для того чтобы получить снимок экрана, вам придется проходить регистрацию заново, причем делать это для каждой локали. Тогда один прогон теста может занять несколько минут вместо двух-трех секунд.
+По этой причине 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 initial state screenshots") {
+
+ }
+ }
+}
+
Чтобы сделать скриншоты и сохранить их в правильные папки на устройстве, необходимо вызвать метод 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 initial state screenshots") {
+ captureScreenshot("Initial state")
+ }
+ }
+}
+
Разрешение на доступ к файлам здесь давать не нужно, это реализовано «под капотом». На данном этапе мы сделали все необходимое, чтобы получить скриншоты экрана и посмотреть, как выглядит приложение на разных локалях, но желательно сделать еще одно изменение.
+Сейчас у нас открывается нужный экран, и сразу делается скриншот, поэтому есть вероятность, что какие-то данные на экране не успеют загрузиться, и снимок будет сделан до того, как мы увидим нужные нам элементы.
+Чтобы решить эту проблему, перед тем, как делать скриншот, мы дождемся загрузки всех необходимых элементов интерфейса. Для всех объектов LoginScreen
мы сделаем проверку на isVisible
. Это проверка в своей реализации использует flakySafely
, поэтому даже если данные мгновенно загружены не будут, то тест будет ждать, пока условие не выполнится в течение нескольких секунд.
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 initial state screenshots") {
+ LoginScreen {
+ inputUsername.isVisible()
+ inputPassword.isVisible()
+ loginButton.isVisible()
+ 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-тестами.
+ + + + + + +В прошлом уроке мы написали тест на экран проверки доступности интернета, код тестового класса выглядел вот так:
+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)
+ }
+ }
+}
+
И мы говорили о том, что одна из проблем этого кода заключается в том, что его сложно читать и поддерживать даже на данном этапе, а если функциональность экрана расширится и нам придется добавлять еще тесты, то код станет абсолютно нечитаемым.
+На самом деле обычно любые тесты (в т.ч. ручные) выполняются по test-кейсам. То есть у тестировщика есть последовательность шагов, которые он выполняет для проверки работоспособности экрана. В нашем случае у нас есть эта последовательность шагов, но записана она сплошным текстом и непонятно, где завершается один шаг и начинается другой. Мы можем решить эту проблему при помощи комментариев.
+Давайте скопируем этот класс WifiSampleTest
и вставим в этот же пакет, но уже с другим названием WifiSampleWithStepsTest
. Это нужно для того, чтобы вы потом смогли сравнить новую и старую реализации этого теста. Код WifiSampleTest
мы сегодня менять не будем. Теперь в новом классе WifiSampleWithStepsTest
мы добавляем комментарии к каждому шагу.
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)
+ }
+ }
+}
+
Это немного улучшит читаемость кода, но всех проблем не решит. Например, у вас какой-то тест упадет, как вы узнаете, на каком шаге это произошло? Вам придется исследовать логи, пытаясь понять, что пошло не так. Было бы гораздо лучше, если бы в логах отображались записи вроде Step 1 started -> ... -> Step 1 succeed
или Step 2 started -> ... -> Step 2 failed
. Это позволит немедленно определить по записям в логе, на каком этапе возникла проблема.
Для этого мы можем сами добавить вывод в лог для каждого шага до и после его выполнения и обернуть это все в блок try catch
, чтобы фиксировать падение теста в логах. В этом случае наш тест выглядел бы следующим образом:
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()
+ }
+ }
+ }
+}
+
Давайте включим интернет на устройстве и проверим работу нашего теста.
+Запускаем. Тест пройден успешно.
+Теперь давайте посмотрим логи. Для этого откройте вкладку Logcat
в нижней части Android Studio
Здесь отображается множество логов и найти наши довольно сложно. Мы можем отфильтровать логи по тэгу, который указали ("KASPRESSO"). Для этого кликните на стрелку в правой верхней части Logcat
и выберите пункт Edit Configuration
У вас откроется окно создания фильтра. Добавьте название фильтра, а также тэг, который нас интересует:
+ +Теперь у нас отображается только полезная информация. Давайте очистим лог
+ +и запустим тест еще раз. Не забываем перед этим включать интернет на устройстве. Читаем логи:
+ +Здесь идут логи, которые мы добавили - шаг 1 запущен, затем выполняются проверки, затем шаг 1 завершился успешно.
+Смотрим дальше:
+ + +Со вторым и третьим шагами также все хорошо. Нам понятно, когда и какой шаг начинает выполнение, видны конкретные действия, которые в данный момент выполняет тест, и виден результат работы теста.
+Теперь давайте выключим интернет и запустим тест еще раз. По нашей логике тест должен завершиться неудачно.
+Несмотря на то, что тест должен был завершиться с ошибкой, все тесты зеленые. Смотрим в лог - сейчас нас интересует step 2, который должен был завершиться неудачно из-за того, что изначально интернет на устройстве выключен
+ +Судя по логам, step 2
действительно завершился неудачно. Был проверен статус заголовка, текст не совпал, программа осуществила еще несколько попыток проверить, что текст на заголовке содержит текст enabled
, но все эти попытки не увенчались успехом и шаг завершился с ошибкой. Почему в этом случае тесты у нас зеленые?
Дело в том, что если тест завершается неудачно, то бросается исключение, и если это исключение никто не обработал в блоке try catch, то тесты будут красными. А мы в коде обрабатываем все исключения для того, чтобы сделать запись в лог о том, что тест завершился с ошибкой.
+try {
+ ...
+} catch (e: Throwable) {
+ /**
+ * Мы обработали исключение и дальше оно проброшено не будет, поэтому такой
+ * тест считается выполненным успешно
+ */
+ Log.i("KASPRESSO", "Step 2. Check correct wifi status -> failed")
+}
+
Для решения этой проблемы необходимо после вывода в лог сообщения об ошибке бросить это исключение дальше, чтобы тест упал. Делается это при помощи ключевого слова throw
. Тогда код теста будет выглядеть следующим образом:
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
+ }
+ }
+ }
+}
+
step 2
в логах больше ничего нет.
+Код, который мы написали, рабочий, но очень громоздкий, и нам приходится для каждого шага писать целое полотно одинакового кода (логи, блоки try catch и т.д).
+Для того чтобы упростить написание тестов и сделать код более читаемым и расширяемым, в Kaspresso были добавлены step-ы. У них "под капотом" реализовано все то, что мы сейчас писали вручную.
+Чтобы использовать step-ы, необходимо вызвать метод run {}
и в фигурных скобках перечислить все шаги, которые будут выполнены во время теста. Каждый шаг нужно вызывать внутри функции step.
Давайте напишем это в коде. Для начала удаляем все лишнее - логи и блоки try catch.
+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)
+ }
+ }
+}
+
Теперь в начале теста мы вызываем метод run, внутри которого для каждого шага вызываем функцию step
. Этой функции в качестве параметра передаем название шага.
@Test
+ fun test() {
+ run {
+ step("Open target screen") {
+ ...
+ }
+ step("Check correct wifi status") {
+ ...
+ }
+ step("Rotate device and check wifi status") {
+ ...
+ }
+ }
+ }
+
Внутри каждого 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() {
+ 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)
+ }
+ }
+ }
+ }
+}
+
Включаем интернет на устройстве и запускаем тест. Тест пройден успешно. Смотрим логи:
+ +Таким образом, благодаря использованию step-ов, не только наш код стал более понятным и легким для восприятия, но также и логи имеют понятную структуру и позволяют быстро определить, какие этапы выполнялись и какой результат этих операций.
+Давайте еще раз запустим этот тест теперь уже с выключенным интернетом. Тест падает. Смотрим логи.
+ +Теперь искать ошибку в тесте становится гораздо проще благодаря понятным логам.
+Наш код стал гораздо лучше, но осталась одна важная проблема: необходимо, чтобы перед каждым тестом устройство приходило в дефолтное состояние - интернет должен быть включен и установлена книжная ориентация.
+В Kaspresso есть возможность добавить блоки before
и after
. Код внутри блока before
будет выполняться перед тестом - здесь мы можем установить настройки по умолчанию. Код внутри блока after
будет выполнен после теста. Во время выполнения теста состояние телефона может меняться: мы можем выключить интернет, сменить ориентацию, но после теста нужно вернуть исходное состояние. Делать это мы будем внутри блока after
.
Тогда код теста будет выглядеть следующим образом:
+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 {
+ /**
+ * Перед тестом устанавливаем книжную ориентацию и включаем Wifi
+ */
+ 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()
+ wifiStatus.hasText(R.string.disabled_status)
+ }
+ }
+ }
+ }
+}
+
Тест практически готов, можем добавить одно небольшое улучшение. Сейчас после переворота устройства мы проверяем, что текст остался прежним, но не проверяем, что ориентация действительно поменялась. Получается, что если метод device.expoit.rotate()
по какой-то причине не сработал, то ориентация не поменяется и проверка на текст будет бесполезной. Давайте добавим проверку, что ориентация девайса стала альбомной.
Assert.assertTrue(device.context.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE)
Теперь полный код теста выглядит так:
+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)
+ }
+ }
+ }
+ }
+}
+
В этом уроке мы значительно улучшили наш код, он стал чище, понятнее, и его стало легче поддерживать. Это стало возможным благодаря таким функциям Kaspresso, как step
, before
и after
. Также мы научились выводить сообщения в лог, а также читать логи, фильтровать их и анализировать.
В предыдущих уроках мы научились писать тесты для элементов пользовательского интерфейса, которые расположены в нашем приложении. Но часто бывают случаи, когда для полноценного тестирования этого недостаточно, и помимо нашего приложения нужно выполнить какие-то действия за его пределами.
+В качестве примера давайте проверим стартовый экран приложения Google Play в неавторизованном состоянии.
+Не забудьте перед запуском теста разлогиниться в приложении.
+Приступаем к написанию теста – создаем класс GooglePlayTest
и наследуемся от TestCase
:
package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+
+class GooglePlayTest : TestCase() {
+}
+
Добавляем тестовый метод:
+package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+import org.junit.Test
+
+class GooglePlayTest : TestCase() {
+
+ @Test
+ fun testNotSignIn() = run {
+
+ }
+}
+
Первый шаг, который нам нужно сделать – запустить Google Play, для этого нам понадобится название пакета приложения. У Google Play это com.android.vending
, позже мы покажем, где можно узнать эту информацию.
Это название пакета в тесте мы будем использовать несколько раз, поэтому, чтобы не дублировать код, создадим константу, куда вынесем это название:
+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"
+ }
+}
+
Для запуска любого экрана в Android нам нужен объект Intent
. Чтобы получить необходимый Intent, мы будем использовать следующий код:
val intent = device.targetContext.packageManager.getLaunchIntentForPackage(GOOGLE_PLAY_PACKAGE)
+
Если говорить коротко, то Context предоставляет доступ к различным ресурсам приложения и позволяет выполнять множество действий, в том числе открывать экраны при помощи Intent-ов. Intent содержит информацию о том, какой именно экран мы хотим открыть, а PackageManager в данном случае позволяет получить Intent для открытия стартового экрана конкретного приложения по названию пакета.
+Info
+Для получения Context
можно воспользоваться методами targetContext
и context
у объекта device
. У них есть одно существенное отличие.
+Когда мы хотим проверить работу какого-то приложения и запускаем автотест, то на самом деле на устройство устанавливается два приложения: то, которое мы тестируем (в данном случае tutorial) и второе, которое запускает все тестовые сценарии.
+Когда мы вызываем метод targetContext
, то обращаемся к тестируемому приложению (tutorial), а если мы вызываем метод context
, то обращение будет уже ко второму приложению, которое запускает тесты.
val intent = device.targetContext.packageManager.getLaunchIntentForPackage(GOOGLE_PLAY_PACKAGE)
+
В приведенном выше коде мы первым делом получаем targetContext
у объекта device
– мы это уже делали в одном из предыдущих уроков. Затем, у targetContext
мы получаем packageManager
, из которого можно получить Intent
для запуска экрана Google Play при помощи метода getLaunchIntentForPackage
.
Данный метод возвращает Intent
для запуска стартового экрана приложения, пакет которого был передан в качестве параметра. Для этого мы передаем название пакета того приложения, которое хотим запустить, в данном случае Google Play.
Мы получили Intent
, теперь с его помощью запустим экран. Для этого у объекта targetContext
нужно вызвать метод startActivity
и передать intent в качестве параметра:
val intent = device.targetContext.packageManager.getLaunchIntentForPackage(GOOGLE_PLAY_PACKAGE)
+device.targetContext.startActivity(intent)
+
В этом коде мы дважды получаем targetContext
у объекта device
. Чтобы не дублировать код, можно эту запись сократить, использовав функцию with
Info
+Подробнее про with
и другие функции области видимости (англ. scope functions) вы можете почитать в документации.
Тогда код теста будет выглядеть так:
+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"
+ }
+}
+
with
, apply
, и другими функциями области видимости, то можно обойтись и без них, в этом случае код теста будет выглядеть так:
+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"
+ }
+}
+
Запускаем. Тест пройден успешно, на устройстве открывается приложение Google Play.
+Теперь нам нужно проверить, что на открывшемся экране есть кнопка с текстом Sign in
. Это не наше приложение, у нас нет доступа к исходному коду, поэтому получить id кнопки через Layout Inspector не получится. Нужно использовать другие инструменты.
UI Automator – это библиотека для поиска компонентов на экране и эмуляции действий пользователя (клики, свайпы, ввод текста и т.д.). Он позволяет управлять приложением так, как бы это делал пользователь – взаимодействовать с любыми его элементами.
+Благодаря этой библиотеке, мы можем тестировать любые приложения, выполнять в них различные действия, несмотря на то что у нас нет доступа к их исходному коду.
+Info
+Более подробно про UiAutomator и его возможности вы можете почитать в документации.
+В Android SDK также встроена программа Ui Automator Viewer. Она позволяет найти id элементов, с которыми мы хотим взаимодействовать, их позицию и другие полезные атрибуты.
+Для того чтобы запустить Ui Automator Viewer, нужно открыть командную строку в папке ../Android/sdk/tools/bin
и выполнить команду uiautomatorviewer
.
У вас должно открыться вот такое окно:
+ +Если этого не произошло и в консоли отобразилась какая-то ошибка, то следует погуглить текст ошибки.
+Наиболее распространенная проблема – версия Java не совместима с uiautomatorviewer. В таком случае следует установить Java 8 (важно, чтобы данная версия была выпущена компанией Oracle) и прописать к ней путь в переменных среды. Как это сделать, мы разбирали в уроке Выполнение adb-команд
+Вернемся к написанию теста. Проверять мы будем приложение Google Play, и, чтобы взаимодействовать с ним из Ui Automator Viewer, необходимо запустить его на эмуляторе и кликнуть на кнопку Device Screenshot
:
На некоторых версиях ОС эти иконки изначально скрыты, поэтому, если они у вас не видны, просто растяните экран.
+В правой части видно всю информацию об элементах пользовательского интерфейса. Сейчас нас интересует кнопка Sign in
. Кликаем на этот элемент и смотрим информацию о кнопке:
Здесь вы можете видеть некоторую полезную информацию:
+Если по какой-то причине вам неудобно пользоваться Ui Automator Viewer, или вы не смогли его запустить, то можно воспользоваться приложением Developer Assistant. Его можно скачать в Google Play.
+После установки и запуска Developer Assistant необходимо в настройках выбрать его, как приложение-ассистент по умолчанию. Для этого кликните на кнопку Choose
и следуйте инструкциям:
После настройки вы можете запускать анализ приложений. Открывайте приложение Google Play и осуществите долгое нажатие по кнопке Home
:
У вас появится окно с информацией о приложении, которое можно при необходимости переместить или расширить. На вкладке App
есть информация о приложении – название пакета, запущенная в настоящий момент Activity и т.д.
На вкладке Element
можно исследовать элементы пользовательского интерфейса.
Здесь есть все те же атрибуты, которые мы видели в Ui Automator Viewer
.
В некоторых случаях, о которых мы поговорим дальше в этом уроке, использовать Developer Assistant не получится, поскольку он не умеет отображать информацию о системном UI (уведомления, диалоги и т.д.). Если вы оказались в такой ситуации, что возможностей Developer Assistant недостаточно, а Ui Automator Viewer запустить не удалось, то есть третий вариант – выполнить adb shell-команду uiautomator dump
.
Для этого на эмуляторе откройте экран, информацию о котором вам нужно получить (в данном случае Google Play). Откройте консоль и выполните команду:
+adb shell uiautomator dump
+
На вашем эмуляторе должен был появиться файл window_dump.xml
, который можно найти через Device Explorer
. Если он у вас не отображается, то выберите папку sdcard
и нажмите Synchronize
:
Если после этих шагов файл все равно не появился, то выполните еще одну команду в консоли:
+adb pull /sdcard/window_dump.xml
+
После этого найдите файл на вашем компьютере через Device File Explorer
и откройте его в Android Studio:
Этот файл представляет собой описание экрана в формате xml. Тут можно также найти все необходимые объекты, их свойства и id. Если он у вас отображается в одну строчку, то следует сделать автоформатирование, чтобы было легче читать код. Для этого нажмите комбинацию клавиш ctrl + alt + L
на Windows или cmd + option + L
на Mac.
Можно найти кнопку логина и посмотреть все ее атрибуты. Для этого нажимаем комбинацию клавиш ctrl + F
(или cmd + F
на Mac) и вводим текст, который установлен на кнопке «Sign in».
Теперь, когда мы нашли нужные нам элементы интерфейса, можем приступать к тестированию. Как обычно мы начнем с создания Page Object экрана Google Play
+package com.kaspersky.kaspresso.tutorial.screen
+
+object GooglePlayScreen {
+
+}
+
Ранее все Page Object-ы мы наследовали от класса KScreen
. В этом случае нам нужно было переопределить два свойства: layoutId
и viewClass
override val layoutId: Int? = null
+override val viewClass: Class<*>? = null
+
Мы так делали, потому что тестировали экран, который находится внутри нашего приложения, у нас был доступ к исходному коду, макету и Activity, с которой работаем. Но сейчас мы хотим протестировать экран из стороннего приложения, поэтому искать какие-то элементы в нем, кликать по кнопкам и выполнять любые другие действия с ним тем способом, который применяли в прошлых уроках, невозможно.
+Для этих целей в Kaspresso есть компонент Kautomator - обертка над известным инструментом UiAutomator. Kautomator позволяет значительно упростить написание тестов, а также добавляет ряд преимуществ в сравнении с UiAutomator, о которых подробно можно почитать в Wiki.
+Page object-ы для экранов сторонних приложений нужно наследовать не от KScreen
, а от UiScreen
. Дополнительно требуется переопределить свойство packageName
, чтобы оно возвращало название пакета тестируемого приложения:
package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.components.kautomator.screen.UiScreen
+
+object GooglePlayScreen : UiScreen<GooglePlayScreen>() {
+
+ override val packageName: String = "com.android.vending"
+}
+
Далее все элементы пользовательского интерфейса будут представлять собой экземпляры классов с приставкой Ui
(UiButton
, UiTextView
, UiEditText
...), а не K
(KButton
, KTextView
, KEditText
...), как это было раньше. Дело в том, что сейчас мы тестируем другое приложение и нам нужна функциональность, доступная в компонентах Kautomator.
На этом экране нас интересует кнопка signIn
, добавляем ее:
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 { }
+}
+
В фигурных скобках UiButton {…}
нужно использовать какой-то matcher, благодаря которому мы найдем элемент на экране. Ранее мы использовали только withId
, но сейчас id кнопки не доступен и придется использовать какой-то другой.
Чтобы посмотреть все доступные matcher-ы, можно перейти в определение UiButton
(удерживая ctrl
, кликаем левой кнопкой мыши по названию класса). Внутри него вы увидите класс UiViewBuilder
:
В классе UiViewBuilder
находится множество matcher-ов, которые вы можете использовать. Перейдя в него (удерживая ctrl
, кликаем левой кнопкой мыши по названию класса) можно увидеть полный актуальный список:
Например, можно использовать withText
, чтобы найти элемент, содержащий определенный текст, или при помощи withClassName
найти экземпляр какого-то класса.
Давайте найдем кнопку по тексту, который на ней указан
+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") }
+}
+
Можем добавлять тест – проверим, что на экране Google Play отображается кнопка логина:
+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"
+ }
+}
+
Запускаем. Тест пройден успешно.
+Мы рассмотрели один вариант, когда для тестирования нужно использовать UI automator – если мы взаимодействуем со сторонним приложением. Но это не единственный случай, когда его стоит применять.
+Давайте откроем наше приложение tutorial
и перейдем на экран Notification Activity
:
Кликаем по кнопке “Show notification” – сверху отображается уведомление.
+Info
+Подробнее про уведомления (notifications) в Android можно почитать здесь.
+Давайте попробуем протестировать этот экран.
+Сначала создадим Page Object для экрана с кнопкой «Показать уведомление». Этот экран находится в нашем приложении, значит можем унаследоваться от KScreen
. Id кнопки можем найти через 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) }
+}
+
В Page Object главного экрана добавим кнопку открытия 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) }
+}
+
Можно создавать тест, сначала просто покажем уведомление, кликнув на кнопку на главном экране.
+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()
+ }
+ }
+ }
+}
+
Запускаем. Тест пройден успешно, уведомление отображается.
+Теперь давайте проверим, что заголовок и контент самого уведомления содержат необходимый текст.
+Найти id элементов при помощи Layout Inspector
или Developer Assistant
не получится, т.к. отображение уведомлений относится к системному UI. В этом случае нам придется использовать один из двух вариантов : запустить Ui Automator Viewer и посмотреть через него, либо выполнить команду adb shell uiautomator dump
.
Далее мы покажем решение через Ui Automator Viewer
, а также прикрепим скриншот, где найти View-элементы в файле window_dump.xml
Открываем список уведомлений и делаем скриншот:
+ +При помощи команды dump
необходимые элементы можно найти следующим образом
Здесь по названию пакета вы можете видеть, что шторка уведомлений не относится к нашему приложению, поэтому для тестирования необходимо унаследоваться от класса UiScreen и использовать Kautomator.
+Создаем Page Object экрана уведомления:
+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
было указано значение, полученное с помощью dump
или Ui Automator Viewer
.
Объявляем элементы, с которыми будем взаимодействовать.
+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 { }
+}
+
Найти элементы можно по разным критериям, например по тексту или по id. Давайте найдем элемент по его id. Вызываем 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("", "") }
+}
+
Первым параметром нужно передать название пакета, в ресурсах которого будет осуществлен поиск элемента. Мы могли бы передать ранее полученные значения packageName
и resource_id
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") }
+}
+
Но в таком случае элементы не будут найдены. Схема id
элемента, который мы ищем на экране другого приложения, выглядит так: package_name:id/resource_id
. Эта строка будет сформирована из двух параметров, которые мы передали в метод withId
. Вместо package_name
будет подставлено имя пакета com.android.systemui
, вместо resource_id
– идентификатор android:id/title
. В итоге получившийся resource_id будет выглядеть так: com.android.systemui:id/android:id/title
. Получается, что символы :id/
будут добавлены за нас, а передавать нам нужно только то, что правее косой черты, это и есть идентификатор:
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") }
+}
+
Теперь полный resource_id выглядит так: com.android.systemui:id/title
и com.android.systemui:id/text
Обратите внимание на то, что первая часть (package_name
) отличается от того, что указано в Ui Automator Viewer
, мы указали название пакета com.android.systemui
, а в программе написано android
.
Дело в том, что в каждом приложении могут быть свои ресурсы, и в этом случае первая часть идентификатора ресурса будет содержать имя пакета того приложения, где ресурс создан, а также приложение может использовать ресурсы системы Android. Они являются общими для разных приложений и имеют название пакета android
.
Это как раз такой случай, поэтому в качестве первого параметра мы указываем android
.
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") }
+}
+
Теперь можем добавлять проверки на данный экран. Убедимся, что в заголовке и в теле уведомления установлены правильные тексты:
+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")
+ }
+ }
+ }
+}
+
Запускаем. Тест пройден успешно.
+В этом уроке мы научились запускать тесты для сторонних приложений, а также узнали, как можно проверить системный UI при помощи UiAutomator
, а точнее его обертки - Kautomator
. Кроме того, мы познакомились с программами, позволяющими анализировать UI приложений, даже если у нас нет доступа к их исходному коду – это Ui Automator Viewer, Developer Assistant и UiAutomator Dump.
В этом уроке мы создадим тест, который проверяет работу экрана Internet Availability (WifiActivity
)
Запускаем наше приложение tutorial и кликаем по кнопке Internet Availability
.
Давайте сначала протестируем этот экран руками.
+Изначально у нас есть кнопка CHECK WIFI STATUS
, больше никакого текста на экране нет. На текущий момент Wifi на устройстве включен.
Кликаем на кнопку.
+ +Эта кнопка кликабельна, после клика отображается корректный статус состояния Wifi - enabled. Отключаем Wifi.
+ +Кликаем на кнопку снова и проверяем статус Wifi сейчас:
+ +Состояние определяется корректно. Последняя проверка - давайте перевернем устройство и убедимся, что текст на экране сохраняется.
+ +Текст сохраняется успешно, все тесты пройдены. Теперь нам необходимо добиться такого результата, чтобы все те же проверки выполнялись в автоматическом режиме.
+Сейчас во время теста нужно будет автоматически включать и выключать интернет, а также менять ориентацию устройства на альбомную. Это выходит за рамки ответственности нашего приложения, а значит, для тестов нам придется использовать adb-команды. Для этого необходимо, чтобы был запущен ADB-сервер. Мы разбирали этот момент в предыдущем уроке. Если вдруг забыли, как это делается, пересмотрите его.
+Сейчас в нашем тесте нужно будет на главном экране кликнуть по кнопке Internet Availability
. А значит, необходимо доработать 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.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) }
+}
+
Теперь можем добавлять новый тестовый класс. В том же пакете, где у нас лежат другие тесты, мы добавляем WifiSampleTest.
+package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+
+class WifiSampleTest: TestCase() {
+
+}
+
Для проверки экрана с доступностью Internet на него нужно перейти. Для этого мы проделаем такие же шаги, как в уроке, в котором писали наш первый автотест:
+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()
+ }
+ }
+ }
+}
+
Запускаем. Тест пройден успешно и экран проверки Wifi запускается. Теперь можем тестировать его.
+Для полноценного тестирования этого экрана нам понадобится менять состояние подключения к Wifi, а также менять ориентацию устройства. Для этого в классе BaseTestCase
(от которого унаследован наш класс WifiSampleTest
) есть экземпляр класса Device
, который так и называется device
. Мы уже сталкивались с ним в предыдущем уроке, когда получали packageName нашего приложения.
У этого объекта есть множество полезных методов, подробно про которые вы можете почитать тут.
+Первым делом нас интересует метод, который включает/отключает интернет. За работу с сетью отвечает объект network
, который есть в классе Device
.
Если мы хотим изменить состояние Wifi, то можем это сделать следующим образом:
+/**
+* В качестве параметра передаем тип boolean, false если хотим выключить Wifi, true - если хотим включить
+*/
+device.network.toggleWiFi(false)
+
Кроме Wifi мы можем также управлять мобильной сетью, а также интернет-подключением на устройстве в целом (Wifi + мобильная сеть). Для того чтобы посмотреть все доступные методы, можно перейти в документацию, указанную выше, но есть способ проще - после названия объекта поставить точку и посмотреть, какие методы можно вызвать у этого объекта. По их названию обычно понятно, что они делают.
+ +Давайте напишем тест, который выполнит все необходимые проверки, кроме переворота устройства - переворотом мы займемся чуть позже. Первым делом нужно создать Page Object экрана проверки интернет-подключения WifiScreen
. Добавляем его в пакете com.kaspersky.kaspresso.tutorial.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
+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) }
+}
+
Теперь добавляем шаги:
+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")
+ }
+ }
+}
+
Вспоминаем, что использовать захардкоженные строки не стоит, лучше вместо них использовать строковые ресурсы.
+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
+Не забывайте перед запуском теста включить Wifi на устройстве, т.к. после каждого запуска он у вас будет выключен и при втором прогоне тест не пройдет.
+Теперь нам нужно научиться переворачивать устройство, чтобы выполнить остальные проверки. За переворот устройства отвечает объект exploit
из класса Device
, про который вы также можете подробнее почитать в документации
Весь процесс теста теперь будет выглядеть следующим образом:
+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)
+ }
+ }
+}
+
Итак, в этом уроке мы попрактиковались с объектом device
, научились менять статус интернет-соединения и ориентацию экрана из тестового класса. При этом тест запускается, все проверки завершаются успешно, но в нашем коде есть несколько серьезных проблем:
В следующих уроках мы узнаем, как можно улучшить этот код и решить возникшие проблемы.
+В прошлом уроке мы написали первый тест на Kaspresso, и на данном этапе наш тест умеет взаимодействовать с элементами интерфейса приложения, может на них каким-то образом воздействовать (например, клик по кнопке) и проверять их состояние (видимость, кликабельность и т.д.).
+Но часто бывают случаи, когда для тестирования недостаточно использовать возможности только нашего приложения. Например, проверить работу приложения в различных внешних состояниях:
+Во всех перечисленных сценариях тест должен управлять устройством и выполнять команды, которые находятся вне зоны ответственности приложения, которое мы тестируем. В этих случаях мы можем использовать возможности Android Debug Bridge
(ADB
).
ADB
- это инструмент командной строки, который позволяет взаимодействовать с девайсом посредством различных команд. С их помощью вы можете выполнять такие действия, как установка и удаление программ, получение списка установленных приложений, запуск определенной Activity, отключение интернет-соединения и многое другое.
Все adb команды мы можем выполнять сами через командную строку, при этом библиотека Kaspresso поддерживает работу с adb и может выполнять их в автоматическом режиме. Для того, чтобы тесты, которые работают с adb, могли выполняться, необходимо запустить adb-server.
+Процесс запуска adb-server очень простой, если на вашем компьютере корректно прописаны пути к java и adb. Но если пути не прописаны, то придется их прописывать. Поэтому первое, что мы сделаем - проверим, требуется ли какая-то дополнительная работа или у вас и так все готово для запуска adb-server.
+Откройте командную строку.
+На Windows - комбинация клавиш Win + R
, в открывшемся окне вводим cmd
и нажимаем Enter
.
Сначала проверяем, что путь к java прописан корректно. Для этого пишем java -version
.
Если все хорошо, то вы увидите вашу версию Java.
+ +Если же пути прописаны некорректно, то вы увидите что-то похожее на это:
+ +Теперь делаем такую же проверку для adb. Печатаем в консоли adb version
.
Если все хорошо, то вы увидите вашу версию ADB.
+ +В противном случае вы увидите примерно вот такую ошибку:
+ +Если по обоим пунктам у вас все работает, то следующий шаг можете пропустить.
+Решение возникших проблем может отличаться в зависимости от вашей операционной системы и некоторых других факторов, поэтому мы здесь приведем самый популярный вариант решения для OS Windows. Если у вас другая ОС либо по какой-то причине данное решение вам не поможет, то поищите информацию в интернете, как сделать приведенные ниже действия в вашей ситуации. Не решив этих проблем, запустить adb-server не получится и тесты работать не будут.
+Если вы дошли до этого урока, значит успешно запустили приложение из Android Studio на эмуляторе, а это значит, что java и adb на вашем компьютере установлены. Просто система не знает, где искать эти программы. Что нужно сделать - найти расположение этих программ и прописать в системе пути к ним.
+Ищем путь к java, обычно она находится в папке jre\bin
(в некоторых версиях она будет находится в jbr\bin
). Часто ее можно найти по пути C:\Program Files\Java\jre1.8.0\bin
.
Если нашли - копируйте этот путь, если нет - открывайте Android Studio. Переходите в File
-> Settings
-> Build, Execution, Deployment
-> Build Tools
-> Gradle
.
Тут будет прописан путь к нужной папке - скопируйте его.
+Теперь его нужно прописать в переменных среды, для этого кликаем win + x
-> выбираем System
-> Advanced System Settings
-> Advanced
-> Environment Variables
.
В разделе System Variables
выбираем Path
и нажимаем Edit
-> New
-> Вставляем скопированный путь к папке с java
-> Нажимаем OK
.
+
Перезапускаем компьютер, чтобы изменения вступили в силу и снова проверяем команду java -version
.
+
Нам осталось проделать все то же самое для adb. Ищем путь к папке platform-tools
, в которой лежит adb
.
Открываем Android Studio
-> Tools
-> SDK Manager
. В поле Android SDK Location
указан путь к папке Sdk
, в которой находится platform-tools
.
Копируем этот путь и добавляем в System Variables
, как мы это делали ранее с java
.
+
Перезапускаем компьютер и проверяем команду adb version
.
+
Теперь можем приступить к запуску adb-server. Если у вас все еще команды java
и adb
не работают, то погуглите, вариантов решения проблемы достаточно много. Все, что нужно сделать - найти путь к java и adb и прописать их в переменные среды.
Перед запуском тестов, давайте посмотрим, что можно сделать с помощью adb, рассмотрим несколько команд.
+Во-первых, можем посмотреть, какие устройства сейчас подключены к adb. Для этого вводим команду adb devices
.
+
Сейчас мы не подключили никакое устройство к adb, поэтому список пустой, давайте запустим приложение на эмуляторе и выполним команду еще раз.
++
Теперь в списке устройств отображается наш эмулятор.
+С помощью adb-команд мы можем:
+Для практики давайте удалим приложение tutorial, которое мы только что запустили. Это делается при помощи команды adb uninstall package_name
+
Наиболее интересные задачи можно выполнять, если запустить команду adb shell
. Она вызывает консоль Android (shell
) для выполнения Linux-команд на устройстве.
+
Приведем несколько примеров таких команд.
+Получение списка всех установленных приложений pm list packages
.
+
Обратите внимание, что мы сначала запустили shell-консоль, а потом писали команды, уже находясь в ней. Поэтому на текущем этапе другие adb команды у вас работать не будут, пока вы не закроете shell-консоль через команду exit.
++
При этом выполнять shell-команды можно и не открывая shell-консоль, для этого достаточно указывать полное наименование команды вместе с adb shell
. Например, давайте попробуем сделать скриншот и сохраним его на устройстве. В Android Studio можно открыть File Explorer, в котором отображаются все файлы и папки на девайсе.
+
Обычно скришоты сохраняют на sdcard, мы поступим так же.
+Для создания скриншота используется команда adb shell screencap /{pathToFile}/{name_of_image.png}
. В нашем случае она будет выглядеть так: adb shell screencap /sdcard/my_screen.png
.
+
В Device File Explorer
кликаем правой кнопкой мыши и нажимаем Synchronize
, после чего в папке отобразится созданный нами скриншот.
+
Итак, мы немного попрактиковались в работе с adb, теперь нам нужно научиться работать с ним во время прогона теста. То есть тест, который мы создадим, должен уметь запускать adb-команды и проверять работу приложения после выполнения этих команд.
+Для того чтобы тесты могли выполнять adb-команды, необходимо на нашем компьютере запустить adb-server. Сперва нужно скачать файл adbserver-desktop.jar
на официальном гитхабе Kaspresso и выполнить следующую команду в терминале:
java -jar <path/to/file>/adbserver-desktop.jar
+
Для того, чтобы в консоли был корректно прописан путь к файлу, достаточно написать команду java -jar
и просто перетянуть файл adbserver-desctop.jar
в консоль, путь к файлу будет подставлен автоматически.
+
После ввода команды, нажмите Enter
. Запустится AdbServer. При запуске теста девайс сообщит десктопу необходимые для выполнения теста adb команды.
+
Можем приступить к созданию автотеста.
+В пакете com.kaspersky.kaspresso.tutorial
создаем новый класс AdbTest
и наследуемся от класса TestCase
.
package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+
+class AdbTest : TestCase() {
+}
+
В Kaspresso для работы с adb есть специальная абстракция AdbServer
. Экземпляр этого класса доступен в BaseTestContext
и в BaseTestCase
, наследником которого является наш класс AdbTest
.
Ранее в консоли мы запускали команду adb devices
, которая выводила список подключенных устройств. Давайте запустим эту же команду при помощи теста. Создаем метод test()
и помечаем аннотацией @Test
.
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
, performCmd
или performShell
. По названиям методов должно быть понятно, что они делают.
+Сейчас мы хотим вызвать adb команду devices
, вызываем соответствующий метод 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")
+ }
+}
+
С первым пунктом мы разобрались раньше, сейчас давайте разберемся со вторым. Каждое приложение, которое взаимодействует с интернетом, должно содержать разрешение на использование интернета. Оно прописывается в манифесте.
++
Если вы забудете указать это разрешение, тест работать не будет.
+Сейчас тест запускает adb-команду, но не проверяет результат ее выполнения. Данная команда adb devices
возвращает список строк с результатом (тип List<String>
). На данный момент эта коллекция (список строк) содержит всего одну строку вот такого вида: exitCode=0, message=List of devices attached emulator-5555 device
. Давайте добавим проверку, что первый (и единственный) элемент этой коллекции содержит слово "emulator". Просто для того, чтобы попрактиковаться и убедиться, что мы корректно получаем результат выполнения команды adb.
package com.kaspersky.kaspresso.tutorial
+
+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( // Для проверки на то, что какое-то условие выполняется, можно воспользоваться методом Assert.assertTrue(), обратите внимание на импорты
+ Assert.assertTrue("emulator" in result.first()) //тут метод in проверяет, что в ответе (первый элемент из списка result) содержит слово "emulator"
+ )
+ }
+}
+
Теперь давайте попробуем выполнить несуществующую adb команду. Сначала посмотрим, как ее выполнение выглядит в терминале. Выполним adb undefined_command
.
Info
+Обращаем ваше внимание, что в терминале сейчас запущен adb-server, если мы хотим работать с командной строкой, пока запущен сервер, нужно запустить еще одно окно терминала и работать в нем
++
При выполнении этой команды внутри теста у нас будет брошено исключение AdbServerException
и в поле message будет содержаться строка с текстом, который мы видели в консоли: unknown command undefined_command
. Чтобы тест не завершился с ошибкой, нам нужно обработать это исключение в блоке try catch
и внутри блока catch
можем добавить проверку, что сообщение об ошибке действительно содержит текст, указанный выше.
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)
+ }
+ }
+}
+
Мы научились запускать adb-команды внутри тестов. Давайте попрактикуемся в adb shell командах. Ранее мы получали список установленных приложений при помощи запроса вида adb shell pm list packages
. Сейчас мы выполним его внутри теста и проверим, что наше приложение находится в списке установленных.
val packages = adbServer.performShell("pm list packages")
+ Assert.assertTrue("com.kaspersky.kaspresso.tutorial" in packages.first())
+
performShell
, то писать adb shell
не нужно.
+Сейчас мы захардкодили имя пакета приложения, но есть способ гораздо удобнее, внутри тестов мы можем взаимодействовать с объектом Device, получать какую-то информацию об устройстве, текущем приложении и многое другое. Из этого объекта мы можем получить название пакета текущего приложения. Для этого у объекта device
нужно обратиться к свойству targetContext
и у контекста получить packageName
. Код теста в этом случае изменится на такой:
...
+val packages = adbServer.performShell("pm list packages")
+Assert.assertTrue(device.targetContext.packageName in packages.first())
+...
+
Последний тип команд, которые мы рассмотрим в этом уроке - команды cmd. Это те команды, которые мы пишем в консоли. Например, чтобы запустить adb-команду, мы в консоли пишем adb command_name
. Теперь, если мы в тесте вместо performAdb
вызовем performCmd
, то нам нужно будет написать команду целиком:
val result = adbServer.performCmd("adb devices")
+Assert.assertTrue("emulator" in result.first())
+
Для практики можем выполнить какую-нибудь cmd-команду. Например, hostname
выводит название хоста (вашего компьютера). Если мы запустим ее в консоли, то результат будет примерно следующим:
+
Давайте эту же команду выполним внутри теста и проверим, что результат не пустой.
+val hostname = adbServer.performCmd("hostname")
+Assert.assertTrue(hostname.isNotEmpty())
+
Запускаем. Тест пройден успешно.
+Один из тестов, который мы добавили, проверяет, что в списке подключенных устройств есть эмулятор.
+val result = adbServer.performCmd("adb devices")
+Assert.assertTrue("emulator" in result.first())
+
AdbTest
будет выглядеть следующим образом:
+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())
+ }
+}
+
В этом уроке мы узнали, что такое adb
, настроили работу adb-server
, научились выполнять различные типы команд (cmd
, adb
, shell
) в консоли и в автотестах, а также узнали про объект Device
, у которого мы можем получать различную информацию об устройстве и приложении, которое мы тестируем.
+
+
В Android Studio вы можете переключаться между ветками и, таким образом, видеть разные версии проекта. Изначально, после загрузки Kaspresso вы будете находиться в главной ветке - master
.
В этой ветке находится исходный код приложения, которое мы будем покрывать тестами. В текущем и последующих уроках будет приведена пошаговая инструкция в формате codelabs по написанию автотестов. Итоговый результат со всеми написанными тестами доступен в ветке TECH-tutorial-results
, вы в любой момент сможете переключиться на нее и посмотреть решение.
Для этого кликните на название ветки, в которой находитесь, и в поиске введите название интересующей вас ветки.
+ +Прежде чем приступать к написанию теста, давайте поближе познакомимся с функционалом, который мы будем покрывать автотестами. Для этого переключаемся на master-ветку.
+Открываем выбор конфигурации (1) и выбираем tutorial (2):
+ +Проверяем, что выбран нужный девайс (1) и запускаем приложение (2):
+ +После успешного запуска приложения мы видим основной экран приложения Tutorial.
+ +Нажимаем на кнопку с текстом "Simple test" и видим следующий экран:
+ +Экран состоит из:
+1. Заголовка TextView
+2. Поля ввода EditText
+3. Кнопки Button
Info
+Полный список виджетов в Android с подробной информацией можно найти здесь
+При нажатии на кнопку текст в заголовке меняется на введенный в поле ввода.
+Мы вручную проверили, что результат работы приложения соответствует ожиданиям:
+Теперь нам нужно все те же проверки написать в коде, чтобы они осуществлялись в автоматическом режиме.
+Чтобы покрыть приложение тестами Kaspresso, необходимо начать с подключения библиотеки Kaspresso в зависимостях проекта.
+Переключаем отображение файлов проекта как Project (1) и добавляем зависимость в существующую секцию dependencies
в файле build.gradle
модуля Tutorial
:
dependencies {
+ androidTestImplementation("com.kaspersky.android-components:kaspresso:1.5.1")
+ androidTestUtil("androidx.test:orchestrator:1.4.2")
+}
+
Можем писать код нашего теста. Чтобы это сделать, необходимо для каждого экрана, который участвует в тесте, создать модель (класс), внутри которого объявить все элементы интерфейса (кнопки, текстовые поля и т.д.), из которых состоит экран, с которыми будет взаимодействовать тест. Такой подход называется Page Object
и подробнее о нем вы можете почитать в документации.
В первых четырех пунктах теста мы взаимодействуем с главным экраном, поэтому первым делом необходимо создать Page Object главного экрана.
+Работать мы будем в папке androidTest
в модуле tutorial
. Если у вас этой папки нет, то ее необходимо создать, для этого кликаем правой кнопкой мыши на папку src
и выбираем пункт New
-> Directory
.
Выбираем пункт androidTest/kotlin
:
Внутри папки kotlin давайте создадим отдельный пакет (package), в котором будем хранить все Page Object-ы:
+ +Создание отдельного пакета на функциональность не влияет, мы это делаем просто для удобства, чтобы все модели экранов лежали в одном месте. Вы можете дать пакету любое имя (за некоторым исключением), но обычно в тестах используют такие же названия, как в самом приложении. Мы можем перейти в файл MainActivity, где сверху будет указано имя пакета.
+ +Копируем это имя и вставляем в название пакета. Конкретно в этом пакете мы будем хранить только модели экранов (Page Object-ы), поэтому в конце давайте добавим .screen
.
Когда мы будем добавлять другие классы в папку с тестами, то будем класть их уже в другие пакеты, но при этом первая часть их названия будет такой же com.kaspersky.kaspresso.tutorial
.
Теперь в созданном пакете мы добавляем модель экрана (класс):
+ +Выбираем тип Object и именуем MainScreen.
+ +MainScreen представляет собой модель главного экрана. Для того чтобы эту модель можно было использовать в автотестах, необходимо унаследоваться от класса KScreen и в угловых скобках указать название этого класса.
+Info
+Указание типа в угловых скобках в Java и Kotlin называется Generics. Подробнее об этом вы можете почитать в документации по Java и Kotlin
+package com.kaspersky.kaspresso.tutorial.screen
+
+import com.kaspersky.kaspresso.screens.KScreen
+
+object MainScreen : KScreen<MainScreen>() {
+}
+
ctrl + i
и выбрать элементы, которые мы хотим переопределить.
+
+Удерживая ctrl
, выбираем все пункты и нажимаем 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")
+}
+
В файле появились новые строчки кода. Вместо TODO
нужно написать корректную реализацию - id макета (layoutId
), который установлен на экране, и название класса (viewClass
). Это необходимо для связывания теста с конкретным файлом верстки и классом activity. Такое связывание сделает дальнейшую поддержку и доработку теста более удобной, но пока перед нами стоит задача написать первый тест, поэтому оставим значение null
.
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
+}
+
Теперь внутри класса KScreen мы будем объявлять все элементы пользовательского интерфейса, с которыми будет взаимодействовать тест. В нашем случае на главном экране нас интересует только кнопка SimpleTest
.
Чтобы тест мог с ней взаимодействовать, нужно знать id, по которому эту кнопку можно найти на экране. Эти идентификаторы присваивает разработчик при написании приложения.
+Чтобы узнать, какой id был присвоен какому-то элементу интерфейса, можно воспользоваться инструментом, встроенным в Android Studio - LayoutInspector
.
Ищем пункт id - это тот идентификатор, который нас интересует.
+ +Также важно понимать, с каким элементом UI мы работаем. Для этого можно перейти в макет, где элемент был объявлен, и посмотреть всю информацию о нем.
+ +В данном случае это элемент Button c вот таким id: simple_activity_btn
Можем добавлять эту кнопку в MainScreen
, обычно название переменной дают такое же, как id, но без нижних подчеркиваний, каждое следующее слово с заглавной буквы (это называется 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 =
+}
+
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) }
+}
+
Во-первых, давайте перейдем в определение KButton и посмотрим, что это. Для этого, удерживая ctrl
, кликаем на название класса KButton левой кнопкой мыши.
Видим, что это класс, который наследуется от KBaseView и реализует интерфейс TextViewAssertions. Можем перейти в определение KBaseView и посмотреть всех наследников этого класса, их тут достаточно много.
+ +Зачем они все нужны?
+Дело в том, что каждый элемент пользовательского интерфейса можно протестировать по-разному. К примеру, для TextView мы можем проверить, какой текст сейчас в него установлен, можем установить новый текст, а ProgressBar не содержит никакой текст, и осуществлять проверку на то, какой текст в него установлен, нет смысла.
+Поэтому в зависимости от того, какой элемент интерфейса мы тестируем, нужно выбирать правильную реализацию KBaseView. Сейчас мы тестируем кнопку, поэтому выбрали KButton. На следующем экране мы будем тестировать заголовок (TextView) и поле ввода (EditText) и выберем соответствующие реализации KBaseView.
+ +Идем дальше, эту кнопку тест должен найти на экране по какому-то критерию. В данном случае мы осуществим поиск элемента по id, поэтому используем матчер withId
, куда в качестве параметра передаем идентификатор кнопки, который мы нашли благодаря Layout Inpector
.
Для того чтобы указать этот id, мы использовали синтаксис R.id..., где R
- это класс со всеми ресурсами приложения. Благодаря нему можно находить id элементов интерфейса, строк, которые есть в проекте, картинок и т.д. При вводе названия этого класса Android Studio должна импортировать его автоматически, но иногда этого не происходит, тогда нужно ввести этот импорт вручную.
import com.kaspersky.kaspresso.tutorial.R
+
Все, теперь у нас есть модель главного экрана и эта модель содержит кнопку, которую можно тестировать. Можем приступать к написанию самого теста.
+В папке androidTest
-> kotlin
, в созданном нами пакете добавляем класс SimpleActivityTest
.
Новый класс был размещен в пакете screen
, но мы хотели бы, чтобы в нем лежали только модели экранов, поэтому созданный тест мы переместим в корень пакета com.kaspersky.kaspresso.tutorial
. Для того, чтобы это сделать, кликаем на название класса правой кнопкой мыши и выбираем Refactor
-> Move
И убираем из названия пакета последнюю часть .screen
.
Класс тестов должен быть унаследован от класса TestCase
. Обратите внимание на импорты, класс TestCase должен быть импортирован из пакета import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
.
package com.kaspersky.kaspresso.tutorial
+
+import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
+
+class SimpleActivityTest: TestCase() {
+}
+
И добавляем метод test()
, в котором будем проверять работу приложения. У него может быть любое имя, необязательно "test", но важно, чтобы он был помечен аннотацией @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() {
+
+ }
+}
+
Тест SimpleActivityTest
можно запустить. Информацию по запуску тестов в Android Studio можно найти в предыдущем уроке.
Сейчас этот тест ничего не делает, поэтому и завершается успешно. Давайте добавим ему логики и протестируем 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()
+ }
+ }
+ }
+}
+
Внутри метода test мы получаем объект MainScreen, открываем фигурные скобки и обращаемся к кнопке, которую будем тестировать, дальше открываем еще раз фигурные скобки и тут пишем все проверки. Сейчас, благодаря методам isVisible()
и isClickable()
мы проверяем, что кнопка видима и по ней можно кликнуть. Запускаем и наш тест падает.
Дело в том, что Page Object MainScreen
относится к MainActivity
(именно эту активити видит пользователь, когда запускает приложение) и, для того чтобы элементы отобразились на экране, эту активити нужно запустить перед выполнением теста. Для того, чтобы перед тестом была запущена какая-то активити, ножно добавить следующие строки:
@get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
Этот тест осуществит запуск указанной activity MainActivity
перед запуском теста и закроет после прогона теста.
Подробнее про activityScenarioRule
можно почитать здесь.
Тогда весь код теста будет выглядеть следующим образом:
+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()
+ }
+ }
+ }
+}
+
Запускаем. Все отлично, у нас тест проходит успешно, и вы можете увидеть на девайсе, что во время теста открывается нужная нам активити и закрывается после прогона.
+ +Хорошей практикой во время написания тестов является проверка, что тест не только успешно выполняется, но и падает, если условие не выполняется. Так вы исключите ситуацию, когда тесты "зеленые", но на самом деле из-за какой-то ошибки в коде проверки вообще не выполнялись. Давайте это сделаем, проверим, что кнопка содержит некорректный текст.
+class SimpleActivityTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ MainScreen {
+ simpleActivityButton {
+ isVisible()
+ isClickable()
+ containsText("Incorrect text")
+ }
+ }
+ }
+}
+
Тест падает, меняем текст на корректный.
+class SimpleActivityTest : TestCase() {
+
+ @get:Rule
+ val activityRule = activityScenarioRule<MainActivity>()
+
+ @Test
+ fun test() {
+ MainScreen {
+ simpleActivityButton {
+ isVisible()
+ isClickable()
+ containsText("Simple test")
+ }
+ }
+ }
+}
+
Тест проходит успешно.
+Теперь нам нужно протестировать SimpleActivity
. Делаем по аналогии с MainScreen
- создаем Page Object.
object SimpleActivityScreen : KScreen<SimpleActivityScreen>() {
+
+ override val layoutId: Int? = null
+ override val viewClass: Class<*>? = null
+}
+
Ищем id элементов через Layout Inspector
:
Не забываем указывать корректные View элементы, для заголовка - KTextView, для поля ввода - KEditText, для кнопки - KButton
+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) }
+}
+
И теперь можем тестировать этот экран. Для того, чтобы на него перейти, на главном экране нужно кликнуть на кнопку, вызываем click()
.
Добавляем проверки для этого экрана:
+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")
+
+ }
+ }
+}
+
Наш первый тест практически готов. Единственное изменение, которое стоит сделать - тут мы используем захардкоженный текст "Default title". При этом тест успешно проходит, но если вдруг приложение будет локализовано на разные языки, то при запуске теста с английской локалью тест может проходить успешно, а если запустим на устройстве с российской локалью, то тест упадет.
+Поэтому вместо того, чтобы хардкодить строку, мы возьмем ее из ресурсов приложения. В макете активити мы можем посмотреть, какая строка использовалась в этом TextView.
+ +Переходим в строковые ресурсы (файл values/strings.xml
) и копируем id строки.
Теперь в методе hasText вместо использования строки "Default title" используем ее id R.string.simple_activity_default_title
.
+Не забываем импортировать класс ресурсов R import com.kaspersky.kaspresso.tutorial.R
.
Финальный код теста выглядит вот так:
+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")
+
+ }
+ }
+}
+
В этом уроке мы написали наш первый тест на Kaspresso. На практике познакомились с подходом PageObject. Научились получать идентификаторы элементов интерфейса при помощи Layout inspector
.
Всем привет!
+
Если вы находитесь здесь, значит, вы интересуетесь автотестами под Android. Kaspresso - отличное решение, которое может помочь вам. Получить больше информации о нашем фреймворке можно здесь.
+
Команда Kaspresso подготовила Tutorial в формате codelabs. Этот Tutorial призван помочь с первыми шагами в Kaspresso и ознакомиться с его основными возможностями.
Tutorial разбит на шаги (уроки). Каждый урок начинается с краткого обзора и заканчивается промежуточными итогами и выводами.
+Мы стремимся сделать уроки независимыми друг от друга, но это не всегда возможно. Для лучшего ознакомления с Kaspresso рекомендуем начать с первого урока и двигаться последовательно к следующим.
+
Формат codelab предполагает, что вы будете сопровождать обучение практикой на своем компьютере, повторяя шаг за шагом действия из уроков. В проекте Kaspresso в папке 'tutorial' лежит код тестируемого приложения. В первом уроке будет рассказано, как его скачать. В ветке tutorial_results
можно увидеть финальную реализацию всех тестов из Tutorial.
Мы не ставим перед собой задачу "Научить автотестам с нуля", но в то же время не выставляем никаких пороговых ограничений по знаниям и опыту и стараемся вести повествование так, чтобы было понятно новичкам в автотестах и Android-е. Практически невозможно рассказывать о Kaspresso без терминов из языков программирования Java и Kotlin, фреймворков Espresso, Kakao, UiAutomator и прочих, операционной системы Android и самого тестирования как области IT. Все же, основной акцент сделан именно на объяснении самого Kaspresso, а во всех местах упоминания различных терминов мы делимся ссылками на официальные источники для подробного ознакомления и лучшего понимания.
+Если вы нашли опечатку, ошибку или неточность в материале, хотите предложить улучшение или дополнить Tutorial новыми уроками, то можете создать Issue в проекте Kaspresso или оформить Pull request (материалы из Tutorial лежат в открытом доступе в папке docs).
+
Если Tutorial не помог решить ваш вопрос, вы можете поискать ответ в разделе Wiki или в разделах Kaspresso in articles и Kaspresso in video.
+
Вы можете присоединиться к нашим Telegram-каналам ru и en и задать свой вопрос там.
Если вам нравится наш фреймворк, вы можете поставить свою звезду нашему проекту на Github.
+ + + + + + +Kaspresso основан на фреймворке Espresso от Google (если вы не знакомы с Espresso, подробности можно найти в официальной документации). +Espresso позволяет вам работать с элементами вашего приложения нативно и методом белого ящика (тестирование белого ящика). Найти нужный элемент на экране можно с помощью matcher-ов, а затем выполнить с ними различные действия или проверки.
+У этого фреймворка есть несколько недостатков, и невозможно покрыть все потребности в автотестировании Android только с помощью Espresso из-за отсутствия определенных фич.
+Kaspresso основан на Kakao - Android фреймворке для автотестов пользовательского интерфейса. Он основан на Espresso. Kakao предоставляет простой Kotlin DSL. Это делает тесты более читабельными. Вам больше не нужно использовать длинные конструкции с matcher-ами для поиска элементов на экране для взаимодействия из теста. Результат вызова метода Espresso onView()
кэшируется. Затем вы можете обратиться к необходимому элементу как к свойству по ссылке.
+Kakao также предоставляет реализацию паттерна Page object с объектом Screen
. Вы можете описать все элементы интерфейса, с которыми будет взаимодействовать ваш тест, в одном месте (в одном объекте Screen).
Kaspresso обернул некоторые вызовы Espresso в более стабильную реализацию. Например, метод flakySafely().
+Kaspresso обернул некоторые вызовы Espresso не только для большей стабильности. Мы также внедрили перехватчик, который печатает больше отладочных сообщений в логи.
+Мы создали интерфейс Device
как фасад для всех интерфейсов, с которыми можно работать. UiAutomator может помочь вам только в некоторых случаях, но чаще вам нужна возможность выполнять различные команды (adb, shell). Например, с помощью команды adb emu
вы можете эмулировать различные действия или события.
+Тесты Espresso запускаются непосредственно на устройстве Android, поэтому нам нужен какой-то внешний сервер для отправки команд. В Kaspresso вы можете использовать AdbServer.
Используя описанную выше реализацию паттерна Page object, вы можете сделать свой код в тестовых файлах более читабельным, удобным для дальнейшей поддержки, повторно используемым и понятным. Kaspresso также предоставляет различные методы и абстракции для улучшения архитектуры (такие как step
, Scenario
, тестовые разделы и многое другое).
Как вы помните из предыдущей части, посвященной интерфейсу Device, под капотом интерфейса устройства находятся следующие сущности:
+Внимательный читатель мог заметить, что ADB недоступен в тестах Espresso. Но используя некоторые другие фреймворки, такие как Appium, вы можете выполнять команды ADB. Поэтому мы решили добавить и эту важную функцию.
+Мы разработали специальный AdbServer для автотестов, чтобы компенсировать отсутствие этой функции.
+Основная идея инструмента аналогична идее в Appium. Мы создали простую клиент-серверную систему, состоящую из двух частей:
Алгоритм использования AdbServer:
+java -jar <path/to/kaspresso>/artifacts/adbserver-desktop.jar
в терминалеНапример, введите shell input text abc
в EditText приложения и нажмите кнопку Выполнить. В результате вы получите shell input text abcabc
+в EditText, потому что команда ADB была выполнена и символы abc
были добавлены в сфокусированный EditText.
+Вы можете заметить, что приложение использует класс AdbTerminal для выполнения команд ADB.
В Kaspresso мы оборачиваем AdbTerminal в специальный интерфейс AdbServer.
+Экземпляр AdbServer
доступен в области BaseTestContext
и BaseTestCase
со свойством adbServer
:
+
@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" />
+
Вы также можете использовать несколько специальных флагов, когда запускате adbserver-desktop.jar
.
+Например, java -jar adbserver-desktop.jar -e emulator-5554,emulator-5556 -p 5041 -l VERBOSE
.
+Флаги:
e
, --emulators
- список эмуляторов, которые могут быть захвачены adbserver-desktop.jar
(по умолчанию adbserver-desktop.jar
захватывает все доступные эмуляторы)p
, --port
- номер порта сервера adb (значение по умолчанию 5037)l
, --logs
- какой тип логов показывать (значение по умолчанию INFO).
+ Для получения дополнительной информации вы можете запустить java -jar adbserver-desktop.jar --help
Рассмотрим доступные типы логов:
+1. ERROR
+ В выводе вы увидите только сообщения об ошибках. Например,
+
ERROR 09.10.2020 11:37:19.893 рабочий стол = рабочий стол-25920 устройство = эмулятор-5554 сообщение: неверный тип сообщения...
+
WARN
+ Печатает сообщения об ошибках и предупреждения.
INFO
+ Значение по умолчанию, предоставляет все базовые события. Например,
+
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.
+ Это может занять некоторое время, поскольку устройство может быть не готово. Возможная причина: не запущен тест kaspresso
+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
в конце:
+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
+ Бывают случаи, когда вам может потребоваться отладка Desktop-части AdbServer. Поэтому существует специальный очень подробный формат — VERBOSE.
+ Взгляните на логи, отражающие аналогичные события, представленные выше (инициализация, подключение устройства и выполнение команды):
+
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.
+ Это может занять некоторое время, поскольку устройство может быть не готово. Возможная причина: не запущен тест kaspresso
+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 09.10.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=Команда была выполнена на рабочем столе=Desktop-27398))
+
tag
и method
. Оба поля автоматически генерируются с использованием метода Throwable().stacktrace
.
+DEBUG
+ В отличие от типа VERBOSE, DEBUG упаковывает повторяющиеся фрагменты логов. Например,
+
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
+
В Kaspresso интерфейс AdbServer имеет реализацию по умолчанию AdbServerImpl
. Эта реализация устанавливает уровень журнала WARN для AdbServer.
+Итак, в 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)
+
VERBOSE
:
+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)
+
Исходный код AdbServer доступен в модуле adb-server.
+Если вы хотите собрать adbserver-desktop.jar
вручную, просто выполните ./gradlew :adb-server:adbserver-desktop:assemble
.
Поддержка Jetpack Compose состоит из двух частей: библиотека Kakao Compose и механизм Kaspresso Interceptors.
+Вся подробная информация доступна в README библиотеки.
+Поддержка Jetpack Compose обеспечивается отдельным модулем, чтобы не заставлять разработчиков обновлять версию minSDK до 21.
+Итак, прежде всего, добавьте зависимость в build.gradle: +
dependencies {
+ androidTestImplementation "com.kaspersky.android-components:kaspresso-compose-support:<latest_version>"
+}
+
Вкратце, давайте посмотрим, как выглядит Kakao Compose DSL: +
// Screen class
+class ComposeMainScreen(semanticsProvider: SemanticsNodeInteractionsProvider) :
+ ComposeScreen<ComposeMainScreen>(
+ semanticsProvider = semanticsProvider,
+ // Экран в Kakao Compose тоже может быть Node-ой из-за параметра viewBuilderAction.
+ // Параметр 'viewBuilderAction' может принимать значение NULL.
+ viewBuilderAction = { hasTestTag ("ComposeMainScreen") }
+) {
+
+ // Вы можете установить четкие отношения родитель-потомок благодаря расширению 'child'
+ // Здесь 'simpleFlakyButton' является дочерним элементом 'ComposeMainScreen' (это тоже Node)
+ val simpleFlakyButton: KNode = child {
+ hasTestTag("main_screen_simple_flaky_button")
+ }
+}
+
+// Эта аннотация предназначена для того, чтобы тест подходил для среды JVM (с Robolectric)
+@RunWith(AndroidJUnit4::class)
+// Объявление тестового класса
+class ComposeSimpleFlakyTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.withComposeSupport()
+) {
+
+ // Специальный класс Rule для тестов Compose
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule<JetpackComposeActivity>()
+
+ // Тест с DSL. Это так похоже на Kakao или 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()
+ }
+ }
+ }
+
+ // ...
+ }
+}
+
Перехватчики — одно из главных преимуществ и возможностей библиотеки Kaspresso.
+Перечислим дефолтные перехватчики, которые по умолчанию работают под капотом, когда вы пишете тесты с Kaspresso.
FailureLoggingSemanticsBehaviorInterceptor
FlakySafeSemanticsBehaviorInterceptor
FlakySafetyParams
.SystemDialogSafetySemanticsBehaviorInterceptor
AutoScrollSemanticsBehaviorInterceptor
ElementLoaderSemanticsBehaviorInterceptor
SemanticNodeInteraction
, используя сохраненный Matcher
, когда элемент не найден.LoggingSemanticsWatcherInterceptor
. Interceptor создает удобочитаемые журналы. Пример:
+
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
+
Помните, что Jetpack Compose и все сопутствующие инструменты находятся в стадии разработки. +Это означает, что Jetpack Compose изучен не очень хорошо, и некоторые вещи могут быть неожиданными. +Покажу интересный случай.
+Например, этот код +
composeSimpleFlakyScreen (composeTestRule) {
+ firstButton {
+ performClick()
+ }
+}
+
firstButton
расположен в области, невидимой для пользователя
+(вам просто нужно прокрутить, чтобы увидеть элемент).
+Но, этот код всегда будет стабильно работать: +
composeSimpleFlakyScreen (composeTestRule) {
+ firstButton {
+ assertIsDisplayed()
+ performClick()
+ }
+}
+
Объяснение кроется в природе SemanticsNode Tree и Jetpack Compose. Элемент firstButton — это узел, представленный в дереве.
+Это означает, что performClick()
может сработать и ничего страшного не произойдет. Но firstButton
физически не виден, и настоящий клик не происходит.
+Такое поведение приводит к падению теста чуть позже.
+Но проверка assertIsDisplayed()
не проходит с первой попытки (мы не видим элемент на экране) и запускает работу всех перехватчиков, включая перехватчик Autoscroll, который прокручивает экран до нужного элемента.
Пожалуйста, поделитесь своим опытом, чтобы помочь другим разработчикам.
+Поддержка Jetpack Compose полностью настраивается. Взгляните на различные параметры для настройки: +
// Редактируем только semanticsBehaviorInterceptors
+// Теперь semanticsBehaviorInterceptors содержит только FailureLoggingSemanticsBehaviorInterceptor
+class ComposeCustomizeTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.withComposeSupport { composeBuilder ->
+ composeBuilder.semanticsBehaviorInterceptors = composeBuilder.semanticsBehaviorInterceptors.filter {
+ it is FailureLoggingSemanticsBehaviorInterceptor
+ }.toMutableList()
+ }
+)
+
+// Редактируем flakySafetyParams и semanticsBehaviorInterceptors
+// Также мы меняем semanticsBehaviorInterceptors, исключая SystemDialogSafetySemanticsBehaviorInterceptor
+class ComposeCustomizeTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.withComposeSupport(
+ // Очень важно изменить flakySafetyParams в разделе настройки
+ // В противном случае все перехватчики будут использовать версию flakySafetyParams по умолчанию
+ customize = {
+ flakySafetyParams = FlakySafetyParams.custom(timeoutMs = 5000, intervalMs = 1000)
+ },
+ lateComposeCustomize = { composeBuilder ->
+ composeBuilder.semanticsBehaviorInterceptors = composeBuilder.semanticsBehaviorInterceptors.filter {
+ it !is SystemDialogSafetySemanticsBehaviorInterceptor
+ }.toMutableList()
+ }
+ ).apply {
+ // Помните, лучше настраивать ComposeSupport только после настройки Kaspresso
+ // Поскольку перехватчики ComposeSupport могут зависеть от некоторых сущностей Kaspresso
+ // Например, изменение flakySafetyParams в этом разделе не повлияет на перехватчики ComposeSupport
+ }
+)
+
+// Есть еще один способ сделать то же самое
+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()
+ }
+ }
+)
+
Вы можете запускать тесты Compose в среде JVM с помощью Robolectric.
+В качестве примера можно запустить тест ComposeSimpleFlakyTest (из модуля kaspresso-sample
) на JVM прямо сейчас:
+
./gradlew :samples:kaspresso-compose-support-sample:testDebugUnitTest --info --tests "com.kaspersky.kaspresso.composesupport.sample.test.ComposeSimpleFlakyTest"
+
Расширения Kaspresso подразумевают использование таких конструкций, как:
+flakySafely
continuously
Идет поддержка некоторых конструкций: issue-317.
+ + + + + + +В версии 1.3.0 Kaspresso была добавлена поддержка allure-framework. Теперь очень легко создавать красивые тестовые отчеты, используя фреймворки Kaspresso и Allure.
+В этом выпуске семейство классов управления файлами, отвечающее за предоставление файлов для снимков экрана и журналов, было реорганизовано для лучшего использования и расширяемости. Это изменение затронуло старые классы, которые сейчас помечены как устаревшие (см. пакет com.kaspersky.kaspresso.files). Пример использования: CustomizedSimpleTest.
+Также были добавлены следующие перехватчики:
+В пакете com.kaspersky.components.alluresupport.interceptors есть специальные перехватчики Kaspresso, помогающие связать и обработать файлы для Allure-отчета.
+Прежде всего, добавьте следующую зависимость Gradle и Allure runner в файл gradle вашего проекта, чтобы включить модуль allure-support Kaspresso: +
android {
+ defaultConfig {
+ //...
+ testInstrumentationRunner "com.kaspersky.kaspresso.runner.KaspressoRunner"
+ }
+ //...
+}
+
+dependencies {
+ //...
+ androidTestImplementation "com.kaspersky.android-components:kaspresso-allure-support:<последняя_версия>"
+}
+
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),
+ )
+ )
+ }
+) {
+...
+}
+
Итак, вы добавили в свою конфигурацию Kaspresso список необходимых перехватчиков, поддерживающих Allure, и запустили тест. После завершения теста на устройстве будет создан каталог sdcard/allure-results со всеми обработанными файлами, которые будут включены в отчет Allure.
+Этот каталог следует переместить с устройства на хост-компьютер, который будет генерировать отчет.
+Например, вы можете использовать для этого команду adb pull на своем хосте. Допустим, вы хотите найти данные для отчета в /Users/username/Desktop/allure-results, поэтому вы вызываете: +
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
+
Теперь мы хотим создать и просмотреть отчет. Для этого на нашей машине должен быть установлен сервер Allure. Чтобы узнать, как это сделать со всеми подробностями, следуйте документации Allure.
+Например, чтобы установить сервер Allure на MacOS, мы можем использовать следующую команду: +
brew install allure
+
allure serve /Users/username/Desktop/allure-results
+
Если вы хотите сохранить сгенерированный html-отчет в определенном каталоге для использования в будущем, вы можете просто вызвать: +
allure generate -o ~/kaspresso-allure-report /Users/username/Desktop/allure-results
+
allure open ~/kaspresso-allure-report
+
Детали успешного теста: +
+Сведения о неудачном тесте: +
+По умолчанию, Kaspresso-Allure вводит дополнительные тайм-ауты, чтобы максимально гарантировать правильность видеозаписи. Эти тайм-ауты увеличивают время выполнения теста на 5 секунд.
+Вы можете изменить эти значения, настроив videoParams
в Kaspresso.Builder
. См. пример выше.
Начиная с Robolectric 4.0, мы также можем запускать тесты, подобные Espresso, также на JVM с помощью Robolectric. +Это часть проекта Nitrogen от Google (стала унифицированной тестовой платформой), с помощью которой разработчики могут один раз написать тест пользовательского интерфейса и запускать их везде.
+Однако до Kaspresso 1.3.0, если вы пытались запустить тест, подобный Kaspresso, расширяющий TestCase на JVM с помощью Robolectric, вы получали следующую ошибку: +
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)
+ ...
+
Теперь все тесты Kaspresso могут корректно выполняться на JVM с Robolectric со следующими ограничениями:
+UiDevice
и UiAutomation
. Вот почему многие (не все!) реализации в Device
будут падать на JVM с Robolectric с NotSupportedInstrumentalTestException
.UiDevice
и UiAutomation
затрагивает весь Kautomator. Таким образом, тесты с использованием Kaautomator будут аварийно завершать работу на JVM с Robolectric с KautomatorInUnitTestException
.DocLocScreenshotTestCase
будет аварийно завершать работу на JVM с Robolectric с DocLocInUnitTestException
.Чтобы создать тест, который может работать на устройстве/эмуляторе и на JVM, мы рекомендуем создать папку sharedTest
и соответствующим образом настроить sourceSets
в gradle.
sourceSets {
+ ...
+ //настраиваем общую тестовую папку
+ 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)
+ }
+}
+
Также важно, чтобы такие тесты использовали @RunWith(AndroidJUnit4::class)
, так как это требуется Robolectric.
Чтобы запустить ваши общие тесты как модульные тесты на JVM, вам нужно запустить команду, выглядящую следующим образом: +
./gradlew :MODULE:testVARIANTUnitTest --info --tests "PACKAGE.CLASS"
+
Например, чтобы запустить RobolectricTest на JVM, вам нужно выполнить: +
./gradlew :samples:kaspresso-sample:testDebugUnitTest --info --tests "com.kaspersky.kaspressample.sharedtest.SharedSimpleFlakyTest"
+
Чтобы запустить их на устройстве/эмуляторе, команда для запуска будет выглядеть так: +
./gradlew :MODULE:connectedVARIANTAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=PACKAGE.CLASS
+
Например, чтобы запустить SharedTest на устройстве/эмуляторе, вам нужно выполнить: +
./gradlew :samples:kaspresso-sample:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.kaspersky.kaspressample.sharedtest.SharedSimpleFlakyTest
+
Мы подготовили набор инструментов и советов, чтобы приспособить ваши тесты к среде JVM (с Robolectric).
+Рассмотрим наиболее популярную проблему, когда в тесте используется класс, содержащий вызовы UiDevice
/UiAutomation
/AdbServer
или другие не работающие в среде JVM вещи.
Например, ваш тест выглядит следующим образом: +
@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()
вызывает UiDevice
под капотом, что приводит к сбою среды JVM.
Существует следующее возможное решение: +
// изменить реализацию класса Exploit
+@RunWith(AndroidJUnit4::class)
+class FailingSharedTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple {
+ exploit =
+ if (isAndroidRuntime) ExploitImpl() // старая реализация
+ else ExploitUnit() // новая реализация без UiDevice
+ }
+) { ... }
+
+// свойство isAndroidRuntime доступно в Kaspresso.Builder.
+
Кроме того, если ваш пользовательский перехватчик использует UiDevice
/UiAutomation
/AdbServer
, вы можете отключить этот перехватчик для JVM. Пример:
+
class KaspressoConfiguringTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple {
+ viewBehaviorInterceptors = if (isAndroidRuntime) mutableListOf(
+ YourCustomInterceptor(),
+ FlakySafeViewBehaviorInterceptor (flakySafetyParams, libLogger)
+ ) else mutableListOf(
+ FlakySafeViewBehaviorInterceptor (flakySafetyParams, libLogger)
+ )
+ }
+) { ... }
+
Конечно, есть очень очевидный последний вариант. Просто не включайте тест в набор модульных тестов.
+Дополнительные замечания
+Начиная с Robolectric 4.8.1, у sharedTest есть некоторые ограничения: эти тесты работают безупречно на эмуляторе/устройстве, но не работают на JVM.
+Класс Kaspresso — это единственная точка для установки параметров Kaspresso.
+Разработчик может настроить Kaspresso, установив Kaspresso.Builder
в конструкторах TestCase
, BaseTestCase
, TestCaseRule
, BaseTestCaseRule
.
+Пример:
+
class SomeTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple {
+ beforeEachTest {
+ testLogger.i("The beginning")
+ }
+ afterEachTest {
+ testLogger.i("The end")
+ }
+ }
+) {
+ // ваш тест
+}
+
Конфигурация Kaspresso содержит:
+Kaspresso предоставляет возможность переопределить кастомные клики Espresso. +Kakao библиотека предоставляет набор подготовленных имплементаций кликов, которые улучшают стабильность тестов на девайсах, находящихся под большой нагрузкой.
+Все детали о проблеме и решениях описано в Kakao документации.
+Пример, как заиспользовать кастомные клики в вашем тесте, представлен в CustomClickTest. +
class ClickTest : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple(
+ customize = {
+ clickParams = ClickParams.kakaoVisual()
+ }
+ )
+) {
+ // your test
+}
+
Kaspresso предоставляет следующие подготовленные опции для кастомизации кликов:
+1. ClickParams.kakaoVisual()' - Kakao clicks с визуализацией.
+2.
ClickParams.kakao()' - Kakao clicks.
+3. `ClickParams.default()' - Espresso clicks. Используются по умолчанию.
Kaspresso предоставляет два вида логгеров: libLogger
и testLogger
.
+libLogger
- внутренний логгер Kaspresso
+testLogger
- логгер, который доступен разработчикам в тестах.
+Последний доступен через свойство testLogger
в тестовых разделах (before, after, init, transform, run
) в тестовом DSL (из класса TestContext
).
+Кроме того, он доступен при настройке Kaspresso.Builder
, если вы хотите добавить его, например, в свои пользовательские перехватчики.
Эти перехватчики были введены для упрощения и единообразия использования перехватчиков Kakao и перехватчиков Kautomator.
+Важный момент о смешении перехватчиков Kaspresso и перехватчиков Kakao/Kautomator.
+Перехватчики Kaspresso не будут работать, если вы установите свои собственные перехватчики Kakao, вызвав метод Kakao.intercept
в тесте, или установите свои пользовательские перехватчики Kautomator, вызвав Kautomator.intercept
в тесте.
+Если вы установите свои пользовательские перехватчики Kakao для конкретного экрана или KView и установите для аргумента isOverride значение true, то перехватчики Kaspresso не будут работать для конкретного экрана или KView
. То же самое верно и для Kautomator, где разработчик взаимодействует с UiScreen
и UiBaseView
.
Перехватчики Kaspresso можно разделить на два типа:
Behavior Interceptors
- перехватывают вызовы ViewInteraction
, DataInteraction
, WebInteraction
, UiObjectInteraction
, UiDeviceInteraction
и выполняют свою логику. перехватчиках поведения
в конце этого документа.Watcher Interceptors
- перехватывают вызовы ViewAction
, ViewAssertion
, Atom
, WebAssertion
, UiObjectAssertion
, UiObjectAction
, UiDeviceAssertion
, UiDeviceAction
и еще кое-что.Расширим упомянутые типы перехватчиков Kaspresso:
+Behavior Interceptors
viewBehaviorInterceptors
- перехватывают вызовы ViewInteraction#perform
и ViewInteraction#check
dataBehaviorInterceptors
- перехватывают вызовы DataInteraction#check
webBehaviorInterceptors
- перехватывают вызовы Web.WebInteraction<R>#perform
и Web.WebInteraction<R>#check
objectBehaviorInterceptors
- перехватывают вызовы UiObjectInteraction#perform
и UiObjectInteraction#check
deviceBehaviorInterceptors
- перехватывают вызовы UiDeviceInteraction#perform
и UiDeviceInteraction#check
Watcher Interceptors
viewActionWatcherInterceptors
– выполняют какие-то действия до того, как будет вызван android.support.test.espresso.ViewAction.perform
viewAssertionWatcherInterceptors
– выполняют какие-то действия до того, как будет вызван android.support.test.espresso.ViewAssertion.check
atomWatcherInterceptors
– выполняют какие-то действия до того, как будет вызван android.support.test.espresso.web.model.Atom.transform
webAssertionWatcherInterceptors
— выполняют какие-то действия до того, как будет вызван android.support.test.espresso.web.assertion.WebAssertion.checkResult
objectWatcherInterceptors
- выполняют какие-то действия до того, как будет вызван UiObjectInteraction.perform
или UiObjectInteraction.check
deviceWatcherInterceptors
- выполняют какие-то действия до того, как будет вызван UiDeviceInteraction.perform
или UiDeviceInteraction.check
Пожалуйста, помните! Перехватчики поведения и наблюдателя работают под капотом в каждом действии (actions) и утверждении (assertions) каждого графического элемента (View) Kakao и Kautomator по умолчанию в Kaspresso.
+Эти перехватчики не основаны на какой-то lib. Краткое описание:
+stepWatcherInterceptors
- перехватчик действий жизненного цикла SteptestRunWatcherInterceptors
- перехватчик всех действий жизненного цикла Test.Как вы заметили, эти перехватчики также являются частью Watcher Interceptors.
+Этот watcher interceptor
по умолчанию включен в Kaspresso configurator
для сбора информации о шагах ваших тестов для дальнейшей обработки в оркестраторе тестов.
+Этот перехватчик основан на AllureReportWriter
(подробнее про Allure).
+Этот генератор отчетов работает с каждым TestInfo
после завершения теста, преобразует информацию о шагах в информацию о шагах Allure JSON, а затем печатает JSON в LogCat в следующем формате:
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}]
+
Эти журналы должны обрабатываться вашим тестовым оркестратором (например, Marathon). +Если вы используете Marathon, вы должны знать, что он требует +некоторых дополнительных модификаций для поддержки обработки этих журналов и в настоящий момент не работает должным образом. Но мы усердно работаем над этим.
+Иногда разработчик хочет поместить некоторые действия, повторяющиеся во всех тестах до/после, в одно место, чтобы упростить поддержку тестов.
+Существуют аннотации @beforeTest/@afterTest
для решения указанных задач. Но у разработчика нет доступа к BaseTestContext
в этих методах.
+Вот почему мы ввели специальные действия по умолчанию, которые вы можете установить в конструкторе с помощью Kaspresso.Builder
.
+Пример реализации действий по умолчанию в Kaspresso.Builder
:
+
open class YourTestCase : TestCase(
+ kaspressoBuilder = Kaspresso.Builder.simple {
+ beforeEachTest {
+ testLogger.i("beforeTestFirstAction")
+ }
+ afterEachTest {
+ testLogger.i("afterTestFirstAction")
+ }
+ }
+)
+
beforeEachTest
:
+beforeEachTest(override = true, action = {
+ testLogger.i("beforeTestFirstAction")
+})
+
afterEachTest
аналогичен beforeEachTest
. override
в false
, то последнее beforeAction
будет относиться к родительскому TestCase плюс текущий action
. В противном случае последний beforeAction
будет только текущим action
.
+Чтобы понять, как это работает и как переопределить (или просто расширить) действие по умолчанию, пожалуйста,
+обратите внимание на пример.
+Экземпляр Device
. Подробная информация находится на этой странице в разделе Вики.
Экземпляр AdbServer
. Подробная информация находится на этой странице в разделе Вики.
Пример того, как настроить Kaspresso и как использовать перехватчики Kaspresso, находится здесь.
+BaseTestCase
, TestCase
, BaseTestCaseRule
, TestCaseRule
используют настроенный по умолчанию Kaspresso (Kaspresso.Builder.simple конфигуратор
).
+Ниже приведены наиболее ценные функции настроенного по умолчанию Kaspresso.
Просто запустите SimpleTest. Далее вы увидите эти логи: +
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: ---------------------------------------------------------------------------
+
Если происходит сбой, Kaspresso пытается исправить его, используя большой набор разнообразных способов.
+Эта защита работает для каждого действия и проверки каждого View Kakao и Kautomator! Вам просто нужно расширить свой тестовый класс из TestCase
(BaseTestCase
) или установить TestCaseRule
(BaseTestCaseRule
) в вашем тесте.
Включенные по умолчанию перехватчики:
+Все описанные выше возможности доступны благодаря этим перехватчикам.
+Behavior Interceptors
Любая библиотека для UI-тестов нестабильна. Это суровая правда жизни. Любое действие/проверка в вашем тесте может завершиться ошибкой по какой-то неопределенной причине.
+Какие общие виды флакающих ошибок существуют:
+1. Распространенные плавающие ошибки (флаки), возникающие из-за того, что Espresso/UI Automator был в плохом настроении =)
+ Вот почему Kaspresso оборачивает все действия/проверки (actions/assertions) Kakao/Kautomator и обрабатывает набор потенциально плавающих исключений.
+ Если произошло исключение, Kaspresso пытается повторить неудачные действия/проверку в течение 10 секунд. Такая обработка избавляет разработчиков от любых ненадежных действий/проверок.
+ Подробности доступны по ссылке flakysafety, а примеры — здесь.
+2. Невидимость View. В большинстве случаев вам просто нужно прокрутить экран вниз, чтобы View стало видимым. Итак, Kaspresso пытается выполнить это в автоматическом режиме.
+ Подробности доступны на странице autoscroll.
+3. Также Kaspresso пытается закрыть все системные диалоги, если это препятствует выполнению теста.
+ Подробности доступны на странице systemsafety.
Эти обработки возможны благодаря BehaviorInterceptors
. Кроме того, вы можете установить собственную обработку с помощью Kaspresso.Builder
. Но помните, порядок BehaviorInterceptors
имеет значение: первый элемент будет на самом низком уровне цепочки перехвата, а последний элемент будет на самом высоком уровне.
Рассмотрим принцип работы BehaviorInterceptors
над перехватчиками Kakao. Первый элемент фактически является оболочкой для вызова androidx.test.espresso.ViewInteraction.perform
, второй элемент является оболочкой для первого элемента и так далее.
+Взгляните на порядок включения BehaviorInterceptors
по умолчанию в Kaspresso поверх Kakao. Это:
AutoScrollViewBehaviorInterceptor
SystemDialogSafetyViewBehaviorInterceptor
FlakySafeViewBehaviorInterceptor
Под капотом все действия и проверки Kakao в первую очередь вызывают FlakySafeViewBehaviorInterceptor
, который вызывает SystemDialogSafetyViewBehaviorInterceptor
, а тот вызывает AutoScrollViewBehaviorInterceptor
.
+Если результатом обработки AutoScrollViewBehaviorInterceptor
является ошибка, то SystemDialogSafetyViewBehaviorInterceptor
пытается обработать полученную ошибку. Если результатом обработки SystemDialogSafetyViewBehaviorInterceptor
также является ошибка, тогда FlakySafeViewBehaviorInterceptor
попытается обработать полученную ошибку.
+Для упрощения обсуждаемой темы нарисовали картинку:
Разработчик также может расширить функциональность параметризованных тестов, предоставив MainSectionEnricher
в BaseTestCase
или BaseTestCaseRule
.
+Основная идея - позволить добавить дополнительные шаги тест-кейса до и после главной секции run
.
Все, что вам нужно сделать, это:
+MainSectionEnricher
;class LoggingMainSectionEnricher : MainSectionEnricher<TestCaseData> {
+ ...
+
+}
+
Здесь TestCaseData
- это тот же тип данных, что и в вашей реализации BaseTestCase
.
beforeMainSectionRun
и/или afterMainSectionRun
, чтобы добавить свои действия до/после;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}")
+ }
+ }
+
+}
+
В методах beforeMainSectionRun
и afterMainSectionRun
у вас есть полный доступ к свойствам и методам TestContext<TestCaseData
, так что вы можете использовать логгер, добавлять тестовые шаги и так далее. Также, эти методы получили параметр TestInfo
.
BaseTestCase
.class EnricherBaseTestCase : BaseTestCase<TestCaseDsl, TestCaseData>(
+ kaspresso = Kaspresso.Builder.default(),
+ dataProducer = { action -> TestCaseDataCreator.initData(action) },
+ mainSectionEnrichers = listOf(
+ LoggingMainSectionEnricher(),
+ AnalyticsMainSectionEnricher()
+ )
+)
+
После того, как это будет сделано, описанные вами действия будут выполняться до или после блока run
основной секции.
Kautomator — Хороший и простой DSL для UI Automator в Kotlin, который позволяет ускорить работу самого UI Automator.
+Вдохновлено Kakao и выступлением о UI Automator (спасибо Светлане Смельчаковой).
Тесты, написанные с помощью UI Automator, нечитаемые и сложные в обслуживании, особенно для тестировщиков. +Взгляните на типичный фрагмент кода, написанный с помощью 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")
+ }
+}
+
Еще одним большим преимуществом Kautomator является возможность ускорения UI Automator.
+Взгляните на видео ниже:
+Левое видео — это улучшенный UI Automator, правое видео — это UI Automator по умолчанию.
Почему это возможно? Подробности доступны ниже.
+Создайте свой объект UiScreen
, куда вы добавите UI-элементы, участвующие во взаимодействии тестов:
+
class FormScreen : UiScreen<FormScreen>()
+
UiScreen
может представлять весь пользовательский интерфейс или его часть.
+Если вы используете шаблон Page Object, вы можете поместить взаимодействия с Kautomator внутри Page Objects.
+UiScreen
содержит различные UiView
. Внутри UiScreen описываются все UI-элементы, с которыми будет взаимодействовать тест:
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
Каждый UiView
содержит Matcher-ы для получения экземпляра ViewInteraction
-а. Некоторые примеры Matcher-ов из Kakao:
withId
withText
withPackage
withContentDescription
textStartsWith
Как и в Ui Automator, вы можете комбинировать разные Matcher-ы: +
val email = UiEditText {
+ withId(this@FormScreen.packageName, "email")
+ withText(this@FormScreen.packageName, "matsyuk@kaspresso.com")
+}
+
Синтаксис теста с Kautomator очень прост, как только вы определили UiScreen
и UiView
, вам нужно только выполнить action или assertion, как в UI Automator:
+
FormScreen {
+ phone {
+ hasText("971201771")
+ }
+ button {
+ click()
+ }
+}
+
В Espresso все взаимодействие с View
обрабатывается через ViewInteraction
, который имеет два основных метода: onCheck
и onPerform
, которые принимают ViewAction
и ViewAssertion
в качестве аргументов. Kakao был написан на основе этой архитектуры.
Итак, мы поставили перед собой цель написать Kautomator, максимально похожий на Kakao. Вот почему мы ввели дополнительный слой поверх UiObject2 и UiDevice, который так похож на ViewInteraction
. Этот уровень представлен UiObjectInteraction
и UiDeviceInteraction
, которые имеют два метода: onCheck
и onPerform
, принимающие UiObjectAssertion и UiObjectAction или UiDeviceAssertion и UiDeviceAction в качестве аргументов.
UiObjectInteraction
предназначен для работы с конкретным View
, таким как ViewInteraction
. UiDeviceInteraction
был создан, потому что UI Automator имеет функцию, позволяющую вам выполнять некоторые системные действия, такие как нажатие кнопки «Домой» или кнопки «Назад», открытие быстрых настроек, открытие уведомлений и так далее. Все подобные вещи скрыты классом UiSystem
.
Так что, наслаждайтесь =)
+Если у вас есть нестандартные (кастомные) UI-элементы в ваших тестах и вы хотите создать свой собственный UiView
, у нас есть UiBaseView
. Просто унаследуйте этот класс и реализуйте столько дополнительных интерфейсов Action/Assertion, сколько хотите.
+Вам также необходимо переопределить конструкторы, которые вам нужны.
class UiMyView : UiBaseView<KView>, UiMyActions, UiMyAssertions {
+ constructor(selector: UiViewSelector) : super(selector)
+ constructor(builder: UiViewBuilder.() -> Unit) : super(builder)
+}
+
Если вам нужно добавить свою логику во время цепочки вызовов Kautomator -> UI Automator
(например, ведение журнала) или если вам нужно полностью изменить UiAssertion
или UiAction
, которые отправляются в UI Automator во время выполнения, в некоторых случаях можно использовать механизм перехвата.
Перехватчики — это лямбда-выражения, которые вы передаете конфигурационному DSL. Они будут вызываться перед реальными вызовами внутри классов UiObject2
и UiDevice
в UI Automator.
У вас есть возможность предоставлять перехватчики на 3 разных уровнях: время выполнения Kautomator, на уровне ваших классов UiScreen
и на уровне отдельного экземпляра UiView
.
При каждом вызове функции UI Automator, которую можно перехватить, Kautomator агрегирует все доступные перехватчики для этого конкретного вызова и вызывает их в следующем порядке: UiView interceptor -> Active Screens interceptors -> Kautomator interceptor
.
Каждый из перехватчиков может разорвать вызов цепочки, установив isOverride
в true во время настройки.
+В этом случае Kautomator не только перестанет вызывать оставшиеся перехватчики в цепочке, но и не будет выполнять вызовы UI Automator. Это означает, что в таком случае ответственность за фактический вызов KAutomator лежит на плечах разработчика.
Вот примеры конфигураций: +
class SomeTest {
+ @Before
+ fun setup() {
+ KautomatorConfigurator { // Kautomator runtime
+ intercept {
+ onUiInteraction { // Перехват вызовов классов UiInteraction во время выполнения
+ onPerform { uiInteraction, uiAction -> // Перехватываем вызов execute()
+ testLogger.i("KautomatorIntercept", "interaction=$uiInteraction, action=$uiAction")
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ fun test() {
+ MyScreen {
+ intercept {
+ onUiInteraction { // Перехват вызовов классов UiInteraction в контексте MyScreen
+ onCheck { uiInteraction, uiAssert -> // Перехват вызова check()
+ testLogger.i("KautomatorIntercept", "interaction=$uiInteraction, assert=$uiAssert")
+ }
+ }
+ }
+
+ myView {
+ intercept { // Перехват вызовов ViewInteraction для этого отдельного UI-элемента
+ onPerform(true) { uiInteraction, uiAction -> // Перехватываем вызов execute() и переопределяем цепочку
+ // При выполнении действий над этим элементом не будет вызываться перехватчик уровня Kautomator
+ // и теперь нам нужно вручную вызывать UI Automator.
+ Log.d("KAUTOMATOR_VIEW", "$uiInteraction выполняет $uiAction")
+ uiInteraction.perform(uiAction)
+ }
+ }
+ }
+ }
+ }
+}
+
Как вы помните, мы рассказывали о возможном ускорении UI Automator. Как это становится возможным?
+UI Automator имеет внутренний механизм для предотвращения потенциальной нестабильности. Под капотом библиотека слушает и отдает команды через AccessibilityManagerService. AccessibilityManagerService — это единая точка для всех событий доступности в системе. В какой-то момент создатели UI Automator столкнулись с проблемой ненадёжности. Одной из самых популярных причин такого неопределенного поведения является большое количество обрабатываемых в системе событий в текущий момент. Но UI Automator имеет связь с AccessibilityManagerService. Такое подключение дает возможность прослушивать все события доступности в системе и ждать спокойного состояния, когда нет никаких действий. Спокойное состояние приводит к детерминированному поведению системы и снижает вероятность нестабильности.
+Все это подтолкнуло авторов UI Automator к внедрению следующего алгоритма: UI Automator ожидает 500 мс (waitForIdleTimeout
и waitForSelectorTimeout
в окне androidx.test.uiautomator.Configurator
) в течение 10 секунд для каждого действия. КАЖДОЕ ДЕЙСТВИЕ.
Возможно, описанное решение сделало UI Automator более стабильным. Но скорость упала, спору нет.
+Kautomator — это DSL поверх UI Automator, который предоставляет механизм перехватчиков. Kaspresso предлагает большой набор перехватчиков по умолчанию, что устраняет любые потенциальные нестабильные действия. Итак, Kaspresso + Kautomator помогает UI Automator бороться с ненадёжностью.
+Через какое-то время мы подумали, зачем нам сохранять искусственные таймауты внутри UI Automator, в то время как Kaspresso + Kautomator делает ту же работу. Взгляните на пример измерения: +
@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()
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
Также бывают случаи, когда UI Automator не может поймать окно 500 мс. Например, когда один элемент обновляется слишком быстро (одно обновление за 100 мс). Просто взгляните на этот тест. Только KautomatorWaitForIdleSettings.boost()
позволяет пройти тест.
Как видите, мы добавили в конфигуратор Kaspresso специальное свойство kautomatorWaitForIdleSettings
. По умолчанию это свойство не повышает производительность. Почему? Потому что:
+1. У вас могут быть тесты, в которых вы напрямую используете UI Automator. Но указанные таймауты являются глобальными параметрами. Сброс этих тайм-аутов может привести к неопределенному состоянию.
+2. Мы хотим потратить время на сбор данных со всего мира, а затем проанализировать потенциальные проблемы наших решений (но мы считаем, что это стабильное и блестящее решение).
Еще одно важное замечание касается конфигурации kaspressoBuilder = Kaspresso.Builder.simple
. Эта конфигурация быстрее, чем advanced
, из-за отсутствия перехватчика скриншотов на каждом шаге. При необходимости добавьте их вручную.
В любом случае, это небольшое изменение для разработчика, но большой шаг для всех нас =)
+ + + + + + +Kaspresso основан на Espresso (если вы не знакомы с Espresso, ознакомьтесь с официальной документацией). +В официальной документации указаны следующие основные компоненты:
+onView()
и onData()
). Также предоставляет API, которые не обязательно привязаны к какому-либо элементу интерфейса, например, pressBack()
.Matcher<? super View>
. Вы можете передать один или несколько из них методу onView()
, чтобы найти нужный элемент в текущей иерархии экрана.ViewInteraction.perform()
, например, click()
. Набор действий, которые могут быть выполнены с UI-элементами.ViewInteraction.check()
. Набор проверок, которые могут быть выполнены для различных UI-элементов. В большинстве случаев используют проверки, которые принимают Matcher-ы, для проверки состояния view.// withId(R.id.my_view) является ViewMatcher
+// click() является ViewAction
+// matches(isDisplayed()) является ViewAssertion
+onView(withId(R.id.my_view))
+ .perform(click())
+ .check(matches(isDisplayed()))
+
Наиболее востребованные экземпляры Matcher, ViewActions и ViewAssertions можно найти в шпаргалке Google. +
+Результаты вызова метода onView()
(ViewInteractors
) могут быть закэшированы. В Kakao вы можете получить ссылку на ViewInteractor и повторно использовать ее в своем коде. Это делает ваш код в тестах более читабельным и понятным.
+Паттерн PageObject позволяет разделить поиск элемента и действия над ним. Kakao представила KView и различные реализации для самых доступных виджетов Android. Этот KView реализует интерфейсы BaseAssertions и BaseActions с некоторыми дополнительными методами. Каждый наследник KView реализует свои собственные интерфейсы для некоторых методов, специфичных для каждого виджета.
Поскольку Kaspresso наследует все лучшее от этих двух фреймворков, вам доступно все, что описано выше.
Паттерн Page object хорошо объяснен Мартином Фаулером в этой статье. Если коротко, то это тестовая абстракция, которая описывает экран с некоторыми элементами интерфейса. С этими элементами можно взаимодействовать во время тестов. В результате, описание элементов экрана будет в отдельном классе. Вам больше не нужно постоянно искать нужный UI-элемент с несколькими matcher-ами в тестах. Это можно сделать один раз, сохранив ссылку на экран.
+Kaspresso предоставляет KScreen
и UiScreen
в качестве реализации паттерна Page object.
Kaspresso основан на Kakao и UiAutomator. +Когда у нас есть вся информация о коде приложения (случай «тестирования белого ящика»), мы должны использовать KScreen для описания структуры PageObject, как это делает Kakao. Это класс в Kaspresso - расширение для класса Screen из Kakao. +Когда у нас нет доступа к исходному коду приложения (это могут быть какие-то системные диалоги, окна или приложения), мы должны использовать UiScreen. +Вот два примера:
+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") }
+}
+
В наследниках KScreen необходимо проинициализировать поля layoutId
(файл макета экрана) и viewClass
(имя класса экрана - activity или fragment). Эти поля можно проинициализировать значением null
, но рекомендуется присвоить им корректные значения. Они помогут поддерживать, модифицировать и отлаживать тесты, сохраняя информацию о связанных с конкретным тестом файлах в основом коде приложения. В случае рефакторинга основного кода приложения, разработчик также увидит, что некоторые тесты завязаны на этот код.
+В наследниках UiScreen необходимо проинициализировать поле packageName
(полное имя пакета приложения).
Применение этого паттерна позволяет вынести описание экрана в отдельный файл и повторно использовать экраны и ссылки на UI-элементы в разных тестах. Когда у вас есть некоторые изменения в пользовательском интерфейсе приложения, вы можете изменить только код в файле экрана без необходимости большого рефакторинга тестов.
+В одних командах автотесты пишут только разработчики, в других QA инженеры. В некоторых случаях автотесты пишет кто-то, кто не знает деталей кода (исходный код есть, но плохо понятен). В этом случае разработчики могут написать сущности Screen для дальнейших автотестов. Наличие реализованных экранов (Page object Screen) помогает другому человеку писать тесты с использованием Kotlin DSL.
+ + + + + + +Иногда при разработке новых функций возникает необходимость проверить, корректно ли работает приложение на всех поддерживаемых языках. Ручное изменение настроек локали может занять много времени и потребовать усилий разработчиков, QA-инженеров и т. д. Кроме того, это может увеличить продолжительность процесса локализации.
+Чтобы избежать этого, Kaspresso предоставляет класс DocLocScreenshotTestCase
, что позволяет делать скриншоты во всех указанных вами локалях. DocLocScreenshotTestCase
расширяется класс TestCase
и предлагает возможность делать скриншоты из коробки, вызывая метод DocLocScreenshotTestCase#captureScreenshot(String)
.
Чтобы создать скриншот тест, вы должны расширить класс DocLocScreenshotTestCase
, как показано ниже:
@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")
+ }
+ }
+ }
+}
+
В базовый конструктор передается один параметр: locales
- строка с разделенными запятыми локалями для запуска теста. Сделанные скриншоты будут доступны в памяти устройства по пути /sdcard/screenshots/
.
Полный пример см. в ScreenshotSampleTest.
+Обратите внимание, что тест помечен аннотацией @ScreenShooterTest
. Эта аннотация предназначена для фильтрации скриншот тестов от всех остальных для запуска. Например, вы можете передать эту аннотацию стандартному AndroidJUnitRunner
при помощи команды:
adb shell am instrument -w -e annotation com.kaspersky.kaspresso.annotations.ScreenShooterTest your.package.name/android.support.test.runner.AndroidJUnitRunner
+
Все файлы снимков экрана хранятся по умолчанию в каталоге screenshots
.
+Они отсортированы по локали и названию теста:
<base directory>/<test class canonical name>/<locale>/<your tag>.png
Для тестового кейса из примера дерево файлов должно выглядеть так:
+- screenshots
+ - com.kaspersky.kaspressample.tests.docloc.ScreenshotSampleTest
+ - en
+ // файлы скриншотов
+ - ru
+ // файлы скриншотов
+
+Итак, для сохранения скриншотов на внешнее хранилище тестовому приложению требуется разрешение android.permission.WRITE_EXTERNAL_STORAGE
.
Когда разработчик вызывает метод captureScreenshot("la-la-la")
, Kaspresso создает не только снимок экрана, но и специальный xml-файл. Этот xml-файл содержит данные обо всех элементах пользовательского интерфейса с их идентификаторами, расположенными на экране. Пример:
+
<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>
+
Иногда вам нужно сделать скриншоты системных диалогов или окон. Вот почему вы должны изменить язык для всей системы. Для этого в конструкторе DocLocScreenshotTestCase
есть дополнительный параметр - changeSystemLocale
. Обратите внимание на то, что changeSystemLocale
, определенный в true, требует системного разрешения Manifest.permission.CHANGE_CONFIGURATION
.
+Взгляните на код ниже:
+
@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")
+ }
+ }
+ }
+}
+
В большинстве случаев нет необходимости запускать какую-то Activity, делать много шагов, прежде чем добраться до необходимого функционала. Часто показа фрагментов будет достаточно, чтобы сделать нужные скриншоты. +Кроме того, когда вы используете архитектурный шаблон Model-View-Presenter, вы можете управлять состоянием пользовательского интерфейса непосредственно через интерфейс View. Таким образом, нет необходимости взаимодействовать с интерфейсом приложения и ждать изменений.
+Сначала создайте базовую тестовую Activity с методом setFragment(Fragment)
в вашем приложении:
class FragmentTestActivity : AppCompatActivity() {
+
+ fun setFragment(fragment: Fragment) = with(supportFragmentManager.beginTransaction()) {
+ replace(android.R.id.content, fragment)
+ commit()
+ }
+}
+
Затем добавьте тестовый пример скриншота базового продукта:
+```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
+
+} +
Этот тестовый пример будет запускать вашу `FragmentTestActivity` при запуске. Теперь вы можете писать тесты для скриншотов.
+Например, создайте новый тестовый класс, который расширяет `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")
+ }
+
+ // ... другие шаги
+ }
+ }
+}
+
Как вы могли заметить, метод getUiSafeProxy
вызывается для получения экземпляра FeatureView
.
+Этот метод обертывает ваш интерфейс View и возвращает на него прокси.
+Прокси гарантирует, что все методы интерфейса View
, которые вы вызвали, будут вызываться в основном потоке.
+Существует также getUiSafeProxyFromImplementation
, который оборачивает реализацию, а не интерфейс.
Полный пример см. в классе AdvancedScreenshotSampleTest.
+По умолчанию все скриншоты хранятся по адресу:
+/sdcard/screenshots/<locale>/<full qualified test class name>/<method name>.
+Вы можете изменить это поведение, предоставив свою реализацию интерфейсов
+ResourcesRootDirsProvider,
+ResourcesDirsProvider,
+ResourceFileNamesProvider и ResourcesDirNameProvider.
+Узнайте подробности здесь.
Мы были вынуждены изменить нашу систему предоставления ресурсов для поддержки Allure.
+Изменения затронули основной конструктор DocLocScreenshotTestCase.
+Но мы сохранили старый вариант использования DocLocScreenshotTestCase
со старой системой предоставления ресурсов в качестве вторичного конструктора.
+Вы можете просмотреть вторичный конструктор как пример миграции со старой системы на новую.
+Кроме того, мы сохранили тесты с использованием старой системы предоставления ресурсов в примерах, чтобы убедиться, что ничего не сломано.
Все поддерживаемые UI-виджеты Android в Kakao можно увидеть в списке наследников класса KBaseView
.
+Вот некоторые из них:
+
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
Если вы расширяете абстрактый класс UiScreen
, то вам доступны следующие элементы:
+
UiView
+
UiEditText
+
UiTextView
+
UiButton
+
UiCheckbox
+
UiChipGroup
+
UiSwitchView
+
UiScrollView
+
UiBottomNavigationView
Device
.Device — это поставщик менеджеров для всей работы вне приложения.
+Все примеры находятся в device_tests. +Класс Device содержит следующие свойства:
+apps
позволяет устанавливать или удалять приложения. Использует команды adb install
и adb uninstall
. См. пример DeviceAppSampleTest.activities
— это интерфейс для работы с отображаемыми в данный момент Activity. AdbServer не требуется. См. пример DeviceActivitiesSampleTest.files
обеспечивает возможность загрузки или удаления файлов с устройства. Использует команды adb push
и adb rm
и не требует разрешения android.permission.WRITE_EXTERNAL_STORAGE
. См. пример DeviceFilesSampleTest.internet
позволяет переключать настройки Wi-Fi и передачи данных по сети. Будьте осторожны при использовании этого интерфейса, изменения настроек Wi-Fi могут не работать с некоторыми версиями Android. См. пример DeviceNetworkSampleTest.keyboard
— это интерфейс для отправки событий клавиатуры через adb. Используйте его только тогда, когда Espresso или UiAutomator не подходят (например, экран заблокирован). См. пример DeviceKeyboardSampleTest.location
имитирует поддельное местоположение и позволяет переключать настройки GPS. См. пример DeviceLocationSampleTest.phone
позволяет эмулировать входящие звонки и принимать SMS-сообщения. Работает только на эмуляторах, так как использует команды adb emu
. См. пример DevicePhoneSampleTest.screenshots
— интерфейс для скриншотов пользовательского интерфейса. Требуется разрешение android.permission.WRITE_EXTERNAL_STORAGE
. См. пример DeviceScreenshotSampleTest.accessibility
- позволяет включать или отключать специальные возможности. Доступно с API 24. См. пример DeviceAccessibilitySampleTest.permissions
- предоставляет возможность выдавать или отклонять запросы разрешений через диалоговое окно разрешений Android по умолчанию. См. пример DevicePermissionsSampleTest.hackPermissions
предоставляет возможность выдавать любые разрешения без системного диалога Android по умолчанию. См. пример DeviceHackPermissionsSampleTest.exploit
позволяет менять ориентацию устройства или нажимать системные кнопки. См. пример DeviceExploitSampleTest.language
позволяет переключать язык. См. пример DeviceLanguageSampleTest.logcat
обеспечивает доступ к adb logcat. См. пример DeviceLogcatSampleTest. logcat
: Logcat
, предоставляющий множество способов проверки logcat.uiDevice
возвращает экземпляр android.support.test.uiautomator.UiDevice
. Мы не рекомендуем использовать его напрямую, потому что есть Kautomator, который предлагает более читаемый, предсказуемый и стабильный API для работы вне вашего приложения.Также Device предоставляет контексты приложений и тестов — targetContext
и context
.
Экземпляр Device
доступен в области BaseTestContext
и BaseTestCase
через свойство device
.
+
@Test
+fun test() =
+ run {
+ step("Open Simple Screen") {
+ activityTestRule.launchActivity(null)
+ ======> device.screenshots.take("Additional_screenshot") <======
+
+ mainScreen {
+ simpleButton {
+ isVisible()
+ click()
+ }
+ }
+ }
+ // ....
+}
+
Большинство функций, которые предоставляет Device, используют команды adb и требуют запуска AdbServer.
+Некоторые из них, такие как эмуляция звонков или прием СМС, могли выполняться только на эмуляторе. Все такие методы отмечены аннотацией @RequiresAdbServer
.
Все методы, использующие команды ADB, требуют разрешения android.permission.INTERNET
.
+Для получения дополнительной информации смотрите документацию AdbServer.
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.
Здесь вы можете найти подробную информацию о всех возможностях Kaspresso.
+ + + + + + +Kaspresso — это фреймворк для тестирования пользовательского интерфейса Android. Он основан на Espresso и UI Automator и предоставляет широкий спектр дополнительных функций, таких как:
+И многое другое!
+ +Чтобы интегрировать Kaspresso в свой проект:
+1. Включите репозиторий mavenCentral в корневой файл build.gradle
:
allprojects {
+ repositories {
+ mavenCentral()
+ }
+}
+
build.gradle
:dependencies {
+ androidTestImplementation 'com.kaspersky.android-components:kaspresso:<последняя_версия>'
+ // Поддержка Allure
+ androidTestImplementation "com.kaspersky.android-components:kaspresso-allure-support:<последняя_версия>"
+ // Поддержка Jetpack Compose
+ androidTestImplementation "com.kaspersky.android-components:kaspresso-compose-support:<последняя_версия>"
+}
+
Чтобы интегрировать наиболее свежую версию Kaspresso до официального релиза, добавьте постфикс "-SNAPSHOT" к версии:
+dependencies {
+ androidTestImplementation 'com.kaspersky.android-components:kaspresso:<latest_version>-SNAPSHOT'
+}
+
Если вы все еще используете старые библиотеки поддержки Android, мы настоятельно рекомендуем перейти на AndroidX.
+Последняя версия с библиотеками поддержки Android:
+dependencies {
+ androidTestImplementation 'com.kaspersky.android-components:kaspresso:1.0.1-support'
+}
+
Чтобы упростить изучение фреймворка, доступно пошаговое руководство на нашем веб-сайте.
+Нам нравится синтаксис, который Kakao применяет для написания тестов пользовательского интерфейса. Эта оболочка над Espresso использует подход Kotlin DSL, что делает код +значительно короче и читабельнее. Можно увидеть разницу:
+Espresso: +
@Test
+fun testFirstFeature() {
+ onView(withId(R.id.toFirstFeature))
+ .check(ViewAssertions.matches(
+ ViewMatchers.withEffectiveVisibility(
+ ViewMatchers.Visibility.VISIBLE)))
+ onView(withId(R.id.toFirstFeature)).perform(click())
+}
+
@Test
+fun testFirstFeature() {
+ mainScreen {
+ toFirstFeatureButton {
+ isVisible()
+ click()
+ }
+ }
+}
+
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")
+ }
+}
+
Однако, сами Kakao и Kautomator не помогут вам увидеть связь между тестом и соответствующим ему тестовым сценарием. Кроме того, длинный тест часто превращается в гигантский кусок кода, который невозможно разделить на более мелкие части. +Вот почему мы создали дополнительный Kotlin DSL, который упрощает чтение теста.
+См. пример ниже:
+@Test
+fun shouldPassOnNoInternetScanTest() =
+ beforeTest {
+ activityTestRule.launchActivity(null)
+ ...
+ }.afterTest {
+ ...
+ }.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 {
+ flakySafely(timeoutMs = 7000) { isVisible() }
+ hasText(R.string.text_edit_text)
+ }
+ }
+ }
+ step("Check all possibilities of edit") {
+ scenario(
+ CheckEditScenario()
+ )
+ }
+ }
+
Иногда ваш тест может выполняться успешно десять раз, а затем ломается на одиннадцатой попытке по какой-то загадочной причине. Это называется флак(flakiness).
+Самая популярная причина ненадёжности — нестабильность библиотек UI-тестов, таких как Espresso и UI Automator. Чтобы устранить эту нестабильность, Kaspresso использует DSL обертки и перехватчики.
+Давайте посмотрим короткое видео, показывающее разницу между оригинальным UI Automator (справа) и ускоренным (слева).
+ +Здесь предоставлено краткое объяснение, почему это возможно.
+Мы разработали перехватчики поведения Kaspresso (Kaspresso behavior interceptors) на основе перехватчиков Kakao/Kautomator для обработки сбоев.
+Благодаря перехватчикам можно делать много полезных вещей, таких как:
+и многое другое (см. руководство).
+Kaspresso пишет собственные сообщения в журнал, подробные и читабельные:
++
+Espresso и UI Automator не позволяют вызывать команды ADB из теста. Чтобы решить эту проблему, мы разработали AdbServer (см. вики).
+Вы можете использовать классы Kaspresso для работы с системой Android.
+Например, с помощью класса Device
вы можете:
(подробнее о Device class).
+Если вы разрабатываете приложение, доступное по всему миру, вам необходимо локализировать его на разные языки. Когда интерфейс локализован, для переводчика важно увидеть контекст слова или фразы на конкретном экране.
+С Kaspresso переводчики могут автоматически делать скриншот любого экрана. Это невероятно быстро, даже для устаревших экранов, и не требует дополнительного рефакторинга (см. руководство).
+Вы можете настроить любую часть Kaspresso (подробнее).
+Вы можете запускать свои UI-тесты в среде JVM. Кроме того, почти все перехватчики, улучшающие стабильность, читабельность и другие, будут работать. +Читать подробнее.
+Kaspresso может генерировать очень подробные Allure-отчеты для каждого теста: + +Более подробная информация доступна здесь.
+Теперь вы можете писать свои тесты Kaspresso для экранов Jetpack Compose! DSL и все принципы одинаковы. +Таким образом, вы не увидите никакой разницы между тестами для View и для экранов Compose. +Более подробная информация доступна здесь.
+** Имейте в виду, что это ранний доступ, который может содержать ошибки. Также возможно изменение API, но мы будем стараться этого не делать. Не стесняйтесь сообщать о багах в разделе issue, если вы столкнулись с какой-либо проблемой.**
+Сам инструмент, даже идеальный, не может решить всех проблем написания UI-тестов. Важно знать, как писать тесты и как организовать весь процесс. +Наша команда имеет большой опыт внедрения автотестов в разных компаниях. Мы поделились своими знаниями на Wiki.
+Для получения всей информации посетите Kaspresso wiki
+Все примеры доступны в папке samples.
+Для большинства примеров требуется AdbServer. Чтобы запустить AdbServer, вы должны сделать следующие шаги:
+Kaspresso
.
+cd ~/Workspace/Kaspresso
+
java -jar artifacts/adbserver-desktop.jar
+
Все существующие проблемы в Kaspresso можно найти здесь.
+Критические изменения можно найти здесь
+Kaspresso — это проект с открытым исходным кодом, поэтому вы можете внести свой вклад. См. рекомендации).
+Kaspresso доступен по Лицензии Apache, версия 2.0.
+ + + + + + +