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 @@ + + + + + + + + + + + + + + + + + + Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ +

404 - Not found

+ +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Home/Breaking-changes/index.html b/Home/Breaking-changes/index.html new file mode 100644 index 000000000..bb624eb35 --- /dev/null +++ b/Home/Breaking-changes/index.html @@ -0,0 +1,1121 @@ + + + + + + + + + + + + + + + + + + + + + + Breaking changes - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Breaking changes

+ +

1.2.0

+
    +
  • We've totally reworked AdbServer and Kaspresso 1.2.0 works only with new artifacts/adbserver-desktop.jar
    + The old version artifacts/desktop_1_1_0.jar is also available for use with older versions of Kaspresso.
  • +
  • If you use 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.
  • +
+

1.2.1

+
    +
  • Kaspresso migrated to a new version of Kakao which has 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.
  • +
+

1.5.0

+
    +
  • In order to support the system storage restrictions artifacts are saved under /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.
  • +
+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Home/Contribution_guide/index.html b/Home/Contribution_guide/index.html new file mode 100644 index 000000000..4d26b7aa5 --- /dev/null +++ b/Home/Contribution_guide/index.html @@ -0,0 +1,1123 @@ + + + + + + + + + + + + + + + + + + + + + + Contribution guide - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Contribution guide

+

Сode contribution workflow

+
    +
  1. Find an open issue or create a new issue on issue tracker for the feature you want to contribute.
  2. +
  3. Fork the project on GitHub. You need to create a feature-branch for your work on your fork, as this way you be able to submit a pull request.
  4. +
  5. Make any necessary changes to the source code.
  6. +
  7. Add tests that verify that your contribution works as expected and modify existing tests if required.
  8. +
  9. Run all unit and UI tests and make sure all of them pass.
  10. +
  11. Run code coverage to check if the lines of code you added are covered by unit tests.
  12. +
  13. Once your feature is complete, prepare the commit with appropriate message and the issue number.
  14. +
  15. Create a pull request and wait for the users to review. When you submit a pull request, please, agree to the terms of CLA.
  16. +
  17. Once everything is done, your pull request gets merged. Your feature will be available with the next release and your name will be added to AUTHORS.
  18. +
+

Branch naming

+

issue-***/detailed_description. Example: issue-306/fix-padding-breaks-autoscroll-interceptor

+

Commits

+

The commit message should begin with: "Issue #***: ...". Example: "Issue #306: Fixed padding-breaks autoscroll interceptor".

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Home/Kaspresso users/index.html b/Home/Kaspresso users/index.html new file mode 100644 index 000000000..6746af1cb --- /dev/null +++ b/Home/Kaspresso users/index.html @@ -0,0 +1,1125 @@ + + + + + + + + + + + + + + + + + + + + + + Our users - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Our Users

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ www.kaspersky.ru + + hh.ru + + aliexpress.ru + + www.sber.ru + + www.revolut.com +
+ www.delivery-club.ru + + www.vtb.ru + + www.tinkoff.ru + + www.x5.ru + + www.zen.yandex.ru +
+ www.psbank.ru + + www.letoile.ru + + rtkit.ru + + ooo.technology + + www.blinkist.com +
+ www.rabota.ru + + www.cian.ru + + squaregps.com + + nexign.com + + profi.ru +
+ alohabrowser.com + + vivid.money + + raiffeisen.ru + + cft.ru + + superjob.ru +
+ dmtech.team +
+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Home/Kaspresso-in-articles/index.html b/Home/Kaspresso-in-articles/index.html new file mode 100644 index 000000000..2fb4bf843 --- /dev/null +++ b/Home/Kaspresso-in-articles/index.html @@ -0,0 +1,1040 @@ + + + + + + + + + + + + + + + + + + + + + + Kaspresso in articles - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Kaspresso in articles

+

[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! +

+
+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Home/Kaspresso-in-videos/index.html b/Home/Kaspresso-in-videos/index.html new file mode 100644 index 000000000..f47e186f1 --- /dev/null +++ b/Home/Kaspresso-in-videos/index.html @@ -0,0 +1,1040 @@ + + + + + + + + + + + + + + + + + + + + + + Kaspresso in videos - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Issues/Storage_issue/index.html b/Issues/Storage_issue/index.html new file mode 100644 index 000000000..ee9373503 --- /dev/null +++ b/Issues/Storage_issue/index.html @@ -0,0 +1,1070 @@ + + + + + + + + + + + + + + + + + + + + Storage issues - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Storage issues

+ +
+

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:

+
    +
  1. AndroidManifest.xml (in your debug build variant to keep production manifest without any changes) +
    # 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>             
    +
  2. +
  3. Your test class: +
    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
    +    )
    +
    +    //...
    +}    
    +
  4. +
+

This is a temporary solution. We recommend migrating to the latest version of Kaspresso (1.5.0 and above) to avoid these problems.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Issues/index.html b/Issues/index.html new file mode 100644 index 000000000..84ab79685 --- /dev/null +++ b/Issues/index.html @@ -0,0 +1,1207 @@ + + + + + + + + + + + + + + + + + + + + + + Found an issue? - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Found an issue?

+

Kaspresso has a great community that helps make it better by suggesting new ideas, reporting bugs with detailed descriptions and making pull requests.

+

Creating new issues

+

In our Issues tab you can create a new one. There are two most popular types of issues: bug and enhancement.

+

Template for bugs

+

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.
+

+

Template for enhancements

+

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):
+...
+
+

Pull requests are allways welcome

+

If you have not only an issue, but also a ready implementation, you can always submit the pull request on Github.

+

Thanks!

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Tutorial/Android_permissions/index.html b/Tutorial/Android_permissions/index.html new file mode 100644 index 000000000..282339b79 --- /dev/null +++ b/Tutorial/Android_permissions/index.html @@ -0,0 +1,1953 @@ + + + + + + + + + + + + + + + + + + + + + + 10. Working with Android permissions - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Test apps that require permissions

+

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

+

Main Screen

+

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

+

Make call screen

+

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.

+

Request permissions

+

If we click “Allow”, then the call will begin to the subscriber at the number that you specified in the input field

+

Calling

+

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

+

Deny permission

+

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.

+

Create a test

+

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.

+

Testing with the TestRule

+

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.

+

FlakySafely for assertions

+

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)
+            }
+        }
+    }
+}
+
+Now the test works, but it has several problems.

+

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.

+

Testing with Device.Permissions

+

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

+

Rename

+

And enter a new class name:

+

Rename

+

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:

+

Device permission methods

+

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()
+            }
+        }
+    }
+}
+
+

Testing against different API versions

+

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()
+            }
+        }
+    }
+}
+
+

Summary

+

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:

+
    +
  1. The Permissions object makes it possible to test whether a dialog requesting permission is displayed
  2. +
  3. When using Permissions, we can test the application's behavior not only when accepting a permission, but also when denying it
  4. +
  5. Tests with the GrantPermissionRule will fail if the permission was previously denied. You will need to reinstall the application or cancel previously issued permissions through the adb shell command
  6. +
  7. If you revoke the permission using the adb shell command while the test is running, then the test will work correctly if the Permissions object is used, but a crash will occur if the GrantPermissionRule is used
  8. +
+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Tutorial/Download_Kaspresso_project_and_Android_studio/index.html b/Tutorial/Download_Kaspresso_project_and_Android_studio/index.html new file mode 100644 index 000000000..3a1b346b4 --- /dev/null +++ b/Tutorial/Download_Kaspresso_project_and_Android_studio/index.html @@ -0,0 +1,1163 @@ + + + + + + + + + + + + + + + + + + + + + + 2. Download Kaspresso project and Android studio - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Setting up the required environment.

+

In this lesson, we will download the Kaspresso project, install Android studio and set up the emulator.

+

Download Android Studio

+

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.

+

Downloading the Kaspresso project

+

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

+

Download Kaspresso button

+

Open Android Studio.

+

If you have not previously opened any project in the studio, then you must select the Get From VCS item

+

Get Project from VCS

+

If a project has already been launched, then you can load a new one from GIT as follows: File -> New -> Project From Version Control

+

Get Project from VCS

+

In the window that opens, enter the copied project URL, select the folder where Kaspresso will be placed and click clone.

+

Clone Project

+

Setting up the emulator.

+

In the top menu of Android Studio, select 'Tools' -> 'Device Manager'

+

Tools Device Manager

+

The tab for managing emulators and real devices will appear on the screen. Click on 'Create Device':

+

Create Device

+

We will see the following screen:

+

Select hardware

+

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:

+

System image

+

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.

+

SDK_component_isntaller

+

When the installation process is completed, click the Finish button:

+

SDK_component_isntaller_finish

+

Select the installed version ('R') and click 'Next':

+

SDK_component_installer_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'.

+

Device_name

+

The device is set up and ready for work. We launch it by the 'Play' icon to the right of the device name:

+

Launch_device

+

In some cases, Android Studio may recommend installing Hypervisor:

+

Hyper_Visor

+

Hyper_Visor_next

+

Summary

+

Android Studio is installed, emulator is configured, Kaspresso project is loaded. In the next lesson, we will run the first tests.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Tutorial/FlakySafely/index.html b/Tutorial/FlakySafely/index.html new file mode 100644 index 000000000..ba67454cf --- /dev/null +++ b/Tutorial/FlakySafely/index.html @@ -0,0 +1,1652 @@ + + + + + + + + + + + + + + + + + + + + + + 9. flakySafely - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Flaky Safely. Testing with timeout

+

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

+

Flaky activity button

+

This screen displays several TextView for which some data is being loaded

+

Flaky screen 1

+

After one second, the text for the first element is loaded

+

Flaky screen 2

+

After another three seconds, text appears on the second element

+

Flaky screen 3

+

After 10 seconds, the rest of the data will be loaded and the texts will appear in all TextView

+

Flaky screen 4

+

Testing FlakyScreen

+

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) }
+}
+
+To go to 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) }
+}
+
+Let's first check that the screen is open, all elements are visible and the 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.

+

When to use flakySafely

+

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 невидимый
+            }
+        }
+    }
+}
+
+Let's launch the test. It still succeeds.

+

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()
+            }
+        }
+    }
+}
+
+The next step is 10 seconds after the data for the second element is loaded, the text appears in all the other 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()
+                }
+            }
+        }
+    }
+}
+
+

Thread.sleep vs FlakySafely

+

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.

+

What timeout to specify?

+

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.

+

Features of working with ScrollView

+

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()
+                }
+            }
+        }
+    }
+}
+
+Test passed successfully. Now let's change the check for the fifth element of the list. Now instead of the 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.

+

Summary

+

In this tutorial, we covered the following points:

+
    +
  1. The `flakySafely` method for testing a stateful screen
  2. +
  3. Set different timeouts for different operations
  4. +
  5. Features of Kaspresso on scrollable screens
  6. +
  7. Difference between Thread.sleep and flakySafely
  8. +
+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Tutorial/Logger_and_screenshot/index.html b/Tutorial/Logger_and_screenshot/index.html new file mode 100644 index 000000000..d798a0973 --- /dev/null +++ b/Tutorial/Logger_and_screenshot/index.html @@ -0,0 +1,1630 @@ + + + + + + + + + + + + + + + + + + + + + + 12. Logger and screenshots - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Logging and screenshots

+

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

+

Tutorial main screen

+

and click on the Login Activity button

+

Login Activity

+

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.

+

Screen after auth

+

External system for test data

+

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

+

Create package 1

+

Create package 2

+

In the created package, add the TestData class, select the type Object

+

Create class

+

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"
+}
+
+Now let's create a separate test class in which we will check for a successful login using the data received from the 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 launch. Test passed successfully.

+

Analysis of failed tests

+

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"
+}
+
+Let's run the test again. This time the test fails.

+

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

+

Test failed

+

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.

+

Adding logs

+

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:

+

Custom Log

+

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

+

Kaspresso test tag

+

Screenshots

+

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

+

Screenshots

+

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

+

Setup password

+

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.

+
+

Setting up Kaspresso.Builder

+

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().

+

Test Case Params

+
+

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()
+)
+
+In this case, the call to methods that take screenshots can be removed, they will be saved automatically if the test fails.

+
+

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):

+

Advanced Builder

+

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

+
+

Interceptors

+

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.

+
    +
  • ScreenshotStepWatcherInterceptor - adds screenshots whether the step failed or not +
  • +
  • ScreenshotFailStepWatcherInterceptor - adds a screenshot of only the step that failed +
  • +
  • TestRunnerScreenshotWatcherInterceptor - adds a screenshot if an error occurs in the `before` or `after` section +
  • +
+ +

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))
+    }
+)
+
+Here we first get the default builder, call its 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

+

Customized Builder

+

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.

+

Summary

+

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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Tutorial/Logger_and_screenshots/index.html b/Tutorial/Logger_and_screenshots/index.html new file mode 100644 index 000000000..249633caa --- /dev/null +++ b/Tutorial/Logger_and_screenshots/index.html @@ -0,0 +1,1010 @@ + + + + + + + + + + + + + + + + + + Logger and screenshots - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Logger and screenshots

+ + + + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Tutorial/Recyclerview/index.html b/Tutorial/Recyclerview/index.html new file mode 100644 index 000000000..ba8a91c80 --- /dev/null +++ b/Tutorial/Recyclerview/index.html @@ -0,0 +1,1818 @@ + + + + + + + + + + + + + + + + + + + + + + 11. RecyclerView. Testing list of elements - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

RecyclerView. Testing list of elements

+

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.

+

Main Screen

+

You will see the following screen:

+

Todo List

+

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.

+

Swipe element

+

Remove element

+

Let's write tests for this screen. We need the IDs of the list elements, we will use the LayoutInspector to find them.

+

Layout Inspector

+

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) }
+}
+
+If we write such code, then we will have some errors. The fact is that if you are testing a 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.

+
+

Info

+

You can read more about classes and objects in the official Kotlin documentation.

+
+

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) {
+
+    }
+}
+
+We can add interface elements to NoteItemScreen that we will test.

+

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) }
+    }
+}
+
+Pay attention to two important points:

+

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) },
+)
+
+The second is 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) }
+    }
+}
+
+Now let's add a button to go to this screen in Page Object 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) }
+}
+
+Now you can start checking the screen with a list of notes

+

Testing NoteListScreen

+

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()
+                    }
+                }
+            }
+        }
+    }
+}
+
+We can also test each element separately. Let's check that each note contains the correct texts and background colors:

+
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")
+                    }
+                }
+            }
+        }
+    }
+}
+
+

Swipe check

+

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.

+

Wait for idle

+

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")
+                    }
+                }
+            }
+        }
+    }
+}
+
+

Extract methods to Page Object

+

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())
+    }
+}
+
+Now, in any place where we need to make a swipe, we simply call the method we created on the NoteItemScreen object:

+

childAt<NoteListScreen.NoteItemScreen>(0) {
+    swipeLeft()
+    device.uiDevice.waitForIdle()
+}
+
+Then the whole 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.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.

+
+

Summary

+

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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Tutorial/Scenario/index.html b/Tutorial/Scenario/index.html new file mode 100644 index 000000000..de270598f --- /dev/null +++ b/Tutorial/Scenario/index.html @@ -0,0 +1,1754 @@ + + + + + + + + + + + + + + + + + + + + + + 7. Reduce duplicate steps the Scenario - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Reduce duplicate steps with Scenario

+

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.

+

Main Screen login button

+

We have an authorization screen where the user can enter a login and password and click on the Login button

+

Login activity

+

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.

+

Screen After Login

+

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.

+

Test 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:

+
    +
  1. All elements are visible and the button is clickable
  2. +
  3. Input fields contain appropriate hints
  4. +
  5. If the input fields contain valid data, then transition to the next screen is performed
  6. +
+ +

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:

+
    +
  1. Declare the variables `username` and `password`, assigning different values to them depending on the check we will perform
  2. +
  3. Open the login screen
  4. +
  5. Check the visibility of elements
  6. +
  7. Enter the login and password created in step (1) in the appropriate fields and click on the "Login" button
  8. +
  9. Check that we have the desired screen
  10. +
+ +

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.

+

Create a Scenario

+

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()
+                }
+            }
+        }
+    }
+}
+
+

Using Scenario

+

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:

+
    +
  1. Create a step in which we try to log in with the correct data
  2. +
  3. Call the `scenario` function
  4. +
  5. Pass the LoginScenario object as a parameter to this function
  6. +
  7. Pass the correct login and password to the LoginScenario constructor
  8. +
  9. Add a step in which we check that the `AfterLoginActivity` screen opens after login
  10. +
+ +
@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.

+

Best practices

+

Scenario is very handy if you use it correctly.

+
    +
  • If you have to follow the same steps to run different tests, then this is the case when it is worth creating a Scenario. Examples: screens for authorization, payment for purchases, etc.
  • +
  • You shouldn't use one Scenario inside another because this code can become very confusing, making it harder to read and reuse it, and in the end you lose all benefits of Scenarios.
  • +
  • Use Scenario only when needed. You should not create them just because sometime in the future these steps may be used in other tests. If you see that the steps are repeated in different tests, then you can create a `Scenario`, if not, you should not do this. Their number in the project should be minimal.
  • +
+ +

Summary

+

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.

+


+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Tutorial/Screenshot_tests_1/index.html b/Tutorial/Screenshot_tests_1/index.html new file mode 100644 index 000000000..ce5966ea5 --- /dev/null +++ b/Tutorial/Screenshot_tests_1/index.html @@ -0,0 +1,1310 @@ + + + + + + + + + + + + + + + + + + + + + + 13. Screenshot-tests. Part 1. Simple screenshot-test - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Screenshot-тесты. Часть 1. Простой screenshot тест

+

В этом уроке мы научимся писать screenshot-тесты, узнаем, зачем они нужны, и как правильно разрабатывать приложение, чтобы его можно было покрыть тестами.

+

Продвинутый уровень

+

Для успешного прохождения предыдущих уроков было достаточно базовых навыков программирования на Kotlin, знания Android разработки при этом не требовались, и успешно пройти все уроки могли как разработчики, так и тестировщики. Но для нашей сегодняшней темы, а также всех последующих, нужно понимание того, как разрабатываются приложения, чем отличаются архитектурные шаблоны MVVM и MVP, как применять Dependency Injection и другое.

+

Поэтому предполагается, что все дальнейшие действия (или бОльшая их часть), которые мы будем проходить в курсе, находятся в зоне ответственности разработчиков, и эти уроки ориентированы на них. Если же с Android разработкой вы не знакомы, то можете все равно проходить эти уроки, чтобы иметь представление о возможностях Kaspresso, но учитывайте тот факт, что часть материала может быть непонятной.

+

Тестирование LoginActivity на разных локалях

+

Чтобы узнать, зачем нужны скриншот-тесты, разберем небольшой пример. Представим, что наше приложение должно быть локализовано на французский язык. Для этого в проекте были добавлены переводы в файл strings.xml в папку values-fr.

+

French resources

+

Давайте установим на устройстве французский язык

+

Install french locale

+

и запустим LoginActivityTest.

+

Tests completed successfully

+

Тест пройден успешно, значит теоретически это приложение рабочее, и его можно раскатывать на пользователей. Но давайте откроем LoginActivity вручную (французский язык должен быть установлен на устройстве) и посмотрим, как выглядит этот экран.

+

Todo instead of strings

+

Видим, что вместо корректных текстов здесь указано «TODO: add french locale». Похоже, что разработчики во время добавления строк оставили комментарий, чтобы добавить переводы в будущем, но забыли это сделать, поэтому приложение выглядит некорректно. Обнаружить эту ошибку тесты не могут, потому что они не знают, какой должен быть текст на французском языке. По этой причине приложение работает неправильно, но тесты проходят успешно.

+

Screenshot-тесты, как решение проблемы со строками

+

Возникшую проблему могут решить скриншот-тесты. Их суть заключается в том, что для всех экранов, где пользователю отображаются строки, создаются так называемые «скриншотилки» – классы, которые делают скриншоты экрана во всех необходимых состояниях и для всех поддерживаемых языков.

+

После выполнения таких тестов скриншоты складываются в определенные папки. Тогда люди, ответственные за переводы и строки, смогут просмотреть снимки и убедиться, что для всех локалей и для всех состояний используются корректные значения.

+

Screenshot-тесты будут отличаться от тестов, которые мы писали ранее:

+

Во-первых, нас интересуют только строки на определенном экране, поэтому нет необходимости проходить весь процесс от старта приложения до открытия нужного экрана. Вместо этого, в тесте мы сразу будем открывать Activity или Fragment, скриншоты которого хотим получить.

+

Во-вторых, мы хотим получить снимки всех возможных состояний экрана для каждой локали, поэтому добавлять проверки элементов или выполнять шаги, имитирующие действия пользователя, как мы делали ранее, мы не будем. Наша цель –

+
    +
  1. Открыть экран
  2. +
  3. Установить нужное состояние
  4. +
  5. Сделать скриншот
  6. +
  7. При необходимости изменить состояние и снова сделать скриншот
  8. +
+ +

Дальше нужно поменять локаль и повторить все перечисленные действия.

+

Подробнее про состояния (или стейты, как их часто называют), и как их правильно устанавливать, мы поговорим позже, а сейчас напишем простой screenshot-тест, который откроет экран LoginActivity, сделает скриншот, затем сменит язык на устройстве на французский и снова сделает скриншот.

+

Простой screenshot-тест

+

Создание screenshot-теста начинается так же, как мы делали ранее – в папке тестов создаем новый класс. Классы для скриншотов обычно называются с окончанием Screenshots. Давайте все скриншот-тесты будем хранить в отдельном пакете, назовем его screenshot_tests.

+

В этом пакете создаем класс LoginActivityScreenshots

+

Creating screenshot test class

+

У тестов, которые мы сейчас будем создавать, есть особенности: во-первых, они должны запускаться для разных локалей, во-вторых, полученные скриншоты должны быть размещены в удобной структуре папок – для каждого языка своя папка. По этим причинам тестовый класс мы унаследуем от класса 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 вы сможете найти все скриншоты, при этом для каждой локали была создана своя папка и вы сможете просмотреть, как выглядит ваше приложение на разных языках.

+

Screenshot test results

+

Initial state en

+

Initial state fr

+

Теперь, просмотрев скриншоты, можно увидеть проблему в приложении, что не все строки были добавлены корректно, и разработчик может исправить ошибку, добавив необходимые значения в файл values-fr/strings.xml.

+
+

Info

+

Возможно, на некоторых устройствах при смене локали у вас возникнет проблема с заголовком экрана – весь контент на экране будет корректно переведен на необходимый язык, а заголовок останется прежним. Проблема связана с багом в библиотеке Google. Его уже пофиксили, как только опубликуют соответствующий релиз, внесем изменения в Kaspresso.

+
+

Итог

+

В данном уроке мы рассмотрели: зачем нужны скриншот-тесты, как их писать и где смотреть результаты выполнения тестов.

+

Тема screenshot-тестов довольно обширная, и для более комфортного освоения мы ее разбили на несколько частей. Для более углубленного изучения переходите к следующему уроку

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Tutorial/Screenshot_tests_2/index.html b/Tutorial/Screenshot_tests_2/index.html new file mode 100644 index 000000000..8a9fa8b2a --- /dev/null +++ b/Tutorial/Screenshot_tests_2/index.html @@ -0,0 +1,1924 @@ + + + + + + + + + + + + + + + + + + + + + + 14. Screenshot-tests. Part 2. Working with ViewModel and setting states - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Screenshot-тесты. Часть 2. Установка стейтов и работа с ViewModel.

+

Если в вашем приложении планируется использование screenshot-тестов, то этот момент нужно учитывать не только при написании тестов, но также при разработке приложения. В сегодняшнем уроке мы поближе познакомимся с установкой стейтов, внесем изменения в код приложения, чтобы его можно было покрыть тестами, и напишем первый скриншот тест, в котором будет работа с ViewModel.

+

Предварительные знания

+

Если вы ранее не разрабатывали приложения под Android, то сегодняшний урок может быть сложным для понимания. Поэтому мы настоятельно рекомендуем перед прохождением данного урока ознакомиться со следующими темами:

+
    +
  1. Фрагменты – что это, и как с ними работать
  2. +
  3. ViewModel и шаблон проектирования MVVM
  4. +
  5. StateFlow
  6. +
  7. Библиотека Mockk
  8. +
+

Обзор тестируемого приложения

+

В приложении, которое мы сегодня будем покрывать тестами, имитируется загрузка данных. При клике на кнопку начинается загрузка, отображается прогресс бар, после окончания загрузки, в зависимости от результата, мы увидим либо загруженные данные, либо сообщение об ошибке.

+

Откройте приложение tutorial и кликнете по кнопке «Load User Activity»

+

Tutorial app

+

Давайте сразу начнем разбираться, что такое стейты. После перехода по кнопке у вас откроется экран, на котором располагается одна кнопка и больше ничего нет.

+

Initial state

+

При клике на данную кнопку внешний вид экрана изменится, то есть изменится его состояние или, как его часто называют, стейт. Сейчас мы видим исходный стейт экрана, назовем его Initial.

+

Кликнете по этой кнопке и обратите внимание, как изменится стейт экрана.

+

Progress state

+

Кнопка стала неактивной, то есть по ней больше нельзя кликать, и на экране появился Progress Bar, который показывает, что идет процесс загрузки. Этот стейт отличается от исходного, назовем этот стейт Progress.

+

Через несколько секунд загрузка будет завершена, и вы увидите на экране загруженные данные пользователя (его имя и фамилию).

+

Content state

+

Это третий стейт экрана. В таком состоянии кнопка снова становится активной, прогресс бар скрывается, и на экране отображаются имя и фамилия пользователя. Назовем такой стейт Content.

+

В данном случае мы имитируем загрузку данных, в реальных приложениях работа с интернетом может завершиться с ошибкой. Такие ошибки нужно каким-то образом обрабатывать, к примеру, показывать уведомление пользователю. В нашем тестовом приложении мы сымитировали подобное поведение. Если вы попытаетесь загрузить пользователя несколько раз, то какая-то из этих попыток завершится с ошибкой и вы увидите следующее состояние экрана:

+

Error state

+

Это четвертый и последний стейт экрана, который показывает состояние ошибки, назовем его Error.

+

Простой Screenshot-тест

+

Перед тем, как мы изучим правильный способ покрытия тестами данного экрана, давайте напишем тесты тем способом, который мы уже знаем. Это нужно для того, чтобы вы понимали, почему так делать не стоит, и зачем нам вносить изменения в уже написанный код.

+

В пакете screenshot_tests создаем класс LoadUserScreenshots

+

Create class

+

Наследуемся от DocLocScreenshotTestCase и передаем список языков в качестве параметра конструктору, сделаем скриншоты для английской и французской локалей

+

package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+
+class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+}
+
+Как мы говорили ранее – screenshot-тесты должны быть максимально легковесными, чтобы их прохождение занимало как можно меньше времени, поэтому вместо открытия главного экрана и перехода на экран загрузки данных пользователя, мы сразу будем открывать 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

+

Create page 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, и каждый из этих тестов будет зависеть от скорости интернет-соединения и от работоспособности сервера.

+

Также сервер может вернуть ошибку, когда мы ее не ожидали, в этом случае тест завершится неудачно.

+

На определенном этапе теста мы хотим сделать скриншот состояния ошибки. В данном случае мы одинаково реагируем на любой тип ошибки, показывая строчку «Что-то пошло не так», но часто бывают случаи, когда пользователю нужно сообщать о том, что конкретно произошло. Например, при отсутствии интернета, показать уведомление об этом, при ошибке на сервере показать соответствующий диалог и так далее. Поэтому в каких-то случаях для воспроизведения стейта с ошибкой будет достаточно отключить интернет на устройстве, а в каких-то это может стать проблемой, которую не так просто решить.

+

Так мы приходим к следующему выводу: получить все необходимые стейты, имитируя действия пользователя, для скриншот-тестов нецелесообразно.

+

Во-первых, это может сильно замедлить выполнение теста.

+

Во-вторых, такие тесты начинают зависеть от многих факторов, таких как скорость интернета и работоспособность сервера, следовательно вероятность того, что эти тесты завершатся неудачно, возрастает.

+

В-третьих, некоторые состояния экрана получить очень сложно, и этот процесс может занять длительное время

+

Взаимодействие View и ViewModel

+

По причинам, рассмотренным выше, в скриншот-тестах стейты устанавливают другим способом. Имитировать действия пользователя мы не будем.

+

На этом этапе важно понимать паттерн 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
+        }
+    }
+}
+
+View (в данном случае фрагмент 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

+

Давайте внутри тестового класса создадим объект ViewModel, у которого будем устанавливать стейт

+

class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+    val viewModel = LoadUserViewModel()
+
+
+}
+
+Теперь в эту ViewModel внутри тестового метода мы будем устанавливать новый стейт. Давайте попробуем установить какое-то новое значение в переменную 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()
+        }
+    }
+}
+
+В этой Activity почти нет кода. Дело в том, что в последнее время большинство приложений используют подход Single Activity. При таком подходе все экраны создаются на фрагментах, а активити служит лишь контейнером для них. Если вы хотите узнать больше о преимуществах этого подхода, то мы советуем почитать документацию. Что нужно понимать сейчас – внешний вид экрана и взаимодействие с ViewModel реализовано внутри 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, вызов этого метода 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 можно передать стиль, который должен использоваться внутри фрагмента, его можно найти в манифесте приложения

+

Style

+

Передать этот стиль в метод 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-тестами.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Tutorial/Steps_and_sections/index.html b/Tutorial/Steps_and_sections/index.html new file mode 100644 index 000000000..bb8e67e3e --- /dev/null +++ b/Tutorial/Steps_and_sections/index.html @@ -0,0 +1,1621 @@ + + + + + + + + + + + + + + + + + + + + + + 6. Steps and sections - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Sections and steps

+

Improve the code

+

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

+

Logcat

+

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

+

Edit configuration

+

A filter creation window will open. Add the name of the filter and the tag that we are interested in:

+

Create filter

+

Now we can see only useful information. Let's clear the log

+

Clear logcat

+

and run the test again. Do not forget to turn on the Internet on the device before this. Read the logs:

+

Log step 1

+

Here are the logs we added: step 1 is run, then checks are done, then step 1 succeeds.

+

Looking further:

+

Log step 2

+

Log step 2

+

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.

+

Log step 2 failed

+

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.).

+

Steps

+

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:

+

Log with steps

+

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.

+

Test fail with steps

+

Now it becomes much easier to find an error in the test, thanks to understandable logs.

+

Before and After sections

+

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)
+                }
+            }
+        }
+    }
+}
+
+

Summary

+

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.

+


+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Tutorial/UiAutomator/index.html b/Tutorial/UiAutomator/index.html new file mode 100644 index 000000000..51ba6d63f --- /dev/null +++ b/Tutorial/UiAutomator/index.html @@ -0,0 +1,1685 @@ + + + + + + + + + + + + + + + + + + + + + + 8. Kautomator. Third Party Application Testing - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Kautomator. Third Party Application Testing

+

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.

+
    +
  1. Open Google Play
  2. +
  3. Check that there is a `Sign In` button on the screen
  4. +
+ +

Google play unauthorized

+

Do not forget to log out before starting the test.

+

Autotest for Google Play functionality

+

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.

+

Tools for working with other applications

+

UIAutomator

+

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:

+

UiAutomatorViewer first launch

+

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:

+

UiAutomatorViewer create screenshot

+

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:

+

UiAutomatorViewer button info

+

Here you can see some useful information:

+
    +
  1. Package is the name of the application package that we specified in the test. One way to find it out is through this program
  2. +
  3. Resource-id is the element id you can use to search for the button and interact with it from the test. In our case, it is not possible, because the id value indicates that the resource name has been obfuscated, that is, encrypted. Therefore, it is not possible to search for an element by id for this screen
  4. +
  5. Text - one way to find an element on the screen is by the text that is displayed on it. It turns out that we can find the button on this screen by the text attribute
  6. +
+ +

Developer Assistant

+

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:

+

Developer Assistant Settings

+

Developer Assistant Settings

+

Developer Assistant Settings

+

Developer Assistant Settings

+

Developer Assistant Settings

+

Developer Assistant Settings

+

Once configured, you can run application analysis. Open the Google Play app and long press the Home button:

+

Developer Assistant Google play

+

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.

+

Developer Assistant Google play

+

The Element tab allows you to explore the user interface elements.

+

Developer Assistant Google play

+

The Sign in button has all the same attributes that we saw in Ui Automator Viewer.

+

Dump

+

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
+
+

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:

+

Uiautomator Dump

+

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:

+

Uiautomator Dump

+

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.

+

Uiautomator Dump

+

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.

+

Uiautomator Dump

+

Writing a test

+

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.

+

UI Button

+

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:

+

Matchers

+

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.

+

Testing the system UI

+

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:

+

Notification Activity Button

+

Click on the “Show notification” button - a notification is displayed on top.

+
+

Info

+

You can read more about notifications in Android here.

+
+

Notification Shown

+

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:

+

Ui automator notification

+

Using the dump command, the necessary elements can be found as follows

+

Dump

+

Dump

+

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.

+

Ui automator package

+

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.

+

Summary

+

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.

+


+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Tutorial/Wifi_sample_test/index.html b/Tutorial/Wifi_sample_test/index.html new file mode 100644 index 000000000..191345a24 --- /dev/null +++ b/Tutorial/Wifi_sample_test/index.html @@ -0,0 +1,1352 @@ + + + + + + + + + + + + + + + + + + + + + + 5. Testing the Internet connection and working with the Device class - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Testing the Internet connection and working with the Device class

+

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

+

Button Internet Availability

+

Manual testing

+

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.

+

Launch Wifi Test Activity

+

Launch Wifi Test Activity

+

Let's click on the button.

+

Wifi enabled

+

This button is clickable, after clicking, the correct Wifi state status is displayed - enabled. Disable WiFi.

+

Turn-off wifi

+

Click on the button again and check the Wifi status now:

+

Wifi disabled

+

The state is determined correctly. One last check - let's flip the device over and make sure the text on the screen is preserved.

+

Wifi disabled landscape

+

The text is saved successfully, all tests passed. Now we need to achieve the same result with all the checks performed automatically.

+

Writing autotests

+

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:

+
    +
  1. Add an activityRule so that when the test starts, we open MainActivity
  2. +
  3. Check that the button to go to the Internet check screen is visible and clickable
  4. +
  5. Click on the "Internet Availability" button
  6. +
+ +
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.

+

Available methods

+

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:

+
    +
  1. Check if the button is visible and clickable
  2. +
  3. Check that the title contains no text
  4. +
  5. Click on the button
  6. +
  7. Checking that the title text is "enabled"
  8. +
  9. Disable Wifi
  10. +
  11. Click on the button
  12. +
  13. Checking that the title text is "disabled"
  14. +
+ +
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:

+
    +
  1. Set device to portrait orientation
  2. +
  3. Check that the button is visible and clickable
  4. +
  5. Check that the title does not contain text
  6. +
  7. Click on the button
  8. +
  9. Check that the title text is "enabled"
  10. +
  11. Disable Wifi
  12. +
  13. Click on the button
  14. +
  15. Check that the title text is "disabled"
  16. +
  17. Flip the device
  18. +
  19. Check that the title text is still "disabled"
  20. +
+ +

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)
+        }
+    }
+}
+
+Let's launch the test. It passed successfully.

+

Summary

+

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:

+
    +
  • The test is not broken into steps. As a result, we have a large canvas of code, which is quite difficult to understand
  • +
  • The test only succeeds if we have previously enabled internet on the device. At the same time, at each next start, the test will fall due to the fact that Wifi is turned off inside it
  • +
+ +

In the following lessons, we will learn how we can improve this code and solve the problems that have arisen.

+


+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Tutorial/Working_with_adb/index.html b/Tutorial/Working_with_adb/index.html new file mode 100644 index 000000000..978e31e11 --- /dev/null +++ b/Tutorial/Working_with_adb/index.html @@ -0,0 +1,1377 @@ + + + + + + + + + + + + + + + + + + + + + + 4. Working with adb - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Working with adb

+

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:

+
    +
  • When there is no Internet
  • +
  • During an incoming call
  • +
  • With a low battery
  • +
  • When changing device orientation
  • +
  • Etc.
  • +
+ +

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.

+

Check java and adb

+

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.

+

Open cmd on windows 1

+

Open cmd on windows 2

+

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.

+

Java version showed

+

If the paths are written incorrectly, you will see something similar to this:

+

Java version failed

+

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.

+

Adb version success

+

Otherwise, you will see something like this error:

+

Adb version failed

+

If everything works for you on both points, then you can skip the next step.

+

Setting up java and adb

+

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.

+

Show jsdk path in android studio

+

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.

+

Show system variables

+

In the System Variables section, select Path and click Edit -> New -> Paste the copied path to the folder with java -> Click OK.

+

Java bin path

+

Restart the computer for the changes to take effect and check the java -version command again.

+

Java version success

+

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.

+

Adb path

+

Restart the computer and check the adb version command.

+

Adb version success

+

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.

+

Try different commands

+

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.

+

Empty devices list

+

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.

+

Devices list

+

Now our emulator is displayed in the list of devices.

+

With adb commands we can:

+
    +
  • Reboot device
  • +
  • Install an application
  • +
  • Remove an application
  • +
  • Upload files from/to a phone
  • +
  • etc.
  • +
+ +

For practice, let's remove the tutorial app we just launched. This is done with the command adb uninstall package_name.

+

Uninstall app

+

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.

+

Open shell console

+

Here are some examples of such commands.

+

Getting a list of all installed applications pm list packages.

+

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.

+

Exit shell console

+

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.

+

Device file explorer

+

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.

+

Create screenshot

+

In Device File Explorer, right-click and press Synchronize, after which the screenshot we created will be displayed in the folder.

+

Success screenshot

+

Working with adb in autotests

+

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.

+

Drag server

+

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.

+

Launch Server

+

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.

+
    +
  • `performAdb` executes an adb command
  • +
  • `performShell` executes a shell command
  • +
  • `performCmd` executes a command line
  • +
+ +

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:

+
    +
  1. adb-server is running
  2. +
  3. the application you are testing must have permission to use the Internet in the manifest
  4. +
+ +

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.

+

Manifest Location

+

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

+
+

Undefined command

+

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:

+

Hostname

+

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())
+    }
+}
+
+
+

+

Summary

+

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.

+


+

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Tutorial/Writing_simple_test/index.html b/Tutorial/Writing_simple_test/index.html new file mode 100644 index 000000000..fb7767c84 --- /dev/null +++ b/Tutorial/Writing_simple_test/index.html @@ -0,0 +1,1552 @@ + + + + + + + + + + + + + + + + + + + + + + 3. Writing your first Kaspresso test - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Writing your first Kaspresso test

+

Switch to the desired branch in GIT

+

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.

+

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.

+

Switch to results

+

Manual testing

+

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):

+

Select tutorial

+

Check that the desired device is selected (1) and run the application (2):

+

Launch tutorial

+

After successfully launching the application, we will see the main screen of the Tutorial application.

+

Tutorial main

+

Click on the button with the text "Simple test" and see the following screen:

+

Page object example

+

The screen consists of:

+
    +
  1. +

    Header TextView

    +
  2. +
  3. +

    EditText input fields

    +
  4. +
  5. +

    Buttons

    +
  6. +
+
+

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.

+

Automatic testing

+

We manually checked that the result of the application meets the expectations:

+
    +
  1. On the main screen there is a button to go to the `SimpleTest` screen (the rest of the elements on this screen do not interest us for now)
  2. +
  3. This button is visible
  4. +
  5. This button is clickable
  6. +
  7. Clicking on it takes us to the SimpleTest screen
  8. +
  9. `SimpleTest` screen has three UI elements: title, input field and button
  10. +
  11. All these elements are visible
  12. +
  13. Header contains default text
  14. +
  15. If you enter some text in the input field and click on the button, then the text in the title changes to the entered one
  16. +
+ +

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.

+

Including Kaspresso in the project

+

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:

+

Tutorial build gradle

+
dependencies {
+    androidTestImplementation("com.kaspersky.android-components:kaspresso:1.5.1")
+    androidTestUtil("androidx.test:orchestrator:1.4.2")
+}
+
+

Let's start writing the test by creating a Page object for the current screen

+

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.

+

Create directory

+

Select the item androidTest/kotlin:

+

Name directory androidTest

+

Inside the kotlin folder, let's create a separate package in which we will store all Page Objects:

+

Create package

+

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.

+

MainActivity Package name

+

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.

+

Screen Package name

+

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):

+

Create class

+

Choose the type Object and name it MainScreen.

+

Create 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.

+

Override methods

+

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.

+

Override methods

+

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.

+
    +
  1. Launch the application
  2. +
  3. In the bottom right corner of Android Studio select Layout Inspector Find bottom layout inspector
  4. +
  5. Wait for the screen to load Layout inspector loaded
  6. +
  7. If the screen does not load, then check that you have the desired process selected Choose process
  8. +
+ +

Select an item on the screen and look for its id. This is the identifier that interests us.

+

Search for button id

+

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.

+

Find layout

+

In this case, it's a Button element with id simple_activity_btn

+

Find button in layout

+

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.

+

Find source of KButton

+

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.

+

Find kbaseview children

+

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.

+

Show children which we need

+

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.

+

Add SimpleActivityTest

+

In the folder androidTest -> kotlin, in the package we created, add the class SimpleActivityTest.

+

Creating Test First part

+

Creating Test Second part

+

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

+

Move to another package

+

And remove the last part .screen from the package name.

+

Change 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.

+

Success passed test

+

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.

+

Feailed test

+

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.

+

Success test

+

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:

+

Title id in inspector

+

Input id in inspector

+

Button id in 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.

+

Find string in layout

+

Go to string resources (file values/strings.xml) and copy the string id.

+

Find string in values folder

+

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")
+
+        }
+    }
+}
+
+

Summary

+

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.

+


+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Create_device.png b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Create_device.png new file mode 100644 index 000000000..309b069b7 Binary files /dev/null and b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Create_device.png differ diff --git a/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Device_name.png b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Device_name.png new file mode 100644 index 000000000..866006c1a Binary files /dev/null and b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Device_name.png differ diff --git a/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Github_download_button.png b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Github_download_button.png new file mode 100644 index 000000000..23b23e197 Binary files /dev/null and b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Github_download_button.png differ diff --git a/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Github_download_zip.png b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Github_download_zip.png new file mode 100644 index 000000000..ba3a8042d Binary files /dev/null and b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Github_download_zip.png differ diff --git a/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Hyper_Visor.png b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Hyper_Visor.png new file mode 100644 index 000000000..ddb5363fe Binary files /dev/null and b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Hyper_Visor.png differ diff --git a/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Hyper_Visor_next.png b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Hyper_Visor_next.png new file mode 100644 index 000000000..ef549b800 Binary files /dev/null and b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Hyper_Visor_next.png differ diff --git a/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Launch_device.png b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Launch_device.png new file mode 100644 index 000000000..6afa04120 Binary files /dev/null and b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Launch_device.png differ diff --git a/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_installer_finish.png b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_installer_finish.png new file mode 100644 index 000000000..7401966ed Binary files /dev/null and b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_installer_finish.png differ diff --git a/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_installer_next.png b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_installer_next.png new file mode 100644 index 000000000..8a8f5c610 Binary files /dev/null and b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_installer_next.png differ diff --git a/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_isntaller.png b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_isntaller.png new file mode 100644 index 000000000..4fef5bb70 Binary files /dev/null and b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_isntaller.png differ diff --git a/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Select_hardware.png b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Select_hardware.png new file mode 100644 index 000000000..2e43d988e Binary files /dev/null and b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Select_hardware.png differ diff --git a/Tutorial/images/Download_Kaspresso_project_and_Android_studio/System_Image.png b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/System_Image.png new file mode 100644 index 000000000..20616ba4e Binary files /dev/null and b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/System_Image.png differ diff --git a/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Tools_Device_Manager.png b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Tools_Device_Manager.png new file mode 100644 index 000000000..d0b82bc56 Binary files /dev/null and b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Tools_Device_Manager.png differ diff --git a/Tutorial/images/Download_Kaspresso_project_and_Android_studio/clone_project.png b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/clone_project.png new file mode 100644 index 000000000..e7f2dbe2d Binary files /dev/null and b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/clone_project.png differ diff --git a/Tutorial/images/Download_Kaspresso_project_and_Android_studio/download_by_git.png b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/download_by_git.png new file mode 100644 index 000000000..071912c0b Binary files /dev/null and b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/download_by_git.png differ diff --git a/Tutorial/images/Download_Kaspresso_project_and_Android_studio/get_from_vcs.png b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/get_from_vcs.png new file mode 100644 index 000000000..cbf3813ba Binary files /dev/null and b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/get_from_vcs.png differ diff --git a/Tutorial/images/Download_Kaspresso_project_and_Android_studio/new_project_from_vcs.png b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/new_project_from_vcs.png new file mode 100644 index 000000000..ad6935fa7 Binary files /dev/null and b/Tutorial/images/Download_Kaspresso_project_and_Android_studio/new_project_from_vcs.png differ diff --git a/Tutorial/images/Running_the_first_test/device_select.png b/Tutorial/images/Running_the_first_test/device_select.png new file mode 100644 index 000000000..d8a50d26f Binary files /dev/null and b/Tutorial/images/Running_the_first_test/device_select.png differ diff --git a/Tutorial/images/Running_the_first_test/launch_test.png b/Tutorial/images/Running_the_first_test/launch_test.png new file mode 100644 index 000000000..92ec54b26 Binary files /dev/null and b/Tutorial/images/Running_the_first_test/launch_test.png differ diff --git a/Tutorial/images/Running_the_first_test/logcat.png b/Tutorial/images/Running_the_first_test/logcat.png new file mode 100644 index 000000000..e618856b8 Binary files /dev/null and b/Tutorial/images/Running_the_first_test/logcat.png differ diff --git a/Tutorial/images/Running_the_first_test/logcat_search.png b/Tutorial/images/Running_the_first_test/logcat_search.png new file mode 100644 index 000000000..23712adae Binary files /dev/null and b/Tutorial/images/Running_the_first_test/logcat_search.png differ diff --git a/Tutorial/images/Running_the_first_test/run_application.png b/Tutorial/images/Running_the_first_test/run_application.png new file mode 100644 index 000000000..83adc4281 Binary files /dev/null and b/Tutorial/images/Running_the_first_test/run_application.png differ diff --git a/Tutorial/images/Running_the_first_test/run_simple_test.png b/Tutorial/images/Running_the_first_test/run_simple_test.png new file mode 100644 index 000000000..6e33e7bae Binary files /dev/null and b/Tutorial/images/Running_the_first_test/run_simple_test.png differ diff --git a/Tutorial/images/Running_the_first_test/run_simple_test_1.png b/Tutorial/images/Running_the_first_test/run_simple_test_1.png new file mode 100644 index 000000000..e33f7bbaa Binary files /dev/null and b/Tutorial/images/Running_the_first_test/run_simple_test_1.png differ diff --git a/Tutorial/images/Running_the_first_test/run_simple_test_2.png b/Tutorial/images/Running_the_first_test/run_simple_test_2.png new file mode 100644 index 000000000..e33778e10 Binary files /dev/null and b/Tutorial/images/Running_the_first_test/run_simple_test_2.png differ diff --git a/Tutorial/images/Running_the_first_test/test_result.png b/Tutorial/images/Running_the_first_test/test_result.png new file mode 100644 index 000000000..2efd131e6 Binary files /dev/null and b/Tutorial/images/Running_the_first_test/test_result.png differ diff --git a/Tutorial/images/adb_lesson/adb_path.png b/Tutorial/images/adb_lesson/adb_path.png new file mode 100644 index 000000000..87f94c3f5 Binary files /dev/null and b/Tutorial/images/adb_lesson/adb_path.png differ diff --git a/Tutorial/images/adb_lesson/adb_version_failed.png b/Tutorial/images/adb_lesson/adb_version_failed.png new file mode 100644 index 000000000..05c8fe45f Binary files /dev/null and b/Tutorial/images/adb_lesson/adb_version_failed.png differ diff --git a/Tutorial/images/adb_lesson/adb_version_success.png b/Tutorial/images/adb_lesson/adb_version_success.png new file mode 100644 index 000000000..fc7fe75af Binary files /dev/null and b/Tutorial/images/adb_lesson/adb_version_success.png differ diff --git a/Tutorial/images/adb_lesson/bin_path.png b/Tutorial/images/adb_lesson/bin_path.png new file mode 100644 index 000000000..26bf43fff Binary files /dev/null and b/Tutorial/images/adb_lesson/bin_path.png differ diff --git a/Tutorial/images/adb_lesson/create_screenshot.png b/Tutorial/images/adb_lesson/create_screenshot.png new file mode 100644 index 000000000..8dbd6e2cd Binary files /dev/null and b/Tutorial/images/adb_lesson/create_screenshot.png differ diff --git a/Tutorial/images/adb_lesson/device_file_explorer.png b/Tutorial/images/adb_lesson/device_file_explorer.png new file mode 100644 index 000000000..cb961010f Binary files /dev/null and b/Tutorial/images/adb_lesson/device_file_explorer.png differ diff --git a/Tutorial/images/adb_lesson/devices_list.png b/Tutorial/images/adb_lesson/devices_list.png new file mode 100644 index 000000000..101eff51c Binary files /dev/null and b/Tutorial/images/adb_lesson/devices_list.png differ diff --git a/Tutorial/images/adb_lesson/drag_server.png b/Tutorial/images/adb_lesson/drag_server.png new file mode 100644 index 000000000..453063cbb Binary files /dev/null and b/Tutorial/images/adb_lesson/drag_server.png differ diff --git a/Tutorial/images/adb_lesson/empty_devices_list.png b/Tutorial/images/adb_lesson/empty_devices_list.png new file mode 100644 index 000000000..d2a4fc418 Binary files /dev/null and b/Tutorial/images/adb_lesson/empty_devices_list.png differ diff --git a/Tutorial/images/adb_lesson/exit_shell_console.png b/Tutorial/images/adb_lesson/exit_shell_console.png new file mode 100644 index 000000000..0a9c478d4 Binary files /dev/null and b/Tutorial/images/adb_lesson/exit_shell_console.png differ diff --git a/Tutorial/images/adb_lesson/hostname.png b/Tutorial/images/adb_lesson/hostname.png new file mode 100644 index 000000000..fadf4bdbe Binary files /dev/null and b/Tutorial/images/adb_lesson/hostname.png differ diff --git a/Tutorial/images/adb_lesson/java_version_failed.png b/Tutorial/images/adb_lesson/java_version_failed.png new file mode 100644 index 000000000..9e97cf235 Binary files /dev/null and b/Tutorial/images/adb_lesson/java_version_failed.png differ diff --git a/Tutorial/images/adb_lesson/java_version_success.png b/Tutorial/images/adb_lesson/java_version_success.png new file mode 100644 index 000000000..e5c8d1482 Binary files /dev/null and b/Tutorial/images/adb_lesson/java_version_success.png differ diff --git a/Tutorial/images/adb_lesson/jdk_in_android_studio.png b/Tutorial/images/adb_lesson/jdk_in_android_studio.png new file mode 100644 index 000000000..ab34f2798 Binary files /dev/null and b/Tutorial/images/adb_lesson/jdk_in_android_studio.png differ diff --git a/Tutorial/images/adb_lesson/launch_server.png b/Tutorial/images/adb_lesson/launch_server.png new file mode 100644 index 000000000..c0639bb8e Binary files /dev/null and b/Tutorial/images/adb_lesson/launch_server.png differ diff --git a/Tutorial/images/adb_lesson/list_packages.png b/Tutorial/images/adb_lesson/list_packages.png new file mode 100644 index 000000000..f10ae6bda Binary files /dev/null and b/Tutorial/images/adb_lesson/list_packages.png differ diff --git a/Tutorial/images/adb_lesson/manifest_location.png b/Tutorial/images/adb_lesson/manifest_location.png new file mode 100644 index 000000000..f68d5d998 Binary files /dev/null and b/Tutorial/images/adb_lesson/manifest_location.png differ diff --git a/Tutorial/images/adb_lesson/open_shell_console.png b/Tutorial/images/adb_lesson/open_shell_console.png new file mode 100644 index 000000000..d61a7c5f1 Binary files /dev/null and b/Tutorial/images/adb_lesson/open_shell_console.png differ diff --git a/Tutorial/images/adb_lesson/success_screen.png b/Tutorial/images/adb_lesson/success_screen.png new file mode 100644 index 000000000..3670fde11 Binary files /dev/null and b/Tutorial/images/adb_lesson/success_screen.png differ diff --git a/Tutorial/images/adb_lesson/system_variables.png b/Tutorial/images/adb_lesson/system_variables.png new file mode 100644 index 000000000..dff868aa5 Binary files /dev/null and b/Tutorial/images/adb_lesson/system_variables.png differ diff --git a/Tutorial/images/adb_lesson/undefined_command.png b/Tutorial/images/adb_lesson/undefined_command.png new file mode 100644 index 000000000..53cbc26ee Binary files /dev/null and b/Tutorial/images/adb_lesson/undefined_command.png differ diff --git a/Tutorial/images/adb_lesson/uninstall_app.png b/Tutorial/images/adb_lesson/uninstall_app.png new file mode 100644 index 000000000..75dabcbf7 Binary files /dev/null and b/Tutorial/images/adb_lesson/uninstall_app.png differ diff --git a/Tutorial/images/adb_lesson/windows_cmd_open_1.png b/Tutorial/images/adb_lesson/windows_cmd_open_1.png new file mode 100644 index 000000000..0aa81e12c Binary files /dev/null and b/Tutorial/images/adb_lesson/windows_cmd_open_1.png differ diff --git a/Tutorial/images/adb_lesson/windows_cmd_open_2.png b/Tutorial/images/adb_lesson/windows_cmd_open_2.png new file mode 100644 index 000000000..39c88f381 Binary files /dev/null and b/Tutorial/images/adb_lesson/windows_cmd_open_2.png differ diff --git a/Tutorial/images/flaky/flaky_1.png b/Tutorial/images/flaky/flaky_1.png new file mode 100644 index 000000000..087ccff96 Binary files /dev/null and b/Tutorial/images/flaky/flaky_1.png differ diff --git a/Tutorial/images/flaky/flaky_2.png b/Tutorial/images/flaky/flaky_2.png new file mode 100644 index 000000000..d87e8cb81 Binary files /dev/null and b/Tutorial/images/flaky/flaky_2.png differ diff --git a/Tutorial/images/flaky/flaky_3.png b/Tutorial/images/flaky/flaky_3.png new file mode 100644 index 000000000..be54c0962 Binary files /dev/null and b/Tutorial/images/flaky/flaky_3.png differ diff --git a/Tutorial/images/flaky/flaky_4.png b/Tutorial/images/flaky/flaky_4.png new file mode 100644 index 000000000..be2d0bcb7 Binary files /dev/null and b/Tutorial/images/flaky/flaky_4.png differ diff --git a/Tutorial/images/flaky/flaky_activity_btn.png b/Tutorial/images/flaky/flaky_activity_btn.png new file mode 100644 index 000000000..84a4cac9e Binary files /dev/null and b/Tutorial/images/flaky/flaky_activity_btn.png differ diff --git a/Tutorial/images/logs/advanced_builder.png b/Tutorial/images/logs/advanced_builder.png new file mode 100644 index 000000000..9a13e2583 Binary files /dev/null and b/Tutorial/images/logs/advanced_builder.png differ diff --git a/Tutorial/images/logs/after_auth.png b/Tutorial/images/logs/after_auth.png new file mode 100644 index 000000000..ebbdb7aff Binary files /dev/null and b/Tutorial/images/logs/after_auth.png differ diff --git a/Tutorial/images/logs/create_class.png b/Tutorial/images/logs/create_class.png new file mode 100644 index 000000000..1c634304f Binary files /dev/null and b/Tutorial/images/logs/create_class.png differ diff --git a/Tutorial/images/logs/create_package.png b/Tutorial/images/logs/create_package.png new file mode 100644 index 000000000..0303b1e68 Binary files /dev/null and b/Tutorial/images/logs/create_package.png differ diff --git a/Tutorial/images/logs/create_package_2.png b/Tutorial/images/logs/create_package_2.png new file mode 100644 index 000000000..ea4f0014e Binary files /dev/null and b/Tutorial/images/logs/create_package_2.png differ diff --git a/Tutorial/images/logs/custom_log.png b/Tutorial/images/logs/custom_log.png new file mode 100644 index 000000000..e1884366b Binary files /dev/null and b/Tutorial/images/logs/custom_log.png differ diff --git a/Tutorial/images/logs/custom_log_test.png b/Tutorial/images/logs/custom_log_test.png new file mode 100644 index 000000000..b39961d40 Binary files /dev/null and b/Tutorial/images/logs/custom_log_test.png differ diff --git a/Tutorial/images/logs/customized_builder.png b/Tutorial/images/logs/customized_builder.png new file mode 100644 index 000000000..9df6b88d0 Binary files /dev/null and b/Tutorial/images/logs/customized_builder.png differ diff --git a/Tutorial/images/logs/kaspresso_test_tag.png b/Tutorial/images/logs/kaspresso_test_tag.png new file mode 100644 index 000000000..647b11e55 Binary files /dev/null and b/Tutorial/images/logs/kaspresso_test_tag.png differ diff --git a/Tutorial/images/logs/logcat.png b/Tutorial/images/logs/logcat.png new file mode 100644 index 000000000..75fb9ab71 Binary files /dev/null and b/Tutorial/images/logs/logcat.png differ diff --git a/Tutorial/images/logs/login_activity.png b/Tutorial/images/logs/login_activity.png new file mode 100644 index 000000000..b0539938d Binary files /dev/null and b/Tutorial/images/logs/login_activity.png differ diff --git a/Tutorial/images/logs/main_screen.png b/Tutorial/images/logs/main_screen.png new file mode 100644 index 000000000..aaeda9dde Binary files /dev/null and b/Tutorial/images/logs/main_screen.png differ diff --git a/Tutorial/images/logs/screenshots.png b/Tutorial/images/logs/screenshots.png new file mode 100644 index 000000000..8ae6e61aa Binary files /dev/null and b/Tutorial/images/logs/screenshots.png differ diff --git a/Tutorial/images/logs/setup_password.png b/Tutorial/images/logs/setup_password.png new file mode 100644 index 000000000..1170a163a Binary files /dev/null and b/Tutorial/images/logs/setup_password.png differ diff --git a/Tutorial/images/logs/test_case_params.png b/Tutorial/images/logs/test_case_params.png new file mode 100644 index 000000000..5aad51445 Binary files /dev/null and b/Tutorial/images/logs/test_case_params.png differ diff --git a/Tutorial/images/logs/test_failed_1.png b/Tutorial/images/logs/test_failed_1.png new file mode 100644 index 000000000..ee8d1e1a5 Binary files /dev/null and b/Tutorial/images/logs/test_failed_1.png differ diff --git a/Tutorial/images/permissions/call_1.png b/Tutorial/images/permissions/call_1.png new file mode 100644 index 000000000..ad820d5db Binary files /dev/null and b/Tutorial/images/permissions/call_1.png differ diff --git a/Tutorial/images/permissions/deny_permission_settings.png b/Tutorial/images/permissions/deny_permission_settings.png new file mode 100644 index 000000000..8eae4e90a Binary files /dev/null and b/Tutorial/images/permissions/deny_permission_settings.png differ diff --git a/Tutorial/images/permissions/device_perm_methods.png b/Tutorial/images/permissions/device_perm_methods.png new file mode 100644 index 000000000..82cfa04ea Binary files /dev/null and b/Tutorial/images/permissions/device_perm_methods.png differ diff --git a/Tutorial/images/permissions/main_screen.png b/Tutorial/images/permissions/main_screen.png new file mode 100644 index 000000000..d562478ad Binary files /dev/null and b/Tutorial/images/permissions/main_screen.png differ diff --git a/Tutorial/images/permissions/make_call_screen.png b/Tutorial/images/permissions/make_call_screen.png new file mode 100644 index 000000000..e5dcca234 Binary files /dev/null and b/Tutorial/images/permissions/make_call_screen.png differ diff --git a/Tutorial/images/permissions/rename.png b/Tutorial/images/permissions/rename.png new file mode 100644 index 000000000..dea224268 Binary files /dev/null and b/Tutorial/images/permissions/rename.png differ diff --git a/Tutorial/images/permissions/rename_2.png b/Tutorial/images/permissions/rename_2.png new file mode 100644 index 000000000..b57a3c222 Binary files /dev/null and b/Tutorial/images/permissions/rename_2.png differ diff --git a/Tutorial/images/permissions/request_permission_1.png b/Tutorial/images/permissions/request_permission_1.png new file mode 100644 index 000000000..dce36a02d Binary files /dev/null and b/Tutorial/images/permissions/request_permission_1.png differ diff --git a/Tutorial/images/recycler_view/layout_inspector.png b/Tutorial/images/recycler_view/layout_inspector.png new file mode 100644 index 000000000..fea71e7bc Binary files /dev/null and b/Tutorial/images/recycler_view/layout_inspector.png differ diff --git a/Tutorial/images/recycler_view/main_screen.png b/Tutorial/images/recycler_view/main_screen.png new file mode 100644 index 000000000..9b48062ec Binary files /dev/null and b/Tutorial/images/recycler_view/main_screen.png differ diff --git a/Tutorial/images/recycler_view/removed.png b/Tutorial/images/recycler_view/removed.png new file mode 100644 index 000000000..fade38d9b Binary files /dev/null and b/Tutorial/images/recycler_view/removed.png differ diff --git a/Tutorial/images/recycler_view/swiped.png b/Tutorial/images/recycler_view/swiped.png new file mode 100644 index 000000000..0c6578c6c Binary files /dev/null and b/Tutorial/images/recycler_view/swiped.png differ diff --git a/Tutorial/images/recycler_view/todo_list.png b/Tutorial/images/recycler_view/todo_list.png new file mode 100644 index 000000000..b29cee6a4 Binary files /dev/null and b/Tutorial/images/recycler_view/todo_list.png differ diff --git a/Tutorial/images/scenario/login_activity.png b/Tutorial/images/scenario/login_activity.png new file mode 100644 index 000000000..b84ef1757 Binary files /dev/null and b/Tutorial/images/scenario/login_activity.png differ diff --git a/Tutorial/images/scenario/main_screen_login_button.png b/Tutorial/images/scenario/main_screen_login_button.png new file mode 100644 index 000000000..f6f0b00ba Binary files /dev/null and b/Tutorial/images/scenario/main_screen_login_button.png differ diff --git a/Tutorial/images/scenario/screen_after_login.png b/Tutorial/images/scenario/screen_after_login.png new file mode 100644 index 000000000..b7f7351be Binary files /dev/null and b/Tutorial/images/scenario/screen_after_login.png differ diff --git a/Tutorial/images/screenshot_tests_1/Initial_state_en.png b/Tutorial/images/screenshot_tests_1/Initial_state_en.png new file mode 100644 index 000000000..040e39892 Binary files /dev/null and b/Tutorial/images/screenshot_tests_1/Initial_state_en.png differ diff --git a/Tutorial/images/screenshot_tests_1/Initial_state_fr.png b/Tutorial/images/screenshot_tests_1/Initial_state_fr.png new file mode 100644 index 000000000..f8dbbca32 Binary files /dev/null and b/Tutorial/images/screenshot_tests_1/Initial_state_fr.png differ diff --git a/Tutorial/images/screenshot_tests_1/create_screenshot_test.png b/Tutorial/images/screenshot_tests_1/create_screenshot_test.png new file mode 100644 index 000000000..b70bb2510 Binary files /dev/null and b/Tutorial/images/screenshot_tests_1/create_screenshot_test.png differ diff --git a/Tutorial/images/screenshot_tests_1/fr_locale.png b/Tutorial/images/screenshot_tests_1/fr_locale.png new file mode 100644 index 000000000..59fcd3b91 Binary files /dev/null and b/Tutorial/images/screenshot_tests_1/fr_locale.png differ diff --git a/Tutorial/images/screenshot_tests_1/french.png b/Tutorial/images/screenshot_tests_1/french.png new file mode 100644 index 000000000..965c22eed Binary files /dev/null and b/Tutorial/images/screenshot_tests_1/french.png differ diff --git a/Tutorial/images/screenshot_tests_1/initial_en.png b/Tutorial/images/screenshot_tests_1/initial_en.png new file mode 100644 index 000000000..040e39892 Binary files /dev/null and b/Tutorial/images/screenshot_tests_1/initial_en.png differ diff --git a/Tutorial/images/screenshot_tests_1/initial_fr.png b/Tutorial/images/screenshot_tests_1/initial_fr.png new file mode 100644 index 000000000..f8dbbca32 Binary files /dev/null and b/Tutorial/images/screenshot_tests_1/initial_fr.png differ diff --git a/Tutorial/images/screenshot_tests_1/screenshot_test.png b/Tutorial/images/screenshot_tests_1/screenshot_test.png new file mode 100644 index 000000000..fafde0c64 Binary files /dev/null and b/Tutorial/images/screenshot_tests_1/screenshot_test.png differ diff --git a/Tutorial/images/screenshot_tests_1/success_tests.png b/Tutorial/images/screenshot_tests_1/success_tests.png new file mode 100644 index 000000000..f9956a3bc Binary files /dev/null and b/Tutorial/images/screenshot_tests_1/success_tests.png differ diff --git a/Tutorial/images/screenshot_tests_1/todo_on_screen.png b/Tutorial/images/screenshot_tests_1/todo_on_screen.png new file mode 100644 index 000000000..9d30def3f Binary files /dev/null and b/Tutorial/images/screenshot_tests_1/todo_on_screen.png differ diff --git a/Tutorial/images/screenshot_tests_2/create_class.png b/Tutorial/images/screenshot_tests_2/create_class.png new file mode 100644 index 000000000..b6eebf742 Binary files /dev/null and b/Tutorial/images/screenshot_tests_2/create_class.png differ diff --git a/Tutorial/images/screenshot_tests_2/example_1.png b/Tutorial/images/screenshot_tests_2/example_1.png new file mode 100644 index 000000000..ab5f4aa77 Binary files /dev/null and b/Tutorial/images/screenshot_tests_2/example_1.png differ diff --git a/Tutorial/images/screenshot_tests_2/example_2.png b/Tutorial/images/screenshot_tests_2/example_2.png new file mode 100644 index 000000000..b26032132 Binary files /dev/null and b/Tutorial/images/screenshot_tests_2/example_2.png differ diff --git a/Tutorial/images/screenshot_tests_2/example_3.png b/Tutorial/images/screenshot_tests_2/example_3.png new file mode 100644 index 000000000..0bf3ba1d1 Binary files /dev/null and b/Tutorial/images/screenshot_tests_2/example_3.png differ diff --git a/Tutorial/images/screenshot_tests_2/example_4.png b/Tutorial/images/screenshot_tests_2/example_4.png new file mode 100644 index 000000000..1dbf30df4 Binary files /dev/null and b/Tutorial/images/screenshot_tests_2/example_4.png differ diff --git a/Tutorial/images/screenshot_tests_2/example_5.png b/Tutorial/images/screenshot_tests_2/example_5.png new file mode 100644 index 000000000..46106b5e0 Binary files /dev/null and b/Tutorial/images/screenshot_tests_2/example_5.png differ diff --git a/Tutorial/images/screenshot_tests_2/page_object.png b/Tutorial/images/screenshot_tests_2/page_object.png new file mode 100644 index 000000000..631c30b95 Binary files /dev/null and b/Tutorial/images/screenshot_tests_2/page_object.png differ diff --git a/Tutorial/images/screenshot_tests_2/style.png b/Tutorial/images/screenshot_tests_2/style.png new file mode 100644 index 000000000..f252d4ff6 Binary files /dev/null and b/Tutorial/images/screenshot_tests_2/style.png differ diff --git a/Tutorial/images/simple_test/First_tutorial_screen.png b/Tutorial/images/simple_test/First_tutorial_screen.png new file mode 100644 index 000000000..cab74400f Binary files /dev/null and b/Tutorial/images/simple_test/First_tutorial_screen.png differ diff --git a/Tutorial/images/simple_test/Launch_tutorial.png b/Tutorial/images/simple_test/Launch_tutorial.png new file mode 100644 index 000000000..07f08844d Binary files /dev/null and b/Tutorial/images/simple_test/Launch_tutorial.png differ diff --git a/Tutorial/images/simple_test/Layout_inspector_in_studio.png b/Tutorial/images/simple_test/Layout_inspector_in_studio.png new file mode 100644 index 000000000..d8d53ee12 Binary files /dev/null and b/Tutorial/images/simple_test/Layout_inspector_in_studio.png differ diff --git a/Tutorial/images/simple_test/Select_tutorial.png b/Tutorial/images/simple_test/Select_tutorial.png new file mode 100644 index 000000000..cd898ed00 Binary files /dev/null and b/Tutorial/images/simple_test/Select_tutorial.png differ diff --git a/Tutorial/images/simple_test/Tutorial_build_gradle.png b/Tutorial/images/simple_test/Tutorial_build_gradle.png new file mode 100644 index 000000000..10c329ad9 Binary files /dev/null and b/Tutorial/images/simple_test/Tutorial_build_gradle.png differ diff --git a/Tutorial/images/simple_test/Tutorial_main.png b/Tutorial/images/simple_test/Tutorial_main.png new file mode 100644 index 000000000..b38c5127a Binary files /dev/null and b/Tutorial/images/simple_test/Tutorial_main.png differ diff --git a/Tutorial/images/simple_test/bottom_layout_inspector.png b/Tutorial/images/simple_test/bottom_layout_inspector.png new file mode 100644 index 000000000..51b6f3ce1 Binary files /dev/null and b/Tutorial/images/simple_test/bottom_layout_inspector.png differ diff --git a/Tutorial/images/simple_test/button_id_search.png b/Tutorial/images/simple_test/button_id_search.png new file mode 100644 index 000000000..3a68a5584 Binary files /dev/null and b/Tutorial/images/simple_test/button_id_search.png differ diff --git a/Tutorial/images/simple_test/button_in_layout.png b/Tutorial/images/simple_test/button_in_layout.png new file mode 100644 index 000000000..7f6473b10 Binary files /dev/null and b/Tutorial/images/simple_test/button_in_layout.png differ diff --git a/Tutorial/images/simple_test/button_inspect.png b/Tutorial/images/simple_test/button_inspect.png new file mode 100644 index 000000000..8f23b309a Binary files /dev/null and b/Tutorial/images/simple_test/button_inspect.png differ diff --git a/Tutorial/images/simple_test/change_package.png b/Tutorial/images/simple_test/change_package.png new file mode 100644 index 000000000..107c943a2 Binary files /dev/null and b/Tutorial/images/simple_test/change_package.png differ diff --git a/Tutorial/images/simple_test/choose_process.png b/Tutorial/images/simple_test/choose_process.png new file mode 100644 index 000000000..6742613d3 Binary files /dev/null and b/Tutorial/images/simple_test/choose_process.png differ diff --git a/Tutorial/images/simple_test/create_class.png b/Tutorial/images/simple_test/create_class.png new file mode 100644 index 000000000..c7517eec9 Binary files /dev/null and b/Tutorial/images/simple_test/create_class.png differ diff --git a/Tutorial/images/simple_test/create_directory.png b/Tutorial/images/simple_test/create_directory.png new file mode 100644 index 000000000..3dde7300c Binary files /dev/null and b/Tutorial/images/simple_test/create_directory.png differ diff --git a/Tutorial/images/simple_test/create_main_screen.png b/Tutorial/images/simple_test/create_main_screen.png new file mode 100644 index 000000000..71871c05c Binary files /dev/null and b/Tutorial/images/simple_test/create_main_screen.png differ diff --git a/Tutorial/images/simple_test/create_package.png b/Tutorial/images/simple_test/create_package.png new file mode 100644 index 000000000..fec70301d Binary files /dev/null and b/Tutorial/images/simple_test/create_package.png differ diff --git a/Tutorial/images/simple_test/create_test_1.png b/Tutorial/images/simple_test/create_test_1.png new file mode 100644 index 000000000..9fc642d55 Binary files /dev/null and b/Tutorial/images/simple_test/create_test_1.png differ diff --git a/Tutorial/images/simple_test/create_test_2.png b/Tutorial/images/simple_test/create_test_2.png new file mode 100644 index 000000000..95633ddd9 Binary files /dev/null and b/Tutorial/images/simple_test/create_test_2.png differ diff --git a/Tutorial/images/simple_test/find_layout.png b/Tutorial/images/simple_test/find_layout.png new file mode 100644 index 000000000..2082d2ba2 Binary files /dev/null and b/Tutorial/images/simple_test/find_layout.png differ diff --git a/Tutorial/images/simple_test/find_string_in_layout.png b/Tutorial/images/simple_test/find_string_in_layout.png new file mode 100644 index 000000000..f61ca2c8f Binary files /dev/null and b/Tutorial/images/simple_test/find_string_in_layout.png differ diff --git a/Tutorial/images/simple_test/input_inspect.png b/Tutorial/images/simple_test/input_inspect.png new file mode 100644 index 000000000..03b77172c Binary files /dev/null and b/Tutorial/images/simple_test/input_inspect.png differ diff --git a/Tutorial/images/simple_test/kbaseview_children.png b/Tutorial/images/simple_test/kbaseview_children.png new file mode 100644 index 000000000..144a6e3b2 Binary files /dev/null and b/Tutorial/images/simple_test/kbaseview_children.png differ diff --git a/Tutorial/images/simple_test/loaded_inspector.png b/Tutorial/images/simple_test/loaded_inspector.png new file mode 100644 index 000000000..9e238777f Binary files /dev/null and b/Tutorial/images/simple_test/loaded_inspector.png differ diff --git a/Tutorial/images/simple_test/master_branch.png b/Tutorial/images/simple_test/master_branch.png new file mode 100644 index 000000000..0995a122d Binary files /dev/null and b/Tutorial/images/simple_test/master_branch.png differ diff --git a/Tutorial/images/simple_test/move_to_package.png b/Tutorial/images/simple_test/move_to_package.png new file mode 100644 index 000000000..cdd8a1e92 Binary files /dev/null and b/Tutorial/images/simple_test/move_to_package.png differ diff --git a/Tutorial/images/simple_test/name_android_test.png b/Tutorial/images/simple_test/name_android_test.png new file mode 100644 index 000000000..625549524 Binary files /dev/null and b/Tutorial/images/simple_test/name_android_test.png differ diff --git a/Tutorial/images/simple_test/needed_children.png b/Tutorial/images/simple_test/needed_children.png new file mode 100644 index 000000000..91ef3350c Binary files /dev/null and b/Tutorial/images/simple_test/needed_children.png differ diff --git a/Tutorial/images/simple_test/override.png b/Tutorial/images/simple_test/override.png new file mode 100644 index 000000000..41118ab95 Binary files /dev/null and b/Tutorial/images/simple_test/override.png differ diff --git a/Tutorial/images/simple_test/package_name_main_activity.png b/Tutorial/images/simple_test/package_name_main_activity.png new file mode 100644 index 000000000..657219e90 Binary files /dev/null and b/Tutorial/images/simple_test/package_name_main_activity.png differ diff --git a/Tutorial/images/simple_test/package_name_screen.png b/Tutorial/images/simple_test/package_name_screen.png new file mode 100644 index 000000000..f968667dd Binary files /dev/null and b/Tutorial/images/simple_test/package_name_screen.png differ diff --git a/Tutorial/images/simple_test/show_kbutton_source.png b/Tutorial/images/simple_test/show_kbutton_source.png new file mode 100644 index 000000000..e0359d18a Binary files /dev/null and b/Tutorial/images/simple_test/show_kbutton_source.png differ diff --git a/Tutorial/images/simple_test/simple_test_button.png b/Tutorial/images/simple_test/simple_test_button.png new file mode 100644 index 000000000..90c7024a8 Binary files /dev/null and b/Tutorial/images/simple_test/simple_test_button.png differ diff --git a/Tutorial/images/simple_test/string_in_values.png b/Tutorial/images/simple_test/string_in_values.png new file mode 100644 index 000000000..e1e5178ce Binary files /dev/null and b/Tutorial/images/simple_test/string_in_values.png differ diff --git a/Tutorial/images/simple_test/success_1.png b/Tutorial/images/simple_test/success_1.png new file mode 100644 index 000000000..d7f7abc45 Binary files /dev/null and b/Tutorial/images/simple_test/success_1.png differ diff --git a/Tutorial/images/simple_test/sucess_2.png b/Tutorial/images/simple_test/sucess_2.png new file mode 100644 index 000000000..5d1c0160d Binary files /dev/null and b/Tutorial/images/simple_test/sucess_2.png differ diff --git a/Tutorial/images/simple_test/switch_to_results.png b/Tutorial/images/simple_test/switch_to_results.png new file mode 100644 index 000000000..b04581b6d Binary files /dev/null and b/Tutorial/images/simple_test/switch_to_results.png differ diff --git a/Tutorial/images/simple_test/test_failed_1.png b/Tutorial/images/simple_test/test_failed_1.png new file mode 100644 index 000000000..8250d2543 Binary files /dev/null and b/Tutorial/images/simple_test/test_failed_1.png differ diff --git a/Tutorial/images/simple_test/title_inspect.png b/Tutorial/images/simple_test/title_inspect.png new file mode 100644 index 000000000..95ca5e262 Binary files /dev/null and b/Tutorial/images/simple_test/title_inspect.png differ diff --git a/Tutorial/images/steps/clear_logcat.png b/Tutorial/images/steps/clear_logcat.png new file mode 100644 index 000000000..93660de8c Binary files /dev/null and b/Tutorial/images/steps/clear_logcat.png differ diff --git a/Tutorial/images/steps/create_filter.png b/Tutorial/images/steps/create_filter.png new file mode 100644 index 000000000..ee8bacb42 Binary files /dev/null and b/Tutorial/images/steps/create_filter.png differ diff --git a/Tutorial/images/steps/edit_configuration.png b/Tutorial/images/steps/edit_configuration.png new file mode 100644 index 000000000..4fbc0b43f Binary files /dev/null and b/Tutorial/images/steps/edit_configuration.png differ diff --git a/Tutorial/images/steps/log_step_1.png b/Tutorial/images/steps/log_step_1.png new file mode 100644 index 000000000..e893e14fc Binary files /dev/null and b/Tutorial/images/steps/log_step_1.png differ diff --git a/Tutorial/images/steps/log_step_2.png b/Tutorial/images/steps/log_step_2.png new file mode 100644 index 000000000..14cb2a69e Binary files /dev/null and b/Tutorial/images/steps/log_step_2.png differ diff --git a/Tutorial/images/steps/log_step_2_failed.png b/Tutorial/images/steps/log_step_2_failed.png new file mode 100644 index 000000000..66143511e Binary files /dev/null and b/Tutorial/images/steps/log_step_2_failed.png differ diff --git a/Tutorial/images/steps/log_step_3.png b/Tutorial/images/steps/log_step_3.png new file mode 100644 index 000000000..36e7a2a24 Binary files /dev/null and b/Tutorial/images/steps/log_step_3.png differ diff --git a/Tutorial/images/steps/log_with_steps.png b/Tutorial/images/steps/log_with_steps.png new file mode 100644 index 000000000..a9bf7f269 Binary files /dev/null and b/Tutorial/images/steps/log_with_steps.png differ diff --git a/Tutorial/images/steps/logcat.png b/Tutorial/images/steps/logcat.png new file mode 100644 index 000000000..790c53d9c Binary files /dev/null and b/Tutorial/images/steps/logcat.png differ diff --git a/Tutorial/images/steps/test_failed_with_steps.png b/Tutorial/images/steps/test_failed_with_steps.png new file mode 100644 index 000000000..b81c51598 Binary files /dev/null and b/Tutorial/images/steps/test_failed_with_steps.png differ diff --git a/Tutorial/images/uiautomator/da_1_settings.png b/Tutorial/images/uiautomator/da_1_settings.png new file mode 100644 index 000000000..127cb5d55 Binary files /dev/null and b/Tutorial/images/uiautomator/da_1_settings.png differ diff --git a/Tutorial/images/uiautomator/da_2_settings.png b/Tutorial/images/uiautomator/da_2_settings.png new file mode 100644 index 000000000..425d630d4 Binary files /dev/null and b/Tutorial/images/uiautomator/da_2_settings.png differ diff --git a/Tutorial/images/uiautomator/da_3_settings.png b/Tutorial/images/uiautomator/da_3_settings.png new file mode 100644 index 000000000..695657f00 Binary files /dev/null and b/Tutorial/images/uiautomator/da_3_settings.png differ diff --git a/Tutorial/images/uiautomator/da_4_settings.png b/Tutorial/images/uiautomator/da_4_settings.png new file mode 100644 index 000000000..069b0b7d7 Binary files /dev/null and b/Tutorial/images/uiautomator/da_4_settings.png differ diff --git a/Tutorial/images/uiautomator/da_5_settings.png b/Tutorial/images/uiautomator/da_5_settings.png new file mode 100644 index 000000000..6cd5b307d Binary files /dev/null and b/Tutorial/images/uiautomator/da_5_settings.png differ diff --git a/Tutorial/images/uiautomator/da_6_settings.png b/Tutorial/images/uiautomator/da_6_settings.png new file mode 100644 index 000000000..8f9ab3417 Binary files /dev/null and b/Tutorial/images/uiautomator/da_6_settings.png differ diff --git a/Tutorial/images/uiautomator/da_gplay_1.png b/Tutorial/images/uiautomator/da_gplay_1.png new file mode 100644 index 000000000..78ba8905f Binary files /dev/null and b/Tutorial/images/uiautomator/da_gplay_1.png differ diff --git a/Tutorial/images/uiautomator/da_gplay_2.png b/Tutorial/images/uiautomator/da_gplay_2.png new file mode 100644 index 000000000..9d420ec3b Binary files /dev/null and b/Tutorial/images/uiautomator/da_gplay_2.png differ diff --git a/Tutorial/images/uiautomator/da_gplay_3.png b/Tutorial/images/uiautomator/da_gplay_3.png new file mode 100644 index 000000000..f66267022 Binary files /dev/null and b/Tutorial/images/uiautomator/da_gplay_3.png differ diff --git a/Tutorial/images/uiautomator/dump_1.png b/Tutorial/images/uiautomator/dump_1.png new file mode 100644 index 000000000..fc4a4f3db Binary files /dev/null and b/Tutorial/images/uiautomator/dump_1.png differ diff --git a/Tutorial/images/uiautomator/dump_2.png b/Tutorial/images/uiautomator/dump_2.png new file mode 100644 index 000000000..0e7a6b8d0 Binary files /dev/null and b/Tutorial/images/uiautomator/dump_2.png differ diff --git a/Tutorial/images/uiautomator/dump_3.png b/Tutorial/images/uiautomator/dump_3.png new file mode 100644 index 000000000..43753705d Binary files /dev/null and b/Tutorial/images/uiautomator/dump_3.png differ diff --git a/Tutorial/images/uiautomator/dump_4.png b/Tutorial/images/uiautomator/dump_4.png new file mode 100644 index 000000000..be4e9ef19 Binary files /dev/null and b/Tutorial/images/uiautomator/dump_4.png differ diff --git a/Tutorial/images/uiautomator/dump_5.png b/Tutorial/images/uiautomator/dump_5.png new file mode 100644 index 000000000..c7862a998 Binary files /dev/null and b/Tutorial/images/uiautomator/dump_5.png differ diff --git a/Tutorial/images/uiautomator/dump_6.png b/Tutorial/images/uiautomator/dump_6.png new file mode 100644 index 000000000..b2ab44ed4 Binary files /dev/null and b/Tutorial/images/uiautomator/dump_6.png differ diff --git a/Tutorial/images/uiautomator/dump_7.png b/Tutorial/images/uiautomator/dump_7.png new file mode 100644 index 000000000..893bde5f4 Binary files /dev/null and b/Tutorial/images/uiautomator/dump_7.png differ diff --git a/Tutorial/images/uiautomator/google_play_unauth.png b/Tutorial/images/uiautomator/google_play_unauth.png new file mode 100644 index 000000000..94394250a Binary files /dev/null and b/Tutorial/images/uiautomator/google_play_unauth.png differ diff --git a/Tutorial/images/uiautomator/matchers.png b/Tutorial/images/uiautomator/matchers.png new file mode 100644 index 000000000..d837cf072 Binary files /dev/null and b/Tutorial/images/uiautomator/matchers.png differ diff --git a/Tutorial/images/uiautomator/notification.png b/Tutorial/images/uiautomator/notification.png new file mode 100644 index 000000000..95fcb06a5 Binary files /dev/null and b/Tutorial/images/uiautomator/notification.png differ diff --git a/Tutorial/images/uiautomator/notification_activity_btn.png b/Tutorial/images/uiautomator/notification_activity_btn.png new file mode 100644 index 000000000..f10de9d54 Binary files /dev/null and b/Tutorial/images/uiautomator/notification_activity_btn.png differ diff --git a/Tutorial/images/uiautomator/ui_button.png b/Tutorial/images/uiautomator/ui_button.png new file mode 100644 index 000000000..e280d5935 Binary files /dev/null and b/Tutorial/images/uiautomator/ui_button.png differ diff --git a/Tutorial/images/uiautomator/uiautomator_button.png b/Tutorial/images/uiautomator/uiautomator_button.png new file mode 100644 index 000000000..42295cbd9 Binary files /dev/null and b/Tutorial/images/uiautomator/uiautomator_button.png differ diff --git a/Tutorial/images/uiautomator/uiautomator_notification.png b/Tutorial/images/uiautomator/uiautomator_notification.png new file mode 100644 index 000000000..cf34c68ff Binary files /dev/null and b/Tutorial/images/uiautomator/uiautomator_notification.png differ diff --git a/Tutorial/images/uiautomator/uiautomator_package.png b/Tutorial/images/uiautomator/uiautomator_package.png new file mode 100644 index 000000000..d3e4e93d9 Binary files /dev/null and b/Tutorial/images/uiautomator/uiautomator_package.png differ diff --git a/Tutorial/images/uiautomator/uiautomatorviewer_1.png b/Tutorial/images/uiautomator/uiautomatorviewer_1.png new file mode 100644 index 000000000..aab4d6c54 Binary files /dev/null and b/Tutorial/images/uiautomator/uiautomatorviewer_1.png differ diff --git a/Tutorial/images/uiautomator/uiautomatorviewer_2.png b/Tutorial/images/uiautomator/uiautomatorviewer_2.png new file mode 100644 index 000000000..22193d087 Binary files /dev/null and b/Tutorial/images/uiautomator/uiautomatorviewer_2.png differ diff --git a/Tutorial/images/wifi_test/available_methods.png b/Tutorial/images/wifi_test/available_methods.png new file mode 100644 index 000000000..3bf2be02d Binary files /dev/null and b/Tutorial/images/wifi_test/available_methods.png differ diff --git a/Tutorial/images/wifi_test/first_launch_1.png b/Tutorial/images/wifi_test/first_launch_1.png new file mode 100644 index 000000000..bfadc03b1 Binary files /dev/null and b/Tutorial/images/wifi_test/first_launch_1.png differ diff --git a/Tutorial/images/wifi_test/first_launch_2.png b/Tutorial/images/wifi_test/first_launch_2.png new file mode 100644 index 000000000..82d833b9d Binary files /dev/null and b/Tutorial/images/wifi_test/first_launch_2.png differ diff --git a/Tutorial/images/wifi_test/internet_availability_button.png b/Tutorial/images/wifi_test/internet_availability_button.png new file mode 100644 index 000000000..4dc1a5c8a Binary files /dev/null and b/Tutorial/images/wifi_test/internet_availability_button.png differ diff --git a/Tutorial/images/wifi_test/turn_off_wifi.png b/Tutorial/images/wifi_test/turn_off_wifi.png new file mode 100644 index 000000000..6a0a297dc Binary files /dev/null and b/Tutorial/images/wifi_test/turn_off_wifi.png differ diff --git a/Tutorial/images/wifi_test/wifi_disabled.png b/Tutorial/images/wifi_test/wifi_disabled.png new file mode 100644 index 000000000..6565ff88c Binary files /dev/null and b/Tutorial/images/wifi_test/wifi_disabled.png differ diff --git a/Tutorial/images/wifi_test/wifi_disabled_portrait.png b/Tutorial/images/wifi_test/wifi_disabled_portrait.png new file mode 100644 index 000000000..16d7c7393 Binary files /dev/null and b/Tutorial/images/wifi_test/wifi_disabled_portrait.png differ diff --git a/Tutorial/images/wifi_test/wifi_enabled.png b/Tutorial/images/wifi_test/wifi_enabled.png new file mode 100644 index 000000000..b4aeddb05 Binary files /dev/null and b/Tutorial/images/wifi_test/wifi_enabled.png differ diff --git a/Tutorial/index.html b/Tutorial/index.html new file mode 100644 index 000000000..e24703c01 --- /dev/null +++ b/Tutorial/index.html @@ -0,0 +1,1151 @@ + + + + + + + + + + + + + + + + + + + + + + 1. Introduction - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Introduction

+

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.

+

Tutorial structure

+

The Tutorial is divided into steps (lessons). Each lesson begins with a brief overview and ends with summary and conclusions.

+

How to study this Tutorial?

+

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.

+

What do you need to know to complete the Tutorial?

+

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.

+

Feedback

+

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.

+

Give thanks

+

If you like our framework, you can give our project a star on Github.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Wiki/Espresso_as_the_basis/index.html b/Wiki/Espresso_as_the_basis/index.html new file mode 100644 index 000000000..2d4edbaa7 --- /dev/null +++ b/Wiki/Espresso_as_the_basis/index.html @@ -0,0 +1,1215 @@ + + + + + + + + + + + + + + + + + + + + + + Espresso as the basis - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Espresso as the basis

+

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.

+

Espresso is not enough

+

This framework has a lot of drawbacks and not all things in Android autotesting can be done with Espresso alone.

+

What do we want:

+
    +
  1. Good readability. Espresso has a problem with this because of the huge hierarchy of matchers. When we have a lot of matches, the code becomes difficult to read. Poor readability means difficult to maintain
  2. +
  3. Hight stability. Espresso does not work well with interfaces whose elements are displayed asynchronously. You can configure Idling, but that still won't solve all problems.
  4. +
  5. Logging. After completing the test with Espresso, you do not have a step-by-step workflow sequence of actions.
  6. +
  7. Screenshots. We also want to have some screenshots for the test report.
  8. +
  9. Working with Android OS. In some cases, we need to interact with the device. In this case you need UiAutomator (as a variant).
  10. +
  11. Сode architecture. We want to have a clean code architecture in our tests, the ability to reuse code, move some blocks in abstractions. One code style for all developers.
  12. +
+

How does Kaspresso solve all these problems?

+

Readability

+

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).

+

Stability

+

Kaspresso has wrapped some Espresso calls into a more stable implementation. For example you can find flakySafely() method in the Kaspresso.

+

Logging

+

Kaspresso has wrapped some Espresso calls not only for higher stability. We have also implemented an interceptor that prints more logs.

+

Working with Android OS

+

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.

+

Code architecture

+

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).

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Wiki/Executing_adb_commands/index.html b/Wiki/Executing_adb_commands/index.html new file mode 100644 index 000000000..2aa78cbf4 --- /dev/null +++ b/Wiki/Executing_adb_commands/index.html @@ -0,0 +1,1358 @@ + + + + + + + + + + + + + + + + + + + + + + Executing adb commands - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Executing adb commands

+

Description

+

As you remember from the previous part devoted to Device interface, Device interface contains the following things under the hood:

+
    +
  • Espresso
  • +
  • UI Automator
  • +
  • ADB
  • +
+ +

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:

+
    +
  • Device that starts up a test acts as client
  • +
  • Desktop sends ADB commands to be executed on the device. + Also, the system uses a port forwarding to be able to organize a socket tunnel between Device and Desktop through any kind of connection (Wi-Fi, Bluetooth, USB and etc.).
  • +
+

Usage

+

The algorithm how to use Autotest AdbServer:

+
    +
  1. Run the Desktop part on your work station.
    + Execute the following command: java -jar <path/to/kaspresso>/artifacts/adbserver-desktop.jar in the terminal
  2. +
  3. Run the Device part.
    + Build and start adbserver-sample module. You should see the following screen: +
  4. +
+

For 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.

+

Usage in Kaspresso

+

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()
+                }
+            }
+        }
+        // ....
+}
+
+Also, don't forget to grant necessary permission: +
<uses-permission android:name="android.permission.INTERNET" />
+

+

Options and Logging

+

Desktop part

+

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...
+
+Take a look at the log format. You can see the type of a message, date and time, the host name and the emulator which executes the command, and the message.

+
    +
  1. +

    WARN
    + Prints error and warning messages.

    +
  2. +
  3. +

    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)
    +
    +Also, the Desktop prints an emulator name, where the concrete command has been executed (this information is available on the Desktop and on the Device). +It could be very useful in debugging. Take a look at the field 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)
    +

    +
  4. +
  5. +

    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))
    +
    +Pay attention that the log row also contains two additional fields: tag and method. Both fields are autogenerated using Throwable().stacktrace method.

    +
  6. +
  7. +

    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
    +

    +
  8. +
+

Device part

+

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)
+
+All the logs are printed with KASPRESSO_ADBSERVER tag with WARN log level.
+If you want to debug the Device part of Autotest AdbServer (the device part), you can set VERBOSE log level: +
class DeviceNetworkSampleTest : TestCase(
+    kaspressoBuilder = Kaspresso.Builder.simple {
+        libLogger = UiTestLoggerImpl(Kaspresso.DEFAULT_LIB_LOGGER_TAG)
+        adbServer = AdbServerImpl(LogLevel.VERBOSE, libLogger)
+    }
+) {...}
+
+Now the logs looks like: +
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)
+

+

Development

+

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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Wiki/Images/Matchers_actions_assertions/Espresso_cheat_sheet.png b/Wiki/Images/Matchers_actions_assertions/Espresso_cheat_sheet.png new file mode 100644 index 000000000..8dea00033 Binary files /dev/null and b/Wiki/Images/Matchers_actions_assertions/Espresso_cheat_sheet.png differ diff --git a/Wiki/Jetpack_Compose/index.html b/Wiki/Jetpack_Compose/index.html new file mode 100644 index 000000000..e92b4a59b --- /dev/null +++ b/Wiki/Jetpack_Compose/index.html @@ -0,0 +1,1407 @@ + + + + + + + + + + + + + + + + + + + + + + Compose support in Kaspresso - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Compose support

+

Jetpack Compose support consists of two parts: Kakao Compose library and Kaspresso Interceptors mechanism.

+

Kakao Compose library

+

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()
+                }
+            }
+        }
+
+        // ...
+    }
+}
+
+Again, all related to DSL information is available in the docs.

+

Kaspresso Interceptors mechanism

+

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.

+

Behavior interceptors

+
    +
  1. FailureLoggingSemanticsBehaviorInterceptor
    + Build the clear and undestandable exception in case of the test failure.
  2. +
  3. FlakySafeSemanticsBehaviorInterceptor
    + Tries to repeat the failed action or assertion during defined timeout. All params for this interceptor are at FlakySafetyParams.
  4. +
  5. SystemDialogSafetySemanticsBehaviorInterceptor
    + Eliminates various system dialogs that prevent correct execution of a test.
  6. +
  7. AutoScrollSemanticsBehaviorInterceptor
    + Performs autoscrolling to an element if the element is not visible on the screen.
  8. +
  9. ElementLoaderSemanticsBehaviorInterceptor
    + Requests the related SemanticNodeInteraction using saved Matcher when the element is not found.
  10. +
+

Watcher interceptors

+

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
+

+

Caveats

+

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()
+    }
+}
+
+can be the source of flakiness behavior if 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.

+

What else

+

Configuration

+

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()
+        }
+    }
+)
+

+

Robolectric support

+

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"  
+
+All information about Robolectric support is available here.

+

Compose is compatible with all sweet Kaspresso extensions

+

Sweet Kaspresso extensions means using of the such constructions as:

+
    +
  1. flakySafely
  2. +
  3. continuously
  4. +
+

The support of some constructions is in progress: issue-317.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Wiki/Kaspresso_Allure/index.html b/Wiki/Kaspresso_Allure/index.html new file mode 100644 index 000000000..1fca4cb52 --- /dev/null +++ b/Wiki/Kaspresso_Allure/index.html @@ -0,0 +1,1234 @@ + + + + + + + + + + + + + + + + + + + + + + Kaspresso with Allure - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Kaspresso-allure support

+

What's new

+

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:

+
    +
  1. VideoRecordingInterceptor. Tests video recording interceptor (please note that it was fully tested on emulators with android api 29 and older).
  2. +
  3. DumpViewsInterceptor. Interceptor that dumps XML-representation of view hierarchy in case of a test failure.
  4. +
+

In the package com.kaspersky.components.alluresupport.interceptors, there are special Kaspresso interceptors helping to link and process files for Allure-report.

+

How to use

+

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>"
+}
+
+Next, use special withForcedAllureSupport function in your TestCase constructor or in your TestCaseRule to turn on all available Allure-supporting interceptors: +
class AllureSupportTest : TestCase(
+    kaspressoBuilder = Kaspresso.Builder.withForcedAllureSupport()
+) {
+
+}
+
+If you want to specify the parameters or add more interceptors you can use addAllureSupport function: +
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() }
+                }
+            })
+        }
+    }
+) {
+...
+}
+
+If you don't need all of these interceptors providing by withForcedAllureSupport and addAllureSupport functions then you may add only interceptors that you prefer. But please note that AllureMapperStepInterceptor.kt is mandatory for Allure support work. For example, if you don't need videos and view hierarchies after test failures then you can do something like: +
class AllureSupportCustomizeTest : TestCase(
+    kaspressoBuilder = Kaspresso.Builder.simple().apply {
+        stepWatcherInterceptors.addAll(
+            listOf(
+                ScreenshotStepInterceptor(screenshots),
+                AllureMapperStepInterceptor()
+            )
+        )
+        testRunWatcherInterceptors.addAll(
+            listOf(
+                DumpLogcatTestInterceptor(logcatDumper),
+                ScreenshotTestInterceptor(screenshots),
+            )
+        )
+    }
+) {
+...
+}
+
+kaspresso-allure-support-sample is available to watch, to launch and to experiment with all of this staff.

+

Watch result

+

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
+
+If there are few devices connected to yout host you should specify the needed device id. To watch the list of connected devices you can call: +
adb devices
+
+The output will be something like: +
List of devices attached
+CLCDU18508004769    device
+emulator-5554   device
+
+Select the needed device and call: +
adb -s emulator-5554 pull /sdcard/allure-results /Users/username/Desktop
+
+And that's it, the allure-results dir with all the test resources is now at /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
+
+Now we are ready to generate and watch the report, just call: +
allure serve /Users/username/Desktop/allure-results
+
+Next, the Allure server generates the html-page representing the report and puts it to temp dir in your system. You will see the report opening in the new tab in your browser (the tab is opening automatically).

+

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
+
+And to watch it then in your browser you just call: +
allure open ~/kaspresso-allure-report
+
+After all of this actions you see something like: +

+

Details for succeeded test: +

+

Details for failed test: +

+

Details that you need to know

+

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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Wiki/Kaspresso_Robolectric/index.html b/Wiki/Kaspresso_Robolectric/index.html new file mode 100644 index 000000000..df023a523 --- /dev/null +++ b/Wiki/Kaspresso_Robolectric/index.html @@ -0,0 +1,1215 @@ + + + + + + + + + + + + + + + + + + + + + + Kaspresso with Robolectric - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Kaspresso tests running on the JVM with Robolectric

+

Main purpose

+

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)
+    ...
+
+That is because Robolectric is just compatible with Espresso and not with UI Automator.

+

Now, all Kaspresso tests are allowed to be executed correctly on the JVM with Robolectric with the following restrictions:

+
    +
  1. Easy configuration of your project according to Robolectric guideline.
  2. +
  3. Not possible to use adb-server because there is no a term like "Desktop" on the JVM environment. Tests that use adb-server will crash on the JVM with Robolectric with very explaining error message.
  4. +
  5. Not possible to work with UiDevice and UiAutomation classes. That's why a lot of (not all!) implementations in Device will crash on the JVM with Robolectric with NotSupportedInstrumentalTestException.
  6. +
  7. Non working Kautomator. Mentioned problem with UiDevice and UiAutomation classes affect the entire Kautomator. So, tests using Kautomator will crash on the JVM with Robolectric with KautomatorInUnitTestException.
  8. +
  9. Interceptors that use UiDevice, UiAutomation or adb-server are turning off on the JVM with Robolectric automatically.
  10. +
  11. DocLocScreenshotTestCase will crash on the JVM with Robolectric with DocLocInUnitTestException.
  12. +
+

Usage

+

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
+

+

Accommodation of tests to work on the JVM (with Robolectric) environment

+

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

+
    +
  1. Robolectric-Espresso supports Idling resources, but doesn't support posting delayed messages to the Looper
  2. +
  3. Robolectric-Espresso will not support tests that start new activities (i.e. activity jumping)
  4. +
+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Wiki/Kaspresso_configuration/index.html b/Wiki/Kaspresso_configuration/index.html new file mode 100644 index 000000000..d81696b47 --- /dev/null +++ b/Wiki/Kaspresso_configuration/index.html @@ -0,0 +1,1593 @@ + + + + + + + + + + + + + + + + + + + + + + Kaspresso configuration - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Kaspresso configurator

+

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
+}
+

+

Structure

+

Kaspresso configuration contains:

+

Kakao clicks

+

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.

+

Loggers

+

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.

+

Kaspresso interceptors based on Kakao/Kautomator Interceptors.

+

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:

+
    +
  1. Behavior Interceptors - are intercepting calls to ViewInteraction, DataInteraction, WebInteraction, UiObjectInteraction, UiDeviceInteraction and do some stuff.
    + Attention, we are going to consider some important notes about Behavior Interceptors at the end of this document.
  2. +
  3. Watcher Interceptors - are intercepting calls to ViewAction, ViewAssertion, Atom, WebAssertion, UiObjectAssertion, UiObjectAction, UiDeviceAssertion, UiDeviceAction and do some stuff.
  4. +
+

Let's expand mentioned Kaspresso interceptors types:

+
    +
  1. Behavior Interceptors
      +
    1. viewBehaviorInterceptors - intercept calls to ViewInteraction#perform and ViewInteraction#check
    2. +
    3. dataBehaviorInterceptors - intercept calls to DataInteraction#check
    4. +
    5. webBehaviorInterceptors - intercept calls to Web.WebInteraction<R>#perform and Web.WebInteraction<R>#check
    6. +
    7. objectBehaviorInterceptors - intercept calls to UiObjectInteraction#perform and UiObjectInteraction#check
    8. +
    9. deviceBehaviorInterceptors - intercept calls to UiDeviceInteraction#perform and UiDeviceInteraction#check
    10. +
    +
  2. +
  3. Watcher Interceptors
      +
    1. viewActionWatcherInterceptors - do some stuff before android.support.test.espresso.ViewAction.perform is actually called
    2. +
    3. viewAssertionWatcherInterceptors - do some stuff before android.support.test.espresso.ViewAssertion.check is actually called
    4. +
    5. atomWatcherInterceptors - do some stuff before android.support.test.espresso.web.model.Atom.transform is actually called
    6. +
    7. webAssertionWatcherInterceptors - do some stuff before android.support.test.espresso.web.assertion.WebAssertion.checkResult is actually called
    8. +
    9. objectWatcherInterceptors - do some stuff before UiObjectInteraction.perform or UiObjectInteraction.check is actually called
    10. +
    11. deviceWatcherInterceptors - do some stuff before UiDeviceInteraction.perform or UiDeviceInteraction.check is actually called
    12. +
    +
  4. +
+

Please, 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.

+

Special Kaspresso interceptors

+

These interceptors are not based on some lib. Short description:

+
    +
  1. stepWatcherInterceptors - an interceptor of Step lifecycle actions
  2. +
  3. testRunWatcherInterceptors - an interceptor of entire Test lifecycle actions
  4. +
+

As you noticed these interceptors are a part of Watcher Interceptors, also.

+

BuildStepReportWatcherInterceptor

+

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.

+

Default actions in before/after sections

+

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")
+        }
+    }
+)
+
+The full signature of beforeEachTest is: +
beforeEachTest(override = true, action = {
+    testLogger.i("beforeTestFirstAction")
+})
+
+afterEachTest is similar to beforeEachTest.
+If you set 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

+

Device instance. Detailed info is at Device wiki.

+

AdbServer

+

AdbServer instance. Detailed info is at AdbServer wiki.

+

Kaspresso configuring and Kaspresso interceptors example

+

The example of how to configure Kaspresso and how to use Kaspresso interceptors is in here.

+

Default Kaspresso settings

+

BaseTestCase, TestCase, BaseTestCaseRule, TestCaseRule are using default customized Kaspresso (Kaspresso.Builder.simple builder).
+Most valuable features of default customized Kaspresso are below.

+

Logging

+

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: ---------------------------------------------------------------------------
+
+Pretty good.

+

Defense from flaky tests

+

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

+

Interceptors turned by default:

+
    +
  1. Watcher interceptors
  2. +
  3. Behavior interceptors
  4. +
  5. Kaspresso interceptors
  6. +
  7. BuildStepReportWatcherInterceptor
  8. +
+

So, all features described above are available thanks to these interceptors.

+

Some words about Behavior 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:

+
    +
  1. Common flaky errors that happened because Espresso/UI Automator was in a bad mood =)
    + That's why Kaspresso wraps all actions/assertions of Kakao/Kautomator and handles set of potential flaky exceptions. + If an exception happened then Kaspresso attempts to repeat failed actions/assert for 10 seconds. Such handling rescues developers of any flaky action/assert.
    + The details are available at flakysafety and examples are here.
  2. +
  3. The reason of a failure is non visibility of a View. In most cases you just need to scroll a parent layout to make the View visible. So, Kaspresso tries to perform it in auto mode.
    + The details are available at autoscroll.
  4. +
  5. Also, Kaspresso attempts to remove all system dialogs if it prevents the test execution.
    + The details are available at systemsafety.
  6. +
+

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:

+
    +
  1. AutoScrollViewBehaviorInterceptor
  2. +
  3. SystemDialogSafetyViewBehaviorInterceptor
  4. +
  5. FlakySafeViewBehaviorInterceptor
  6. +
+

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:

+

+

Main section enrichers

+

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:

+
    +
  1. Define your enricher implementation for MainSectionEnricher interface;
  2. +
+
class LoggingMainSectionEnricher : MainSectionEnricher<TestCaseData> {
+    ...
+
+}
+
+

Here, TestCaseData is the same data type as in your BaseTestCase implementation.

+
    +
  1. Override beforeMainSectionRun or/and afterMainSectionRun methods to add your before/after actions;
  2. +
+
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.

+
    +
  1. Add your enrichers into your BaseTestCase implementation.
  2. +
+
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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Wiki/Kautomator-wrapper_over_UI_Automator/index.html b/Wiki/Kautomator-wrapper_over_UI_Automator/index.html new file mode 100644 index 000000000..ef9263be6 --- /dev/null +++ b/Wiki/Kautomator-wrapper_over_UI_Automator/index.html @@ -0,0 +1,1544 @@ + + + + + + + + + + + + + + + + + + + + + + Kautomator. Wrapper over UI Automator - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Kautomator: wrapper over UI Automator

+

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).

+

Introduction

+

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")
+
+This is an example just to input and check the text. Because we have a successful experience of Kakao using we decided to wrap UI Automator over in the same manner and called it Kautomator: +
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.

+

Benefits

+
    +
  • Readability
  • +
  • Reusability
  • +
  • Extensible DSL
  • +
  • Amazing speed!
  • +
+

How to use it

+

Create Screen

+

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.

+

Create UiView

+

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") }
+}
+
+Kautomator provides different types depending on the type of view:

+
    +
  • UiView
  • +
  • UiEditText
  • +
  • UiTextView
  • +
  • UiButton
  • +
  • UiCheckbox
  • +
  • UiChipGroup
  • +
  • UiSwitchView
  • +
  • UiScrollView
  • +
  • and more
  • +
+

Every UiView contains matchers to retrieve the view involved in the ViewInteraction. Some examples of matchers provided +by Kakao:

+
    +
  • withId
  • +
  • withText
  • +
  • withPackage
  • +
  • withContentDescription
  • +
  • textStartsWith
  • +
  • and more
  • +
+

Like in Ui Automator you can combine different matchers: +

val email = UiEditText {
+    withId(this@FormScreen.packageName, "email")
+    withText(this@FormScreen.packageName, "matsyuk@kaspresso.com")
+}
+

+

Write the interaction

+

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()
+    }
+}
+

+

The difference from Kakao-Espresso

+

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 =)

+

Advanced

+
Custom UiView
+

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)
+}
+
+
Intercepting
+

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)
+                    }
+                }
+            }
+        }
+    }
+}
+

+

Accelerate UI Automator

+

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()
+                            }
+                        }
+                    }
+                }
+            }
+        }
+}
+
+It's a great deal!

+

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 =)

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Wiki/Matchers_actions_assertions/index.html b/Wiki/Matchers_actions_assertions/index.html new file mode 100644 index 000000000..408cfb46d --- /dev/null +++ b/Wiki/Matchers_actions_assertions/index.html @@ -0,0 +1,1054 @@ + + + + + + + + + + + + + + + + + + + + + + View matchers, actions and assertions - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Matchers, Actions and Assertions

+

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:

+
    +
  1. Espresso – Entry point to interactions with views (via onView() and onData()). Also exposes APIs that are not necessarily tied to any view, such as pressBack().
  2. +
  3. ViewMatchers – A collection of objects that implement the 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.
  4. +
  5. ViewActions – A collection of ViewAction objects that can be passed to the ViewInteraction.perform() method, such as click().
  6. +
  7. ViewAssertions – A collection of ViewAssertion objects that can be passed the 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.
  8. +
+
// 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. +Espresso 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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Wiki/Page_object_in_Kaspresso/index.html b/Wiki/Page_object_in_Kaspresso/index.html new file mode 100644 index 000000000..d57db2642 --- /dev/null +++ b/Wiki/Page_object_in_Kaspresso/index.html @@ -0,0 +1,1183 @@ + + + + + + + + + + + + + + + + + + + + + + PageObject in Kaspresso - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Page object pattern in Kaspresso.

+

What is a Page object pattern?

+


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.

+

How is the page object pattern implemented in Kaspresso?

+


Kaspresso provides KScreen and UiScreen as implementations for Page object pattern.

+

What is the difference between KScreen and UiScreen

+


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") }
+}
+
+
In KScreen's inheritors we should initialize the 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 +
In UiScreen's inheritors we must initialize packageName field (the full name of the application's package).

+

Benefits of the page object for refactoring

+


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.

+

Benefits of the Page Object for a work in a team

+


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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Wiki/Screenshot_tests/index.html b/Wiki/Screenshot_tests/index.html new file mode 100644 index 000000000..f2a49b2eb --- /dev/null +++ b/Wiki/Screenshot_tests/index.html @@ -0,0 +1,1335 @@ + + + + + + + + + + + + + + + + + + + + + + Screenshot tests - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Screenshot tests

+

Main purpose

+

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.

+

Usage

+

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>
+
+Similar data may be useful for different systems automating the process of localization of an application. The automating system saves xml for each screen and compares it with new versions received by new screenshot's runs. If some difference were revealed the system gives a signal to prepare and send a portion of new words to translate server.

+

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")
+            }
+        }
+    }        
+}
+
+The full example is located at ChangeSysLanguageTestCase.

+

Important note

+

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.

+

Advanced usage

+

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.

+

Modifying screenshots path and name

+

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.

+

Changes

+

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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Wiki/Supported_Android_UI_elements/index.html b/Wiki/Supported_Android_UI_elements/index.html new file mode 100644 index 000000000..3a62106e7 --- /dev/null +++ b/Wiki/Supported_Android_UI_elements/index.html @@ -0,0 +1,1136 @@ + + + + + + + + + + + + + + + + + + + + + + Supported Android UI-elements - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Supported Android UI widgets

+

Via Kakao

+

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

+

Via KAutomator

+

If you extend the UiScreen abstract class then the following views are available for you: +
UiView +
UiEditText +
UiTextView +
UiButton +
UiCheckbox +
UiChipGroup +
UiSwitchView +
UiScrollView +
UiBottomNavigationView

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Wiki/Working_with_Android_OS/index.html b/Wiki/Working_with_Android_OS/index.html new file mode 100644 index 000000000..6e4c61789 --- /dev/null +++ b/Wiki/Working_with_Android_OS/index.html @@ -0,0 +1,1157 @@ + + + + + + + + + + + + + + + + + + + + + + Working with Android OS - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Working with Android OS. Kaspresso Device abstraction.

+

Device is a provider of managers for all off-screen work.

+

Structure

+

All examples are located in device_tests. +Device provides these managers:

+
    +
  1. apps allows to install or uninstall applications. Uses adb install and adb uninstall commands. See the example DeviceAppSampleTest.
  2. +
  3. activities is an interface to work with currently resumed Activities. AdbServer not required. See the example DeviceActivitiesSampleTest.
  4. +
  5. 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.
  6. +
  7. 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.
  8. +
  9. 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.
  10. +
  11. location emulates fake location and allows to toggle GPS setting. See the example DeviceLocationSampleTest.
  12. +
  13. phone allows to emulate incoming calls and receive SMS messages. Works only on emulators since uses adb emu commands. See the example DevicePhoneSampleTest.
  14. +
  15. screenshots is an interface screenshots of currently resumed activity. Requires android.permission.WRITE_EXTERNAL_STORAGE permission. See the example DeviceScreenshotSampleTest.
  16. +
  17. accessibility allows to enable or disable accessibility services. Available since api 24. See the example DeviceAccessibilitySampleTest.
  18. +
  19. permissions provides the possibility of allowing or denying permission requests via default Android permission dialog. See the example DevicePermissionsSampleTest.
  20. +
  21. hackPermissions provides the possibility of allowing any permission requests without default Android permission dialog. See the example DeviceHackPermissionsSampleTest.
  22. +
  23. exploit allows to rotate device or press system buttons. See the example DeviceExploitSampleTest.
  24. +
  25. language allows to switch language. See the example DeviceLanguageSampleTest.
  26. +
  27. logcat provides access to adb logcat. See the example DeviceLogcatSampleTest.
    + The purpose of logcat:
    + If you have not heard about GDPR and high-profile lawsuits then you are lucky. But, if your application works in Europe then it's so important to enable/disable all analytics/statistics according to acceptance of the agreements. + One of the most reliable ways to check analytics/statistics sending is to verify logcat where all analytics/statistics write their logs (in debug mode, sure). + That's why we have created a special Logcat class providing a wide variety of ways to check logcat.
  28. +
  29. 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.
  30. +
+

Also Device provides application and test contexts - targetContext and context.

+

Usage

+

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()
+                }
+            }
+        }
+        // ....
+}
+

+

Restrictions

+

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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Wiki/how_to_write_autotests/index.html b/Wiki/how_to_write_autotests/index.html new file mode 100644 index 000000000..3e5ccf12c --- /dev/null +++ b/Wiki/how_to_write_autotests/index.html @@ -0,0 +1,1546 @@ + + + + + + + + + + + + + + + + + + How to write autotests - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

How to write autotests

+

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.

+

Abstractions

+

How many abstractions can you have in your tests?

+

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.

+

How to determine whether View (fragment, dialog, anything) in the project has its description in some Kakao 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.

+

Is it ok that your PO contains helper methods?

+

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()
+    }
+}
+
+and +
MainScreen {
+    navigateToTasksScreen()
+}
+
+object MainScreen : KScreen<MainScreen>() {
+    //...
+    fun navigateToTasksScreen() {
+        shieldView {
+            click()
+        }
+    }
+    //...
+}
+
+I am sure that method navigateToTasksScreen() is more "talking" than the simple click on some shieldView.

+

Can 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.

+

Assert help methods inside of PO. Is it ok?

+

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
+    )
+}
+
+We prefer the first variant. But we follow the next naming convention of such methods: assert<YourCheckName>.

+

Test structure

+

Test and Test-case correlation

+

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)
+        }
+    }
+}
+
+Not bad. But can you correlate this code with the test-case easy? +No, you need to read the code of the test and the text of the test-case very attentively. It's not comfortable.
+So we want to have a structure of the test that would suggest what step of the test-case we are looking at in the particular area of the test.

+

Before/after state of a test

+

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.

+

Test structure

+

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()
+            )
+        }
+    }
+
+Let's describe the structure:
+1. before - after - run
+ In the beginning, we think about a state. After the state, we begin to consider the test body. +2. step
+ step in the test is similar to step in the test-case. That's why test reading is easier and understandable. +3. scenario
+ There are cases when some sentences of steps are absolutely identical and occur very often in tests. + For these sentences we have introduced a 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.

+

Test data for the test

+

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 {
+    // ...
+}
+
+1. init
+ Here, you prepare only sets of data without any transforms and connections. Also, you can make requests to your test server, for example.
+ It's an optional block. +2. transform
+ This construction is for transforming of our test data. In our example we join the owner and company.
+ It's an optional block. The block is enabled only after the 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!

+

Available Test DSL forms

+

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

+

Examples

+

You can have a look at examples of how to use and configure Kaspresso +and how to use different forms of DSL.

+

Sweet additional features

+

Some words about BaseTestContext method

+

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.

+

flakySafely

+

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)
+            }
+        }
+    }
+}
+
+More detailed examples are here. Please, observe a documentation about implementation details.

+

continuously

+

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()
+        }
+    }
+}
+
+The example is here.

+

compose

+

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()
+                    }
+                }
+            }
+        }
+    }
+}
+
+The example is here.
+Please, observe additional opportunities and documentation: common info, ComposeProvider and WebComposeProvider.

+

data

+

If you set your test data by init-transform methods then this test data is available by a data field.

+

testAssistants

+

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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/Wiki/index.html b/Wiki/index.html new file mode 100644 index 000000000..2faab6aaa --- /dev/null +++ b/Wiki/index.html @@ -0,0 +1,1034 @@ + + + + + + + + + + + + + + + + + + + + + + Introduction - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Перейти к содержанию + + +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Kaspresso Wiki

+

Here you can find detailed information about all the Kaspresso features.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/assets/images/favicon.png b/assets/images/favicon.png new file mode 100644 index 000000000..1cf13b9f9 Binary files /dev/null and b/assets/images/favicon.png differ diff --git a/assets/javascripts/bundle.220ee61c.min.js b/assets/javascripts/bundle.220ee61c.min.js new file mode 100644 index 000000000..116072a11 --- /dev/null +++ b/assets/javascripts/bundle.220ee61c.min.js @@ -0,0 +1,29 @@ +"use strict";(()=>{var Ci=Object.create;var gr=Object.defineProperty;var Ri=Object.getOwnPropertyDescriptor;var ki=Object.getOwnPropertyNames,Ht=Object.getOwnPropertySymbols,Hi=Object.getPrototypeOf,yr=Object.prototype.hasOwnProperty,nn=Object.prototype.propertyIsEnumerable;var rn=(e,t,r)=>t in e?gr(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,P=(e,t)=>{for(var r in t||(t={}))yr.call(t,r)&&rn(e,r,t[r]);if(Ht)for(var r of Ht(t))nn.call(t,r)&&rn(e,r,t[r]);return e};var on=(e,t)=>{var r={};for(var n in e)yr.call(e,n)&&t.indexOf(n)<0&&(r[n]=e[n]);if(e!=null&&Ht)for(var n of Ht(e))t.indexOf(n)<0&&nn.call(e,n)&&(r[n]=e[n]);return r};var Pt=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var Pi=(e,t,r,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let o of ki(t))!yr.call(e,o)&&o!==r&&gr(e,o,{get:()=>t[o],enumerable:!(n=Ri(t,o))||n.enumerable});return e};var yt=(e,t,r)=>(r=e!=null?Ci(Hi(e)):{},Pi(t||!e||!e.__esModule?gr(r,"default",{value:e,enumerable:!0}):r,e));var sn=Pt((xr,an)=>{(function(e,t){typeof xr=="object"&&typeof an!="undefined"?t():typeof define=="function"&&define.amd?define(t):t()})(xr,function(){"use strict";function e(r){var n=!0,o=!1,i=null,s={text:!0,search:!0,url:!0,tel:!0,email:!0,password:!0,number:!0,date:!0,month:!0,week:!0,time:!0,datetime:!0,"datetime-local":!0};function a(O){return!!(O&&O!==document&&O.nodeName!=="HTML"&&O.nodeName!=="BODY"&&"classList"in O&&"contains"in O.classList)}function f(O){var Qe=O.type,De=O.tagName;return!!(De==="INPUT"&&s[Qe]&&!O.readOnly||De==="TEXTAREA"&&!O.readOnly||O.isContentEditable)}function c(O){O.classList.contains("focus-visible")||(O.classList.add("focus-visible"),O.setAttribute("data-focus-visible-added",""))}function u(O){O.hasAttribute("data-focus-visible-added")&&(O.classList.remove("focus-visible"),O.removeAttribute("data-focus-visible-added"))}function p(O){O.metaKey||O.altKey||O.ctrlKey||(a(r.activeElement)&&c(r.activeElement),n=!0)}function m(O){n=!1}function d(O){a(O.target)&&(n||f(O.target))&&c(O.target)}function h(O){a(O.target)&&(O.target.classList.contains("focus-visible")||O.target.hasAttribute("data-focus-visible-added"))&&(o=!0,window.clearTimeout(i),i=window.setTimeout(function(){o=!1},100),u(O.target))}function v(O){document.visibilityState==="hidden"&&(o&&(n=!0),Y())}function Y(){document.addEventListener("mousemove",N),document.addEventListener("mousedown",N),document.addEventListener("mouseup",N),document.addEventListener("pointermove",N),document.addEventListener("pointerdown",N),document.addEventListener("pointerup",N),document.addEventListener("touchmove",N),document.addEventListener("touchstart",N),document.addEventListener("touchend",N)}function B(){document.removeEventListener("mousemove",N),document.removeEventListener("mousedown",N),document.removeEventListener("mouseup",N),document.removeEventListener("pointermove",N),document.removeEventListener("pointerdown",N),document.removeEventListener("pointerup",N),document.removeEventListener("touchmove",N),document.removeEventListener("touchstart",N),document.removeEventListener("touchend",N)}function N(O){O.target.nodeName&&O.target.nodeName.toLowerCase()==="html"||(n=!1,B())}document.addEventListener("keydown",p,!0),document.addEventListener("mousedown",m,!0),document.addEventListener("pointerdown",m,!0),document.addEventListener("touchstart",m,!0),document.addEventListener("visibilitychange",v,!0),Y(),r.addEventListener("focus",d,!0),r.addEventListener("blur",h,!0),r.nodeType===Node.DOCUMENT_FRAGMENT_NODE&&r.host?r.host.setAttribute("data-js-focus-visible",""):r.nodeType===Node.DOCUMENT_NODE&&(document.documentElement.classList.add("js-focus-visible"),document.documentElement.setAttribute("data-js-focus-visible",""))}if(typeof window!="undefined"&&typeof document!="undefined"){window.applyFocusVisiblePolyfill=e;var t;try{t=new CustomEvent("focus-visible-polyfill-ready")}catch(r){t=document.createEvent("CustomEvent"),t.initCustomEvent("focus-visible-polyfill-ready",!1,!1,{})}window.dispatchEvent(t)}typeof document!="undefined"&&e(document)})});var cn=Pt(Er=>{(function(e){var t=function(){try{return!!Symbol.iterator}catch(c){return!1}},r=t(),n=function(c){var u={next:function(){var p=c.shift();return{done:p===void 0,value:p}}};return r&&(u[Symbol.iterator]=function(){return u}),u},o=function(c){return encodeURIComponent(c).replace(/%20/g,"+")},i=function(c){return decodeURIComponent(String(c).replace(/\+/g," "))},s=function(){var c=function(p){Object.defineProperty(this,"_entries",{writable:!0,value:{}});var m=typeof p;if(m!=="undefined")if(m==="string")p!==""&&this._fromString(p);else if(p instanceof c){var d=this;p.forEach(function(B,N){d.append(N,B)})}else if(p!==null&&m==="object")if(Object.prototype.toString.call(p)==="[object Array]")for(var h=0;hd[0]?1:0}),c._entries&&(c._entries={});for(var p=0;p1?i(d[1]):"")}})})(typeof global!="undefined"?global:typeof window!="undefined"?window:typeof self!="undefined"?self:Er);(function(e){var t=function(){try{var o=new e.URL("b","http://a");return o.pathname="c d",o.href==="http://a/c%20d"&&o.searchParams}catch(i){return!1}},r=function(){var o=e.URL,i=function(f,c){typeof f!="string"&&(f=String(f)),c&&typeof c!="string"&&(c=String(c));var u=document,p;if(c&&(e.location===void 0||c!==e.location.href)){c=c.toLowerCase(),u=document.implementation.createHTMLDocument(""),p=u.createElement("base"),p.href=c,u.head.appendChild(p);try{if(p.href.indexOf(c)!==0)throw new Error(p.href)}catch(O){throw new Error("URL unable to set base "+c+" due to "+O)}}var m=u.createElement("a");m.href=f,p&&(u.body.appendChild(m),m.href=m.href);var d=u.createElement("input");if(d.type="url",d.value=f,m.protocol===":"||!/:/.test(m.href)||!d.checkValidity()&&!c)throw new TypeError("Invalid URL");Object.defineProperty(this,"_anchorElement",{value:m});var h=new e.URLSearchParams(this.search),v=!0,Y=!0,B=this;["append","delete","set"].forEach(function(O){var Qe=h[O];h[O]=function(){Qe.apply(h,arguments),v&&(Y=!1,B.search=h.toString(),Y=!0)}}),Object.defineProperty(this,"searchParams",{value:h,enumerable:!0});var N=void 0;Object.defineProperty(this,"_updateSearchParams",{enumerable:!1,configurable:!1,writable:!1,value:function(){this.search!==N&&(N=this.search,Y&&(v=!1,this.searchParams._fromString(this.search),v=!0))}})},s=i.prototype,a=function(f){Object.defineProperty(s,f,{get:function(){return this._anchorElement[f]},set:function(c){this._anchorElement[f]=c},enumerable:!0})};["hash","host","hostname","port","protocol"].forEach(function(f){a(f)}),Object.defineProperty(s,"search",{get:function(){return this._anchorElement.search},set:function(f){this._anchorElement.search=f,this._updateSearchParams()},enumerable:!0}),Object.defineProperties(s,{toString:{get:function(){var f=this;return function(){return f.href}}},href:{get:function(){return this._anchorElement.href.replace(/\?$/,"")},set:function(f){this._anchorElement.href=f,this._updateSearchParams()},enumerable:!0},pathname:{get:function(){return this._anchorElement.pathname.replace(/(^\/?)/,"/")},set:function(f){this._anchorElement.pathname=f},enumerable:!0},origin:{get:function(){var f={"http:":80,"https:":443,"ftp:":21}[this._anchorElement.protocol],c=this._anchorElement.port!=f&&this._anchorElement.port!=="";return this._anchorElement.protocol+"//"+this._anchorElement.hostname+(c?":"+this._anchorElement.port:"")},enumerable:!0},password:{get:function(){return""},set:function(f){},enumerable:!0},username:{get:function(){return""},set:function(f){},enumerable:!0}}),i.createObjectURL=function(f){return o.createObjectURL.apply(o,arguments)},i.revokeObjectURL=function(f){return o.revokeObjectURL.apply(o,arguments)},e.URL=i};if(t()||r(),e.location!==void 0&&!("origin"in e.location)){var n=function(){return e.location.protocol+"//"+e.location.hostname+(e.location.port?":"+e.location.port:"")};try{Object.defineProperty(e.location,"origin",{get:n,enumerable:!0})}catch(o){setInterval(function(){e.location.origin=n()},100)}}})(typeof global!="undefined"?global:typeof window!="undefined"?window:typeof self!="undefined"?self:Er)});var qr=Pt((Mt,Nr)=>{/*! + * clipboard.js v2.0.11 + * https://clipboardjs.com/ + * + * Licensed MIT © Zeno Rocha + */(function(t,r){typeof Mt=="object"&&typeof Nr=="object"?Nr.exports=r():typeof define=="function"&&define.amd?define([],r):typeof Mt=="object"?Mt.ClipboardJS=r():t.ClipboardJS=r()})(Mt,function(){return function(){var e={686:function(n,o,i){"use strict";i.d(o,{default:function(){return Ai}});var s=i(279),a=i.n(s),f=i(370),c=i.n(f),u=i(817),p=i.n(u);function m(j){try{return document.execCommand(j)}catch(T){return!1}}var d=function(T){var E=p()(T);return m("cut"),E},h=d;function v(j){var T=document.documentElement.getAttribute("dir")==="rtl",E=document.createElement("textarea");E.style.fontSize="12pt",E.style.border="0",E.style.padding="0",E.style.margin="0",E.style.position="absolute",E.style[T?"right":"left"]="-9999px";var H=window.pageYOffset||document.documentElement.scrollTop;return E.style.top="".concat(H,"px"),E.setAttribute("readonly",""),E.value=j,E}var Y=function(T,E){var H=v(T);E.container.appendChild(H);var I=p()(H);return m("copy"),H.remove(),I},B=function(T){var E=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body},H="";return typeof T=="string"?H=Y(T,E):T instanceof HTMLInputElement&&!["text","search","url","tel","password"].includes(T==null?void 0:T.type)?H=Y(T.value,E):(H=p()(T),m("copy")),H},N=B;function O(j){"@babel/helpers - typeof";return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?O=function(E){return typeof E}:O=function(E){return E&&typeof Symbol=="function"&&E.constructor===Symbol&&E!==Symbol.prototype?"symbol":typeof E},O(j)}var Qe=function(){var T=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{},E=T.action,H=E===void 0?"copy":E,I=T.container,q=T.target,Me=T.text;if(H!=="copy"&&H!=="cut")throw new Error('Invalid "action" value, use either "copy" or "cut"');if(q!==void 0)if(q&&O(q)==="object"&&q.nodeType===1){if(H==="copy"&&q.hasAttribute("disabled"))throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');if(H==="cut"&&(q.hasAttribute("readonly")||q.hasAttribute("disabled")))throw new Error(`Invalid "target" attribute. You can't cut text from elements with "readonly" or "disabled" attributes`)}else throw new Error('Invalid "target" value, use a valid Element');if(Me)return N(Me,{container:I});if(q)return H==="cut"?h(q):N(q,{container:I})},De=Qe;function $e(j){"@babel/helpers - typeof";return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?$e=function(E){return typeof E}:$e=function(E){return E&&typeof Symbol=="function"&&E.constructor===Symbol&&E!==Symbol.prototype?"symbol":typeof E},$e(j)}function Ei(j,T){if(!(j instanceof T))throw new TypeError("Cannot call a class as a function")}function tn(j,T){for(var E=0;E0&&arguments[0]!==void 0?arguments[0]:{};this.action=typeof I.action=="function"?I.action:this.defaultAction,this.target=typeof I.target=="function"?I.target:this.defaultTarget,this.text=typeof I.text=="function"?I.text:this.defaultText,this.container=$e(I.container)==="object"?I.container:document.body}},{key:"listenClick",value:function(I){var q=this;this.listener=c()(I,"click",function(Me){return q.onClick(Me)})}},{key:"onClick",value:function(I){var q=I.delegateTarget||I.currentTarget,Me=this.action(q)||"copy",kt=De({action:Me,container:this.container,target:this.target(q),text:this.text(q)});this.emit(kt?"success":"error",{action:Me,text:kt,trigger:q,clearSelection:function(){q&&q.focus(),window.getSelection().removeAllRanges()}})}},{key:"defaultAction",value:function(I){return vr("action",I)}},{key:"defaultTarget",value:function(I){var q=vr("target",I);if(q)return document.querySelector(q)}},{key:"defaultText",value:function(I){return vr("text",I)}},{key:"destroy",value:function(){this.listener.destroy()}}],[{key:"copy",value:function(I){var q=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body};return N(I,q)}},{key:"cut",value:function(I){return h(I)}},{key:"isSupported",value:function(){var I=arguments.length>0&&arguments[0]!==void 0?arguments[0]:["copy","cut"],q=typeof I=="string"?[I]:I,Me=!!document.queryCommandSupported;return q.forEach(function(kt){Me=Me&&!!document.queryCommandSupported(kt)}),Me}}]),E}(a()),Ai=Li},828:function(n){var o=9;if(typeof Element!="undefined"&&!Element.prototype.matches){var i=Element.prototype;i.matches=i.matchesSelector||i.mozMatchesSelector||i.msMatchesSelector||i.oMatchesSelector||i.webkitMatchesSelector}function s(a,f){for(;a&&a.nodeType!==o;){if(typeof a.matches=="function"&&a.matches(f))return a;a=a.parentNode}}n.exports=s},438:function(n,o,i){var s=i(828);function a(u,p,m,d,h){var v=c.apply(this,arguments);return u.addEventListener(m,v,h),{destroy:function(){u.removeEventListener(m,v,h)}}}function f(u,p,m,d,h){return typeof u.addEventListener=="function"?a.apply(null,arguments):typeof m=="function"?a.bind(null,document).apply(null,arguments):(typeof u=="string"&&(u=document.querySelectorAll(u)),Array.prototype.map.call(u,function(v){return a(v,p,m,d,h)}))}function c(u,p,m,d){return function(h){h.delegateTarget=s(h.target,p),h.delegateTarget&&d.call(u,h)}}n.exports=f},879:function(n,o){o.node=function(i){return i!==void 0&&i instanceof HTMLElement&&i.nodeType===1},o.nodeList=function(i){var s=Object.prototype.toString.call(i);return i!==void 0&&(s==="[object NodeList]"||s==="[object HTMLCollection]")&&"length"in i&&(i.length===0||o.node(i[0]))},o.string=function(i){return typeof i=="string"||i instanceof String},o.fn=function(i){var s=Object.prototype.toString.call(i);return s==="[object Function]"}},370:function(n,o,i){var s=i(879),a=i(438);function f(m,d,h){if(!m&&!d&&!h)throw new Error("Missing required arguments");if(!s.string(d))throw new TypeError("Second argument must be a String");if(!s.fn(h))throw new TypeError("Third argument must be a Function");if(s.node(m))return c(m,d,h);if(s.nodeList(m))return u(m,d,h);if(s.string(m))return p(m,d,h);throw new TypeError("First argument must be a String, HTMLElement, HTMLCollection, or NodeList")}function c(m,d,h){return m.addEventListener(d,h),{destroy:function(){m.removeEventListener(d,h)}}}function u(m,d,h){return Array.prototype.forEach.call(m,function(v){v.addEventListener(d,h)}),{destroy:function(){Array.prototype.forEach.call(m,function(v){v.removeEventListener(d,h)})}}}function p(m,d,h){return a(document.body,m,d,h)}n.exports=f},817:function(n){function o(i){var s;if(i.nodeName==="SELECT")i.focus(),s=i.value;else if(i.nodeName==="INPUT"||i.nodeName==="TEXTAREA"){var a=i.hasAttribute("readonly");a||i.setAttribute("readonly",""),i.select(),i.setSelectionRange(0,i.value.length),a||i.removeAttribute("readonly"),s=i.value}else{i.hasAttribute("contenteditable")&&i.focus();var f=window.getSelection(),c=document.createRange();c.selectNodeContents(i),f.removeAllRanges(),f.addRange(c),s=f.toString()}return s}n.exports=o},279:function(n){function o(){}o.prototype={on:function(i,s,a){var f=this.e||(this.e={});return(f[i]||(f[i]=[])).push({fn:s,ctx:a}),this},once:function(i,s,a){var f=this;function c(){f.off(i,c),s.apply(a,arguments)}return c._=s,this.on(i,c,a)},emit:function(i){var s=[].slice.call(arguments,1),a=((this.e||(this.e={}))[i]||[]).slice(),f=0,c=a.length;for(f;f{"use strict";/*! + * escape-html + * Copyright(c) 2012-2013 TJ Holowaychuk + * Copyright(c) 2015 Andreas Lubbe + * Copyright(c) 2015 Tiancheng "Timothy" Gu + * MIT Licensed + */var rs=/["'&<>]/;Yo.exports=ns;function ns(e){var t=""+e,r=rs.exec(t);if(!r)return t;var n,o="",i=0,s=0;for(i=r.index;i0&&i[i.length-1])&&(c[0]===6||c[0]===2)){r=0;continue}if(c[0]===3&&(!i||c[1]>i[0]&&c[1]=e.length&&(e=void 0),{value:e&&e[n++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")}function W(e,t){var r=typeof Symbol=="function"&&e[Symbol.iterator];if(!r)return e;var n=r.call(e),o,i=[],s;try{for(;(t===void 0||t-- >0)&&!(o=n.next()).done;)i.push(o.value)}catch(a){s={error:a}}finally{try{o&&!o.done&&(r=n.return)&&r.call(n)}finally{if(s)throw s.error}}return i}function D(e,t,r){if(r||arguments.length===2)for(var n=0,o=t.length,i;n1||a(m,d)})})}function a(m,d){try{f(n[m](d))}catch(h){p(i[0][3],h)}}function f(m){m.value instanceof et?Promise.resolve(m.value.v).then(c,u):p(i[0][2],m)}function c(m){a("next",m)}function u(m){a("throw",m)}function p(m,d){m(d),i.shift(),i.length&&a(i[0][0],i[0][1])}}function pn(e){if(!Symbol.asyncIterator)throw new TypeError("Symbol.asyncIterator is not defined.");var t=e[Symbol.asyncIterator],r;return t?t.call(e):(e=typeof Ee=="function"?Ee(e):e[Symbol.iterator](),r={},n("next"),n("throw"),n("return"),r[Symbol.asyncIterator]=function(){return this},r);function n(i){r[i]=e[i]&&function(s){return new Promise(function(a,f){s=e[i](s),o(a,f,s.done,s.value)})}}function o(i,s,a,f){Promise.resolve(f).then(function(c){i({value:c,done:a})},s)}}function C(e){return typeof e=="function"}function at(e){var t=function(n){Error.call(n),n.stack=new Error().stack},r=e(t);return r.prototype=Object.create(Error.prototype),r.prototype.constructor=r,r}var It=at(function(e){return function(r){e(this),this.message=r?r.length+` errors occurred during unsubscription: +`+r.map(function(n,o){return o+1+") "+n.toString()}).join(` + `):"",this.name="UnsubscriptionError",this.errors=r}});function Ve(e,t){if(e){var r=e.indexOf(t);0<=r&&e.splice(r,1)}}var Ie=function(){function e(t){this.initialTeardown=t,this.closed=!1,this._parentage=null,this._finalizers=null}return e.prototype.unsubscribe=function(){var t,r,n,o,i;if(!this.closed){this.closed=!0;var s=this._parentage;if(s)if(this._parentage=null,Array.isArray(s))try{for(var a=Ee(s),f=a.next();!f.done;f=a.next()){var c=f.value;c.remove(this)}}catch(v){t={error:v}}finally{try{f&&!f.done&&(r=a.return)&&r.call(a)}finally{if(t)throw t.error}}else s.remove(this);var u=this.initialTeardown;if(C(u))try{u()}catch(v){i=v instanceof It?v.errors:[v]}var p=this._finalizers;if(p){this._finalizers=null;try{for(var m=Ee(p),d=m.next();!d.done;d=m.next()){var h=d.value;try{ln(h)}catch(v){i=i!=null?i:[],v instanceof It?i=D(D([],W(i)),W(v.errors)):i.push(v)}}}catch(v){n={error:v}}finally{try{d&&!d.done&&(o=m.return)&&o.call(m)}finally{if(n)throw n.error}}}if(i)throw new It(i)}},e.prototype.add=function(t){var r;if(t&&t!==this)if(this.closed)ln(t);else{if(t instanceof e){if(t.closed||t._hasParent(this))return;t._addParent(this)}(this._finalizers=(r=this._finalizers)!==null&&r!==void 0?r:[]).push(t)}},e.prototype._hasParent=function(t){var r=this._parentage;return r===t||Array.isArray(r)&&r.includes(t)},e.prototype._addParent=function(t){var r=this._parentage;this._parentage=Array.isArray(r)?(r.push(t),r):r?[r,t]:t},e.prototype._removeParent=function(t){var r=this._parentage;r===t?this._parentage=null:Array.isArray(r)&&Ve(r,t)},e.prototype.remove=function(t){var r=this._finalizers;r&&Ve(r,t),t instanceof e&&t._removeParent(this)},e.EMPTY=function(){var t=new e;return t.closed=!0,t}(),e}();var Sr=Ie.EMPTY;function jt(e){return e instanceof Ie||e&&"closed"in e&&C(e.remove)&&C(e.add)&&C(e.unsubscribe)}function ln(e){C(e)?e():e.unsubscribe()}var Le={onUnhandledError:null,onStoppedNotification:null,Promise:void 0,useDeprecatedSynchronousErrorHandling:!1,useDeprecatedNextContext:!1};var st={setTimeout:function(e,t){for(var r=[],n=2;n0},enumerable:!1,configurable:!0}),t.prototype._trySubscribe=function(r){return this._throwIfClosed(),e.prototype._trySubscribe.call(this,r)},t.prototype._subscribe=function(r){return this._throwIfClosed(),this._checkFinalizedStatuses(r),this._innerSubscribe(r)},t.prototype._innerSubscribe=function(r){var n=this,o=this,i=o.hasError,s=o.isStopped,a=o.observers;return i||s?Sr:(this.currentObservers=null,a.push(r),new Ie(function(){n.currentObservers=null,Ve(a,r)}))},t.prototype._checkFinalizedStatuses=function(r){var n=this,o=n.hasError,i=n.thrownError,s=n.isStopped;o?r.error(i):s&&r.complete()},t.prototype.asObservable=function(){var r=new F;return r.source=this,r},t.create=function(r,n){return new xn(r,n)},t}(F);var xn=function(e){ie(t,e);function t(r,n){var o=e.call(this)||this;return o.destination=r,o.source=n,o}return t.prototype.next=function(r){var n,o;(o=(n=this.destination)===null||n===void 0?void 0:n.next)===null||o===void 0||o.call(n,r)},t.prototype.error=function(r){var n,o;(o=(n=this.destination)===null||n===void 0?void 0:n.error)===null||o===void 0||o.call(n,r)},t.prototype.complete=function(){var r,n;(n=(r=this.destination)===null||r===void 0?void 0:r.complete)===null||n===void 0||n.call(r)},t.prototype._subscribe=function(r){var n,o;return(o=(n=this.source)===null||n===void 0?void 0:n.subscribe(r))!==null&&o!==void 0?o:Sr},t}(x);var Et={now:function(){return(Et.delegate||Date).now()},delegate:void 0};var wt=function(e){ie(t,e);function t(r,n,o){r===void 0&&(r=1/0),n===void 0&&(n=1/0),o===void 0&&(o=Et);var i=e.call(this)||this;return i._bufferSize=r,i._windowTime=n,i._timestampProvider=o,i._buffer=[],i._infiniteTimeWindow=!0,i._infiniteTimeWindow=n===1/0,i._bufferSize=Math.max(1,r),i._windowTime=Math.max(1,n),i}return t.prototype.next=function(r){var n=this,o=n.isStopped,i=n._buffer,s=n._infiniteTimeWindow,a=n._timestampProvider,f=n._windowTime;o||(i.push(r),!s&&i.push(a.now()+f)),this._trimBuffer(),e.prototype.next.call(this,r)},t.prototype._subscribe=function(r){this._throwIfClosed(),this._trimBuffer();for(var n=this._innerSubscribe(r),o=this,i=o._infiniteTimeWindow,s=o._buffer,a=s.slice(),f=0;f0?e.prototype.requestAsyncId.call(this,r,n,o):(r.actions.push(this),r._scheduled||(r._scheduled=ut.requestAnimationFrame(function(){return r.flush(void 0)})))},t.prototype.recycleAsyncId=function(r,n,o){var i;if(o===void 0&&(o=0),o!=null?o>0:this.delay>0)return e.prototype.recycleAsyncId.call(this,r,n,o);var s=r.actions;n!=null&&((i=s[s.length-1])===null||i===void 0?void 0:i.id)!==n&&(ut.cancelAnimationFrame(n),r._scheduled=void 0)},t}(Wt);var Sn=function(e){ie(t,e);function t(){return e!==null&&e.apply(this,arguments)||this}return t.prototype.flush=function(r){this._active=!0;var n=this._scheduled;this._scheduled=void 0;var o=this.actions,i;r=r||o.shift();do if(i=r.execute(r.state,r.delay))break;while((r=o[0])&&r.id===n&&o.shift());if(this._active=!1,i){for(;(r=o[0])&&r.id===n&&o.shift();)r.unsubscribe();throw i}},t}(Dt);var Oe=new Sn(wn);var M=new F(function(e){return e.complete()});function Vt(e){return e&&C(e.schedule)}function Cr(e){return e[e.length-1]}function Ye(e){return C(Cr(e))?e.pop():void 0}function Te(e){return Vt(Cr(e))?e.pop():void 0}function zt(e,t){return typeof Cr(e)=="number"?e.pop():t}var pt=function(e){return e&&typeof e.length=="number"&&typeof e!="function"};function Nt(e){return C(e==null?void 0:e.then)}function qt(e){return C(e[ft])}function Kt(e){return Symbol.asyncIterator&&C(e==null?void 0:e[Symbol.asyncIterator])}function Qt(e){return new TypeError("You provided "+(e!==null&&typeof e=="object"?"an invalid object":"'"+e+"'")+" where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.")}function zi(){return typeof Symbol!="function"||!Symbol.iterator?"@@iterator":Symbol.iterator}var Yt=zi();function Gt(e){return C(e==null?void 0:e[Yt])}function Bt(e){return un(this,arguments,function(){var r,n,o,i;return $t(this,function(s){switch(s.label){case 0:r=e.getReader(),s.label=1;case 1:s.trys.push([1,,9,10]),s.label=2;case 2:return[4,et(r.read())];case 3:return n=s.sent(),o=n.value,i=n.done,i?[4,et(void 0)]:[3,5];case 4:return[2,s.sent()];case 5:return[4,et(o)];case 6:return[4,s.sent()];case 7:return s.sent(),[3,2];case 8:return[3,10];case 9:return r.releaseLock(),[7];case 10:return[2]}})})}function Jt(e){return C(e==null?void 0:e.getReader)}function U(e){if(e instanceof F)return e;if(e!=null){if(qt(e))return Ni(e);if(pt(e))return qi(e);if(Nt(e))return Ki(e);if(Kt(e))return On(e);if(Gt(e))return Qi(e);if(Jt(e))return Yi(e)}throw Qt(e)}function Ni(e){return new F(function(t){var r=e[ft]();if(C(r.subscribe))return r.subscribe(t);throw new TypeError("Provided object does not correctly implement Symbol.observable")})}function qi(e){return new F(function(t){for(var r=0;r=2;return function(n){return n.pipe(e?A(function(o,i){return e(o,i,n)}):de,ge(1),r?He(t):Dn(function(){return new Zt}))}}function Vn(){for(var e=[],t=0;t=2,!0))}function pe(e){e===void 0&&(e={});var t=e.connector,r=t===void 0?function(){return new x}:t,n=e.resetOnError,o=n===void 0?!0:n,i=e.resetOnComplete,s=i===void 0?!0:i,a=e.resetOnRefCountZero,f=a===void 0?!0:a;return function(c){var u,p,m,d=0,h=!1,v=!1,Y=function(){p==null||p.unsubscribe(),p=void 0},B=function(){Y(),u=m=void 0,h=v=!1},N=function(){var O=u;B(),O==null||O.unsubscribe()};return y(function(O,Qe){d++,!v&&!h&&Y();var De=m=m!=null?m:r();Qe.add(function(){d--,d===0&&!v&&!h&&(p=$r(N,f))}),De.subscribe(Qe),!u&&d>0&&(u=new rt({next:function($e){return De.next($e)},error:function($e){v=!0,Y(),p=$r(B,o,$e),De.error($e)},complete:function(){h=!0,Y(),p=$r(B,s),De.complete()}}),U(O).subscribe(u))})(c)}}function $r(e,t){for(var r=[],n=2;ne.next(document)),e}function K(e,t=document){return Array.from(t.querySelectorAll(e))}function z(e,t=document){let r=ce(e,t);if(typeof r=="undefined")throw new ReferenceError(`Missing element: expected "${e}" to be present`);return r}function ce(e,t=document){return t.querySelector(e)||void 0}function _e(){return document.activeElement instanceof HTMLElement&&document.activeElement||void 0}function tr(e){return L(b(document.body,"focusin"),b(document.body,"focusout")).pipe(ke(1),l(()=>{let t=_e();return typeof t!="undefined"?e.contains(t):!1}),V(e===_e()),J())}function Xe(e){return{x:e.offsetLeft,y:e.offsetTop}}function Kn(e){return L(b(window,"load"),b(window,"resize")).pipe(Ce(0,Oe),l(()=>Xe(e)),V(Xe(e)))}function rr(e){return{x:e.scrollLeft,y:e.scrollTop}}function dt(e){return L(b(e,"scroll"),b(window,"resize")).pipe(Ce(0,Oe),l(()=>rr(e)),V(rr(e)))}var Yn=function(){if(typeof Map!="undefined")return Map;function e(t,r){var n=-1;return t.some(function(o,i){return o[0]===r?(n=i,!0):!1}),n}return function(){function t(){this.__entries__=[]}return Object.defineProperty(t.prototype,"size",{get:function(){return this.__entries__.length},enumerable:!0,configurable:!0}),t.prototype.get=function(r){var n=e(this.__entries__,r),o=this.__entries__[n];return o&&o[1]},t.prototype.set=function(r,n){var o=e(this.__entries__,r);~o?this.__entries__[o][1]=n:this.__entries__.push([r,n])},t.prototype.delete=function(r){var n=this.__entries__,o=e(n,r);~o&&n.splice(o,1)},t.prototype.has=function(r){return!!~e(this.__entries__,r)},t.prototype.clear=function(){this.__entries__.splice(0)},t.prototype.forEach=function(r,n){n===void 0&&(n=null);for(var o=0,i=this.__entries__;o0},e.prototype.connect_=function(){!Wr||this.connected_||(document.addEventListener("transitionend",this.onTransitionEnd_),window.addEventListener("resize",this.refresh),va?(this.mutationsObserver_=new MutationObserver(this.refresh),this.mutationsObserver_.observe(document,{attributes:!0,childList:!0,characterData:!0,subtree:!0})):(document.addEventListener("DOMSubtreeModified",this.refresh),this.mutationEventsAdded_=!0),this.connected_=!0)},e.prototype.disconnect_=function(){!Wr||!this.connected_||(document.removeEventListener("transitionend",this.onTransitionEnd_),window.removeEventListener("resize",this.refresh),this.mutationsObserver_&&this.mutationsObserver_.disconnect(),this.mutationEventsAdded_&&document.removeEventListener("DOMSubtreeModified",this.refresh),this.mutationsObserver_=null,this.mutationEventsAdded_=!1,this.connected_=!1)},e.prototype.onTransitionEnd_=function(t){var r=t.propertyName,n=r===void 0?"":r,o=ba.some(function(i){return!!~n.indexOf(i)});o&&this.refresh()},e.getInstance=function(){return this.instance_||(this.instance_=new e),this.instance_},e.instance_=null,e}(),Gn=function(e,t){for(var r=0,n=Object.keys(t);r0},e}(),Jn=typeof WeakMap!="undefined"?new WeakMap:new Yn,Xn=function(){function e(t){if(!(this instanceof e))throw new TypeError("Cannot call a class as a function.");if(!arguments.length)throw new TypeError("1 argument required, but only 0 present.");var r=ga.getInstance(),n=new La(t,r,this);Jn.set(this,n)}return e}();["observe","unobserve","disconnect"].forEach(function(e){Xn.prototype[e]=function(){var t;return(t=Jn.get(this))[e].apply(t,arguments)}});var Aa=function(){return typeof nr.ResizeObserver!="undefined"?nr.ResizeObserver:Xn}(),Zn=Aa;var eo=new x,Ca=$(()=>k(new Zn(e=>{for(let t of e)eo.next(t)}))).pipe(g(e=>L(ze,k(e)).pipe(R(()=>e.disconnect()))),X(1));function he(e){return{width:e.offsetWidth,height:e.offsetHeight}}function ye(e){return Ca.pipe(S(t=>t.observe(e)),g(t=>eo.pipe(A(({target:r})=>r===e),R(()=>t.unobserve(e)),l(()=>he(e)))),V(he(e)))}function bt(e){return{width:e.scrollWidth,height:e.scrollHeight}}function ar(e){let t=e.parentElement;for(;t&&(e.scrollWidth<=t.scrollWidth&&e.scrollHeight<=t.scrollHeight);)t=(e=t).parentElement;return t?e:void 0}var to=new x,Ra=$(()=>k(new IntersectionObserver(e=>{for(let t of e)to.next(t)},{threshold:0}))).pipe(g(e=>L(ze,k(e)).pipe(R(()=>e.disconnect()))),X(1));function sr(e){return Ra.pipe(S(t=>t.observe(e)),g(t=>to.pipe(A(({target:r})=>r===e),R(()=>t.unobserve(e)),l(({isIntersecting:r})=>r))))}function ro(e,t=16){return dt(e).pipe(l(({y:r})=>{let n=he(e),o=bt(e);return r>=o.height-n.height-t}),J())}var cr={drawer:z("[data-md-toggle=drawer]"),search:z("[data-md-toggle=search]")};function no(e){return cr[e].checked}function Ke(e,t){cr[e].checked!==t&&cr[e].click()}function Ue(e){let t=cr[e];return b(t,"change").pipe(l(()=>t.checked),V(t.checked))}function ka(e,t){switch(e.constructor){case HTMLInputElement:return e.type==="radio"?/^Arrow/.test(t):!0;case HTMLSelectElement:case HTMLTextAreaElement:return!0;default:return e.isContentEditable}}function Ha(){return L(b(window,"compositionstart").pipe(l(()=>!0)),b(window,"compositionend").pipe(l(()=>!1))).pipe(V(!1))}function oo(){let e=b(window,"keydown").pipe(A(t=>!(t.metaKey||t.ctrlKey)),l(t=>({mode:no("search")?"search":"global",type:t.key,claim(){t.preventDefault(),t.stopPropagation()}})),A(({mode:t,type:r})=>{if(t==="global"){let n=_e();if(typeof n!="undefined")return!ka(n,r)}return!0}),pe());return Ha().pipe(g(t=>t?M:e))}function le(){return new URL(location.href)}function ot(e){location.href=e.href}function io(){return new x}function ao(e,t){if(typeof t=="string"||typeof t=="number")e.innerHTML+=t.toString();else if(t instanceof Node)e.appendChild(t);else if(Array.isArray(t))for(let r of t)ao(e,r)}function _(e,t,...r){let n=document.createElement(e);if(t)for(let o of Object.keys(t))typeof t[o]!="undefined"&&(typeof t[o]!="boolean"?n.setAttribute(o,t[o]):n.setAttribute(o,""));for(let o of r)ao(n,o);return n}function fr(e){if(e>999){let t=+((e-950)%1e3>99);return`${((e+1e-6)/1e3).toFixed(t)}k`}else return e.toString()}function so(){return location.hash.substring(1)}function Dr(e){let t=_("a",{href:e});t.addEventListener("click",r=>r.stopPropagation()),t.click()}function Pa(e){return L(b(window,"hashchange"),e).pipe(l(so),V(so()),A(t=>t.length>0),X(1))}function co(e){return Pa(e).pipe(l(t=>ce(`[id="${t}"]`)),A(t=>typeof t!="undefined"))}function Vr(e){let t=matchMedia(e);return er(r=>t.addListener(()=>r(t.matches))).pipe(V(t.matches))}function fo(){let e=matchMedia("print");return L(b(window,"beforeprint").pipe(l(()=>!0)),b(window,"afterprint").pipe(l(()=>!1))).pipe(V(e.matches))}function zr(e,t){return e.pipe(g(r=>r?t():M))}function ur(e,t={credentials:"same-origin"}){return ue(fetch(`${e}`,t)).pipe(fe(()=>M),g(r=>r.status!==200?Ot(()=>new Error(r.statusText)):k(r)))}function We(e,t){return ur(e,t).pipe(g(r=>r.json()),X(1))}function uo(e,t){let r=new DOMParser;return ur(e,t).pipe(g(n=>n.text()),l(n=>r.parseFromString(n,"text/xml")),X(1))}function pr(e){let t=_("script",{src:e});return $(()=>(document.head.appendChild(t),L(b(t,"load"),b(t,"error").pipe(g(()=>Ot(()=>new ReferenceError(`Invalid script: ${e}`))))).pipe(l(()=>{}),R(()=>document.head.removeChild(t)),ge(1))))}function po(){return{x:Math.max(0,scrollX),y:Math.max(0,scrollY)}}function lo(){return L(b(window,"scroll",{passive:!0}),b(window,"resize",{passive:!0})).pipe(l(po),V(po()))}function mo(){return{width:innerWidth,height:innerHeight}}function ho(){return b(window,"resize",{passive:!0}).pipe(l(mo),V(mo()))}function bo(){return G([lo(),ho()]).pipe(l(([e,t])=>({offset:e,size:t})),X(1))}function lr(e,{viewport$:t,header$:r}){let n=t.pipe(ee("size")),o=G([n,r]).pipe(l(()=>Xe(e)));return G([r,t,o]).pipe(l(([{height:i},{offset:s,size:a},{x:f,y:c}])=>({offset:{x:s.x-f,y:s.y-c+i},size:a})))}(()=>{function e(n,o){parent.postMessage(n,o||"*")}function t(...n){return n.reduce((o,i)=>o.then(()=>new Promise(s=>{let a=document.createElement("script");a.src=i,a.onload=s,document.body.appendChild(a)})),Promise.resolve())}var r=class extends EventTarget{constructor(n){super(),this.url=n,this.m=i=>{i.source===this.w&&(this.dispatchEvent(new MessageEvent("message",{data:i.data})),this.onmessage&&this.onmessage(i))},this.e=(i,s,a,f,c)=>{if(s===`${this.url}`){let u=new ErrorEvent("error",{message:i,filename:s,lineno:a,colno:f,error:c});this.dispatchEvent(u),this.onerror&&this.onerror(u)}};let o=document.createElement("iframe");o.hidden=!0,document.body.appendChild(this.iframe=o),this.w.document.open(),this.w.document.write(` + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Breaking changes

+ +

1.2.0

+
    +
  • We've totally reworked AdbServer and Kaspresso 1.2.0 works only with new artifacts/adbserver-desktop.jar
    + The old version artifacts/desktop_1_1_0.jar is also available for use with older versions of Kaspresso.
  • +
  • If you use 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.
  • +
+

1.2.1

+
    +
  • Kaspresso migrated to a new version of Kakao which has 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.
  • +
+

1.5.0

+
    +
  • In order to support the system storage restrictions artifacts are saved under /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.
  • +
+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Home/Contribution_guide/index.html b/en/Home/Contribution_guide/index.html new file mode 100644 index 000000000..15db2fdc5 --- /dev/null +++ b/en/Home/Contribution_guide/index.html @@ -0,0 +1,1123 @@ + + + + + + + + + + + + + + + + + + + + + + Contribution guide - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Contribution guide

+

Сode contribution workflow

+
    +
  1. Find an open issue or create a new issue on issue tracker for the feature you want to contribute.
  2. +
  3. Fork the project on GitHub. You need to create a feature-branch for your work on your fork, as this way you be able to submit a pull request.
  4. +
  5. Make any necessary changes to the source code.
  6. +
  7. Add tests that verify that your contribution works as expected and modify existing tests if required.
  8. +
  9. Run all unit and UI tests and make sure all of them pass.
  10. +
  11. Run code coverage to check if the lines of code you added are covered by unit tests.
  12. +
  13. Once your feature is complete, prepare the commit with appropriate message and the issue number.
  14. +
  15. Create a pull request and wait for the users to review. When you submit a pull request, please, agree to the terms of CLA.
  16. +
  17. Once everything is done, your pull request gets merged. Your feature will be available with the next release and your name will be added to AUTHORS.
  18. +
+

Branch naming

+

issue-***/detailed_description. Example: issue-306/fix-padding-breaks-autoscroll-interceptor

+

Commits

+

The commit message should begin with: "Issue #***: ...". Example: "Issue #306: Fixed padding-breaks autoscroll interceptor".

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Home/Kaspresso users/index.html b/en/Home/Kaspresso users/index.html new file mode 100644 index 000000000..2ba79fe89 --- /dev/null +++ b/en/Home/Kaspresso users/index.html @@ -0,0 +1,1125 @@ + + + + + + + + + + + + + + + + + + + + + + Our users - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Our Users

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ www.kaspersky.ru + + hh.ru + + aliexpress.ru + + www.sber.ru + + www.revolut.com +
+ www.delivery-club.ru + + www.vtb.ru + + www.tinkoff.ru + + www.x5.ru + + www.zen.yandex.ru +
+ www.psbank.ru + + www.letoile.ru + + rtkit.ru + + ooo.technology + + www.blinkist.com +
+ www.rabota.ru + + www.cian.ru + + squaregps.com + + nexign.com + + profi.ru +
+ alohabrowser.com + + vivid.money + + raiffeisen.ru + + cft.ru + + superjob.ru +
+ dmtech.team +
+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Home/Kaspresso-in-articles/index.html b/en/Home/Kaspresso-in-articles/index.html new file mode 100644 index 000000000..48d2049c6 --- /dev/null +++ b/en/Home/Kaspresso-in-articles/index.html @@ -0,0 +1,1040 @@ + + + + + + + + + + + + + + + + + + + + + + Kaspresso in articles - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Kaspresso in articles

+

[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! +

+
+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Home/Kaspresso-in-videos/index.html b/en/Home/Kaspresso-in-videos/index.html new file mode 100644 index 000000000..d943fd216 --- /dev/null +++ b/en/Home/Kaspresso-in-videos/index.html @@ -0,0 +1,1040 @@ + + + + + + + + + + + + + + + + + + + + + + Kaspresso in videos - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Issues/Storage_issue/index.html b/en/Issues/Storage_issue/index.html new file mode 100644 index 000000000..63e8c6e23 --- /dev/null +++ b/en/Issues/Storage_issue/index.html @@ -0,0 +1,1070 @@ + + + + + + + + + + + + + + + + + + + + Storage issues - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Storage issues

+ +
+

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:

+
    +
  1. AndroidManifest.xml (in your debug build variant to keep production manifest without any changes) +
    # 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>             
    +
  2. +
  3. Your test class: +
    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
    +    )
    +
    +    //...
    +}    
    +
  4. +
+

This is a temporary solution. We recommend migrating to the latest version of Kaspresso (1.5.0 and above) to avoid these problems.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Issues/index.html b/en/Issues/index.html new file mode 100644 index 000000000..2f1480efa --- /dev/null +++ b/en/Issues/index.html @@ -0,0 +1,1207 @@ + + + + + + + + + + + + + + + + + + + + + + Found an issue? - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Found an issue?

+

Kaspresso has a great community that helps make it better by suggesting new ideas, reporting bugs with detailed descriptions and making pull requests.

+

Creating new issues

+

In our Issues tab you can create a new one. There are two most popular types of issues: bug and enhancement.

+

Template for bugs

+

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.
+

+

Template for enhancements

+

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):
+...
+
+

Pull requests are allways welcome

+

If you have not only an issue, but also a ready implementation, you can always submit the pull request on Github.

+

Thanks!

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Tutorial/Android_permissions/index.html b/en/Tutorial/Android_permissions/index.html new file mode 100644 index 000000000..2e8064024 --- /dev/null +++ b/en/Tutorial/Android_permissions/index.html @@ -0,0 +1,1953 @@ + + + + + + + + + + + + + + + + + + + + + + 10. Working with Android permissions - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+ +
+ + + +
+
+ + + + + + + +

Test apps that require permissions

+

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

+

Main Screen

+

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

+

Make call screen

+

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.

+

Request permissions

+

If we click “Allow”, then the call will begin to the subscriber at the number that you specified in the input field

+

Calling

+

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

+

Deny permission

+

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.

+

Create a test

+

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.

+

Testing with the TestRule

+

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.

+

FlakySafely for assertions

+

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)
+            }
+        }
+    }
+}
+
+Now the test works, but it has several problems.

+

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.

+

Testing with Device.Permissions

+

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

+

Rename

+

And enter a new class name:

+

Rename

+

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:

+

Device permission methods

+

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()
+            }
+        }
+    }
+}
+
+

Testing against different API versions

+

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()
+            }
+        }
+    }
+}
+
+

Summary

+

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:

+
    +
  1. The Permissions object makes it possible to test whether a dialog requesting permission is displayed
  2. +
  3. When using Permissions, we can test the application's behavior not only when accepting a permission, but also when denying it
  4. +
  5. Tests with the GrantPermissionRule will fail if the permission was previously denied. You will need to reinstall the application or cancel previously issued permissions through the adb shell command
  6. +
  7. If you revoke the permission using the adb shell command while the test is running, then the test will work correctly if the Permissions object is used, but a crash will occur if the GrantPermissionRule is used
  8. +
+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Tutorial/Download_Kaspresso_project_and_Android_studio/index.html b/en/Tutorial/Download_Kaspresso_project_and_Android_studio/index.html new file mode 100644 index 000000000..9d9bb5927 --- /dev/null +++ b/en/Tutorial/Download_Kaspresso_project_and_Android_studio/index.html @@ -0,0 +1,1163 @@ + + + + + + + + + + + + + + + + + + + + + + 2. Download Kaspresso project and Android studio - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Setting up the required environment.

+

In this lesson, we will download the Kaspresso project, install Android studio and set up the emulator.

+

Download Android Studio

+

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.

+

Downloading the Kaspresso project

+

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

+

Download Kaspresso button

+

Open Android Studio.

+

If you have not previously opened any project in the studio, then you must select the Get From VCS item

+

Get Project from VCS

+

If a project has already been launched, then you can load a new one from GIT as follows: File -> New -> Project From Version Control

+

Get Project from VCS

+

In the window that opens, enter the copied project URL, select the folder where Kaspresso will be placed and click clone.

+

Clone Project

+

Setting up the emulator.

+

In the top menu of Android Studio, select 'Tools' -> 'Device Manager'

+

Tools Device Manager

+

The tab for managing emulators and real devices will appear on the screen. Click on 'Create Device':

+

Create Device

+

We will see the following screen:

+

Select hardware

+

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:

+

System image

+

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.

+

SDK_component_isntaller

+

When the installation process is completed, click the Finish button:

+

SDK_component_isntaller_finish

+

Select the installed version ('R') and click 'Next':

+

SDK_component_installer_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'.

+

Device_name

+

The device is set up and ready for work. We launch it by the 'Play' icon to the right of the device name:

+

Launch_device

+

In some cases, Android Studio may recommend installing Hypervisor:

+

Hyper_Visor

+

Hyper_Visor_next

+

Summary

+

Android Studio is installed, emulator is configured, Kaspresso project is loaded. In the next lesson, we will run the first tests.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Tutorial/FlakySafely/index.html b/en/Tutorial/FlakySafely/index.html new file mode 100644 index 000000000..f2e1b4c66 --- /dev/null +++ b/en/Tutorial/FlakySafely/index.html @@ -0,0 +1,1652 @@ + + + + + + + + + + + + + + + + + + + + + + 9. flakySafely - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+ +
+
+ + + +
+
+ + + + + + + +

Flaky Safely. Testing with timeout

+

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

+

Flaky activity button

+

This screen displays several TextView for which some data is being loaded

+

Flaky screen 1

+

After one second, the text for the first element is loaded

+

Flaky screen 2

+

After another three seconds, text appears on the second element

+

Flaky screen 3

+

After 10 seconds, the rest of the data will be loaded and the texts will appear in all TextView

+

Flaky screen 4

+

Testing FlakyScreen

+

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) }
+}
+
+To go to 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) }
+}
+
+Let's first check that the screen is open, all elements are visible and the 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.

+

When to use flakySafely

+

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 невидимый
+            }
+        }
+    }
+}
+
+Let's launch the test. It still succeeds.

+

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()
+            }
+        }
+    }
+}
+
+The next step is 10 seconds after the data for the second element is loaded, the text appears in all the other 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()
+                }
+            }
+        }
+    }
+}
+
+

Thread.sleep vs FlakySafely

+

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.

+

What timeout to specify?

+

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.

+

Features of working with ScrollView

+

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()
+                }
+            }
+        }
+    }
+}
+
+Test passed successfully. Now let's change the check for the fifth element of the list. Now instead of the 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.

+

Summary

+

In this tutorial, we covered the following points:

+
    +
  1. The `flakySafely` method for testing a stateful screen
  2. +
  3. Set different timeouts for different operations
  4. +
  5. Features of Kaspresso on scrollable screens
  6. +
  7. Difference between Thread.sleep and flakySafely
  8. +
+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Tutorial/Logger_and_screenshot/index.html b/en/Tutorial/Logger_and_screenshot/index.html new file mode 100644 index 000000000..13af0920a --- /dev/null +++ b/en/Tutorial/Logger_and_screenshot/index.html @@ -0,0 +1,1630 @@ + + + + + + + + + + + + + + + + + + + + + + 12. Logger and screenshots - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Logging and screenshots

+

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

+

Tutorial main screen

+

and click on the Login Activity button

+

Login Activity

+

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.

+

Screen after auth

+

External system for test data

+

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

+

Create package 1

+

Create package 2

+

In the created package, add the TestData class, select the type Object

+

Create class

+

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"
+}
+
+Now let's create a separate test class in which we will check for a successful login using the data received from the 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 launch. Test passed successfully.

+

Analysis of failed tests

+

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"
+}
+
+Let's run the test again. This time the test fails.

+

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

+

Test failed

+

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.

+

Adding logs

+

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:

+

Custom Log

+

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

+

Kaspresso test tag

+

Screenshots

+

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

+

Screenshots

+

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

+

Setup password

+

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.

+
+

Setting up Kaspresso.Builder

+

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().

+

Test Case Params

+
+

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()
+)
+
+In this case, the call to methods that take screenshots can be removed, they will be saved automatically if the test fails.

+
+

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):

+

Advanced Builder

+

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

+
+

Interceptors

+

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.

+
    +
  • ScreenshotStepWatcherInterceptor - adds screenshots whether the step failed or not +
  • +
  • ScreenshotFailStepWatcherInterceptor - adds a screenshot of only the step that failed +
  • +
  • TestRunnerScreenshotWatcherInterceptor - adds a screenshot if an error occurs in the `before` or `after` section +
  • +
+ +

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))
+    }
+)
+
+Here we first get the default builder, call its 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

+

Customized Builder

+

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.

+

Summary

+

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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Tutorial/Logger_and_screenshots/index.html b/en/Tutorial/Logger_and_screenshots/index.html new file mode 100644 index 000000000..e799d2647 --- /dev/null +++ b/en/Tutorial/Logger_and_screenshots/index.html @@ -0,0 +1,1010 @@ + + + + + + + + + + + + + + + + + + Logger and screenshots - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Logger and screenshots

+ + + + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Tutorial/Recyclerview/index.html b/en/Tutorial/Recyclerview/index.html new file mode 100644 index 000000000..952c80b45 --- /dev/null +++ b/en/Tutorial/Recyclerview/index.html @@ -0,0 +1,1818 @@ + + + + + + + + + + + + + + + + + + + + + + 11. RecyclerView. Testing list of elements - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

RecyclerView. Testing list of elements

+

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.

+

Main Screen

+

You will see the following screen:

+

Todo List

+

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.

+

Swipe element

+

Remove element

+

Let's write tests for this screen. We need the IDs of the list elements, we will use the LayoutInspector to find them.

+

Layout Inspector

+

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) }
+}
+
+If we write such code, then we will have some errors. The fact is that if you are testing a 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.

+
+

Info

+

You can read more about classes and objects in the official Kotlin documentation.

+
+

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) {
+
+    }
+}
+
+We can add interface elements to NoteItemScreen that we will test.

+

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) }
+    }
+}
+
+Pay attention to two important points:

+

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) },
+)
+
+The second is 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) }
+    }
+}
+
+Now let's add a button to go to this screen in Page Object 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) }
+}
+
+Now you can start checking the screen with a list of notes

+

Testing NoteListScreen

+

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()
+                    }
+                }
+            }
+        }
+    }
+}
+
+We can also test each element separately. Let's check that each note contains the correct texts and background colors:

+
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")
+                    }
+                }
+            }
+        }
+    }
+}
+
+

Swipe check

+

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.

+

Wait for idle

+

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")
+                    }
+                }
+            }
+        }
+    }
+}
+
+

Extract methods to Page Object

+

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())
+    }
+}
+
+Now, in any place where we need to make a swipe, we simply call the method we created on the NoteItemScreen object:

+

childAt<NoteListScreen.NoteItemScreen>(0) {
+    swipeLeft()
+    device.uiDevice.waitForIdle()
+}
+
+Then the whole 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.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.

+
+

Summary

+

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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Tutorial/Scenario/index.html b/en/Tutorial/Scenario/index.html new file mode 100644 index 000000000..2308fa764 --- /dev/null +++ b/en/Tutorial/Scenario/index.html @@ -0,0 +1,1754 @@ + + + + + + + + + + + + + + + + + + + + + + 7. Reduce duplicate steps the Scenario - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Reduce duplicate steps with Scenario

+

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.

+

Main Screen login button

+

We have an authorization screen where the user can enter a login and password and click on the Login button

+

Login activity

+

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.

+

Screen After Login

+

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.

+

Test 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:

+
    +
  1. All elements are visible and the button is clickable
  2. +
  3. Input fields contain appropriate hints
  4. +
  5. If the input fields contain valid data, then transition to the next screen is performed
  6. +
+ +

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:

+
    +
  1. Declare the variables `username` and `password`, assigning different values to them depending on the check we will perform
  2. +
  3. Open the login screen
  4. +
  5. Check the visibility of elements
  6. +
  7. Enter the login and password created in step (1) in the appropriate fields and click on the "Login" button
  8. +
  9. Check that we have the desired screen
  10. +
+ +

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.

+

Create a Scenario

+

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()
+                }
+            }
+        }
+    }
+}
+
+

Using Scenario

+

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:

+
    +
  1. Create a step in which we try to log in with the correct data
  2. +
  3. Call the `scenario` function
  4. +
  5. Pass the LoginScenario object as a parameter to this function
  6. +
  7. Pass the correct login and password to the LoginScenario constructor
  8. +
  9. Add a step in which we check that the `AfterLoginActivity` screen opens after login
  10. +
+ +
@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.

+

Best practices

+

Scenario is very handy if you use it correctly.

+
    +
  • If you have to follow the same steps to run different tests, then this is the case when it is worth creating a Scenario. Examples: screens for authorization, payment for purchases, etc.
  • +
  • You shouldn't use one Scenario inside another because this code can become very confusing, making it harder to read and reuse it, and in the end you lose all benefits of Scenarios.
  • +
  • Use Scenario only when needed. You should not create them just because sometime in the future these steps may be used in other tests. If you see that the steps are repeated in different tests, then you can create a `Scenario`, if not, you should not do this. Their number in the project should be minimal.
  • +
+ +

Summary

+

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.

+


+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Tutorial/Screenshot_tests_1/index.html b/en/Tutorial/Screenshot_tests_1/index.html new file mode 100644 index 000000000..007919cf7 --- /dev/null +++ b/en/Tutorial/Screenshot_tests_1/index.html @@ -0,0 +1,1310 @@ + + + + + + + + + + + + + + + + + + + + + + 13. Screenshot-tests. Part 1. Simple screenshot-test - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Screenshot-тесты. Часть 1. Простой screenshot тест

+

В этом уроке мы научимся писать screenshot-тесты, узнаем, зачем они нужны, и как правильно разрабатывать приложение, чтобы его можно было покрыть тестами.

+

Продвинутый уровень

+

Для успешного прохождения предыдущих уроков было достаточно базовых навыков программирования на Kotlin, знания Android разработки при этом не требовались, и успешно пройти все уроки могли как разработчики, так и тестировщики. Но для нашей сегодняшней темы, а также всех последующих, нужно понимание того, как разрабатываются приложения, чем отличаются архитектурные шаблоны MVVM и MVP, как применять Dependency Injection и другое.

+

Поэтому предполагается, что все дальнейшие действия (или бОльшая их часть), которые мы будем проходить в курсе, находятся в зоне ответственности разработчиков, и эти уроки ориентированы на них. Если же с Android разработкой вы не знакомы, то можете все равно проходить эти уроки, чтобы иметь представление о возможностях Kaspresso, но учитывайте тот факт, что часть материала может быть непонятной.

+

Тестирование LoginActivity на разных локалях

+

Чтобы узнать, зачем нужны скриншот-тесты, разберем небольшой пример. Представим, что наше приложение должно быть локализовано на французский язык. Для этого в проекте были добавлены переводы в файл strings.xml в папку values-fr.

+

French resources

+

Давайте установим на устройстве французский язык

+

Install french locale

+

и запустим LoginActivityTest.

+

Tests completed successfully

+

Тест пройден успешно, значит теоретически это приложение рабочее, и его можно раскатывать на пользователей. Но давайте откроем LoginActivity вручную (французский язык должен быть установлен на устройстве) и посмотрим, как выглядит этот экран.

+

Todo instead of strings

+

Видим, что вместо корректных текстов здесь указано «TODO: add french locale». Похоже, что разработчики во время добавления строк оставили комментарий, чтобы добавить переводы в будущем, но забыли это сделать, поэтому приложение выглядит некорректно. Обнаружить эту ошибку тесты не могут, потому что они не знают, какой должен быть текст на французском языке. По этой причине приложение работает неправильно, но тесты проходят успешно.

+

Screenshot-тесты, как решение проблемы со строками

+

Возникшую проблему могут решить скриншот-тесты. Их суть заключается в том, что для всех экранов, где пользователю отображаются строки, создаются так называемые «скриншотилки» – классы, которые делают скриншоты экрана во всех необходимых состояниях и для всех поддерживаемых языков.

+

После выполнения таких тестов скриншоты складываются в определенные папки. Тогда люди, ответственные за переводы и строки, смогут просмотреть снимки и убедиться, что для всех локалей и для всех состояний используются корректные значения.

+

Screenshot-тесты будут отличаться от тестов, которые мы писали ранее:

+

Во-первых, нас интересуют только строки на определенном экране, поэтому нет необходимости проходить весь процесс от старта приложения до открытия нужного экрана. Вместо этого, в тесте мы сразу будем открывать Activity или Fragment, скриншоты которого хотим получить.

+

Во-вторых, мы хотим получить снимки всех возможных состояний экрана для каждой локали, поэтому добавлять проверки элементов или выполнять шаги, имитирующие действия пользователя, как мы делали ранее, мы не будем. Наша цель –

+
    +
  1. Открыть экран
  2. +
  3. Установить нужное состояние
  4. +
  5. Сделать скриншот
  6. +
  7. При необходимости изменить состояние и снова сделать скриншот
  8. +
+ +

Дальше нужно поменять локаль и повторить все перечисленные действия.

+

Подробнее про состояния (или стейты, как их часто называют), и как их правильно устанавливать, мы поговорим позже, а сейчас напишем простой screenshot-тест, который откроет экран LoginActivity, сделает скриншот, затем сменит язык на устройстве на французский и снова сделает скриншот.

+

Простой screenshot-тест

+

Создание screenshot-теста начинается так же, как мы делали ранее – в папке тестов создаем новый класс. Классы для скриншотов обычно называются с окончанием Screenshots. Давайте все скриншот-тесты будем хранить в отдельном пакете, назовем его screenshot_tests.

+

В этом пакете создаем класс LoginActivityScreenshots

+

Creating screenshot test class

+

У тестов, которые мы сейчас будем создавать, есть особенности: во-первых, они должны запускаться для разных локалей, во-вторых, полученные скриншоты должны быть размещены в удобной структуре папок – для каждого языка своя папка. По этим причинам тестовый класс мы унаследуем от класса 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 вы сможете найти все скриншоты, при этом для каждой локали была создана своя папка и вы сможете просмотреть, как выглядит ваше приложение на разных языках.

+

Screenshot test results

+

Initial state en

+

Initial state fr

+

Теперь, просмотрев скриншоты, можно увидеть проблему в приложении, что не все строки были добавлены корректно, и разработчик может исправить ошибку, добавив необходимые значения в файл values-fr/strings.xml.

+
+

Info

+

Возможно, на некоторых устройствах при смене локали у вас возникнет проблема с заголовком экрана – весь контент на экране будет корректно переведен на необходимый язык, а заголовок останется прежним. Проблема связана с багом в библиотеке Google. Его уже пофиксили, как только опубликуют соответствующий релиз, внесем изменения в Kaspresso.

+
+

Итог

+

В данном уроке мы рассмотрели: зачем нужны скриншот-тесты, как их писать и где смотреть результаты выполнения тестов.

+

Тема screenshot-тестов довольно обширная, и для более комфортного освоения мы ее разбили на несколько частей. Для более углубленного изучения переходите к следующему уроку

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Tutorial/Screenshot_tests_2/index.html b/en/Tutorial/Screenshot_tests_2/index.html new file mode 100644 index 000000000..18ac9873e --- /dev/null +++ b/en/Tutorial/Screenshot_tests_2/index.html @@ -0,0 +1,1924 @@ + + + + + + + + + + + + + + + + + + + + + + 14. Screenshot-tests. Part 2. Working with ViewModel and setting states - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Screenshot-тесты. Часть 2. Установка стейтов и работа с ViewModel.

+

Если в вашем приложении планируется использование screenshot-тестов, то этот момент нужно учитывать не только при написании тестов, но также при разработке приложения. В сегодняшнем уроке мы поближе познакомимся с установкой стейтов, внесем изменения в код приложения, чтобы его можно было покрыть тестами, и напишем первый скриншот тест, в котором будет работа с ViewModel.

+

Предварительные знания

+

Если вы ранее не разрабатывали приложения под Android, то сегодняшний урок может быть сложным для понимания. Поэтому мы настоятельно рекомендуем перед прохождением данного урока ознакомиться со следующими темами:

+
    +
  1. Фрагменты – что это, и как с ними работать
  2. +
  3. ViewModel и шаблон проектирования MVVM
  4. +
  5. StateFlow
  6. +
  7. Библиотека Mockk
  8. +
+

Обзор тестируемого приложения

+

В приложении, которое мы сегодня будем покрывать тестами, имитируется загрузка данных. При клике на кнопку начинается загрузка, отображается прогресс бар, после окончания загрузки, в зависимости от результата, мы увидим либо загруженные данные, либо сообщение об ошибке.

+

Откройте приложение tutorial и кликнете по кнопке «Load User Activity»

+

Tutorial app

+

Давайте сразу начнем разбираться, что такое стейты. После перехода по кнопке у вас откроется экран, на котором располагается одна кнопка и больше ничего нет.

+

Initial state

+

При клике на данную кнопку внешний вид экрана изменится, то есть изменится его состояние или, как его часто называют, стейт. Сейчас мы видим исходный стейт экрана, назовем его Initial.

+

Кликнете по этой кнопке и обратите внимание, как изменится стейт экрана.

+

Progress state

+

Кнопка стала неактивной, то есть по ней больше нельзя кликать, и на экране появился Progress Bar, который показывает, что идет процесс загрузки. Этот стейт отличается от исходного, назовем этот стейт Progress.

+

Через несколько секунд загрузка будет завершена, и вы увидите на экране загруженные данные пользователя (его имя и фамилию).

+

Content state

+

Это третий стейт экрана. В таком состоянии кнопка снова становится активной, прогресс бар скрывается, и на экране отображаются имя и фамилия пользователя. Назовем такой стейт Content.

+

В данном случае мы имитируем загрузку данных, в реальных приложениях работа с интернетом может завершиться с ошибкой. Такие ошибки нужно каким-то образом обрабатывать, к примеру, показывать уведомление пользователю. В нашем тестовом приложении мы сымитировали подобное поведение. Если вы попытаетесь загрузить пользователя несколько раз, то какая-то из этих попыток завершится с ошибкой и вы увидите следующее состояние экрана:

+

Error state

+

Это четвертый и последний стейт экрана, который показывает состояние ошибки, назовем его Error.

+

Простой Screenshot-тест

+

Перед тем, как мы изучим правильный способ покрытия тестами данного экрана, давайте напишем тесты тем способом, который мы уже знаем. Это нужно для того, чтобы вы понимали, почему так делать не стоит, и зачем нам вносить изменения в уже написанный код.

+

В пакете screenshot_tests создаем класс LoadUserScreenshots

+

Create class

+

Наследуемся от DocLocScreenshotTestCase и передаем список языков в качестве параметра конструктору, сделаем скриншоты для английской и французской локалей

+

package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+
+class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+}
+
+Как мы говорили ранее – screenshot-тесты должны быть максимально легковесными, чтобы их прохождение занимало как можно меньше времени, поэтому вместо открытия главного экрана и перехода на экран загрузки данных пользователя, мы сразу будем открывать 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

+

Create page 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, и каждый из этих тестов будет зависеть от скорости интернет-соединения и от работоспособности сервера.

+

Также сервер может вернуть ошибку, когда мы ее не ожидали, в этом случае тест завершится неудачно.

+

На определенном этапе теста мы хотим сделать скриншот состояния ошибки. В данном случае мы одинаково реагируем на любой тип ошибки, показывая строчку «Что-то пошло не так», но часто бывают случаи, когда пользователю нужно сообщать о том, что конкретно произошло. Например, при отсутствии интернета, показать уведомление об этом, при ошибке на сервере показать соответствующий диалог и так далее. Поэтому в каких-то случаях для воспроизведения стейта с ошибкой будет достаточно отключить интернет на устройстве, а в каких-то это может стать проблемой, которую не так просто решить.

+

Так мы приходим к следующему выводу: получить все необходимые стейты, имитируя действия пользователя, для скриншот-тестов нецелесообразно.

+

Во-первых, это может сильно замедлить выполнение теста.

+

Во-вторых, такие тесты начинают зависеть от многих факторов, таких как скорость интернета и работоспособность сервера, следовательно вероятность того, что эти тесты завершатся неудачно, возрастает.

+

В-третьих, некоторые состояния экрана получить очень сложно, и этот процесс может занять длительное время

+

Взаимодействие View и ViewModel

+

По причинам, рассмотренным выше, в скриншот-тестах стейты устанавливают другим способом. Имитировать действия пользователя мы не будем.

+

На этом этапе важно понимать паттерн 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
+        }
+    }
+}
+
+View (в данном случае фрагмент 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

+

Давайте внутри тестового класса создадим объект ViewModel, у которого будем устанавливать стейт

+

class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+    val viewModel = LoadUserViewModel()
+
+
+}
+
+Теперь в эту ViewModel внутри тестового метода мы будем устанавливать новый стейт. Давайте попробуем установить какое-то новое значение в переменную 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()
+        }
+    }
+}
+
+В этой Activity почти нет кода. Дело в том, что в последнее время большинство приложений используют подход Single Activity. При таком подходе все экраны создаются на фрагментах, а активити служит лишь контейнером для них. Если вы хотите узнать больше о преимуществах этого подхода, то мы советуем почитать документацию. Что нужно понимать сейчас – внешний вид экрана и взаимодействие с ViewModel реализовано внутри 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, вызов этого метода 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 можно передать стиль, который должен использоваться внутри фрагмента, его можно найти в манифесте приложения

+

Style

+

Передать этот стиль в метод 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-тестами.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Tutorial/Steps_and_sections/index.html b/en/Tutorial/Steps_and_sections/index.html new file mode 100644 index 000000000..ec6635f09 --- /dev/null +++ b/en/Tutorial/Steps_and_sections/index.html @@ -0,0 +1,1621 @@ + + + + + + + + + + + + + + + + + + + + + + 6. Steps and sections - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Sections and steps

+

Improve the code

+

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

+

Logcat

+

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

+

Edit configuration

+

A filter creation window will open. Add the name of the filter and the tag that we are interested in:

+

Create filter

+

Now we can see only useful information. Let's clear the log

+

Clear logcat

+

and run the test again. Do not forget to turn on the Internet on the device before this. Read the logs:

+

Log step 1

+

Here are the logs we added: step 1 is run, then checks are done, then step 1 succeeds.

+

Looking further:

+

Log step 2

+

Log step 2

+

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.

+

Log step 2 failed

+

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.).

+

Steps

+

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:

+

Log with steps

+

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.

+

Test fail with steps

+

Now it becomes much easier to find an error in the test, thanks to understandable logs.

+

Before and After sections

+

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)
+                }
+            }
+        }
+    }
+}
+
+

Summary

+

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.

+


+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Tutorial/UiAutomator/index.html b/en/Tutorial/UiAutomator/index.html new file mode 100644 index 000000000..256c89621 --- /dev/null +++ b/en/Tutorial/UiAutomator/index.html @@ -0,0 +1,1685 @@ + + + + + + + + + + + + + + + + + + + + + + 8. Kautomator. Third Party Application Testing - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Kautomator. Third Party Application Testing

+

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.

+
    +
  1. Open Google Play
  2. +
  3. Check that there is a `Sign In` button on the screen
  4. +
+ +

Google play unauthorized

+

Do not forget to log out before starting the test.

+

Autotest for Google Play functionality

+

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.

+

Tools for working with other applications

+

UIAutomator

+

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:

+

UiAutomatorViewer first launch

+

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:

+

UiAutomatorViewer create screenshot

+

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:

+

UiAutomatorViewer button info

+

Here you can see some useful information:

+
    +
  1. Package is the name of the application package that we specified in the test. One way to find it out is through this program
  2. +
  3. Resource-id is the element id you can use to search for the button and interact with it from the test. In our case, it is not possible, because the id value indicates that the resource name has been obfuscated, that is, encrypted. Therefore, it is not possible to search for an element by id for this screen
  4. +
  5. Text - one way to find an element on the screen is by the text that is displayed on it. It turns out that we can find the button on this screen by the text attribute
  6. +
+ +

Developer Assistant

+

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:

+

Developer Assistant Settings

+

Developer Assistant Settings

+

Developer Assistant Settings

+

Developer Assistant Settings

+

Developer Assistant Settings

+

Developer Assistant Settings

+

Once configured, you can run application analysis. Open the Google Play app and long press the Home button:

+

Developer Assistant Google play

+

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.

+

Developer Assistant Google play

+

The Element tab allows you to explore the user interface elements.

+

Developer Assistant Google play

+

The Sign in button has all the same attributes that we saw in Ui Automator Viewer.

+

Dump

+

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
+
+

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:

+

Uiautomator Dump

+

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:

+

Uiautomator Dump

+

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.

+

Uiautomator Dump

+

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.

+

Uiautomator Dump

+

Writing a test

+

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.

+

UI Button

+

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:

+

Matchers

+

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.

+

Testing the system UI

+

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:

+

Notification Activity Button

+

Click on the “Show notification” button - a notification is displayed on top.

+
+

Info

+

You can read more about notifications in Android here.

+
+

Notification Shown

+

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:

+

Ui automator notification

+

Using the dump command, the necessary elements can be found as follows

+

Dump

+

Dump

+

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.

+

Ui automator package

+

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.

+

Summary

+

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.

+


+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Tutorial/Wifi_sample_test/index.html b/en/Tutorial/Wifi_sample_test/index.html new file mode 100644 index 000000000..baa165aad --- /dev/null +++ b/en/Tutorial/Wifi_sample_test/index.html @@ -0,0 +1,1352 @@ + + + + + + + + + + + + + + + + + + + + + + 5. Testing the Internet connection and working with the Device class - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Testing the Internet connection and working with the Device class

+

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

+

Button Internet Availability

+

Manual testing

+

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.

+

Launch Wifi Test Activity

+

Launch Wifi Test Activity

+

Let's click on the button.

+

Wifi enabled

+

This button is clickable, after clicking, the correct Wifi state status is displayed - enabled. Disable WiFi.

+

Turn-off wifi

+

Click on the button again and check the Wifi status now:

+

Wifi disabled

+

The state is determined correctly. One last check - let's flip the device over and make sure the text on the screen is preserved.

+

Wifi disabled landscape

+

The text is saved successfully, all tests passed. Now we need to achieve the same result with all the checks performed automatically.

+

Writing autotests

+

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:

+
    +
  1. Add an activityRule so that when the test starts, we open MainActivity
  2. +
  3. Check that the button to go to the Internet check screen is visible and clickable
  4. +
  5. Click on the "Internet Availability" button
  6. +
+ +
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.

+

Available methods

+

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:

+
    +
  1. Check if the button is visible and clickable
  2. +
  3. Check that the title contains no text
  4. +
  5. Click on the button
  6. +
  7. Checking that the title text is "enabled"
  8. +
  9. Disable Wifi
  10. +
  11. Click on the button
  12. +
  13. Checking that the title text is "disabled"
  14. +
+ +
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:

+
    +
  1. Set device to portrait orientation
  2. +
  3. Check that the button is visible and clickable
  4. +
  5. Check that the title does not contain text
  6. +
  7. Click on the button
  8. +
  9. Check that the title text is "enabled"
  10. +
  11. Disable Wifi
  12. +
  13. Click on the button
  14. +
  15. Check that the title text is "disabled"
  16. +
  17. Flip the device
  18. +
  19. Check that the title text is still "disabled"
  20. +
+ +

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)
+        }
+    }
+}
+
+Let's launch the test. It passed successfully.

+

Summary

+

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:

+
    +
  • The test is not broken into steps. As a result, we have a large canvas of code, which is quite difficult to understand
  • +
  • The test only succeeds if we have previously enabled internet on the device. At the same time, at each next start, the test will fall due to the fact that Wifi is turned off inside it
  • +
+ +

In the following lessons, we will learn how we can improve this code and solve the problems that have arisen.

+


+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Tutorial/Working_with_adb/index.html b/en/Tutorial/Working_with_adb/index.html new file mode 100644 index 000000000..ba0a39a51 --- /dev/null +++ b/en/Tutorial/Working_with_adb/index.html @@ -0,0 +1,1377 @@ + + + + + + + + + + + + + + + + + + + + + + 4. Working with adb - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Working with adb

+

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:

+
    +
  • When there is no Internet
  • +
  • During an incoming call
  • +
  • With a low battery
  • +
  • When changing device orientation
  • +
  • Etc.
  • +
+ +

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.

+

Check java and adb

+

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.

+

Open cmd on windows 1

+

Open cmd on windows 2

+

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.

+

Java version showed

+

If the paths are written incorrectly, you will see something similar to this:

+

Java version failed

+

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.

+

Adb version success

+

Otherwise, you will see something like this error:

+

Adb version failed

+

If everything works for you on both points, then you can skip the next step.

+

Setting up java and adb

+

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.

+

Show jsdk path in android studio

+

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.

+

Show system variables

+

In the System Variables section, select Path and click Edit -> New -> Paste the copied path to the folder with java -> Click OK.

+

Java bin path

+

Restart the computer for the changes to take effect and check the java -version command again.

+

Java version success

+

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.

+

Adb path

+

Restart the computer and check the adb version command.

+

Adb version success

+

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.

+

Try different commands

+

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.

+

Empty devices list

+

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.

+

Devices list

+

Now our emulator is displayed in the list of devices.

+

With adb commands we can:

+
    +
  • Reboot device
  • +
  • Install an application
  • +
  • Remove an application
  • +
  • Upload files from/to a phone
  • +
  • etc.
  • +
+ +

For practice, let's remove the tutorial app we just launched. This is done with the command adb uninstall package_name.

+

Uninstall app

+

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.

+

Open shell console

+

Here are some examples of such commands.

+

Getting a list of all installed applications pm list packages.

+

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.

+

Exit shell console

+

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.

+

Device file explorer

+

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.

+

Create screenshot

+

In Device File Explorer, right-click and press Synchronize, after which the screenshot we created will be displayed in the folder.

+

Success screenshot

+

Working with adb in autotests

+

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.

+

Drag server

+

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.

+

Launch Server

+

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.

+
    +
  • `performAdb` executes an adb command
  • +
  • `performShell` executes a shell command
  • +
  • `performCmd` executes a command line
  • +
+ +

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:

+
    +
  1. adb-server is running
  2. +
  3. the application you are testing must have permission to use the Internet in the manifest
  4. +
+ +

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.

+

Manifest Location

+

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

+
+

Undefined command

+

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:

+

Hostname

+

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())
+    }
+}
+
+
+

+

Summary

+

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.

+


+

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Tutorial/Writing_simple_test/index.html b/en/Tutorial/Writing_simple_test/index.html new file mode 100644 index 000000000..f6dc0f334 --- /dev/null +++ b/en/Tutorial/Writing_simple_test/index.html @@ -0,0 +1,1552 @@ + + + + + + + + + + + + + + + + + + + + + + 3. Writing your first Kaspresso test - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Writing your first Kaspresso test

+

Switch to the desired branch in GIT

+

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.

+

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.

+

Switch to results

+

Manual testing

+

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):

+

Select tutorial

+

Check that the desired device is selected (1) and run the application (2):

+

Launch tutorial

+

After successfully launching the application, we will see the main screen of the Tutorial application.

+

Tutorial main

+

Click on the button with the text "Simple test" and see the following screen:

+

Page object example

+

The screen consists of:

+
    +
  1. +

    Header TextView

    +
  2. +
  3. +

    EditText input fields

    +
  4. +
  5. +

    Buttons

    +
  6. +
+
+

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.

+

Automatic testing

+

We manually checked that the result of the application meets the expectations:

+
    +
  1. On the main screen there is a button to go to the `SimpleTest` screen (the rest of the elements on this screen do not interest us for now)
  2. +
  3. This button is visible
  4. +
  5. This button is clickable
  6. +
  7. Clicking on it takes us to the SimpleTest screen
  8. +
  9. `SimpleTest` screen has three UI elements: title, input field and button
  10. +
  11. All these elements are visible
  12. +
  13. Header contains default text
  14. +
  15. If you enter some text in the input field and click on the button, then the text in the title changes to the entered one
  16. +
+ +

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.

+

Including Kaspresso in the project

+

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:

+

Tutorial build gradle

+
dependencies {
+    androidTestImplementation("com.kaspersky.android-components:kaspresso:1.5.1")
+    androidTestUtil("androidx.test:orchestrator:1.4.2")
+}
+
+

Let's start writing the test by creating a Page object for the current screen

+

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.

+

Create directory

+

Select the item androidTest/kotlin:

+

Name directory androidTest

+

Inside the kotlin folder, let's create a separate package in which we will store all Page Objects:

+

Create package

+

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.

+

MainActivity Package name

+

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.

+

Screen Package name

+

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):

+

Create class

+

Choose the type Object and name it MainScreen.

+

Create 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.

+

Override methods

+

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.

+

Override methods

+

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.

+
    +
  1. Launch the application
  2. +
  3. In the bottom right corner of Android Studio select Layout Inspector Find bottom layout inspector
  4. +
  5. Wait for the screen to load Layout inspector loaded
  6. +
  7. If the screen does not load, then check that you have the desired process selected Choose process
  8. +
+ +

Select an item on the screen and look for its id. This is the identifier that interests us.

+

Search for button id

+

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.

+

Find layout

+

In this case, it's a Button element with id simple_activity_btn

+

Find button in layout

+

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.

+

Find source of KButton

+

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.

+

Find kbaseview children

+

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.

+

Show children which we need

+

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.

+

Add SimpleActivityTest

+

In the folder androidTest -> kotlin, in the package we created, add the class SimpleActivityTest.

+

Creating Test First part

+

Creating Test Second part

+

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

+

Move to another package

+

And remove the last part .screen from the package name.

+

Change 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.

+

Success passed test

+

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.

+

Feailed test

+

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.

+

Success test

+

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:

+

Title id in inspector

+

Input id in inspector

+

Button id in 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.

+

Find string in layout

+

Go to string resources (file values/strings.xml) and copy the string id.

+

Find string in values folder

+

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")
+
+        }
+    }
+}
+
+

Summary

+

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.

+


+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Create_device.png b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Create_device.png new file mode 100644 index 000000000..309b069b7 Binary files /dev/null and b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Create_device.png differ diff --git a/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Device_name.png b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Device_name.png new file mode 100644 index 000000000..866006c1a Binary files /dev/null and b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Device_name.png differ diff --git a/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Github_download_button.png b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Github_download_button.png new file mode 100644 index 000000000..23b23e197 Binary files /dev/null and b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Github_download_button.png differ diff --git a/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Github_download_zip.png b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Github_download_zip.png new file mode 100644 index 000000000..ba3a8042d Binary files /dev/null and b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Github_download_zip.png differ diff --git a/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Hyper_Visor.png b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Hyper_Visor.png new file mode 100644 index 000000000..ddb5363fe Binary files /dev/null and b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Hyper_Visor.png differ diff --git a/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Hyper_Visor_next.png b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Hyper_Visor_next.png new file mode 100644 index 000000000..ef549b800 Binary files /dev/null and b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Hyper_Visor_next.png differ diff --git a/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Launch_device.png b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Launch_device.png new file mode 100644 index 000000000..6afa04120 Binary files /dev/null and b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Launch_device.png differ diff --git a/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_installer_finish.png b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_installer_finish.png new file mode 100644 index 000000000..7401966ed Binary files /dev/null and b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_installer_finish.png differ diff --git a/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_installer_next.png b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_installer_next.png new file mode 100644 index 000000000..8a8f5c610 Binary files /dev/null and b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_installer_next.png differ diff --git a/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_isntaller.png b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_isntaller.png new file mode 100644 index 000000000..4fef5bb70 Binary files /dev/null and b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_isntaller.png differ diff --git a/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Select_hardware.png b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Select_hardware.png new file mode 100644 index 000000000..2e43d988e Binary files /dev/null and b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Select_hardware.png differ diff --git a/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/System_Image.png b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/System_Image.png new file mode 100644 index 000000000..20616ba4e Binary files /dev/null and b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/System_Image.png differ diff --git a/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Tools_Device_Manager.png b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Tools_Device_Manager.png new file mode 100644 index 000000000..d0b82bc56 Binary files /dev/null and b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Tools_Device_Manager.png differ diff --git a/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/clone_project.png b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/clone_project.png new file mode 100644 index 000000000..e7f2dbe2d Binary files /dev/null and b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/clone_project.png differ diff --git a/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/download_by_git.png b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/download_by_git.png new file mode 100644 index 000000000..071912c0b Binary files /dev/null and b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/download_by_git.png differ diff --git a/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/get_from_vcs.png b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/get_from_vcs.png new file mode 100644 index 000000000..cbf3813ba Binary files /dev/null and b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/get_from_vcs.png differ diff --git a/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/new_project_from_vcs.png b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/new_project_from_vcs.png new file mode 100644 index 000000000..ad6935fa7 Binary files /dev/null and b/en/Tutorial/images/Download_Kaspresso_project_and_Android_studio/new_project_from_vcs.png differ diff --git a/en/Tutorial/images/Running_the_first_test/device_select.png b/en/Tutorial/images/Running_the_first_test/device_select.png new file mode 100644 index 000000000..d8a50d26f Binary files /dev/null and b/en/Tutorial/images/Running_the_first_test/device_select.png differ diff --git a/en/Tutorial/images/Running_the_first_test/launch_test.png b/en/Tutorial/images/Running_the_first_test/launch_test.png new file mode 100644 index 000000000..92ec54b26 Binary files /dev/null and b/en/Tutorial/images/Running_the_first_test/launch_test.png differ diff --git a/en/Tutorial/images/Running_the_first_test/logcat.png b/en/Tutorial/images/Running_the_first_test/logcat.png new file mode 100644 index 000000000..e618856b8 Binary files /dev/null and b/en/Tutorial/images/Running_the_first_test/logcat.png differ diff --git a/en/Tutorial/images/Running_the_first_test/logcat_search.png b/en/Tutorial/images/Running_the_first_test/logcat_search.png new file mode 100644 index 000000000..23712adae Binary files /dev/null and b/en/Tutorial/images/Running_the_first_test/logcat_search.png differ diff --git a/en/Tutorial/images/Running_the_first_test/run_application.png b/en/Tutorial/images/Running_the_first_test/run_application.png new file mode 100644 index 000000000..83adc4281 Binary files /dev/null and b/en/Tutorial/images/Running_the_first_test/run_application.png differ diff --git a/en/Tutorial/images/Running_the_first_test/run_simple_test.png b/en/Tutorial/images/Running_the_first_test/run_simple_test.png new file mode 100644 index 000000000..6e33e7bae Binary files /dev/null and b/en/Tutorial/images/Running_the_first_test/run_simple_test.png differ diff --git a/en/Tutorial/images/Running_the_first_test/run_simple_test_1.png b/en/Tutorial/images/Running_the_first_test/run_simple_test_1.png new file mode 100644 index 000000000..e33f7bbaa Binary files /dev/null and b/en/Tutorial/images/Running_the_first_test/run_simple_test_1.png differ diff --git a/en/Tutorial/images/Running_the_first_test/run_simple_test_2.png b/en/Tutorial/images/Running_the_first_test/run_simple_test_2.png new file mode 100644 index 000000000..e33778e10 Binary files /dev/null and b/en/Tutorial/images/Running_the_first_test/run_simple_test_2.png differ diff --git a/en/Tutorial/images/Running_the_first_test/test_result.png b/en/Tutorial/images/Running_the_first_test/test_result.png new file mode 100644 index 000000000..2efd131e6 Binary files /dev/null and b/en/Tutorial/images/Running_the_first_test/test_result.png differ diff --git a/en/Tutorial/images/adb_lesson/adb_path.png b/en/Tutorial/images/adb_lesson/adb_path.png new file mode 100644 index 000000000..87f94c3f5 Binary files /dev/null and b/en/Tutorial/images/adb_lesson/adb_path.png differ diff --git a/en/Tutorial/images/adb_lesson/adb_version_failed.png b/en/Tutorial/images/adb_lesson/adb_version_failed.png new file mode 100644 index 000000000..05c8fe45f Binary files /dev/null and b/en/Tutorial/images/adb_lesson/adb_version_failed.png differ diff --git a/en/Tutorial/images/adb_lesson/adb_version_success.png b/en/Tutorial/images/adb_lesson/adb_version_success.png new file mode 100644 index 000000000..fc7fe75af Binary files /dev/null and b/en/Tutorial/images/adb_lesson/adb_version_success.png differ diff --git a/en/Tutorial/images/adb_lesson/bin_path.png b/en/Tutorial/images/adb_lesson/bin_path.png new file mode 100644 index 000000000..26bf43fff Binary files /dev/null and b/en/Tutorial/images/adb_lesson/bin_path.png differ diff --git a/en/Tutorial/images/adb_lesson/create_screenshot.png b/en/Tutorial/images/adb_lesson/create_screenshot.png new file mode 100644 index 000000000..8dbd6e2cd Binary files /dev/null and b/en/Tutorial/images/adb_lesson/create_screenshot.png differ diff --git a/en/Tutorial/images/adb_lesson/device_file_explorer.png b/en/Tutorial/images/adb_lesson/device_file_explorer.png new file mode 100644 index 000000000..cb961010f Binary files /dev/null and b/en/Tutorial/images/adb_lesson/device_file_explorer.png differ diff --git a/en/Tutorial/images/adb_lesson/devices_list.png b/en/Tutorial/images/adb_lesson/devices_list.png new file mode 100644 index 000000000..101eff51c Binary files /dev/null and b/en/Tutorial/images/adb_lesson/devices_list.png differ diff --git a/en/Tutorial/images/adb_lesson/drag_server.png b/en/Tutorial/images/adb_lesson/drag_server.png new file mode 100644 index 000000000..453063cbb Binary files /dev/null and b/en/Tutorial/images/adb_lesson/drag_server.png differ diff --git a/en/Tutorial/images/adb_lesson/empty_devices_list.png b/en/Tutorial/images/adb_lesson/empty_devices_list.png new file mode 100644 index 000000000..d2a4fc418 Binary files /dev/null and b/en/Tutorial/images/adb_lesson/empty_devices_list.png differ diff --git a/en/Tutorial/images/adb_lesson/exit_shell_console.png b/en/Tutorial/images/adb_lesson/exit_shell_console.png new file mode 100644 index 000000000..0a9c478d4 Binary files /dev/null and b/en/Tutorial/images/adb_lesson/exit_shell_console.png differ diff --git a/en/Tutorial/images/adb_lesson/hostname.png b/en/Tutorial/images/adb_lesson/hostname.png new file mode 100644 index 000000000..fadf4bdbe Binary files /dev/null and b/en/Tutorial/images/adb_lesson/hostname.png differ diff --git a/en/Tutorial/images/adb_lesson/java_version_failed.png b/en/Tutorial/images/adb_lesson/java_version_failed.png new file mode 100644 index 000000000..9e97cf235 Binary files /dev/null and b/en/Tutorial/images/adb_lesson/java_version_failed.png differ diff --git a/en/Tutorial/images/adb_lesson/java_version_success.png b/en/Tutorial/images/adb_lesson/java_version_success.png new file mode 100644 index 000000000..e5c8d1482 Binary files /dev/null and b/en/Tutorial/images/adb_lesson/java_version_success.png differ diff --git a/en/Tutorial/images/adb_lesson/jdk_in_android_studio.png b/en/Tutorial/images/adb_lesson/jdk_in_android_studio.png new file mode 100644 index 000000000..ab34f2798 Binary files /dev/null and b/en/Tutorial/images/adb_lesson/jdk_in_android_studio.png differ diff --git a/en/Tutorial/images/adb_lesson/launch_server.png b/en/Tutorial/images/adb_lesson/launch_server.png new file mode 100644 index 000000000..c0639bb8e Binary files /dev/null and b/en/Tutorial/images/adb_lesson/launch_server.png differ diff --git a/en/Tutorial/images/adb_lesson/list_packages.png b/en/Tutorial/images/adb_lesson/list_packages.png new file mode 100644 index 000000000..f10ae6bda Binary files /dev/null and b/en/Tutorial/images/adb_lesson/list_packages.png differ diff --git a/en/Tutorial/images/adb_lesson/manifest_location.png b/en/Tutorial/images/adb_lesson/manifest_location.png new file mode 100644 index 000000000..f68d5d998 Binary files /dev/null and b/en/Tutorial/images/adb_lesson/manifest_location.png differ diff --git a/en/Tutorial/images/adb_lesson/open_shell_console.png b/en/Tutorial/images/adb_lesson/open_shell_console.png new file mode 100644 index 000000000..d61a7c5f1 Binary files /dev/null and b/en/Tutorial/images/adb_lesson/open_shell_console.png differ diff --git a/en/Tutorial/images/adb_lesson/success_screen.png b/en/Tutorial/images/adb_lesson/success_screen.png new file mode 100644 index 000000000..3670fde11 Binary files /dev/null and b/en/Tutorial/images/adb_lesson/success_screen.png differ diff --git a/en/Tutorial/images/adb_lesson/system_variables.png b/en/Tutorial/images/adb_lesson/system_variables.png new file mode 100644 index 000000000..dff868aa5 Binary files /dev/null and b/en/Tutorial/images/adb_lesson/system_variables.png differ diff --git a/en/Tutorial/images/adb_lesson/undefined_command.png b/en/Tutorial/images/adb_lesson/undefined_command.png new file mode 100644 index 000000000..53cbc26ee Binary files /dev/null and b/en/Tutorial/images/adb_lesson/undefined_command.png differ diff --git a/en/Tutorial/images/adb_lesson/uninstall_app.png b/en/Tutorial/images/adb_lesson/uninstall_app.png new file mode 100644 index 000000000..75dabcbf7 Binary files /dev/null and b/en/Tutorial/images/adb_lesson/uninstall_app.png differ diff --git a/en/Tutorial/images/adb_lesson/windows_cmd_open_1.png b/en/Tutorial/images/adb_lesson/windows_cmd_open_1.png new file mode 100644 index 000000000..0aa81e12c Binary files /dev/null and b/en/Tutorial/images/adb_lesson/windows_cmd_open_1.png differ diff --git a/en/Tutorial/images/adb_lesson/windows_cmd_open_2.png b/en/Tutorial/images/adb_lesson/windows_cmd_open_2.png new file mode 100644 index 000000000..39c88f381 Binary files /dev/null and b/en/Tutorial/images/adb_lesson/windows_cmd_open_2.png differ diff --git a/en/Tutorial/images/flaky/flaky_1.png b/en/Tutorial/images/flaky/flaky_1.png new file mode 100644 index 000000000..087ccff96 Binary files /dev/null and b/en/Tutorial/images/flaky/flaky_1.png differ diff --git a/en/Tutorial/images/flaky/flaky_2.png b/en/Tutorial/images/flaky/flaky_2.png new file mode 100644 index 000000000..d87e8cb81 Binary files /dev/null and b/en/Tutorial/images/flaky/flaky_2.png differ diff --git a/en/Tutorial/images/flaky/flaky_3.png b/en/Tutorial/images/flaky/flaky_3.png new file mode 100644 index 000000000..be54c0962 Binary files /dev/null and b/en/Tutorial/images/flaky/flaky_3.png differ diff --git a/en/Tutorial/images/flaky/flaky_4.png b/en/Tutorial/images/flaky/flaky_4.png new file mode 100644 index 000000000..be2d0bcb7 Binary files /dev/null and b/en/Tutorial/images/flaky/flaky_4.png differ diff --git a/en/Tutorial/images/flaky/flaky_activity_btn.png b/en/Tutorial/images/flaky/flaky_activity_btn.png new file mode 100644 index 000000000..84a4cac9e Binary files /dev/null and b/en/Tutorial/images/flaky/flaky_activity_btn.png differ diff --git a/en/Tutorial/images/logs/advanced_builder.png b/en/Tutorial/images/logs/advanced_builder.png new file mode 100644 index 000000000..9a13e2583 Binary files /dev/null and b/en/Tutorial/images/logs/advanced_builder.png differ diff --git a/en/Tutorial/images/logs/after_auth.png b/en/Tutorial/images/logs/after_auth.png new file mode 100644 index 000000000..ebbdb7aff Binary files /dev/null and b/en/Tutorial/images/logs/after_auth.png differ diff --git a/en/Tutorial/images/logs/create_class.png b/en/Tutorial/images/logs/create_class.png new file mode 100644 index 000000000..1c634304f Binary files /dev/null and b/en/Tutorial/images/logs/create_class.png differ diff --git a/en/Tutorial/images/logs/create_package.png b/en/Tutorial/images/logs/create_package.png new file mode 100644 index 000000000..0303b1e68 Binary files /dev/null and b/en/Tutorial/images/logs/create_package.png differ diff --git a/en/Tutorial/images/logs/create_package_2.png b/en/Tutorial/images/logs/create_package_2.png new file mode 100644 index 000000000..ea4f0014e Binary files /dev/null and b/en/Tutorial/images/logs/create_package_2.png differ diff --git a/en/Tutorial/images/logs/custom_log.png b/en/Tutorial/images/logs/custom_log.png new file mode 100644 index 000000000..e1884366b Binary files /dev/null and b/en/Tutorial/images/logs/custom_log.png differ diff --git a/en/Tutorial/images/logs/custom_log_test.png b/en/Tutorial/images/logs/custom_log_test.png new file mode 100644 index 000000000..b39961d40 Binary files /dev/null and b/en/Tutorial/images/logs/custom_log_test.png differ diff --git a/en/Tutorial/images/logs/customized_builder.png b/en/Tutorial/images/logs/customized_builder.png new file mode 100644 index 000000000..9df6b88d0 Binary files /dev/null and b/en/Tutorial/images/logs/customized_builder.png differ diff --git a/en/Tutorial/images/logs/kaspresso_test_tag.png b/en/Tutorial/images/logs/kaspresso_test_tag.png new file mode 100644 index 000000000..647b11e55 Binary files /dev/null and b/en/Tutorial/images/logs/kaspresso_test_tag.png differ diff --git a/en/Tutorial/images/logs/logcat.png b/en/Tutorial/images/logs/logcat.png new file mode 100644 index 000000000..75fb9ab71 Binary files /dev/null and b/en/Tutorial/images/logs/logcat.png differ diff --git a/en/Tutorial/images/logs/login_activity.png b/en/Tutorial/images/logs/login_activity.png new file mode 100644 index 000000000..b0539938d Binary files /dev/null and b/en/Tutorial/images/logs/login_activity.png differ diff --git a/en/Tutorial/images/logs/main_screen.png b/en/Tutorial/images/logs/main_screen.png new file mode 100644 index 000000000..aaeda9dde Binary files /dev/null and b/en/Tutorial/images/logs/main_screen.png differ diff --git a/en/Tutorial/images/logs/screenshots.png b/en/Tutorial/images/logs/screenshots.png new file mode 100644 index 000000000..8ae6e61aa Binary files /dev/null and b/en/Tutorial/images/logs/screenshots.png differ diff --git a/en/Tutorial/images/logs/setup_password.png b/en/Tutorial/images/logs/setup_password.png new file mode 100644 index 000000000..1170a163a Binary files /dev/null and b/en/Tutorial/images/logs/setup_password.png differ diff --git a/en/Tutorial/images/logs/test_case_params.png b/en/Tutorial/images/logs/test_case_params.png new file mode 100644 index 000000000..5aad51445 Binary files /dev/null and b/en/Tutorial/images/logs/test_case_params.png differ diff --git a/en/Tutorial/images/logs/test_failed_1.png b/en/Tutorial/images/logs/test_failed_1.png new file mode 100644 index 000000000..ee8d1e1a5 Binary files /dev/null and b/en/Tutorial/images/logs/test_failed_1.png differ diff --git a/en/Tutorial/images/permissions/call_1.png b/en/Tutorial/images/permissions/call_1.png new file mode 100644 index 000000000..ad820d5db Binary files /dev/null and b/en/Tutorial/images/permissions/call_1.png differ diff --git a/en/Tutorial/images/permissions/deny_permission_settings.png b/en/Tutorial/images/permissions/deny_permission_settings.png new file mode 100644 index 000000000..8eae4e90a Binary files /dev/null and b/en/Tutorial/images/permissions/deny_permission_settings.png differ diff --git a/en/Tutorial/images/permissions/device_perm_methods.png b/en/Tutorial/images/permissions/device_perm_methods.png new file mode 100644 index 000000000..82cfa04ea Binary files /dev/null and b/en/Tutorial/images/permissions/device_perm_methods.png differ diff --git a/en/Tutorial/images/permissions/main_screen.png b/en/Tutorial/images/permissions/main_screen.png new file mode 100644 index 000000000..d562478ad Binary files /dev/null and b/en/Tutorial/images/permissions/main_screen.png differ diff --git a/en/Tutorial/images/permissions/make_call_screen.png b/en/Tutorial/images/permissions/make_call_screen.png new file mode 100644 index 000000000..e5dcca234 Binary files /dev/null and b/en/Tutorial/images/permissions/make_call_screen.png differ diff --git a/en/Tutorial/images/permissions/rename.png b/en/Tutorial/images/permissions/rename.png new file mode 100644 index 000000000..dea224268 Binary files /dev/null and b/en/Tutorial/images/permissions/rename.png differ diff --git a/en/Tutorial/images/permissions/rename_2.png b/en/Tutorial/images/permissions/rename_2.png new file mode 100644 index 000000000..b57a3c222 Binary files /dev/null and b/en/Tutorial/images/permissions/rename_2.png differ diff --git a/en/Tutorial/images/permissions/request_permission_1.png b/en/Tutorial/images/permissions/request_permission_1.png new file mode 100644 index 000000000..dce36a02d Binary files /dev/null and b/en/Tutorial/images/permissions/request_permission_1.png differ diff --git a/en/Tutorial/images/recycler_view/layout_inspector.png b/en/Tutorial/images/recycler_view/layout_inspector.png new file mode 100644 index 000000000..fea71e7bc Binary files /dev/null and b/en/Tutorial/images/recycler_view/layout_inspector.png differ diff --git a/en/Tutorial/images/recycler_view/main_screen.png b/en/Tutorial/images/recycler_view/main_screen.png new file mode 100644 index 000000000..9b48062ec Binary files /dev/null and b/en/Tutorial/images/recycler_view/main_screen.png differ diff --git a/en/Tutorial/images/recycler_view/removed.png b/en/Tutorial/images/recycler_view/removed.png new file mode 100644 index 000000000..fade38d9b Binary files /dev/null and b/en/Tutorial/images/recycler_view/removed.png differ diff --git a/en/Tutorial/images/recycler_view/swiped.png b/en/Tutorial/images/recycler_view/swiped.png new file mode 100644 index 000000000..0c6578c6c Binary files /dev/null and b/en/Tutorial/images/recycler_view/swiped.png differ diff --git a/en/Tutorial/images/recycler_view/todo_list.png b/en/Tutorial/images/recycler_view/todo_list.png new file mode 100644 index 000000000..b29cee6a4 Binary files /dev/null and b/en/Tutorial/images/recycler_view/todo_list.png differ diff --git a/en/Tutorial/images/scenario/login_activity.png b/en/Tutorial/images/scenario/login_activity.png new file mode 100644 index 000000000..b84ef1757 Binary files /dev/null and b/en/Tutorial/images/scenario/login_activity.png differ diff --git a/en/Tutorial/images/scenario/main_screen_login_button.png b/en/Tutorial/images/scenario/main_screen_login_button.png new file mode 100644 index 000000000..f6f0b00ba Binary files /dev/null and b/en/Tutorial/images/scenario/main_screen_login_button.png differ diff --git a/en/Tutorial/images/scenario/screen_after_login.png b/en/Tutorial/images/scenario/screen_after_login.png new file mode 100644 index 000000000..b7f7351be Binary files /dev/null and b/en/Tutorial/images/scenario/screen_after_login.png differ diff --git a/en/Tutorial/images/screenshot_tests_1/Initial_state_en.png b/en/Tutorial/images/screenshot_tests_1/Initial_state_en.png new file mode 100644 index 000000000..040e39892 Binary files /dev/null and b/en/Tutorial/images/screenshot_tests_1/Initial_state_en.png differ diff --git a/en/Tutorial/images/screenshot_tests_1/Initial_state_fr.png b/en/Tutorial/images/screenshot_tests_1/Initial_state_fr.png new file mode 100644 index 000000000..f8dbbca32 Binary files /dev/null and b/en/Tutorial/images/screenshot_tests_1/Initial_state_fr.png differ diff --git a/en/Tutorial/images/screenshot_tests_1/create_screenshot_test.png b/en/Tutorial/images/screenshot_tests_1/create_screenshot_test.png new file mode 100644 index 000000000..b70bb2510 Binary files /dev/null and b/en/Tutorial/images/screenshot_tests_1/create_screenshot_test.png differ diff --git a/en/Tutorial/images/screenshot_tests_1/fr_locale.png b/en/Tutorial/images/screenshot_tests_1/fr_locale.png new file mode 100644 index 000000000..59fcd3b91 Binary files /dev/null and b/en/Tutorial/images/screenshot_tests_1/fr_locale.png differ diff --git a/en/Tutorial/images/screenshot_tests_1/french.png b/en/Tutorial/images/screenshot_tests_1/french.png new file mode 100644 index 000000000..965c22eed Binary files /dev/null and b/en/Tutorial/images/screenshot_tests_1/french.png differ diff --git a/en/Tutorial/images/screenshot_tests_1/initial_en.png b/en/Tutorial/images/screenshot_tests_1/initial_en.png new file mode 100644 index 000000000..040e39892 Binary files /dev/null and b/en/Tutorial/images/screenshot_tests_1/initial_en.png differ diff --git a/en/Tutorial/images/screenshot_tests_1/initial_fr.png b/en/Tutorial/images/screenshot_tests_1/initial_fr.png new file mode 100644 index 000000000..f8dbbca32 Binary files /dev/null and b/en/Tutorial/images/screenshot_tests_1/initial_fr.png differ diff --git a/en/Tutorial/images/screenshot_tests_1/screenshot_test.png b/en/Tutorial/images/screenshot_tests_1/screenshot_test.png new file mode 100644 index 000000000..fafde0c64 Binary files /dev/null and b/en/Tutorial/images/screenshot_tests_1/screenshot_test.png differ diff --git a/en/Tutorial/images/screenshot_tests_1/success_tests.png b/en/Tutorial/images/screenshot_tests_1/success_tests.png new file mode 100644 index 000000000..f9956a3bc Binary files /dev/null and b/en/Tutorial/images/screenshot_tests_1/success_tests.png differ diff --git a/en/Tutorial/images/screenshot_tests_1/todo_on_screen.png b/en/Tutorial/images/screenshot_tests_1/todo_on_screen.png new file mode 100644 index 000000000..9d30def3f Binary files /dev/null and b/en/Tutorial/images/screenshot_tests_1/todo_on_screen.png differ diff --git a/en/Tutorial/images/screenshot_tests_2/create_class.png b/en/Tutorial/images/screenshot_tests_2/create_class.png new file mode 100644 index 000000000..b6eebf742 Binary files /dev/null and b/en/Tutorial/images/screenshot_tests_2/create_class.png differ diff --git a/en/Tutorial/images/screenshot_tests_2/example_1.png b/en/Tutorial/images/screenshot_tests_2/example_1.png new file mode 100644 index 000000000..ab5f4aa77 Binary files /dev/null and b/en/Tutorial/images/screenshot_tests_2/example_1.png differ diff --git a/en/Tutorial/images/screenshot_tests_2/example_2.png b/en/Tutorial/images/screenshot_tests_2/example_2.png new file mode 100644 index 000000000..b26032132 Binary files /dev/null and b/en/Tutorial/images/screenshot_tests_2/example_2.png differ diff --git a/en/Tutorial/images/screenshot_tests_2/example_3.png b/en/Tutorial/images/screenshot_tests_2/example_3.png new file mode 100644 index 000000000..0bf3ba1d1 Binary files /dev/null and b/en/Tutorial/images/screenshot_tests_2/example_3.png differ diff --git a/en/Tutorial/images/screenshot_tests_2/example_4.png b/en/Tutorial/images/screenshot_tests_2/example_4.png new file mode 100644 index 000000000..1dbf30df4 Binary files /dev/null and b/en/Tutorial/images/screenshot_tests_2/example_4.png differ diff --git a/en/Tutorial/images/screenshot_tests_2/example_5.png b/en/Tutorial/images/screenshot_tests_2/example_5.png new file mode 100644 index 000000000..46106b5e0 Binary files /dev/null and b/en/Tutorial/images/screenshot_tests_2/example_5.png differ diff --git a/en/Tutorial/images/screenshot_tests_2/page_object.png b/en/Tutorial/images/screenshot_tests_2/page_object.png new file mode 100644 index 000000000..631c30b95 Binary files /dev/null and b/en/Tutorial/images/screenshot_tests_2/page_object.png differ diff --git a/en/Tutorial/images/screenshot_tests_2/style.png b/en/Tutorial/images/screenshot_tests_2/style.png new file mode 100644 index 000000000..f252d4ff6 Binary files /dev/null and b/en/Tutorial/images/screenshot_tests_2/style.png differ diff --git a/en/Tutorial/images/simple_test/First_tutorial_screen.png b/en/Tutorial/images/simple_test/First_tutorial_screen.png new file mode 100644 index 000000000..cab74400f Binary files /dev/null and b/en/Tutorial/images/simple_test/First_tutorial_screen.png differ diff --git a/en/Tutorial/images/simple_test/Launch_tutorial.png b/en/Tutorial/images/simple_test/Launch_tutorial.png new file mode 100644 index 000000000..07f08844d Binary files /dev/null and b/en/Tutorial/images/simple_test/Launch_tutorial.png differ diff --git a/en/Tutorial/images/simple_test/Layout_inspector_in_studio.png b/en/Tutorial/images/simple_test/Layout_inspector_in_studio.png new file mode 100644 index 000000000..d8d53ee12 Binary files /dev/null and b/en/Tutorial/images/simple_test/Layout_inspector_in_studio.png differ diff --git a/en/Tutorial/images/simple_test/Select_tutorial.png b/en/Tutorial/images/simple_test/Select_tutorial.png new file mode 100644 index 000000000..cd898ed00 Binary files /dev/null and b/en/Tutorial/images/simple_test/Select_tutorial.png differ diff --git a/en/Tutorial/images/simple_test/Tutorial_build_gradle.png b/en/Tutorial/images/simple_test/Tutorial_build_gradle.png new file mode 100644 index 000000000..10c329ad9 Binary files /dev/null and b/en/Tutorial/images/simple_test/Tutorial_build_gradle.png differ diff --git a/en/Tutorial/images/simple_test/Tutorial_main.png b/en/Tutorial/images/simple_test/Tutorial_main.png new file mode 100644 index 000000000..b38c5127a Binary files /dev/null and b/en/Tutorial/images/simple_test/Tutorial_main.png differ diff --git a/en/Tutorial/images/simple_test/bottom_layout_inspector.png b/en/Tutorial/images/simple_test/bottom_layout_inspector.png new file mode 100644 index 000000000..51b6f3ce1 Binary files /dev/null and b/en/Tutorial/images/simple_test/bottom_layout_inspector.png differ diff --git a/en/Tutorial/images/simple_test/button_id_search.png b/en/Tutorial/images/simple_test/button_id_search.png new file mode 100644 index 000000000..3a68a5584 Binary files /dev/null and b/en/Tutorial/images/simple_test/button_id_search.png differ diff --git a/en/Tutorial/images/simple_test/button_in_layout.png b/en/Tutorial/images/simple_test/button_in_layout.png new file mode 100644 index 000000000..7f6473b10 Binary files /dev/null and b/en/Tutorial/images/simple_test/button_in_layout.png differ diff --git a/en/Tutorial/images/simple_test/button_inspect.png b/en/Tutorial/images/simple_test/button_inspect.png new file mode 100644 index 000000000..8f23b309a Binary files /dev/null and b/en/Tutorial/images/simple_test/button_inspect.png differ diff --git a/en/Tutorial/images/simple_test/change_package.png b/en/Tutorial/images/simple_test/change_package.png new file mode 100644 index 000000000..107c943a2 Binary files /dev/null and b/en/Tutorial/images/simple_test/change_package.png differ diff --git a/en/Tutorial/images/simple_test/choose_process.png b/en/Tutorial/images/simple_test/choose_process.png new file mode 100644 index 000000000..6742613d3 Binary files /dev/null and b/en/Tutorial/images/simple_test/choose_process.png differ diff --git a/en/Tutorial/images/simple_test/create_class.png b/en/Tutorial/images/simple_test/create_class.png new file mode 100644 index 000000000..c7517eec9 Binary files /dev/null and b/en/Tutorial/images/simple_test/create_class.png differ diff --git a/en/Tutorial/images/simple_test/create_directory.png b/en/Tutorial/images/simple_test/create_directory.png new file mode 100644 index 000000000..3dde7300c Binary files /dev/null and b/en/Tutorial/images/simple_test/create_directory.png differ diff --git a/en/Tutorial/images/simple_test/create_main_screen.png b/en/Tutorial/images/simple_test/create_main_screen.png new file mode 100644 index 000000000..71871c05c Binary files /dev/null and b/en/Tutorial/images/simple_test/create_main_screen.png differ diff --git a/en/Tutorial/images/simple_test/create_package.png b/en/Tutorial/images/simple_test/create_package.png new file mode 100644 index 000000000..fec70301d Binary files /dev/null and b/en/Tutorial/images/simple_test/create_package.png differ diff --git a/en/Tutorial/images/simple_test/create_test_1.png b/en/Tutorial/images/simple_test/create_test_1.png new file mode 100644 index 000000000..9fc642d55 Binary files /dev/null and b/en/Tutorial/images/simple_test/create_test_1.png differ diff --git a/en/Tutorial/images/simple_test/create_test_2.png b/en/Tutorial/images/simple_test/create_test_2.png new file mode 100644 index 000000000..95633ddd9 Binary files /dev/null and b/en/Tutorial/images/simple_test/create_test_2.png differ diff --git a/en/Tutorial/images/simple_test/find_layout.png b/en/Tutorial/images/simple_test/find_layout.png new file mode 100644 index 000000000..2082d2ba2 Binary files /dev/null and b/en/Tutorial/images/simple_test/find_layout.png differ diff --git a/en/Tutorial/images/simple_test/find_string_in_layout.png b/en/Tutorial/images/simple_test/find_string_in_layout.png new file mode 100644 index 000000000..f61ca2c8f Binary files /dev/null and b/en/Tutorial/images/simple_test/find_string_in_layout.png differ diff --git a/en/Tutorial/images/simple_test/input_inspect.png b/en/Tutorial/images/simple_test/input_inspect.png new file mode 100644 index 000000000..03b77172c Binary files /dev/null and b/en/Tutorial/images/simple_test/input_inspect.png differ diff --git a/en/Tutorial/images/simple_test/kbaseview_children.png b/en/Tutorial/images/simple_test/kbaseview_children.png new file mode 100644 index 000000000..144a6e3b2 Binary files /dev/null and b/en/Tutorial/images/simple_test/kbaseview_children.png differ diff --git a/en/Tutorial/images/simple_test/loaded_inspector.png b/en/Tutorial/images/simple_test/loaded_inspector.png new file mode 100644 index 000000000..9e238777f Binary files /dev/null and b/en/Tutorial/images/simple_test/loaded_inspector.png differ diff --git a/en/Tutorial/images/simple_test/master_branch.png b/en/Tutorial/images/simple_test/master_branch.png new file mode 100644 index 000000000..0995a122d Binary files /dev/null and b/en/Tutorial/images/simple_test/master_branch.png differ diff --git a/en/Tutorial/images/simple_test/move_to_package.png b/en/Tutorial/images/simple_test/move_to_package.png new file mode 100644 index 000000000..cdd8a1e92 Binary files /dev/null and b/en/Tutorial/images/simple_test/move_to_package.png differ diff --git a/en/Tutorial/images/simple_test/name_android_test.png b/en/Tutorial/images/simple_test/name_android_test.png new file mode 100644 index 000000000..625549524 Binary files /dev/null and b/en/Tutorial/images/simple_test/name_android_test.png differ diff --git a/en/Tutorial/images/simple_test/needed_children.png b/en/Tutorial/images/simple_test/needed_children.png new file mode 100644 index 000000000..91ef3350c Binary files /dev/null and b/en/Tutorial/images/simple_test/needed_children.png differ diff --git a/en/Tutorial/images/simple_test/override.png b/en/Tutorial/images/simple_test/override.png new file mode 100644 index 000000000..41118ab95 Binary files /dev/null and b/en/Tutorial/images/simple_test/override.png differ diff --git a/en/Tutorial/images/simple_test/package_name_main_activity.png b/en/Tutorial/images/simple_test/package_name_main_activity.png new file mode 100644 index 000000000..657219e90 Binary files /dev/null and b/en/Tutorial/images/simple_test/package_name_main_activity.png differ diff --git a/en/Tutorial/images/simple_test/package_name_screen.png b/en/Tutorial/images/simple_test/package_name_screen.png new file mode 100644 index 000000000..f968667dd Binary files /dev/null and b/en/Tutorial/images/simple_test/package_name_screen.png differ diff --git a/en/Tutorial/images/simple_test/show_kbutton_source.png b/en/Tutorial/images/simple_test/show_kbutton_source.png new file mode 100644 index 000000000..e0359d18a Binary files /dev/null and b/en/Tutorial/images/simple_test/show_kbutton_source.png differ diff --git a/en/Tutorial/images/simple_test/simple_test_button.png b/en/Tutorial/images/simple_test/simple_test_button.png new file mode 100644 index 000000000..90c7024a8 Binary files /dev/null and b/en/Tutorial/images/simple_test/simple_test_button.png differ diff --git a/en/Tutorial/images/simple_test/string_in_values.png b/en/Tutorial/images/simple_test/string_in_values.png new file mode 100644 index 000000000..e1e5178ce Binary files /dev/null and b/en/Tutorial/images/simple_test/string_in_values.png differ diff --git a/en/Tutorial/images/simple_test/success_1.png b/en/Tutorial/images/simple_test/success_1.png new file mode 100644 index 000000000..d7f7abc45 Binary files /dev/null and b/en/Tutorial/images/simple_test/success_1.png differ diff --git a/en/Tutorial/images/simple_test/sucess_2.png b/en/Tutorial/images/simple_test/sucess_2.png new file mode 100644 index 000000000..5d1c0160d Binary files /dev/null and b/en/Tutorial/images/simple_test/sucess_2.png differ diff --git a/en/Tutorial/images/simple_test/switch_to_results.png b/en/Tutorial/images/simple_test/switch_to_results.png new file mode 100644 index 000000000..b04581b6d Binary files /dev/null and b/en/Tutorial/images/simple_test/switch_to_results.png differ diff --git a/en/Tutorial/images/simple_test/test_failed_1.png b/en/Tutorial/images/simple_test/test_failed_1.png new file mode 100644 index 000000000..8250d2543 Binary files /dev/null and b/en/Tutorial/images/simple_test/test_failed_1.png differ diff --git a/en/Tutorial/images/simple_test/title_inspect.png b/en/Tutorial/images/simple_test/title_inspect.png new file mode 100644 index 000000000..95ca5e262 Binary files /dev/null and b/en/Tutorial/images/simple_test/title_inspect.png differ diff --git a/en/Tutorial/images/steps/clear_logcat.png b/en/Tutorial/images/steps/clear_logcat.png new file mode 100644 index 000000000..93660de8c Binary files /dev/null and b/en/Tutorial/images/steps/clear_logcat.png differ diff --git a/en/Tutorial/images/steps/create_filter.png b/en/Tutorial/images/steps/create_filter.png new file mode 100644 index 000000000..ee8bacb42 Binary files /dev/null and b/en/Tutorial/images/steps/create_filter.png differ diff --git a/en/Tutorial/images/steps/edit_configuration.png b/en/Tutorial/images/steps/edit_configuration.png new file mode 100644 index 000000000..4fbc0b43f Binary files /dev/null and b/en/Tutorial/images/steps/edit_configuration.png differ diff --git a/en/Tutorial/images/steps/log_step_1.png b/en/Tutorial/images/steps/log_step_1.png new file mode 100644 index 000000000..e893e14fc Binary files /dev/null and b/en/Tutorial/images/steps/log_step_1.png differ diff --git a/en/Tutorial/images/steps/log_step_2.png b/en/Tutorial/images/steps/log_step_2.png new file mode 100644 index 000000000..14cb2a69e Binary files /dev/null and b/en/Tutorial/images/steps/log_step_2.png differ diff --git a/en/Tutorial/images/steps/log_step_2_failed.png b/en/Tutorial/images/steps/log_step_2_failed.png new file mode 100644 index 000000000..66143511e Binary files /dev/null and b/en/Tutorial/images/steps/log_step_2_failed.png differ diff --git a/en/Tutorial/images/steps/log_step_3.png b/en/Tutorial/images/steps/log_step_3.png new file mode 100644 index 000000000..36e7a2a24 Binary files /dev/null and b/en/Tutorial/images/steps/log_step_3.png differ diff --git a/en/Tutorial/images/steps/log_with_steps.png b/en/Tutorial/images/steps/log_with_steps.png new file mode 100644 index 000000000..a9bf7f269 Binary files /dev/null and b/en/Tutorial/images/steps/log_with_steps.png differ diff --git a/en/Tutorial/images/steps/logcat.png b/en/Tutorial/images/steps/logcat.png new file mode 100644 index 000000000..790c53d9c Binary files /dev/null and b/en/Tutorial/images/steps/logcat.png differ diff --git a/en/Tutorial/images/steps/test_failed_with_steps.png b/en/Tutorial/images/steps/test_failed_with_steps.png new file mode 100644 index 000000000..b81c51598 Binary files /dev/null and b/en/Tutorial/images/steps/test_failed_with_steps.png differ diff --git a/en/Tutorial/images/uiautomator/da_1_settings.png b/en/Tutorial/images/uiautomator/da_1_settings.png new file mode 100644 index 000000000..127cb5d55 Binary files /dev/null and b/en/Tutorial/images/uiautomator/da_1_settings.png differ diff --git a/en/Tutorial/images/uiautomator/da_2_settings.png b/en/Tutorial/images/uiautomator/da_2_settings.png new file mode 100644 index 000000000..425d630d4 Binary files /dev/null and b/en/Tutorial/images/uiautomator/da_2_settings.png differ diff --git a/en/Tutorial/images/uiautomator/da_3_settings.png b/en/Tutorial/images/uiautomator/da_3_settings.png new file mode 100644 index 000000000..695657f00 Binary files /dev/null and b/en/Tutorial/images/uiautomator/da_3_settings.png differ diff --git a/en/Tutorial/images/uiautomator/da_4_settings.png b/en/Tutorial/images/uiautomator/da_4_settings.png new file mode 100644 index 000000000..069b0b7d7 Binary files /dev/null and b/en/Tutorial/images/uiautomator/da_4_settings.png differ diff --git a/en/Tutorial/images/uiautomator/da_5_settings.png b/en/Tutorial/images/uiautomator/da_5_settings.png new file mode 100644 index 000000000..6cd5b307d Binary files /dev/null and b/en/Tutorial/images/uiautomator/da_5_settings.png differ diff --git a/en/Tutorial/images/uiautomator/da_6_settings.png b/en/Tutorial/images/uiautomator/da_6_settings.png new file mode 100644 index 000000000..8f9ab3417 Binary files /dev/null and b/en/Tutorial/images/uiautomator/da_6_settings.png differ diff --git a/en/Tutorial/images/uiautomator/da_gplay_1.png b/en/Tutorial/images/uiautomator/da_gplay_1.png new file mode 100644 index 000000000..78ba8905f Binary files /dev/null and b/en/Tutorial/images/uiautomator/da_gplay_1.png differ diff --git a/en/Tutorial/images/uiautomator/da_gplay_2.png b/en/Tutorial/images/uiautomator/da_gplay_2.png new file mode 100644 index 000000000..9d420ec3b Binary files /dev/null and b/en/Tutorial/images/uiautomator/da_gplay_2.png differ diff --git a/en/Tutorial/images/uiautomator/da_gplay_3.png b/en/Tutorial/images/uiautomator/da_gplay_3.png new file mode 100644 index 000000000..f66267022 Binary files /dev/null and b/en/Tutorial/images/uiautomator/da_gplay_3.png differ diff --git a/en/Tutorial/images/uiautomator/dump_1.png b/en/Tutorial/images/uiautomator/dump_1.png new file mode 100644 index 000000000..fc4a4f3db Binary files /dev/null and b/en/Tutorial/images/uiautomator/dump_1.png differ diff --git a/en/Tutorial/images/uiautomator/dump_2.png b/en/Tutorial/images/uiautomator/dump_2.png new file mode 100644 index 000000000..0e7a6b8d0 Binary files /dev/null and b/en/Tutorial/images/uiautomator/dump_2.png differ diff --git a/en/Tutorial/images/uiautomator/dump_3.png b/en/Tutorial/images/uiautomator/dump_3.png new file mode 100644 index 000000000..43753705d Binary files /dev/null and b/en/Tutorial/images/uiautomator/dump_3.png differ diff --git a/en/Tutorial/images/uiautomator/dump_4.png b/en/Tutorial/images/uiautomator/dump_4.png new file mode 100644 index 000000000..be4e9ef19 Binary files /dev/null and b/en/Tutorial/images/uiautomator/dump_4.png differ diff --git a/en/Tutorial/images/uiautomator/dump_5.png b/en/Tutorial/images/uiautomator/dump_5.png new file mode 100644 index 000000000..c7862a998 Binary files /dev/null and b/en/Tutorial/images/uiautomator/dump_5.png differ diff --git a/en/Tutorial/images/uiautomator/dump_6.png b/en/Tutorial/images/uiautomator/dump_6.png new file mode 100644 index 000000000..b2ab44ed4 Binary files /dev/null and b/en/Tutorial/images/uiautomator/dump_6.png differ diff --git a/en/Tutorial/images/uiautomator/dump_7.png b/en/Tutorial/images/uiautomator/dump_7.png new file mode 100644 index 000000000..893bde5f4 Binary files /dev/null and b/en/Tutorial/images/uiautomator/dump_7.png differ diff --git a/en/Tutorial/images/uiautomator/google_play_unauth.png b/en/Tutorial/images/uiautomator/google_play_unauth.png new file mode 100644 index 000000000..94394250a Binary files /dev/null and b/en/Tutorial/images/uiautomator/google_play_unauth.png differ diff --git a/en/Tutorial/images/uiautomator/matchers.png b/en/Tutorial/images/uiautomator/matchers.png new file mode 100644 index 000000000..d837cf072 Binary files /dev/null and b/en/Tutorial/images/uiautomator/matchers.png differ diff --git a/en/Tutorial/images/uiautomator/notification.png b/en/Tutorial/images/uiautomator/notification.png new file mode 100644 index 000000000..95fcb06a5 Binary files /dev/null and b/en/Tutorial/images/uiautomator/notification.png differ diff --git a/en/Tutorial/images/uiautomator/notification_activity_btn.png b/en/Tutorial/images/uiautomator/notification_activity_btn.png new file mode 100644 index 000000000..f10de9d54 Binary files /dev/null and b/en/Tutorial/images/uiautomator/notification_activity_btn.png differ diff --git a/en/Tutorial/images/uiautomator/ui_button.png b/en/Tutorial/images/uiautomator/ui_button.png new file mode 100644 index 000000000..e280d5935 Binary files /dev/null and b/en/Tutorial/images/uiautomator/ui_button.png differ diff --git a/en/Tutorial/images/uiautomator/uiautomator_button.png b/en/Tutorial/images/uiautomator/uiautomator_button.png new file mode 100644 index 000000000..42295cbd9 Binary files /dev/null and b/en/Tutorial/images/uiautomator/uiautomator_button.png differ diff --git a/en/Tutorial/images/uiautomator/uiautomator_notification.png b/en/Tutorial/images/uiautomator/uiautomator_notification.png new file mode 100644 index 000000000..cf34c68ff Binary files /dev/null and b/en/Tutorial/images/uiautomator/uiautomator_notification.png differ diff --git a/en/Tutorial/images/uiautomator/uiautomator_package.png b/en/Tutorial/images/uiautomator/uiautomator_package.png new file mode 100644 index 000000000..d3e4e93d9 Binary files /dev/null and b/en/Tutorial/images/uiautomator/uiautomator_package.png differ diff --git a/en/Tutorial/images/uiautomator/uiautomatorviewer_1.png b/en/Tutorial/images/uiautomator/uiautomatorviewer_1.png new file mode 100644 index 000000000..aab4d6c54 Binary files /dev/null and b/en/Tutorial/images/uiautomator/uiautomatorviewer_1.png differ diff --git a/en/Tutorial/images/uiautomator/uiautomatorviewer_2.png b/en/Tutorial/images/uiautomator/uiautomatorviewer_2.png new file mode 100644 index 000000000..22193d087 Binary files /dev/null and b/en/Tutorial/images/uiautomator/uiautomatorviewer_2.png differ diff --git a/en/Tutorial/images/wifi_test/available_methods.png b/en/Tutorial/images/wifi_test/available_methods.png new file mode 100644 index 000000000..3bf2be02d Binary files /dev/null and b/en/Tutorial/images/wifi_test/available_methods.png differ diff --git a/en/Tutorial/images/wifi_test/first_launch_1.png b/en/Tutorial/images/wifi_test/first_launch_1.png new file mode 100644 index 000000000..bfadc03b1 Binary files /dev/null and b/en/Tutorial/images/wifi_test/first_launch_1.png differ diff --git a/en/Tutorial/images/wifi_test/first_launch_2.png b/en/Tutorial/images/wifi_test/first_launch_2.png new file mode 100644 index 000000000..82d833b9d Binary files /dev/null and b/en/Tutorial/images/wifi_test/first_launch_2.png differ diff --git a/en/Tutorial/images/wifi_test/internet_availability_button.png b/en/Tutorial/images/wifi_test/internet_availability_button.png new file mode 100644 index 000000000..4dc1a5c8a Binary files /dev/null and b/en/Tutorial/images/wifi_test/internet_availability_button.png differ diff --git a/en/Tutorial/images/wifi_test/turn_off_wifi.png b/en/Tutorial/images/wifi_test/turn_off_wifi.png new file mode 100644 index 000000000..6a0a297dc Binary files /dev/null and b/en/Tutorial/images/wifi_test/turn_off_wifi.png differ diff --git a/en/Tutorial/images/wifi_test/wifi_disabled.png b/en/Tutorial/images/wifi_test/wifi_disabled.png new file mode 100644 index 000000000..6565ff88c Binary files /dev/null and b/en/Tutorial/images/wifi_test/wifi_disabled.png differ diff --git a/en/Tutorial/images/wifi_test/wifi_disabled_portrait.png b/en/Tutorial/images/wifi_test/wifi_disabled_portrait.png new file mode 100644 index 000000000..16d7c7393 Binary files /dev/null and b/en/Tutorial/images/wifi_test/wifi_disabled_portrait.png differ diff --git a/en/Tutorial/images/wifi_test/wifi_enabled.png b/en/Tutorial/images/wifi_test/wifi_enabled.png new file mode 100644 index 000000000..b4aeddb05 Binary files /dev/null and b/en/Tutorial/images/wifi_test/wifi_enabled.png differ diff --git a/en/Tutorial/index.html b/en/Tutorial/index.html new file mode 100644 index 000000000..b455b27a1 --- /dev/null +++ b/en/Tutorial/index.html @@ -0,0 +1,1151 @@ + + + + + + + + + + + + + + + + + + + + + + 1. Introduction - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Introduction

+

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.

+

Tutorial structure

+

The Tutorial is divided into steps (lessons). Each lesson begins with a brief overview and ends with summary and conclusions.

+

How to study this Tutorial?

+

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.

+

What do you need to know to complete the Tutorial?

+

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.

+

Feedback

+

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.

+

Give thanks

+

If you like our framework, you can give our project a star on Github.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Wiki/Espresso_as_the_basis/index.html b/en/Wiki/Espresso_as_the_basis/index.html new file mode 100644 index 000000000..e47398b83 --- /dev/null +++ b/en/Wiki/Espresso_as_the_basis/index.html @@ -0,0 +1,1215 @@ + + + + + + + + + + + + + + + + + + + + + + Espresso as the basis - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Espresso as the basis

+

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.

+

Espresso is not enough

+

This framework has a lot of drawbacks and not all things in Android autotesting can be done with Espresso alone.

+

What do we want:

+
    +
  1. Good readability. Espresso has a problem with this because of the huge hierarchy of matchers. When we have a lot of matches, the code becomes difficult to read. Poor readability means difficult to maintain
  2. +
  3. Hight stability. Espresso does not work well with interfaces whose elements are displayed asynchronously. You can configure Idling, but that still won't solve all problems.
  4. +
  5. Logging. After completing the test with Espresso, you do not have a step-by-step workflow sequence of actions.
  6. +
  7. Screenshots. We also want to have some screenshots for the test report.
  8. +
  9. Working with Android OS. In some cases, we need to interact with the device. In this case you need UiAutomator (as a variant).
  10. +
  11. Сode architecture. We want to have a clean code architecture in our tests, the ability to reuse code, move some blocks in abstractions. One code style for all developers.
  12. +
+

How does Kaspresso solve all these problems?

+

Readability

+

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).

+

Stability

+

Kaspresso has wrapped some Espresso calls into a more stable implementation. For example you can find flakySafely() method in the Kaspresso.

+

Logging

+

Kaspresso has wrapped some Espresso calls not only for higher stability. We have also implemented an interceptor that prints more logs.

+

Working with Android OS

+

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.

+

Code architecture

+

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).

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Wiki/Executing_adb_commands/index.html b/en/Wiki/Executing_adb_commands/index.html new file mode 100644 index 000000000..47bed66e7 --- /dev/null +++ b/en/Wiki/Executing_adb_commands/index.html @@ -0,0 +1,1358 @@ + + + + + + + + + + + + + + + + + + + + + + Executing adb commands - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Executing adb commands

+

Description

+

As you remember from the previous part devoted to Device interface, Device interface contains the following things under the hood:

+
    +
  • Espresso
  • +
  • UI Automator
  • +
  • ADB
  • +
+ +

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:

+
    +
  • Device that starts up a test acts as client
  • +
  • Desktop sends ADB commands to be executed on the device. + Also, the system uses a port forwarding to be able to organize a socket tunnel between Device and Desktop through any kind of connection (Wi-Fi, Bluetooth, USB and etc.).
  • +
+

Usage

+

The algorithm how to use Autotest AdbServer:

+
    +
  1. Run the Desktop part on your work station.
    + Execute the following command: java -jar <path/to/kaspresso>/artifacts/adbserver-desktop.jar in the terminal
  2. +
  3. Run the Device part.
    + Build and start adbserver-sample module. You should see the following screen: +
  4. +
+

For 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.

+

Usage in Kaspresso

+

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()
+                }
+            }
+        }
+        // ....
+}
+
+Also, don't forget to grant necessary permission: +
<uses-permission android:name="android.permission.INTERNET" />
+

+

Options and Logging

+

Desktop part

+

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...
+
+Take a look at the log format. You can see the type of a message, date and time, the host name and the emulator which executes the command, and the message.

+
    +
  1. +

    WARN
    + Prints error and warning messages.

    +
  2. +
  3. +

    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)
    +
    +Also, the Desktop prints an emulator name, where the concrete command has been executed (this information is available on the Desktop and on the Device). +It could be very useful in debugging. Take a look at the field 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)
    +

    +
  4. +
  5. +

    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))
    +
    +Pay attention that the log row also contains two additional fields: tag and method. Both fields are autogenerated using Throwable().stacktrace method.

    +
  6. +
  7. +

    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
    +

    +
  8. +
+

Device part

+

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)
+
+All the logs are printed with KASPRESSO_ADBSERVER tag with WARN log level.
+If you want to debug the Device part of Autotest AdbServer (the device part), you can set VERBOSE log level: +
class DeviceNetworkSampleTest : TestCase(
+    kaspressoBuilder = Kaspresso.Builder.simple {
+        libLogger = UiTestLoggerImpl(Kaspresso.DEFAULT_LIB_LOGGER_TAG)
+        adbServer = AdbServerImpl(LogLevel.VERBOSE, libLogger)
+    }
+) {...}
+
+Now the logs looks like: +
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)
+

+

Development

+

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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Wiki/Images/Matchers_actions_assertions/Espresso_cheat_sheet.png b/en/Wiki/Images/Matchers_actions_assertions/Espresso_cheat_sheet.png new file mode 100644 index 000000000..8dea00033 Binary files /dev/null and b/en/Wiki/Images/Matchers_actions_assertions/Espresso_cheat_sheet.png differ diff --git a/en/Wiki/Jetpack_Compose/index.html b/en/Wiki/Jetpack_Compose/index.html new file mode 100644 index 000000000..9ae6e9279 --- /dev/null +++ b/en/Wiki/Jetpack_Compose/index.html @@ -0,0 +1,1407 @@ + + + + + + + + + + + + + + + + + + + + + + Compose support in Kaspresso - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+ +
+ + + +
+
+ + + + + + + +

Compose support

+

Jetpack Compose support consists of two parts: Kakao Compose library and Kaspresso Interceptors mechanism.

+

Kakao Compose library

+

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()
+                }
+            }
+        }
+
+        // ...
+    }
+}
+
+Again, all related to DSL information is available in the docs.

+

Kaspresso Interceptors mechanism

+

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.

+

Behavior interceptors

+
    +
  1. FailureLoggingSemanticsBehaviorInterceptor
    + Build the clear and undestandable exception in case of the test failure.
  2. +
  3. FlakySafeSemanticsBehaviorInterceptor
    + Tries to repeat the failed action or assertion during defined timeout. All params for this interceptor are at FlakySafetyParams.
  4. +
  5. SystemDialogSafetySemanticsBehaviorInterceptor
    + Eliminates various system dialogs that prevent correct execution of a test.
  6. +
  7. AutoScrollSemanticsBehaviorInterceptor
    + Performs autoscrolling to an element if the element is not visible on the screen.
  8. +
  9. ElementLoaderSemanticsBehaviorInterceptor
    + Requests the related SemanticNodeInteraction using saved Matcher when the element is not found.
  10. +
+

Watcher interceptors

+

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
+

+

Caveats

+

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()
+    }
+}
+
+can be the source of flakiness behavior if 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.

+

What else

+

Configuration

+

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()
+        }
+    }
+)
+

+

Robolectric support

+

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"  
+
+All information about Robolectric support is available here.

+

Compose is compatible with all sweet Kaspresso extensions

+

Sweet Kaspresso extensions means using of the such constructions as:

+
    +
  1. flakySafely
  2. +
  3. continuously
  4. +
+

The support of some constructions is in progress: issue-317.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Wiki/Kaspresso_Allure/index.html b/en/Wiki/Kaspresso_Allure/index.html new file mode 100644 index 000000000..fad24060b --- /dev/null +++ b/en/Wiki/Kaspresso_Allure/index.html @@ -0,0 +1,1234 @@ + + + + + + + + + + + + + + + + + + + + + + Kaspresso with Allure - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Kaspresso-allure support

+

What's new

+

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:

+
    +
  1. VideoRecordingInterceptor. Tests video recording interceptor (please note that it was fully tested on emulators with android api 29 and older).
  2. +
  3. DumpViewsInterceptor. Interceptor that dumps XML-representation of view hierarchy in case of a test failure.
  4. +
+

In the package com.kaspersky.components.alluresupport.interceptors, there are special Kaspresso interceptors helping to link and process files for Allure-report.

+

How to use

+

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>"
+}
+
+Next, use special withForcedAllureSupport function in your TestCase constructor or in your TestCaseRule to turn on all available Allure-supporting interceptors: +
class AllureSupportTest : TestCase(
+    kaspressoBuilder = Kaspresso.Builder.withForcedAllureSupport()
+) {
+
+}
+
+If you want to specify the parameters or add more interceptors you can use addAllureSupport function: +
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() }
+                }
+            })
+        }
+    }
+) {
+...
+}
+
+If you don't need all of these interceptors providing by withForcedAllureSupport and addAllureSupport functions then you may add only interceptors that you prefer. But please note that AllureMapperStepInterceptor.kt is mandatory for Allure support work. For example, if you don't need videos and view hierarchies after test failures then you can do something like: +
class AllureSupportCustomizeTest : TestCase(
+    kaspressoBuilder = Kaspresso.Builder.simple().apply {
+        stepWatcherInterceptors.addAll(
+            listOf(
+                ScreenshotStepInterceptor(screenshots),
+                AllureMapperStepInterceptor()
+            )
+        )
+        testRunWatcherInterceptors.addAll(
+            listOf(
+                DumpLogcatTestInterceptor(logcatDumper),
+                ScreenshotTestInterceptor(screenshots),
+            )
+        )
+    }
+) {
+...
+}
+
+kaspresso-allure-support-sample is available to watch, to launch and to experiment with all of this staff.

+

Watch result

+

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
+
+If there are few devices connected to yout host you should specify the needed device id. To watch the list of connected devices you can call: +
adb devices
+
+The output will be something like: +
List of devices attached
+CLCDU18508004769    device
+emulator-5554   device
+
+Select the needed device and call: +
adb -s emulator-5554 pull /sdcard/allure-results /Users/username/Desktop
+
+And that's it, the allure-results dir with all the test resources is now at /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
+
+Now we are ready to generate and watch the report, just call: +
allure serve /Users/username/Desktop/allure-results
+
+Next, the Allure server generates the html-page representing the report and puts it to temp dir in your system. You will see the report opening in the new tab in your browser (the tab is opening automatically).

+

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
+
+And to watch it then in your browser you just call: +
allure open ~/kaspresso-allure-report
+
+After all of this actions you see something like: +

+

Details for succeeded test: +

+

Details for failed test: +

+

Details that you need to know

+

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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Wiki/Kaspresso_Robolectric/index.html b/en/Wiki/Kaspresso_Robolectric/index.html new file mode 100644 index 000000000..d907e8138 --- /dev/null +++ b/en/Wiki/Kaspresso_Robolectric/index.html @@ -0,0 +1,1215 @@ + + + + + + + + + + + + + + + + + + + + + + Kaspresso with Robolectric - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Kaspresso tests running on the JVM with Robolectric

+

Main purpose

+

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)
+    ...
+
+That is because Robolectric is just compatible with Espresso and not with UI Automator.

+

Now, all Kaspresso tests are allowed to be executed correctly on the JVM with Robolectric with the following restrictions:

+
    +
  1. Easy configuration of your project according to Robolectric guideline.
  2. +
  3. Not possible to use adb-server because there is no a term like "Desktop" on the JVM environment. Tests that use adb-server will crash on the JVM with Robolectric with very explaining error message.
  4. +
  5. Not possible to work with UiDevice and UiAutomation classes. That's why a lot of (not all!) implementations in Device will crash on the JVM with Robolectric with NotSupportedInstrumentalTestException.
  6. +
  7. Non working Kautomator. Mentioned problem with UiDevice and UiAutomation classes affect the entire Kautomator. So, tests using Kautomator will crash on the JVM with Robolectric with KautomatorInUnitTestException.
  8. +
  9. Interceptors that use UiDevice, UiAutomation or adb-server are turning off on the JVM with Robolectric automatically.
  10. +
  11. DocLocScreenshotTestCase will crash on the JVM with Robolectric with DocLocInUnitTestException.
  12. +
+

Usage

+

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
+

+

Accommodation of tests to work on the JVM (with Robolectric) environment

+

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

+
    +
  1. Robolectric-Espresso supports Idling resources, but doesn't support posting delayed messages to the Looper
  2. +
  3. Robolectric-Espresso will not support tests that start new activities (i.e. activity jumping)
  4. +
+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Wiki/Kaspresso_configuration/index.html b/en/Wiki/Kaspresso_configuration/index.html new file mode 100644 index 000000000..f688bf738 --- /dev/null +++ b/en/Wiki/Kaspresso_configuration/index.html @@ -0,0 +1,1593 @@ + + + + + + + + + + + + + + + + + + + + + + Kaspresso configuration - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Kaspresso configurator

+

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
+}
+

+

Structure

+

Kaspresso configuration contains:

+

Kakao clicks

+

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.

+

Loggers

+

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.

+

Kaspresso interceptors based on Kakao/Kautomator Interceptors.

+

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:

+
    +
  1. Behavior Interceptors - are intercepting calls to ViewInteraction, DataInteraction, WebInteraction, UiObjectInteraction, UiDeviceInteraction and do some stuff.
    + Attention, we are going to consider some important notes about Behavior Interceptors at the end of this document.
  2. +
  3. Watcher Interceptors - are intercepting calls to ViewAction, ViewAssertion, Atom, WebAssertion, UiObjectAssertion, UiObjectAction, UiDeviceAssertion, UiDeviceAction and do some stuff.
  4. +
+

Let's expand mentioned Kaspresso interceptors types:

+
    +
  1. Behavior Interceptors
      +
    1. viewBehaviorInterceptors - intercept calls to ViewInteraction#perform and ViewInteraction#check
    2. +
    3. dataBehaviorInterceptors - intercept calls to DataInteraction#check
    4. +
    5. webBehaviorInterceptors - intercept calls to Web.WebInteraction<R>#perform and Web.WebInteraction<R>#check
    6. +
    7. objectBehaviorInterceptors - intercept calls to UiObjectInteraction#perform and UiObjectInteraction#check
    8. +
    9. deviceBehaviorInterceptors - intercept calls to UiDeviceInteraction#perform and UiDeviceInteraction#check
    10. +
    +
  2. +
  3. Watcher Interceptors
      +
    1. viewActionWatcherInterceptors - do some stuff before android.support.test.espresso.ViewAction.perform is actually called
    2. +
    3. viewAssertionWatcherInterceptors - do some stuff before android.support.test.espresso.ViewAssertion.check is actually called
    4. +
    5. atomWatcherInterceptors - do some stuff before android.support.test.espresso.web.model.Atom.transform is actually called
    6. +
    7. webAssertionWatcherInterceptors - do some stuff before android.support.test.espresso.web.assertion.WebAssertion.checkResult is actually called
    8. +
    9. objectWatcherInterceptors - do some stuff before UiObjectInteraction.perform or UiObjectInteraction.check is actually called
    10. +
    11. deviceWatcherInterceptors - do some stuff before UiDeviceInteraction.perform or UiDeviceInteraction.check is actually called
    12. +
    +
  4. +
+

Please, 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.

+

Special Kaspresso interceptors

+

These interceptors are not based on some lib. Short description:

+
    +
  1. stepWatcherInterceptors - an interceptor of Step lifecycle actions
  2. +
  3. testRunWatcherInterceptors - an interceptor of entire Test lifecycle actions
  4. +
+

As you noticed these interceptors are a part of Watcher Interceptors, also.

+

BuildStepReportWatcherInterceptor

+

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.

+

Default actions in before/after sections

+

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")
+        }
+    }
+)
+
+The full signature of beforeEachTest is: +
beforeEachTest(override = true, action = {
+    testLogger.i("beforeTestFirstAction")
+})
+
+afterEachTest is similar to beforeEachTest.
+If you set 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

+

Device instance. Detailed info is at Device wiki.

+

AdbServer

+

AdbServer instance. Detailed info is at AdbServer wiki.

+

Kaspresso configuring and Kaspresso interceptors example

+

The example of how to configure Kaspresso and how to use Kaspresso interceptors is in here.

+

Default Kaspresso settings

+

BaseTestCase, TestCase, BaseTestCaseRule, TestCaseRule are using default customized Kaspresso (Kaspresso.Builder.simple builder).
+Most valuable features of default customized Kaspresso are below.

+

Logging

+

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: ---------------------------------------------------------------------------
+
+Pretty good.

+

Defense from flaky tests

+

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

+

Interceptors turned by default:

+
    +
  1. Watcher interceptors
  2. +
  3. Behavior interceptors
  4. +
  5. Kaspresso interceptors
  6. +
  7. BuildStepReportWatcherInterceptor
  8. +
+

So, all features described above are available thanks to these interceptors.

+

Some words about Behavior 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:

+
    +
  1. Common flaky errors that happened because Espresso/UI Automator was in a bad mood =)
    + That's why Kaspresso wraps all actions/assertions of Kakao/Kautomator and handles set of potential flaky exceptions. + If an exception happened then Kaspresso attempts to repeat failed actions/assert for 10 seconds. Such handling rescues developers of any flaky action/assert.
    + The details are available at flakysafety and examples are here.
  2. +
  3. The reason of a failure is non visibility of a View. In most cases you just need to scroll a parent layout to make the View visible. So, Kaspresso tries to perform it in auto mode.
    + The details are available at autoscroll.
  4. +
  5. Also, Kaspresso attempts to remove all system dialogs if it prevents the test execution.
    + The details are available at systemsafety.
  6. +
+

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:

+
    +
  1. AutoScrollViewBehaviorInterceptor
  2. +
  3. SystemDialogSafetyViewBehaviorInterceptor
  4. +
  5. FlakySafeViewBehaviorInterceptor
  6. +
+

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:

+

+

Main section enrichers

+

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:

+
    +
  1. Define your enricher implementation for MainSectionEnricher interface;
  2. +
+
class LoggingMainSectionEnricher : MainSectionEnricher<TestCaseData> {
+    ...
+
+}
+
+

Here, TestCaseData is the same data type as in your BaseTestCase implementation.

+
    +
  1. Override beforeMainSectionRun or/and afterMainSectionRun methods to add your before/after actions;
  2. +
+
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.

+
    +
  1. Add your enrichers into your BaseTestCase implementation.
  2. +
+
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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Wiki/Kautomator-wrapper_over_UI_Automator/index.html b/en/Wiki/Kautomator-wrapper_over_UI_Automator/index.html new file mode 100644 index 000000000..8a8e608f4 --- /dev/null +++ b/en/Wiki/Kautomator-wrapper_over_UI_Automator/index.html @@ -0,0 +1,1544 @@ + + + + + + + + + + + + + + + + + + + + + + Kautomator. Wrapper over UI Automator - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Kautomator: wrapper over UI Automator

+

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).

+

Introduction

+

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")
+
+This is an example just to input and check the text. Because we have a successful experience of Kakao using we decided to wrap UI Automator over in the same manner and called it Kautomator: +
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.

+

Benefits

+
    +
  • Readability
  • +
  • Reusability
  • +
  • Extensible DSL
  • +
  • Amazing speed!
  • +
+

How to use it

+

Create Screen

+

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.

+

Create UiView

+

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") }
+}
+
+Kautomator provides different types depending on the type of view:

+
    +
  • UiView
  • +
  • UiEditText
  • +
  • UiTextView
  • +
  • UiButton
  • +
  • UiCheckbox
  • +
  • UiChipGroup
  • +
  • UiSwitchView
  • +
  • UiScrollView
  • +
  • and more
  • +
+

Every UiView contains matchers to retrieve the view involved in the ViewInteraction. Some examples of matchers provided +by Kakao:

+
    +
  • withId
  • +
  • withText
  • +
  • withPackage
  • +
  • withContentDescription
  • +
  • textStartsWith
  • +
  • and more
  • +
+

Like in Ui Automator you can combine different matchers: +

val email = UiEditText {
+    withId(this@FormScreen.packageName, "email")
+    withText(this@FormScreen.packageName, "matsyuk@kaspresso.com")
+}
+

+

Write the interaction

+

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()
+    }
+}
+

+

The difference from Kakao-Espresso

+

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 =)

+

Advanced

+
Custom UiView
+

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)
+}
+
+
Intercepting
+

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)
+                    }
+                }
+            }
+        }
+    }
+}
+

+

Accelerate UI Automator

+

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()
+                            }
+                        }
+                    }
+                }
+            }
+        }
+}
+
+It's a great deal!

+

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 =)

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Wiki/Matchers_actions_assertions/index.html b/en/Wiki/Matchers_actions_assertions/index.html new file mode 100644 index 000000000..16e351c63 --- /dev/null +++ b/en/Wiki/Matchers_actions_assertions/index.html @@ -0,0 +1,1054 @@ + + + + + + + + + + + + + + + + + + + + + + View matchers, actions and assertions - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Matchers, Actions and Assertions

+

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:

+
    +
  1. Espresso – Entry point to interactions with views (via onView() and onData()). Also exposes APIs that are not necessarily tied to any view, such as pressBack().
  2. +
  3. ViewMatchers – A collection of objects that implement the 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.
  4. +
  5. ViewActions – A collection of ViewAction objects that can be passed to the ViewInteraction.perform() method, such as click().
  6. +
  7. ViewAssertions – A collection of ViewAssertion objects that can be passed the 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.
  8. +
+
// 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. +Espresso 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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Wiki/Page_object_in_Kaspresso/index.html b/en/Wiki/Page_object_in_Kaspresso/index.html new file mode 100644 index 000000000..69783dd0c --- /dev/null +++ b/en/Wiki/Page_object_in_Kaspresso/index.html @@ -0,0 +1,1183 @@ + + + + + + + + + + + + + + + + + + + + + + PageObject in Kaspresso - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Page object pattern in Kaspresso.

+

What is a Page object pattern?

+


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.

+

How is the page object pattern implemented in Kaspresso?

+


Kaspresso provides KScreen and UiScreen as implementations for Page object pattern.

+

What is the difference between KScreen and UiScreen

+


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") }
+}
+
+
In KScreen's inheritors we should initialize the 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 +
In UiScreen's inheritors we must initialize packageName field (the full name of the application's package).

+

Benefits of the page object for refactoring

+


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.

+

Benefits of the Page Object for a work in a team

+


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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Wiki/Screenshot_tests/index.html b/en/Wiki/Screenshot_tests/index.html new file mode 100644 index 000000000..a1d9667bf --- /dev/null +++ b/en/Wiki/Screenshot_tests/index.html @@ -0,0 +1,1335 @@ + + + + + + + + + + + + + + + + + + + + + + Screenshot tests - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Screenshot tests

+

Main purpose

+

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.

+

Usage

+

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>
+
+Similar data may be useful for different systems automating the process of localization of an application. The automating system saves xml for each screen and compares it with new versions received by new screenshot's runs. If some difference were revealed the system gives a signal to prepare and send a portion of new words to translate server.

+

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")
+            }
+        }
+    }        
+}
+
+The full example is located at ChangeSysLanguageTestCase.

+

Important note

+

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.

+

Advanced usage

+

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.

+

Modifying screenshots path and name

+

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.

+

Changes

+

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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Wiki/Supported_Android_UI_elements/index.html b/en/Wiki/Supported_Android_UI_elements/index.html new file mode 100644 index 000000000..77c79c0dc --- /dev/null +++ b/en/Wiki/Supported_Android_UI_elements/index.html @@ -0,0 +1,1136 @@ + + + + + + + + + + + + + + + + + + + + + + Supported Android UI-elements - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Supported Android UI widgets

+

Via Kakao

+

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

+

Via KAutomator

+

If you extend the UiScreen abstract class then the following views are available for you: +
UiView +
UiEditText +
UiTextView +
UiButton +
UiCheckbox +
UiChipGroup +
UiSwitchView +
UiScrollView +
UiBottomNavigationView

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Wiki/Working_with_Android_OS/index.html b/en/Wiki/Working_with_Android_OS/index.html new file mode 100644 index 000000000..a8db39425 --- /dev/null +++ b/en/Wiki/Working_with_Android_OS/index.html @@ -0,0 +1,1157 @@ + + + + + + + + + + + + + + + + + + + + + + Working with Android OS - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Working with Android OS. Kaspresso Device abstraction.

+

Device is a provider of managers for all off-screen work.

+

Structure

+

All examples are located in device_tests. +Device provides these managers:

+
    +
  1. apps allows to install or uninstall applications. Uses adb install and adb uninstall commands. See the example DeviceAppSampleTest.
  2. +
  3. activities is an interface to work with currently resumed Activities. AdbServer not required. See the example DeviceActivitiesSampleTest.
  4. +
  5. 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.
  6. +
  7. 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.
  8. +
  9. 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.
  10. +
  11. location emulates fake location and allows to toggle GPS setting. See the example DeviceLocationSampleTest.
  12. +
  13. phone allows to emulate incoming calls and receive SMS messages. Works only on emulators since uses adb emu commands. See the example DevicePhoneSampleTest.
  14. +
  15. screenshots is an interface screenshots of currently resumed activity. Requires android.permission.WRITE_EXTERNAL_STORAGE permission. See the example DeviceScreenshotSampleTest.
  16. +
  17. accessibility allows to enable or disable accessibility services. Available since api 24. See the example DeviceAccessibilitySampleTest.
  18. +
  19. permissions provides the possibility of allowing or denying permission requests via default Android permission dialog. See the example DevicePermissionsSampleTest.
  20. +
  21. hackPermissions provides the possibility of allowing any permission requests without default Android permission dialog. See the example DeviceHackPermissionsSampleTest.
  22. +
  23. exploit allows to rotate device or press system buttons. See the example DeviceExploitSampleTest.
  24. +
  25. language allows to switch language. See the example DeviceLanguageSampleTest.
  26. +
  27. logcat provides access to adb logcat. See the example DeviceLogcatSampleTest.
    + The purpose of logcat:
    + If you have not heard about GDPR and high-profile lawsuits then you are lucky. But, if your application works in Europe then it's so important to enable/disable all analytics/statistics according to acceptance of the agreements. + One of the most reliable ways to check analytics/statistics sending is to verify logcat where all analytics/statistics write their logs (in debug mode, sure). + That's why we have created a special Logcat class providing a wide variety of ways to check logcat.
  28. +
  29. 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.
  30. +
+

Also Device provides application and test contexts - targetContext and context.

+

Usage

+

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()
+                }
+            }
+        }
+        // ....
+}
+

+

Restrictions

+

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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Wiki/how_to_write_autotests/index.html b/en/Wiki/how_to_write_autotests/index.html new file mode 100644 index 000000000..f54a848cf --- /dev/null +++ b/en/Wiki/how_to_write_autotests/index.html @@ -0,0 +1,1546 @@ + + + + + + + + + + + + + + + + + + How to write autotests - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

How to write autotests

+

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.

+

Abstractions

+

How many abstractions can you have in your tests?

+

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.

+

How to determine whether View (fragment, dialog, anything) in the project has its description in some Kakao 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.

+

Is it ok that your PO contains helper methods?

+

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()
+    }
+}
+
+and +
MainScreen {
+    navigateToTasksScreen()
+}
+
+object MainScreen : KScreen<MainScreen>() {
+    //...
+    fun navigateToTasksScreen() {
+        shieldView {
+            click()
+        }
+    }
+    //...
+}
+
+I am sure that method navigateToTasksScreen() is more "talking" than the simple click on some shieldView.

+

Can 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.

+

Assert help methods inside of PO. Is it ok?

+

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
+    )
+}
+
+We prefer the first variant. But we follow the next naming convention of such methods: assert<YourCheckName>.

+

Test structure

+

Test and Test-case correlation

+

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)
+        }
+    }
+}
+
+Not bad. But can you correlate this code with the test-case easy? +No, you need to read the code of the test and the text of the test-case very attentively. It's not comfortable.
+So we want to have a structure of the test that would suggest what step of the test-case we are looking at in the particular area of the test.

+

Before/after state of a test

+

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.

+

Test structure

+

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()
+            )
+        }
+    }
+
+Let's describe the structure:
+1. before - after - run
+ In the beginning, we think about a state. After the state, we begin to consider the test body. +2. step
+ step in the test is similar to step in the test-case. That's why test reading is easier and understandable. +3. scenario
+ There are cases when some sentences of steps are absolutely identical and occur very often in tests. + For these sentences we have introduced a 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.

+

Test data for the test

+

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 {
+    // ...
+}
+
+1. init
+ Here, you prepare only sets of data without any transforms and connections. Also, you can make requests to your test server, for example.
+ It's an optional block. +2. transform
+ This construction is for transforming of our test data. In our example we join the owner and company.
+ It's an optional block. The block is enabled only after the 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!

+

Available Test DSL forms

+

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

+

Examples

+

You can have a look at examples of how to use and configure Kaspresso +and how to use different forms of DSL.

+

Sweet additional features

+

Some words about BaseTestContext method

+

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.

+

flakySafely

+

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)
+            }
+        }
+    }
+}
+
+More detailed examples are here. Please, observe a documentation about implementation details.

+

continuously

+

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()
+        }
+    }
+}
+
+The example is here.

+

compose

+

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()
+                    }
+                }
+            }
+        }
+    }
+}
+
+The example is here.
+Please, observe additional opportunities and documentation: common info, ComposeProvider and WebComposeProvider.

+

data

+

If you set your test data by init-transform methods then this test data is available by a data field.

+

testAssistants

+

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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/Wiki/index.html b/en/Wiki/index.html new file mode 100644 index 000000000..0ee39a67b --- /dev/null +++ b/en/Wiki/index.html @@ -0,0 +1,1034 @@ + + + + + + + + + + + + + + + + + + + + + + Introduction - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Kaspresso Wiki

+

Here you can find detailed information about all the Kaspresso features.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/index.html b/en/index.html new file mode 100644 index 000000000..e1fc64aef --- /dev/null +++ b/en/index.html @@ -0,0 +1,1617 @@ + + + + + + + + + + + + + + + + + + + + About Kaspresso - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Android Arsenal +Android Weekly +Android Weekly +MavenCentral +Build and Deploy +Telegram +Telegram

+

Kaspresso

+

Kaspresso is a framework for Android UI testing. Based on Espresso and UI +Automator, Kaspresso provides a wide range of additional features, such as:

+
    +
  • 100% stability, no flakiness.
  • +
  • Jetpack Compose support.
  • +
  • Significantly faster execution of UI Automator commands. With Kaspresso, some UI Automator commands run 10 times faster!
  • +
  • Excellent readability due to human DSL.
  • +
  • Useful interceptor mechanism to catch all actions and assertions in one place.
  • +
  • Full logging.
  • +
  • Ability to call ADB commands.
  • +
  • UI tests writing philosophy implemented with DSL.
  • +
  • Features screenshotting.
  • +
  • Robolectric support.
  • +
  • Allure support.
  • +
+

And many more!

+

Kaspresso

+

Integration

+

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()
+    }
+}
+
+
    +
  1. Add a dependency to build.gradle:
  2. +
+
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'
+}
+
+

Tutorial NEW

+

To make it easier to learn the framework, a step-by-step tutorial is available on our website.

+

Capabilities of Kaspresso

+

Readability

+

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())
+}
+
+Kakao: +
@Test
+fun testFirstFeature() {
+    mainScreen {
+        toFirstFeatureButton {
+            isVisible()
+            click()
+        }
+    }
+}
+
+We used the same approach to develop our own wrapper over UI Automator, and we called it Kautomator. Take a look at the code below:

+

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")
+
+Kautomator: +
MainScreen {
+    simpleEditText {
+        replaceText("Kaspresso")
+        hasText("Kaspresso")
+    }
+}
+
+Since Kakao and Kautomator provide almost identical APIs, you don’t have to care about what is under the hood of your tests, either Espresso or UI Automator. With Kaspresso, you write tests in the same style for both.

+

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()
+            )
+        }
+    }
+
+

Stability

+

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.

+

UI test libraries acceleration

+

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.

+

Interceptors

+

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:

+
    +
  • add custom actions to each framework operation like writing a log or taking a screenshot;
  • +
  • overcome flaky operations by re-running failed actions, scrolling the parent layout or closing the android system dialog;
  • +
+

and many more (see the manual).

+

Writing readable logs

+

Kaspresso writes its own logs, detailed and readable:

+

+

+

Ability to call ADB commands

+

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).

+

Ability to work with Android System

+

You can use Kaspresso classes to work with Android System.

+

For example, with the Device class you can:

+
    +
  • push/pull files,
  • +
  • enable/disable network,
  • +
  • give permissions like a user does,
  • +
  • emulate phone calls,
  • +
  • take screenshots,
  • +
  • enable/disable GPS,
  • +
  • set geolocation,
  • +
  • enable/disable accessibility,
  • +
  • change the app language,
  • +
  • collect and parse the logcat output.
  • +
+

(see more about the Device class).

+

Features screenshotting

+

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).

+

Configurability

+

You can tune any part of Kaspresso (read more).

+

Robolectric support

+

You can run your UI-tests on the JVM environment. Additionally, almost all interceptors improving stability, readability and other will work. +Read more.

+

Allure support

+

Kaspresso can generate very detailed Allure-reports for each test: + +More information is available here.

+

Jetpack Compose support

+

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.

+

Philosophy

+

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.

+

Wiki

+

For all information check Kaspresso wiki

+

Samples

+

All samples are available in the samples folder.

+

Most of the samples require AdbServer. To start AdbServer you should do the following steps:

+
    +
  1. Go to the Kaspresso folder +
    cd ~/Workspace/Kaspresso
    +
  2. +
  3. Start adbserver-desktop.jar +
    java -jar artifacts/adbserver-desktop.jar
    +
  4. +
+

Existing issues

+

All existing issues in Kaspresso can be found here.

+

Breaking changes

+

Breaking changes can be found here

+

Contribution

+

Kaspresso is an open source project, so you are welcome to contribute (see the Contribution Guidelines).

+

License

+

Kaspresso is available under the Apache License, Version 2.0.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/en/kaspresso.png b/en/kaspresso.png new file mode 100644 index 000000000..74e60208a Binary files /dev/null and b/en/kaspresso.png differ diff --git a/en/kaspresso_old.png b/en/kaspresso_old.png new file mode 100644 index 000000000..7c0700885 Binary files /dev/null and b/en/kaspresso_old.png differ diff --git a/en/users/RabotaRu.png b/en/users/RabotaRu.png new file mode 100644 index 000000000..dc6fab061 Binary files /dev/null and b/en/users/RabotaRu.png differ diff --git a/en/users/aliexpress.svg b/en/users/aliexpress.svg new file mode 100644 index 000000000..0e4b5bbe2 --- /dev/null +++ b/en/users/aliexpress.svg @@ -0,0 +1,146 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/en/users/aloha.png b/en/users/aloha.png new file mode 100644 index 000000000..f3cb2c92d Binary files /dev/null and b/en/users/aloha.png differ diff --git a/en/users/blinklist.png b/en/users/blinklist.png new file mode 100644 index 000000000..2ce1d9ee2 Binary files /dev/null and b/en/users/blinklist.png differ diff --git a/en/users/cft.png b/en/users/cft.png new file mode 100644 index 000000000..ef56ff579 Binary files /dev/null and b/en/users/cft.png differ diff --git a/en/users/cian.png b/en/users/cian.png new file mode 100644 index 000000000..f10f063ab Binary files /dev/null and b/en/users/cian.png differ diff --git a/en/users/delivery_club.png b/en/users/delivery_club.png new file mode 100644 index 000000000..072042d19 Binary files /dev/null and b/en/users/delivery_club.png differ diff --git a/en/users/dmtech.svg b/en/users/dmtech.svg new file mode 100644 index 000000000..af60ae807 --- /dev/null +++ b/en/users/dmtech.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/en/users/hh.png b/en/users/hh.png new file mode 100644 index 000000000..82887c646 Binary files /dev/null and b/en/users/hh.png differ diff --git a/en/users/kaspersky.svg b/en/users/kaspersky.svg new file mode 100644 index 000000000..2d2decdb2 --- /dev/null +++ b/en/users/kaspersky.svg @@ -0,0 +1,3 @@ + + +Layer 1 \ No newline at end of file diff --git a/en/users/letoile.svg b/en/users/letoile.svg new file mode 100644 index 000000000..812de733c --- /dev/null +++ b/en/users/letoile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/en/users/nexign.jpeg b/en/users/nexign.jpeg new file mode 100644 index 000000000..e3897749a Binary files /dev/null and b/en/users/nexign.jpeg differ diff --git a/en/users/profi.png b/en/users/profi.png new file mode 100644 index 000000000..75f76f4ed Binary files /dev/null and b/en/users/profi.png differ diff --git a/en/users/psb.jpeg b/en/users/psb.jpeg new file mode 100644 index 000000000..6e13a082b Binary files /dev/null and b/en/users/psb.jpeg differ diff --git a/en/users/raiffeisen.svg b/en/users/raiffeisen.svg new file mode 100644 index 000000000..8bb13145f --- /dev/null +++ b/en/users/raiffeisen.svg @@ -0,0 +1,377 @@ + + + + + + + + + + + + + + + + + + + + + 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 +4w== + + + \ No newline at end of file diff --git a/en/users/revolut.svg b/en/users/revolut.svg new file mode 100644 index 000000000..93d36c05d --- /dev/null +++ b/en/users/revolut.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/en/users/rostelecom.png b/en/users/rostelecom.png new file mode 100644 index 000000000..709c55527 Binary files /dev/null and b/en/users/rostelecom.png differ diff --git a/en/users/sberbank.svg b/en/users/sberbank.svg new file mode 100644 index 000000000..e771f2612 --- /dev/null +++ b/en/users/sberbank.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/en/users/squaregps.svg b/en/users/squaregps.svg new file mode 100644 index 000000000..5cc7f4df6 --- /dev/null +++ b/en/users/squaregps.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/en/users/superjob.png b/en/users/superjob.png new file mode 100644 index 000000000..582120979 Binary files /dev/null and b/en/users/superjob.png differ diff --git a/en/users/technology.png b/en/users/technology.png new file mode 100644 index 000000000..3a152d1af Binary files /dev/null and b/en/users/technology.png differ diff --git a/en/users/tinkoff.svg b/en/users/tinkoff.svg new file mode 100644 index 000000000..f51354736 --- /dev/null +++ b/en/users/tinkoff.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/en/users/vivid.money.png b/en/users/vivid.money.png new file mode 100644 index 000000000..e24669857 Binary files /dev/null and b/en/users/vivid.money.png differ diff --git a/en/users/vtb.svg b/en/users/vtb.svg new file mode 100644 index 000000000..0b081dd44 --- /dev/null +++ b/en/users/vtb.svg @@ -0,0 +1,23 @@ + + + + Group 6 + Created with Sketch. + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/en/users/x5.svg b/en/users/x5.svg new file mode 100644 index 000000000..5fde6abcd --- /dev/null +++ b/en/users/x5.svg @@ -0,0 +1,795 @@ + + + + + + + + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 +z/4fmc/kEg== + + + diff --git a/en/users/yandex_zen.svg b/en/users/yandex_zen.svg new file mode 100644 index 000000000..3abe04844 --- /dev/null +++ b/en/users/yandex_zen.svg @@ -0,0 +1,1348 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 000000000..b569cae3f --- /dev/null +++ b/index.html @@ -0,0 +1,1617 @@ + + + + + + + + + + + + + + + + + + + + About Kaspresso - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Android Arsenal +Android Weekly +Android Weekly +MavenCentral +Build and Deploy +Telegram +Telegram

+

Kaspresso

+

Kaspresso is a framework for Android UI testing. Based on Espresso and UI +Automator, Kaspresso provides a wide range of additional features, such as:

+
    +
  • 100% stability, no flakiness.
  • +
  • Jetpack Compose support.
  • +
  • Significantly faster execution of UI Automator commands. With Kaspresso, some UI Automator commands run 10 times faster!
  • +
  • Excellent readability due to human DSL.
  • +
  • Useful interceptor mechanism to catch all actions and assertions in one place.
  • +
  • Full logging.
  • +
  • Ability to call ADB commands.
  • +
  • UI tests writing philosophy implemented with DSL.
  • +
  • Features screenshotting.
  • +
  • Robolectric support.
  • +
  • Allure support.
  • +
+

And many more!

+

Kaspresso

+

Integration

+

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()
+    }
+}
+
+
    +
  1. Add a dependency to build.gradle:
  2. +
+
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'
+}
+
+

Tutorial NEW

+

To make it easier to learn the framework, a step-by-step tutorial is available on our website.

+

Capabilities of Kaspresso

+

Readability

+

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())
+}
+
+Kakao: +
@Test
+fun testFirstFeature() {
+    mainScreen {
+        toFirstFeatureButton {
+            isVisible()
+            click()
+        }
+    }
+}
+
+We used the same approach to develop our own wrapper over UI Automator, and we called it Kautomator. Take a look at the code below:

+

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")
+
+Kautomator: +
MainScreen {
+    simpleEditText {
+        replaceText("Kaspresso")
+        hasText("Kaspresso")
+    }
+}
+
+Since Kakao and Kautomator provide almost identical APIs, you don’t have to care about what is under the hood of your tests, either Espresso or UI Automator. With Kaspresso, you write tests in the same style for both.

+

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()
+            )
+        }
+    }
+
+

Stability

+

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.

+

UI test libraries acceleration

+

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.

+

Interceptors

+

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:

+
    +
  • add custom actions to each framework operation like writing a log or taking a screenshot;
  • +
  • overcome flaky operations by re-running failed actions, scrolling the parent layout or closing the android system dialog;
  • +
+

and many more (see the manual).

+

Writing readable logs

+

Kaspresso writes its own logs, detailed and readable:

+

+

+

Ability to call ADB commands

+

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).

+

Ability to work with Android System

+

You can use Kaspresso classes to work with Android System.

+

For example, with the Device class you can:

+
    +
  • push/pull files,
  • +
  • enable/disable network,
  • +
  • give permissions like a user does,
  • +
  • emulate phone calls,
  • +
  • take screenshots,
  • +
  • enable/disable GPS,
  • +
  • set geolocation,
  • +
  • enable/disable accessibility,
  • +
  • change the app language,
  • +
  • collect and parse the logcat output.
  • +
+

(see more about the Device class).

+

Features screenshotting

+

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).

+

Configurability

+

You can tune any part of Kaspresso (read more).

+

Robolectric support

+

You can run your UI-tests on the JVM environment. Additionally, almost all interceptors improving stability, readability and other will work. +Read more.

+

Allure support

+

Kaspresso can generate very detailed Allure-reports for each test: + +More information is available here.

+

Jetpack Compose support

+

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.

+

Philosophy

+

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.

+

Wiki

+

For all information check Kaspresso wiki

+

Samples

+

All samples are available in the samples folder.

+

Most of the samples require AdbServer. To start AdbServer you should do the following steps:

+
    +
  1. Go to the Kaspresso folder +
    cd ~/Workspace/Kaspresso
    +
  2. +
  3. Start adbserver-desktop.jar +
    java -jar artifacts/adbserver-desktop.jar
    +
  4. +
+

Existing issues

+

All existing issues in Kaspresso can be found here.

+

Breaking changes

+

Breaking changes can be found here

+

Contribution

+

Kaspresso is an open source project, so you are welcome to contribute (see the Contribution Guidelines).

+

License

+

Kaspresso is available under the Apache License, Version 2.0.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/kaspresso.png b/kaspresso.png new file mode 100644 index 000000000..74e60208a Binary files /dev/null and b/kaspresso.png differ diff --git a/kaspresso_old.png b/kaspresso_old.png new file mode 100644 index 000000000..7c0700885 Binary files /dev/null and b/kaspresso_old.png differ diff --git a/ru/Home/Breaking-changes/index.html b/ru/Home/Breaking-changes/index.html new file mode 100644 index 000000000..41ce52d44 --- /dev/null +++ b/ru/Home/Breaking-changes/index.html @@ -0,0 +1,1121 @@ + + + + + + + + + + + + + + + + + + + + + + Критические изменения - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Критические изменения

+ +

1.2.0

+
    +
  • Мы полностью переработали AdbServer. Kaspresso 1.2.0 и выше работает только с новым артефактом artifacts/adbserver-desktop.jar
    + Старая версия artifacts/desktop_1_1_0.jar также доступна для ранних версий Kaspresso.
  • +
  • Если вы использовали метод device.logcat в ваших тестах, вам следует использовать метод device.logcat.disableChatty в секции before. + В предыдущей версии Kaspresso device.logcat.disableChatty вызывался автоматически во время инициализации. Как результат, всегда приходилось перезапускать AdbServer перед каждым тестом.
  • +
+

1.2.1

+
    +
  • Kaspresso мигрировал на новую версию Kakao с новым именем пакета 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' или с помощью утилиты среды разработки для глобальной замены импортов.
  • +
+

1.5.0

+
    +
  • Из-за системных ограничений на доступ к памяти артифакты сохраняются в папку /sdcard/Documents. + Для записи видео необходимо использовать новый Kaspresso builder: Kaspresso.Builder.withForcedAllureSupport() и заменить test runner (io.qameta.allure.android.runners.AllureAndroidJUnitRunner) на com.kaspersky.kaspresso.runner.KaspressoRunner. + TestFailRule устарел. Поправили падающие скриншот-тесты. + Улучшено автоматическое закрытие системных окон. Посмотреть изменения можно здесь.
  • +
+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Home/Contribution_guide/index.html b/ru/Home/Contribution_guide/index.html new file mode 100644 index 000000000..c9db9b1cf --- /dev/null +++ b/ru/Home/Contribution_guide/index.html @@ -0,0 +1,1123 @@ + + + + + + + + + + + + + + + + + + + + + + Инструкция для разработчиков - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Инструкция для разработчиков

+

Инструкция по внесению изменений в проект

+
    +
  1. Выберите существующую задачу или заведите новую на нашем Github для фичи, которую вы хотите доработать.
  2. +
  3. Форкните проект на Github. Необходимо создать отдельную ветку для работы над форком. Это позволит отправить доработки на Pull request в основной проект Kaspresso.
  4. +
  5. Сделайте необходимые дорабтки в исходном коде.
  6. +
  7. Покройте новые доработки необходимыми тестами, которые проверят стабильность и соответствие ожидаемому поведению написанного кода, или исправьте существующие тесты на этот функционал.
  8. +
  9. Запустите все Unit и UI тесты и убедитесь в их успешном прохождении.
  10. +
  11. Запустите проверку покрытия кода Unit-тестами, чтобы убедиться, что для нового кода были написаны Unit-тесты.
  12. +
  13. После завершения разработки подготовьте commit с соответствующим и понятным комментарием и номером issue (задачи из списка).
  14. +
  15. Создайте pull request и дождитесь, пока другие участники посмотрят изменения.Необходимо принять соглашения CLA.
  16. +
  17. После получения подтверждения от других участников, можно вливать дорабтки в основной код. Обновленный код будет доступен пользователям в следующем релизе, а ваше имя будет добавлено в список авторов.
  18. +
+

Именование веток

+

issue-***/detailed_description. Пример: issue-306/fix-padding-breaks-autoscroll-interceptor

+

Коммиты

+

Сообщения к коммитам должны начинаться с: "Issue #***: ...". Пример: "Issue #306: Fixed padding-breaks autoscroll interceptor".

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Home/Kaspresso users/index.html b/ru/Home/Kaspresso users/index.html new file mode 100644 index 000000000..58ae35deb --- /dev/null +++ b/ru/Home/Kaspresso users/index.html @@ -0,0 +1,1125 @@ + + + + + + + + + + + + + + + + + + + + + + Наши пользователи - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Наши пользователи

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ www.kaspersky.ru + + hh.ru + + aliexpress.ru + + www.sber.ru + + www.revolut.com +
+ www.delivery-club.ru + + www.vtb.ru + + www.tinkoff.ru + + www.x5.ru + + www.zen.yandex.ru +
+ www.psbank.ru + + www.letoile.ru + + rtkit.ru + + ooo.technology + + www.blinkist.com +
+ www.rabota.ru + + www.cian.ru + + squaregps.com + + nexign.com + + profi.ru +
+ alohabrowser.com + + vivid.money + + raiffeisen.ru + + cft.ru + + superjob.ru +
+ dmtech.team +
+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Home/Kaspresso-in-articles/index.html b/ru/Home/Kaspresso-in-articles/index.html new file mode 100644 index 000000000..db6c10ce6 --- /dev/null +++ b/ru/Home/Kaspresso-in-articles/index.html @@ -0,0 +1,1040 @@ + + + + + + + + + + + + + + + + + + + + + + Kaspresso в статьях - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Kaspresso в статьях

+

[RU] Евгений Мацюк — Kaspresso: фреймворк для автотестирования, который вы ждали
+[RU] Иван Федянин — Kaspresso tutorials. Часть 1. Запуск первого теста
+[EN] Eugene Matsyuk — Kaspresso: The autotest framework that you have been looking forward to. Part I

+
+

Хочешь попасть в этот список? Все просто! Напиши статью про Kaspresso, пришли нам ссылку, и мы добавим её в этот список! +

+
+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Home/Kaspresso-in-videos/index.html b/ru/Home/Kaspresso-in-videos/index.html new file mode 100644 index 000000000..659b52228 --- /dev/null +++ b/ru/Home/Kaspresso-in-videos/index.html @@ -0,0 +1,1040 @@ + + + + + + + + + + + + + + + + + + + + + + Kaspresso в видео - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + + + + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Issues/Storage_issue/index.html b/ru/Issues/Storage_issue/index.html new file mode 100644 index 000000000..b59c47ed5 --- /dev/null +++ b/ru/Issues/Storage_issue/index.html @@ -0,0 +1,1070 @@ + + + + + + + + + + + + + + + + + + + + Работа с файлами - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Работа с файлами

+ +
+

Info

+

Описанная ниже проблема актуальна для версий Kaspresso ниже 1.5.0. Начиная с этой версии Kaspresso полноценно поддерживает новый формат работы с системной памятью.

+
+

Kaspresso может использовать память девайса для сохранения различных артефактов выполняемых тестов. Например, это могут быть скриншоты, дампы xml, журналы событий, видео и многое другое. +Однако, новые версии Android предполагают абсолютно новый способ взаимодействия с памятью - Scoped storage. +На версиях Kaspresso до 1.5.0 поддерживается работа с Scoped storage только через запрос различных разрешений. +Ниже предоставлена инструкция:

+
    +
  1. AndroidManifest.xml (эти изменения нужно внести только в debug версию сборки, чтобы изменения не затронули основной проектный файл) +
    # Пожалуйста, добавьте эти разрешения
    +<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>             
    +
  2. +
  3. В вашем тестовом классе добавьте: +
    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
    +    )
    +
    +    //...
    +}    
    +
  4. +
+

Это временное решение. Мы рекомендуем мигрировать на свежую версию Kaspresso (1.5.0 и выше) для избежания этих проблем.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Issues/index.html b/ru/Issues/index.html new file mode 100644 index 000000000..712e29201 --- /dev/null +++ b/ru/Issues/index.html @@ -0,0 +1,1207 @@ + + + + + + + + + + + + + + + + + + + + + + Нашли проблему? - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Нашли проблему?

+

Kaspresso объединило вокруг себя большое сообщество, которое позволяет улучшить фреймворк, предлагая новые идеи, сообщая о найденных ошибках с детальным описанием и оформляя pull request-ы, предоставляя готовую реализации доработок.

+

Создание новых issue

+

В нашей вкладке 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):
+...
+
+

Доработки в виде Pull request всегда приветствуются!

+

Если у вас есть не просто запрос на доработку, но и готовая реализация, вы можете отправить ее на Github, оформив Pull request.

+

Спасибо!

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Tutorial/Android_permissions/index.html b/ru/Tutorial/Android_permissions/index.html new file mode 100644 index 000000000..2ae6516ce --- /dev/null +++ b/ru/Tutorial/Android_permissions/index.html @@ -0,0 +1,1953 @@ + + + + + + + + + + + + + + + + + + + + + + 10. Работа с Android permissions - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Тестирование приложений, требующих разрешений

+

В этом уроке мы научимся работать с разрешениями (Permissions).

+

Часто для корректной работы приложению нужен доступ к определенным функциям мобильного устройства: к камере, записи голоса, совершению звонков, отправке SMS-сообщений и т.д. Приложение может получить доступ к ним и использовать только в том случае, если пользователь даст на это разрешение.

+

На старых устройствах ниже шестой версии Android (API level 23) такие разрешения запрашивались в момент установки приложения и, если пользователь установил его, то считалось, что он согласен со всеми разрешениями, и приложение будет иметь возможность использовать все необходимые функции. Это было небезопасно, так как открывало возможность недобросовестным разработчикам незаметно для пользователя получать доступ к микрофону, камере, звонкам и другим важным компонентам и использовать в своих целях.

+

По этой причине на более новых версиях так называемые «опасные» разрешения стали запрашиваться не в момент установки, а во время работы приложения. Теперь пользователь явно будет видеть диалог с предложением разрешить или отклонить запрос на использование какой-то функциональности.

+

Для примера запустите приложение tutorial на одной из последних версий Android (API 23 и выше) и нажмите кнопку Make Call Activity

+

Main Screen

+

У вас откроется экран, на котором есть два элемента – поле ввода и кнопка. В поле ввода можно указать какой-то номер телефона и кликнуть на кнопку Make Call для осуществления вызова

+

Make call screen

+

Совершение звонков – одна из функций, для работы которой требуется запросить разрешение у пользователя. Поэтому у вас отобразится диалог с предложением позволить приложению управлять звонками, на котором есть кнопки «Разрешить» и «Отклонить»

+

Request permissions

+

Если мы нажмем “Allow”, то начнется вызов абонента по тому номеру, который вы указали в поле ввода

+

Calling

+

При следующем открытии приложения разрешение больше не будет запрашиваться, оно сохраняется на устройстве. Если вы хотите отозвать разрешение, то можно это сделать в настройках. Для этого перейдите в раздел приложения, найдите нужное вам и заходите в раздел Permissions

+

Deny permission

+

Здесь вы сможете зайти в любое разрешение и изменить значение с 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 и выше

+
+

Запускаем тест. Тест провален.

+

Это произошло, потому что после клика по кнопке у пользователя было запрошено разрешение. Никто этого разрешения не дал, и следующий экран открыт не был.

+

Тестирование при помощи TestRule

+

Есть несколько вариантов решения проблемы. Первый вариант – использовать 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 для assertions

+

Вспомните урок про метод 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 не получится, так как в этом случае разрешение всегда будет одобрено, и в тестах мы никогда не узнаем, какое будет поведение, если запрос отклонить.

+

Тестирование при помощи Device.Permissions

+

Один из вариантов решения проблемы - взаимодействовать с диалогом при помощи KAutomator, предварительно найдя все необходимые элементы интерфейса, но это не слишком удобно, и в Kaspresso был добавлен намного более удобный способ - Device.Permissions. Он позволяет очень просто проверять диалоги разрешений, а также соглашаться с ними или отклонять.

+

Поэтому вместо Rule мы будем использовать объект Permissions, который можно получить у Device. Давайте сделаем это в отдельном классе, чтобы у вас сохранились оба варианта тестов. Класс, в котором мы сейчас работаем, переименуем в MakeCallActivityRuleTest.

+

Чтобы это сделать, кликните правой кнопкой на название файла и выберите Refactor -> Rename

+

Rename

+

И введите новое название класса:

+

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. После указания объекта можно поставить точку и посмотреть, какие у него есть методы:

+

Device permission methods

+

Есть возможность проверить, отображается ли диалог, а также отклонить или дать разрешение.

+
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()
+            }
+        }
+    }
+}
+
+

Тестирование на разных версиях API

+

На современных версиях ОС 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()
+        }
+    }
+}
+
+Теперь данный тест будет выполняться только на новых версиях ОС Android, а на старых будет пропускаться.

+

Второй вариант решения проблемы – пропускать какие-то определенные шаги или заменять их другими в зависимости от уровня 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.

+

Также мы узнали, что второй вариант предпочтительнее по ряду причин:

+
    +
  1. Объект Permissions дает возможность проверять отображение диалога с запросом разрешения
  2. +
  3. При использовании Permissions мы можем проверить поведение приложения не только при принятии разрешения, но также и при его отклонении
  4. +
  5. Тесты с применением GrantPermissionRule не будут работать, если разрешение было ранее отклонено. Потребуется переустановка приложения либо отмена выданных ранее разрешений через adb shell команду
  6. +
  7. Если во время выполнения теста отозвать разрешение при помощи adb shell команды, то в случае использования объекта Permissions тест будет работать корректно, а в случае использования GrantPermissionRule произойдет краш
  8. +
+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Tutorial/Download_Kaspresso_project_and_Android_studio/index.html b/ru/Tutorial/Download_Kaspresso_project_and_Android_studio/index.html new file mode 100644 index 000000000..529b0cd40 --- /dev/null +++ b/ru/Tutorial/Download_Kaspresso_project_and_Android_studio/index.html @@ -0,0 +1,1163 @@ + + + + + + + + + + + + + + + + + + + + + + 2. Скачиваем проект и Android studio - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Делаем первые шаги. Настраиваем необходимое окружение

+

В этом уроке мы скачаем проект Kaspresso, установим Android studio и настроим эмулятор.

+

Скачиваем Android Studio

+

Android Studio используется для разработки программ. Нам она понадобится для написания и запуска автотестов. +
Если у вас уже установлена Android Studio, то пропустите этот шаг. Если нет, то переходим по ссылке и нажимаем Download Android Studio.

+


Запускаем скачанный файл и проходим все шаги первичной настройки студии. Можно воспользоваться официальной инструкцией или официальной инструкцией в формате codelabs в случае возникновения проблем. +
После того как Android Studio будет скачана, запускаем ее.

+

Скачиваем проект Kaspresso

+

Для загрузки проекта необходимо, чтобы на вашем компьютера была установлена система контроля версий GIT. Загрузить GIT и узнать о нем подробнее вы можете здесь.

+

Когда GIT будет установлен, то вы сможете скачать проект. Для этого переходим по ссылке.

+

Нажимаем кнопку Code и копируем ссылку на репозиторий

+

Download Kaspresso button

+

Открываем Android Studio.

+

Если у вас ранее не был открыт никакой проект в студии, то необходимо выбрать пункт Get From VCS

+

Get Project from VCS

+

Если какой-то проект уже был запущен, то загрузить новый с GIT можно следующим образом: File -> New -> Project From Version Control

+

Get Project from VCS

+

В открывшемся окне введите скопированный URL проекта, выберите папку, в которой будет размещен Kaspresso, и нажмите clone.

+

Clone Project

+

Настройка эмулятора.

+

В верхнем меню Android Studio выбираем 'Tools' -> 'Device Manager'

+

Tools Device Manager

+

На экране появится вкладка управления эмуляторами и реальными устройствами. Нажимаем 'Create Device':

+

Create Device

+

Увидим следующий экран:

+

Select hardware

+

На этом экране можно задать характеристики "железа", эмуляцию которого хотим получить. В секции "1" можно выбрать телефон, планшет, телевизор и так далее. Нас интересует Телефон. В секции "2" - конкретную модель. В рамках туториала нет разницы, что выбрать. Выбираем 'Pixel 6'. Нажимаем 'Next' и попадаем на окно выбора образа операционной системы:

+

System image

+

Этот экран более важен в регулярной работе - здесь выбираем, какую версию Android установить на эмулятор. Давайте выберем 'R'. Нажимаем на иконку скачать справа от буквы 'R', проходим процесс установки и ожидаем.

+

SDK_component_isntaller

+

Когда процесс установки будет окончен, нажимаем кнопку 'Finish':

+

SDK_component_isntaller_finish

+

Выбираем установленную версию 'R' и нажимаем 'Next':

+

SDK_component_installer_next

+

На экране ниже можно сменить название создаваемого эмулятора, чтоб их было легко отличать между собой. Дефолтное значение для наших целей подходит. Нажимаем 'Finish'.

+

Device_name

+

Устройство настроено и готово к работе. Запускаем его по иконке 'Play' справа от названия девайса:

+

Launch_device

+

В некоторых случаях Android Studio может порекомендовать установить Hypervisor:

+

Hyper_Visor

+

Hyper_Visor_next

+

Итог

+

Android Studio установлена, эмулятор настроен, проект Kaspresso загружен. В следующем уроке запустим первые тесты.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Tutorial/FlakySafely/index.html b/ru/Tutorial/FlakySafely/index.html new file mode 100644 index 000000000..d91c325ec --- /dev/null +++ b/ru/Tutorial/FlakySafely/index.html @@ -0,0 +1,1652 @@ + + + + + + + + + + + + + + + + + + + + + + 9. flakySafely - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Flaky Safely. Тестирование с таймаутом

+

В данном уроке мы научимся тестировать экраны, состояние которых меняется с течением времени.

+

До сих пор во всех тестах экраны сразу имели финальный вид, все элементы отображались при их открытии, и мы могли проводить тесты. Для изменения стейта мы сами производили какие-то действия – кликали по кнопке, вводили текст в поле ввода и так далее.

+

Но часто возникает ситуация, когда внешний вид экрана меняется с течением времени. Например, на старте начинается загрузка данных – отображается ProgressBar, после загрузки отображается список элементов или диалог с сообщением об ошибке, если что-то пошло не так. В таких случаях во время теста нужно проверить все промежуточные состояния, при этом не меняя их из тестового метода.

+

Рассмотрим пример. Откройте приложение tutorial и кликните по кнопке Flaky Activity

+

Flaky activity button

+

На этом экране отображаются несколько TextView, для которых загружаются какие-то данные

+

Flaky screen 1

+

Через одну секунду загружается текст для первого элемента

+

Flaky screen 2

+

Еще через три секунды появляется текст у второго элемента

+

Flaky screen 3

+

Спустя 10 секунд произойдет загрузка остальных данных и тексты появятся у всех TextView

+

Flaky screen 4

+

Тестирование FlakyScreen

+

Давайте напишем тест на этот экран. Как обычно начнем с создания 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

+

Наш тест завершается успешно. Теперь давайте проверим, что будет, если мы уберем вызов метода 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()
+            }
+        }
+    }
+}
+
+Следующий шаг – через 10 секунд после загрузки данных для второго элемента, текст появляется во всех остальных 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 vs FlakySafely

+

В некоторых тестах можно увидеть такой код Thread.sleep(delay_in_millis), который используется для решения проблем с таймаутом вместо flakySafely. Этот код останавливает поток на то время, которое было передано в качестве параметра. То есть тест в этом месте прекратит свое выполнение и будет ждать в течение какого-то времени, после завершения таймаута тест продолжит работу.

+

На первый взгляд может показаться, что в этих способах нет разницы, и делают они одно и то же. Но на самом деле в них есть существенное отличие. Если вы используете flakySafely, то независимо от таймаута после успешного прохождения проверки тест продолжит выполняться. А при использовании Thread.sleep в любом случае тест будет ждать, пока таймаут не завершится.

+

В обычном случае все проверки в Kaspresso используют flakySafely с таймаутом 10 секунд, но, несмотря на это, тесты завершаются очень быстро, потому что, если метод вернул корректное значение, то никакого ожидания не будет. Если же все эти методы заменить на Thread.sleep, то каждая такая проверка будет занимать минимум 10 секунд и тесты будут прогоняться очень длительное время.

+

Какой таймаут указывать?

+

Зная о преимуществах flakySafely, которые мы только что обсудили, может возникнуть желание для всех тестов указывать очень большой таймаут просто на всякий случай. Но так делать не стоит по нескольким причинам.

+

Во-первых, если приложение действительно работает некорректно, и какие-то тесты будут падать, то их прохождение будет значительно дольше, чем при стандартном таймауте.

+

Во-вторых, в приложении могут быть какие-то ошибки, которые приводят к тому, что оно работает значительно медленнее, чем ожидается. В таком случае мы могли бы узнать о проблеме из автотестов, но при слишком большом таймауте она останется незамеченной.

+

Поэтому в большинстве случаев вам подойдет стандартный таймаут, и явно указывать его не придется. В остальных случаях указывайте таймаут, который будет приемлемым для пользователя.

+

Особенности работы со ScrollView

+

Вы могли обратить внимание, что все элементы на экране не помещаются, поскольку занимают довольно много места по высоте, поэтому весь контент был добавлен в 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.

+

Итог

+

В этом уроке мы рассмотрели следующие моменты:

+
    +
  1. Метод `flakySafely` для проверки экрана с изменяющимся состоянием
  2. +
  3. Установка разных таймаутов для различных операций
  4. +
  5. Особенности работы Kaspresso на экранах, которые можно скроллить
  6. +
  7. Отличия методов Thread.sleep и flakySafely
  8. + + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Tutorial/Logger_and_screenshot/index.html b/ru/Tutorial/Logger_and_screenshot/index.html new file mode 100644 index 000000000..6cded3833 --- /dev/null +++ b/ru/Tutorial/Logger_and_screenshot/index.html @@ -0,0 +1,1751 @@ + + + + + + + + + + + + + + + + + + + + + + 12. Логирование и скриншоты - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Логирование и скриншоты

+

В этом уроке мы научимся выявлять причины падающих тестов путем добавления дополнительных логов и скриншотов.

+

Вспомним пример, который уже использовался в одном из предыдущих уроков. Открываем приложение tutorial

+

Tutorial main screen

+

и кликаем на кнопку Login Activity

+

Login Activity

+

На этом экране можно ввести логин и пароль, и, если они будут корректные, то откроется экран после авторизации. Корректными в данном случае считаются: логин длиной от трех символов, пароль – от шести.

+

Screen after auth

+

Внешняя система для тестовых данных

+

Мы уже писали тесты для этого экрана, они находятся в классе 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

+

Create package 1

+

Create package 2

+

В созданном пакете добавляем класс TestData, тип выбираем Object

+

Create class

+

Как мы уже говорили ранее – здесь мы будем только моделировать ситуацию, когда данные для теста получаем из внешней системы. В созданном классе у нас будет два метода: один из них возвращает логин, другой – пароль. В реальных проектах эти данные мы бы запрашивали с сервера. Сейчас мы сами укажем, какие логин и пароль вернет система, но представляем, что для нас это «черный ящик», и мы не знаем, какие значения будут получены.

+

Добавляем в этом классе два метода. Пусть они возвращают корректные логин и пароль:

+

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

+

Test failed

+

Что мы отсюда видим? Первый шаг теста - авторизация (LoginScenario) выполнен успешно, а проверка на то, что после успешного логина открыт нужный экран – провалилась.

+

При этом, отсюда совершенно неясно, почему проблема возникла. Мы не видим, с какими данными была попытка залогиниться, действительно ли они корректные, и непонятно, как решать возникшую проблему. Результат был бы более понятный, если бы в логах содержалась информация – какие конкретно логин и пароль были использованы во время тестирования.

+

Добавление логов

+

Для того чтобы выводить различную информацию в Logcat, мы можем воспользоваться классом Log из пакета android.util. Для этого у класса Log необходимо вызвать один из публичных статических методов: i (info), d (debug), w (warning), e (error). Все эти методы по сути делают одно и то же - выводят сообщение в журнал, но среди них есть отличие. Для того чтобы упростить поиск и чтение логов, их делят на несколько уровней:

+
    +
  • Debug — сообщения для отладки программы
  • +
  • Error — серьезные ошибки, возникшие во время работы программы
  • +
  • Warning — предупреждения. Программа может продолжать работу, но рекомендуется обратить внимание на какую-то проблему
  • +
  • Info — простые сообщения, содержащие различного рода информацию. Система работает нормально
  • +
+ +

В зависимости от типа сообщения, которое вы хотите вывести в журнал, необходимо вызвать метод с соответствующим уровнем логирования.

+
+

Info

+

Более подробную информацию про уровни логирования и вывод сообщений в Logcat можно почитать в официальной документации

+
+

Например, в нашем случае мы хотим в журнале показать данные, которые использовались при авторизации - это простое информационное сообщение, которое не говорит об ошибках в работе программы или каких-то предупреждениях, а также не используется для отладки, поэтому нам подойдет уровень логирования info - метод Log.i().

+

В качестве параметра этому методу нужно передать два аргумента типа String - две строчки:

+
    +
  1. Тэг. По этому тэгу мы будем искать нужное нам сообщение в журнале.
  2. +
  3. Текст сообщения
  4. +
+ +

Раньше необходимые сообщения в журнале мы искали по тэгу "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

+

Подробнее о том, как формировать строку с использованием переменных и методов, можно почитать в документации

+
+

Давайте запустим тест еще раз и посмотрим логи:

+

Custom Log

+

После 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)
+            }
+        }
+    }
+}
+
+

Custom Log

+

Добавление собственных логов используется очень часто на практике, поэтому для удобства в 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

+

Screenshots

+

Здесь по скриншотам можно определить, в чем проблема – на этапе установки пароля количество введенных символов – 3

+

Setup password

+

Так, проанализировав скриншоты, можно определить, какая ошибка возникла в момент проведения тестов.

+
+

Info

+

Один из способов сделать скриншот – вызвать метод device.uiDevice.takeScreenshot. Это метод из библиотеки uiautomator и использовать его напрямую никогда не следует.

+

Во-первых, скриншот, сделанный при помощи Kaspresso (device.screenshots.take), будет лежать в нужной папке, которую легко найти по названию теста, и файлы для каждого теста и шага будут находиться в своих папках с понятными названиями, а в случае с uiautomator находить нужные скриншоты будет проблематично.

+

Во-вторых, в Kaspresso сделано множество удобных доработок по работе со скриншотами таких как: масштабирование, настройка качества фото, полноэкранные скрины (когда весь контент не помещается на экране) и так далее.

+

Поэтому для скриншотов всегда используйте только объекты Kaspresso device.screenshots.

+
+

Настройка Kaspresso.Builder

+

Теоретически, все тесты, которые вы пишете, могут упасть. В таких случаях хотелось бы всегда иметь возможность посмотреть скриншоты, чтобы понять, что пошло не так. Как этого добиться? Как вариант – во все шаги всех тестов добавлять вызов метода, который делает скриншот, но это не слишком удобно.

+

Поэтому в Kaspresso была добавлена возможность настройки параметров теста при создании тестового класса. Для этого в конструктор TestCase можно передать объект Kaspresso.Builder, у которого можно указать различные настройки.

+

Test Case Params

+
+

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

+

При использовании advanced builder-а появляется еще несколько изменений. Кроме скриншотов добавляются также файлы с логами, иерархией View и другое.

+

Если вам не нужны все эти артефакты, то можно изменить только определенные настройки простого builder-а

+
+

Info

+

Если вы испытываете сложности с кастомизацией builder-ов, то используйте advanced builder для получения скриншотов

+
+

Interceptors

+

Следует помнить, что в предыдущих тестах кроме выполнения наших методов «под капотом» происходило много дополнительных действий: запись логов для каждого шага, неявный вызов flakySafely, автоматический скролл до элемента, если проверка выполнилась неуспешно, и так далее.

+

Все это работало благодаря Interceptor-ам. Interceptor — это класс, который перехватывает вызываемые нами действия и добавляет в них какую-то функциональность. Таких классов в Kaspresso достаточно много, подробнее о них вы можете почитать в документации

+

Нас интересует добавление скриншотов, за это отвечают классы ScreenshotStepWatcherInterceptor, ScreenshotFailStepWatcherInterceptor и TestRunnerScreenshotWatcherInterceptor.

+
    +
  • ScreenshotStepWatcherInterceptor – добавляет скриншоты независимо от того, шаг завершился с ошибкой или нет +
  • +
  • ScreenshotFailStepWatcherInterceptor – добавляет скриншот только того шага, который завершился с ошибкой +
  • +
  • TestRunnerScreenshotWatcherInterceptor – добавляет скриншот, если произошла ошибка в секции before или after +
  • +
+ +

Если тест падает, то удобно смотреть не только шаг, на котором произошла ошибка, но и предыдущие – таким образом разобраться в проблеме бывает гораздо проще. Поэтому мы добавим первый вариант Interceptor-а, который скриншотит все шаги, независимо от результата. Делается это следующим образом:

+

class LoginActivityGeneratedDataTest : TestCase(
+    kaspressoBuilder = Kaspresso.Builder.simple().apply {
+        stepWatcherInterceptors.add(ScreenshotStepWatcherInterceptor(screenshots))
+    }
+)
+
+Здесь мы сначала получаем builder по умолчанию 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, после каждого шага теста, независимо от результата его выполнения, на устройстве будут сохранены скриншоты.

+

Запускаем. Тест завершился неудачно, и на устройстве были сохранены скриншоты

+

Customized Builder

+

Давайте вернем корректную реализацию класса TestData

+
package com.kaspersky.kaspresso.tutorial.data
+
+object TestData {
+
+    fun generateUsername(): String = "Admin"
+
+    fun generatePassword(): String = "123456"
+}
+
+

Запустим тест еще раз. Тест пройден успешно, и все скриншоты сохранены на устройстве.

+
+

Info

+

Обратите внимание на то, что скриншоты сохраняются на тестируемом устройстве. Поэтому, если вы делаете скриншоты для каждого шага независимо от результата, то размер артефактов после прогона тестов может быть очень большим. Это может стать проблемой, особенно если ваши тесты запускаются на CI. Поэтому злоупотреблять скриншотами не следует, используйте их сохранение только в случае необходимости.

+
+

Итог

+

В этом уроке мы узнали, как в наши тесты добавить логирование и скриншоты. Узнали, в каких случаях стандартных логов бывает недостаточно, научились настраивать Kaspresso.Builder, добавляя в него различные Interceptor-ы.

+


+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Tutorial/Logger_and_screenshots/index.html b/ru/Tutorial/Logger_and_screenshots/index.html new file mode 100644 index 000000000..de3204768 --- /dev/null +++ b/ru/Tutorial/Logger_and_screenshots/index.html @@ -0,0 +1,1010 @@ + + + + + + + + + + + + + + + + + + Logger and screenshots - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Logger and screenshots

+ + + + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Tutorial/Recyclerview/index.html b/ru/Tutorial/Recyclerview/index.html new file mode 100644 index 000000000..f2d08d4a5 --- /dev/null +++ b/ru/Tutorial/Recyclerview/index.html @@ -0,0 +1,1819 @@ + + + + + + + + + + + + + + + + + + + + + + 11. RecyclerView - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

RecyclerView. Тестирование списков

+

На практике часто приходится работать с экранами, которые содержат списки элементов, причем эти списки динамические, и их размер и содержимое могут изменяться. При тестировании таких экранов есть свои особенности. О них мы поговорим в этом уроке.

+

Откройте приложение tutorial и кликните по кнопке List Activity.

+

Main Screen

+

У вас откроется следующий экран:

+

Todo List

+

На нем отображается список дел пользователя. У каждого элемента списка есть порядковый номер, текст и цвет, который устанавливается в зависимости от приоритета. Если приоритет низкий, то цвет фона зеленый, если средний, то оранжевый, если высокий, то красный.

+

Также имеется возможность удалять элементы списка при помощи свайпа.

+

Swipe element

+

Remove element

+

Давайте напишем тесты на этот экран. Нам понадобится id элементов списка, для их поиска воспользуемся LayoutInspector

+

Layout Inspector

+

Обратите внимание, что все элементы списка лежат внутри 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-ом, поэтому мы создаем класс, и для каждого элемента будем получать экземпляр этого класса.

+
+

Info

+

Подробнее про классы и объекты вы можете почитать в официальной документации Kotlin.

+
+

В заметках нам нужны будут элементы - корневой 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) {
+
+    }
+}
+
+Можем в NoteItemScreen добавлять элементы интерфейса, которые будем тестировать.

+

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) }
+    }
+}
+
+Теперь давайте в Page Object 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) }
+}
+
+Теперь можно приступать к проверке экрана со списком заметок.

+

Тестирование NoteListScreen

+

Создаем класс для тестирования, и, как обычно, добавляем переход на данный экран:

+
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”.

+

Wait for idle

+

Вы могли обратить внимание, что все проверки выполняются сразу после свайпа, даже не дожидаясь завершения анимации. Сейчас тест проходит успешно, но иногда это может привести к ошибкам.

+

Поэтому в случаях, когда какое-то действие выполняется с анимацией и для его завершения требуется время, можно вызвать метод 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")
+                    }
+                }
+            }
+        }
+    }
+}
+
+

Extract methods to Page Object

+

Остался еще один момент, который мы рассмотрим в этом уроке.

+

Бывают случаи, когда в 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. Узнали, как можно найти элементы, как взаимодействовать с ними и проверять их поведение на соответствие ожидаемому результату.

+


+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Tutorial/Scenario/index.html b/ru/Tutorial/Scenario/index.html new file mode 100644 index 000000000..c474307e0 --- /dev/null +++ b/ru/Tutorial/Scenario/index.html @@ -0,0 +1,1754 @@ + + + + + + + + + + + + + + + + + + + + + + 7. Вынесение дублирующихся шагов в Scenario - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Scenario

+

В этом уроке мы познакомимся со сценариями (класс Scenario из библиотеки Kaspresso), узнаем, что это, для чего они нужны, когда их стоит использовать, а когда лучше избегать.

+

Открываем приложение tutorial и кликаем по кнопке Login Acitivity.

+

Main Screen login button

+

У нас открывается экран авторизации, где пользователь может ввести логин и пароль и нажать на кнопку Login

+

Login activity

+

Если поле username будет содержать менее трех символов или поле password - менее шести символов, то при клике на кнопку LOGIN ничего не произойдет.

+

Если же данные заполнены корректно, то авторизация проходит успешно и у нас открывается экран AfterLoginActivity.

+

Screen After Login

+

Получается, что для проверки экрана AfterLoginActivity пользователь должен быть авторизован в приложении. Поэтому давайте первым делом протестируем авторизацию - LoginActivity.

+

Тестирование 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()
+                    }
+                }
+            }
+        }
+    }
+}
+
+

Теперь, когда целевой экран открыт, можем тестировать его. На текущем этапе добавим только проверку позитивного сценария, когда пользователь успешно ввел логин и пароль:

+
    +
  1. Все элементы видимы и кнопка кликабельна
  2. +
  3. Поля ввода содержат соответствующие подсказки
  4. +
  5. Если поля ввода содержат валидные данные, то происходит переход на следующий экран
  6. +
+ +

Для того, чтобы проверить, какая активити сейчас открыта, можно воспользоваться методом: 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()
+
+

Запускаем тесты – они все пройдены успешно.

+

Обратите внимание на код, который мы используем внутри этих тестов. Для каждого теста мы делаем следующее:

+
    +
  1. Объявляем переменные `username` и `password`, присваиваем им разные значения в зависимости от проверки, которую будем производить
  2. +
  3. Открываем экран авторизации
  4. +
  5. Проверяем видимость элементов
  6. +
  7. Вводим логин и пароль в соответствующие поля и кликаем на кнопку "Login"
  8. +
  9. Проверяем, что у нас открывается нужный экран
  10. +
+ +

В зависимости от того, что мы проверяем в каждом конкретном тесте, у нас отличаются первый и последний шаги. На первом шаге мы присваиваем разные значения переменным username и password, на последнем шаге мы делаем разные проверки на то, какой экран открыт - LoginActivity или AfterLoginActivity.

+

При этом шаги со второго по четвертый абсолютно одинаковые для всех тестов. Это один из случаев, когда мы можем использовать класс Scenario.

+

Создание 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()
+                }
+            }
+        }
+    }
+}
+
+

Использование Scenario

+

Сценарий готов, можем его использовать в тестах. Давайте сначала используем сценарий в первом тестовом методе, а потом по аналогии сделаем и в остальных:

+
    +
  1. Создаем step, в котором пытаемся залогиниться с корректными данными
  2. +
  3. Вызываем функцию `scenario`
  4. +
  5. В качестве параметра этой функции мы передаем объект LoginScenario
  6. +
  7. В конструктор LoginScenario передаем корректные логин и пароль
  8. +
  9. Добавляем step, в котором проверяем, что после логина открывается экран `AfterLoginActivity`
  10. +
+ +
@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)
+                    }
+                }
+            }
+        }
+    }
+}
+
+

Таким образом, благодаря использованию сценариев, код становится чистым, понятным и переиспользуемым. А для проверки экранов, доступных только авторизованным пользователям, теперь не нужно делать множество одинаковых шагов.

+

Best practices

+

Сценарии очень удобная вещь, если ими правильно пользоваться.

+
    +
  • Если для выполнения разных тестов приходится делать одни и те же шаги, то это тот случай, когда стоит создать сценарий. Примеры: экраны авторизации, онбординга, оплаты покупок и т.д.
  • +
  • Не следует использовать одни сценарии внутри других – такой код может стать сильно запутанным, это усложнит его переиспользование, ухудшит читаемость, и вы потеряете все преимущества сценариев.
  • +
  • Создавайте сценарии только по мере необходимости. Не следует их создавать, только потому что когда-то в будущем эти шаги могут использоваться в других тестах. Если вы видите, что шаги повторяются в разных тестах, то можно создать сценарий, если нет – не стоит этого делать. Их количество в проекте должно быть минимальным.
  • +
+ +

Итог

+

В сегодняшнем уроке мы узнали, что такое сценарии, научились их создавать, использовать и передавать параметры в их конструктор. Также мы рассмотрели случаи, когда их использование приносит пользу проекту, а когда наоборот – ухудшает читаемость кода, увеличивает его связность и усложняет переиспользование.

+


+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Tutorial/Screenshot_tests_1/index.html b/ru/Tutorial/Screenshot_tests_1/index.html new file mode 100644 index 000000000..247f63763 --- /dev/null +++ b/ru/Tutorial/Screenshot_tests_1/index.html @@ -0,0 +1,1290 @@ + + + + + + + + + + + + + + + + + + + + + + 13. Screenshot-тесты. Часть 1. Простой screenshot тест - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Screenshot-тесты. Часть 1. Простой screenshot тест

+

В этом уроке мы научимся писать screenshot-тесты, узнаем, зачем они нужны, и как правильно разрабатывать приложение, чтобы его можно было покрыть тестами.

+

Продвинутый уровень

+

Ранее для успешного прохождения уроков было достаточно базовых навыков программирования на Kotlin, знания Android-разработки не требовались. Однако сегодня мы начинаем углубленное изучение фреймворка Kaspresso, и для последующих тем потребуется более глубокое понимание устройства приложений, архитектурного шаблона MVVM, применения Dependency Injection и других концепций.

+

Если у вас возникают трудности с пониманием этих тем, вы все равно можете приступить к прохождению уроков, чтобы иметь представление о возможностях Kaspresso. Однако имейте в виду, что часть материала может быть непонятной на данном этапе.

+

Тестирование LoginActivity на разных локалях

+

Чтобы узнать, зачем нужны скриншот-тесты, разберем небольшой пример. Представим, что наше приложение должно быть локализовано на французский язык. Для этого в проекте были добавлены переводы в файл strings.xml в папку values-fr.

+

French resources

+

Давайте установим на устройстве французский язык

+

Install french locale

+

и запустим LoginActivityTest.

+

Tests completed successfully

+

Тест пройден успешно, значит теоретически это приложение рабочее, и его можно раскатывать на пользователей. Но давайте откроем LoginActivity вручную (французский язык должен быть установлен на устройстве) и посмотрим, как выглядит этот экран.

+

Todo instead of strings

+

Видим, что вместо корректных текстов здесь указано «TODO: add french locale». Похоже, что разработчики во время добавления строк оставили комментарий, чтобы добавить переводы в будущем, но забыли это сделать, поэтому приложение выглядит некорректно. Обнаружить эту ошибку тесты не могут, потому что они не знают, какой должен быть текст на французском языке. По этой причине приложение работает неправильно, но тесты проходят успешно.

+

Screenshot-тесты, как решение проблемы со строками

+

Возникшую проблему могут решить скриншот-тесты. Их суть заключается в том, что для всех экранов, где пользователю отображаются строки, создаются так называемые «скриншотилки» – классы, которые делают скриншоты экрана во всех необходимых состояниях и для всех поддерживаемых языков.

+

После выполнения таких тестов скриншоты складываются в определенные папки. Затем их можно посмотреть и убедиться, что для всех локалей и для всех состояний используются корректные значения.

+

Для создания screenshot-тестов можно воспользоваться уже написанными ранее тестами, внеся в них несколько изменений. В таком случае будут выполняться те же проверки, что и раньше, но также добавится сохранение скриншотов на определенных этапах. Так можно сделать, но это не считается хорошей практикой.

+

Дело в том, что screenshot-тесты предназначены для того, чтобы предоставить снимки определенного экрана во всех возможных состояниях и для всех локалей. В некоторых случаях получение всех возможных состояний экрана может занять длительное время.

+

К примеру, вам нужно узнать, как будет выглядеть экран, если пользователь только что прошел процесс регистрации. Тогда, для того чтобы получить снимок экрана, вам придется проходить регистрацию заново, причем делать это для каждой локали. Тогда один прогон теста может занять несколько минут вместо двух-трех секунд.

+

По этой причине screenshot-тесты обычно делают максимально "легковесными":

+

Во-первых, вместо того, чтобы проходить весь процесс от старта приложения до открытия нужного экрана, мы сразу будем открывать Activity или Fragment, скриншоты которого хотим получить.

+

Во-вторых, мы не будем добавлять проверки элементов или выполнять шаги, имитирующие действия пользователя, как мы делали ранее. Наши цели –

+
    +
  1. Открыть экран
  2. +
  3. Установить нужное состояние
  4. +
  5. Сделать скриншот
  6. +
  7. При необходимости изменить состояние и снова сделать скриншот
  8. +
+ +

Дальше нужно поменять локаль и повторить все перечисленные действия.

+

Подробнее про состояния (или стейты, как их часто называют), и как их правильно устанавливать, мы поговорим в следующем уроке, а сейчас напишем простой screenshot-тест, который откроет экран LoginActivity, сделает скриншот, затем сменит язык на устройстве на французский и снова сделает скриншот.

+

Простой screenshot-тест

+

Создание screenshot-теста начинается так же, как мы делали ранее – в папке тестов создаем новый класс. Классы для скриншотов обычно называются с окончанием Screenshots. Давайте все скриншот-тесты будем хранить в отдельном пакете, назовем его screenshot_tests.

+

В этом пакете создаем класс LoginActivityScreenshots

+

Creating screenshot test class

+

У тестов, которые мы сейчас будем создавать, есть особенности: во-первых, они должны запускаться для разных локалей, во-вторых, полученные скриншоты должны быть размещены в удобной структуре папок – для каждого языка своя папка. По этим причинам тестовый класс мы унаследуем от класса 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 вы сможете найти все скриншоты, при этом для каждой локали была создана своя папка, и вы сможете просмотреть, как выглядит ваше приложение на разных языках.

+

Screenshot test results

+

Initial state en

+

Initial state fr

+

Теперь, просмотрев скриншоты, можно увидеть проблему в приложении из-за отсутствия необходимых переводов строк и исправить ошибку, добавив необходимые значения в файл values-fr/strings.xml.

+
+

Info

+

Возможно, на некоторых устройствах при смене локали у вас возникнет проблема с заголовком экрана – весь контент на экране будет корректно переведен на необходимый язык, а заголовок останется прежним. Проблема связана с багом в библиотеке Google. Его уже пофиксили, как только опубликуют соответствующий релиз, внесем изменения в Kaspresso.

+
+

Итог

+

В данном уроке мы рассмотрели: зачем нужны скриншот-тесты, как их писать и где смотреть результаты выполнения тестов.

+

Тема screenshot-тестов довольно обширная, и для более комфортного освоения мы ее разбили на несколько частей. В следующем уроке мы более подробно разберем тему стейтов, как их правильно устанавливать, и что нужно учитывать при разработке приложения, чтобы его можно было покрыть тестами.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Tutorial/Screenshot_tests_2/index.html b/ru/Tutorial/Screenshot_tests_2/index.html new file mode 100644 index 000000000..ef55244b3 --- /dev/null +++ b/ru/Tutorial/Screenshot_tests_2/index.html @@ -0,0 +1,1925 @@ + + + + + + + + + + + + + + + + + + + + + + 14. Screenshot-тесты. Часть 2. Установка стейтов и работа с ViewModel - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Screenshot-тесты. Часть 2. Установка стейтов и работа с ViewModel.

+

Если в вашем приложении планируется использование screenshot-тестов, то этот момент нужно учитывать не только при написании тестов, но также при разработке приложения. В сегодняшнем уроке мы поближе познакомимся с установкой стейтов, внесем изменения в код приложения, чтобы его можно было покрыть тестами, и напишем первый скриншот тест, в котором будет работа с ViewModel.

+

Предварительные знания

+

Если вы ранее не разрабатывали приложения под Android, то сегодняшний урок может быть сложным для понимания. Поэтому мы настоятельно рекомендуем перед прохождением данного урока ознакомиться со следующими темами:

+
    +
  1. Фрагменты – что это, и как с ними работать
  2. +
  3. ViewModel и шаблон проектирования MVVM
  4. +
  5. StateFlow
  6. +
  7. Библиотека Mockk
  8. +
  9. Kotlin coroutines
  10. +
+

Обзор тестируемого приложения

+

В приложении, которое мы сегодня будем покрывать тестами, имитируется загрузка данных. При клике на кнопку начинается загрузка, отображается прогресс бар, после окончания загрузки, в зависимости от результата, мы увидим либо загруженные данные, либо сообщение об ошибке.

+

Откройте приложение tutorial и кликнете по кнопке «Load User Activity»

+

Tutorial app

+

Давайте сразу начнем разбираться, что такое стейты. После перехода по кнопке у вас откроется экран, на котором располагается одна кнопка и больше ничего нет.

+

Initial state

+

При клике на данную кнопку внешний вид экрана изменится, то есть изменится его состояние или, как его часто называют, стейт. Сейчас мы видим исходный стейт экрана, назовем его Initial.

+

Кликнете по этой кнопке и обратите внимание, как изменится стейт экрана.

+

Progress state

+

Кнопка стала неактивной, то есть по ней больше нельзя кликать, и на экране появился Progress Bar, который показывает, что идет процесс загрузки. Этот стейт отличается от исходного, назовем этот стейт Progress.

+

Через несколько секунд загрузка будет завершена, и вы увидите на экране загруженные данные пользователя (его имя и фамилию).

+

Content state

+

Это третий стейт экрана. В таком состоянии кнопка снова становится активной, прогресс бар скрывается, и на экране отображаются имя и фамилия пользователя. Назовем такой стейт Content.

+

В данном случае мы имитируем загрузку данных, в реальных приложениях работа с интернетом может завершиться с ошибкой. Такие ошибки нужно каким-то образом обрабатывать, к примеру, показывать уведомление пользователю. В нашем тестовом приложении мы сымитировали подобное поведение. Если вы попытаетесь загрузить пользователя несколько раз, то какая-то из этих попыток завершится с ошибкой и вы увидите следующее состояние экрана:

+

Error state

+

Это четвертый и последний стейт экрана, который показывает состояние ошибки, назовем его Error.

+

Простой Screenshot-тест

+

Перед тем, как мы изучим правильный способ покрытия тестами данного экрана, давайте напишем тесты тем способом, который мы уже знаем. Это нужно для того, чтобы вы понимали, почему так делать не стоит, и зачем нам вносить изменения в уже написанный код.

+

В пакете screenshot_tests создаем класс LoadUserScreenshots

+

Create class

+

Наследуемся от DocLocScreenshotTestCase и передаем список языков в качестве параметра конструктору, сделаем скриншоты для английской и французской локалей

+

package com.kaspersky.kaspresso.tutorial.screenshot_tests
+
+import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase
+
+class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+}
+
+Как мы говорили ранее – screenshot-тесты должны быть максимально легковесными, чтобы их прохождение занимало как можно меньше времени, поэтому вместо открытия главного экрана и перехода на экран загрузки данных пользователя, мы сразу будем открывать 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

+

Create page 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, и каждый из этих тестов будет зависеть от скорости интернет-соединения и от работоспособности сервера.

+

Также сервер может вернуть ошибку, когда мы ее не ожидали, в этом случае тест завершится неудачно.

+

На определенном этапе теста мы хотим сделать скриншот состояния ошибки. В данном случае мы одинаково реагируем на любой тип ошибки, показывая строчку «Что-то пошло не так», но часто бывают случаи, когда пользователю нужно сообщать о том, что конкретно произошло. Например, при отсутствии интернета, показать уведомление об этом, при ошибке на сервере показать соответствующий диалог и так далее. Поэтому в каких-то случаях для воспроизведения стейта с ошибкой будет достаточно отключить интернет на устройстве, а в каких-то это может стать проблемой, которую не так просто решить.

+

Так мы приходим к следующему выводу: получить все необходимые стейты, имитируя действия пользователя, для скриншот-тестов нецелесообразно.

+

Во-первых, это может сильно замедлить выполнение теста.

+

Во-вторых, такие тесты начинают зависеть от многих факторов, таких как скорость интернета и работоспособность сервера, следовательно вероятность того, что эти тесты завершатся неудачно, возрастает.

+

В-третьих, некоторые состояния экрана получить очень сложно, и этот процесс может занять длительное время

+

Взаимодействие View и ViewModel

+

По причинам, рассмотренным выше, в скриншот-тестах стейты устанавливают другим способом. Имитировать действия пользователя мы не будем.

+

На этом этапе важно понимать паттерн 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
+        }
+    }
+}
+
+View (в данном случае фрагмент 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

+

Давайте внутри тестового класса создадим объект ViewModel, у которого будем устанавливать стейт

+

class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") {
+
+    val viewModel = LoadUserViewModel()
+
+
+}
+
+Теперь в эту ViewModel внутри тестового метода мы будем устанавливать новый стейт. Давайте попробуем установить какое-то новое значение в переменную 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()
+        }
+    }
+}
+
+В этой Activity почти нет кода. Дело в том, что в последнее время большинство приложений используют подход Single Activity. При таком подходе все экраны создаются на фрагментах, а активити служит лишь контейнером для них. Если вы хотите узнать больше о преимуществах этого подхода, то мы советуем почитать документацию. Что нужно понимать сейчас – внешний вид экрана и взаимодействие с ViewModel реализовано внутри 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, вызов этого метода 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 можно передать стиль, который должен использоваться внутри фрагмента, его можно найти в манифесте приложения

+

Style

+

Передать этот стиль в метод 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-тестами.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Tutorial/Steps_and_sections/index.html b/ru/Tutorial/Steps_and_sections/index.html new file mode 100644 index 000000000..2d57a088b --- /dev/null +++ b/ru/Tutorial/Steps_and_sections/index.html @@ -0,0 +1,1621 @@ + + + + + + + + + + + + + + + + + + + + + + 6. Steps and sections - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Выделение секций и шагов

+

Улучшаем код

+

В прошлом уроке мы написали тест на экран проверки доступности интернета, код тестового класса выглядел вот так:

+
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

+

Logcat

+

Здесь отображается множество логов и найти наши довольно сложно. Мы можем отфильтровать логи по тэгу, который указали ("KASPRESSO"). Для этого кликните на стрелку в правой верхней части Logcat и выберите пункт Edit Configuration

+

Edit configuration

+

У вас откроется окно создания фильтра. Добавьте название фильтра, а также тэг, который нас интересует:

+

Create filter

+

Теперь у нас отображается только полезная информация. Давайте очистим лог

+

Clear logcat

+

и запустим тест еще раз. Не забываем перед этим включать интернет на устройстве. Читаем логи:

+

Log step 1

+

Здесь идут логи, которые мы добавили - шаг 1 запущен, затем выполняются проверки, затем шаг 1 завершился успешно.

+

Смотрим дальше:

+

Log step 2

+

Log step 2

+

Со вторым и третьим шагами также все хорошо. Нам понятно, когда и какой шаг начинает выполнение, видны конкретные действия, которые в данный момент выполняет тест, и виден результат работы теста.

+

Теперь давайте выключим интернет и запустим тест еще раз. По нашей логике тест должен завершиться неудачно.

+

Несмотря на то, что тест должен был завершиться с ошибкой, все тесты зеленые. Смотрим в лог - сейчас нас интересует step 2, который должен был завершиться неудачно из-за того, что изначально интернет на устройстве выключен

+

Log step 2 failed

+

Судя по логам, 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 и т.д).

+

Steps

+

Для того чтобы упростить написание тестов и сделать код более читаемым и расширяемым, в 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)
+                }
+            }
+        }
+    }
+}
+
+

Включаем интернет на устройстве и запускаем тест. Тест пройден успешно. Смотрим логи:

+

Log with steps

+

Таким образом, благодаря использованию step-ов, не только наш код стал более понятным и легким для восприятия, но также и логи имеют понятную структуру и позволяют быстро определить, какие этапы выполнялись и какой результат этих операций.

+

Давайте еще раз запустим этот тест теперь уже с выключенным интернетом. Тест падает. Смотрим логи.

+

Test fail with steps

+

Теперь искать ошибку в тесте становится гораздо проще благодаря понятным логам.

+

Секции Before и After

+

Наш код стал гораздо лучше, но осталась одна важная проблема: необходимо, чтобы перед каждым тестом устройство приходило в дефолтное состояние - интернет должен быть включен и установлена книжная ориентация.

+

В 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. Также мы научились выводить сообщения в лог, а также читать логи, фильтровать их и анализировать.

+


+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Tutorial/UiAutomator/index.html b/ru/Tutorial/UiAutomator/index.html new file mode 100644 index 000000000..d926af28d --- /dev/null +++ b/ru/Tutorial/UiAutomator/index.html @@ -0,0 +1,1684 @@ + + + + + + + + + + + + + + + + + + + + + + 8. UiAutomator. Тестирование за рамками приложения - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Kautomator. Тестирование сторонних приложений

+

В предыдущих уроках мы научились писать тесты для элементов пользовательского интерфейса, которые расположены в нашем приложении. Но часто бывают случаи, когда для полноценного тестирования этого недостаточно, и помимо нашего приложения нужно выполнить какие-то действия за его пределами.

+

В качестве примера давайте проверим стартовый экран приложения Google Play в неавторизованном состоянии.

+
    +
  1. Открываем Google Play
  2. +
  3. Проверяем, что на экране есть кнопка `Sign In`
  4. +
+ +

Google play unauthorized

+

Не забудьте перед запуском теста разлогиниться в приложении.

+

Автотест на функционал 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, PackageManager и Intent. Подробнее о них можно почитать в документации.

+

Если говорить коротко, то 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 не получится. Нужно использовать другие инструменты.

+

Инструменты для работы с другими приложениями

+

UIAutomator

+

UI Automator – это библиотека для поиска компонентов на экране и эмуляции действий пользователя (клики, свайпы, ввод текста и т.д.). Он позволяет управлять приложением так, как бы это делал пользователь – взаимодействовать с любыми его элементами.

+

Благодаря этой библиотеке, мы можем тестировать любые приложения, выполнять в них различные действия, несмотря на то что у нас нет доступа к их исходному коду.

+
+

Info

+

Более подробно про UiAutomator и его возможности вы можете почитать в документации.

+
+

В Android SDK также встроена программа Ui Automator Viewer. Она позволяет найти id элементов, с которыми мы хотим взаимодействовать, их позицию и другие полезные атрибуты.

+

Для того чтобы запустить Ui Automator Viewer, нужно открыть командную строку в папке ../Android/sdk/tools/bin и выполнить команду uiautomatorviewer.

+

У вас должно открыться вот такое окно:

+

UiAutomatorViewer first launch

+

Если этого не произошло и в консоли отобразилась какая-то ошибка, то следует погуглить текст ошибки.

+

Наиболее распространенная проблема – версия Java не совместима с uiautomatorviewer. В таком случае следует установить Java 8 (важно, чтобы данная версия была выпущена компанией Oracle) и прописать к ней путь в переменных среды. Как это сделать, мы разбирали в уроке Выполнение adb-команд

+

Вернемся к написанию теста. Проверять мы будем приложение Google Play, и, чтобы взаимодействовать с ним из Ui Automator Viewer, необходимо запустить его на эмуляторе и кликнуть на кнопку Device Screenshot:

+

UiAutomatorViewer create screenshot

+

На некоторых версиях ОС эти иконки изначально скрыты, поэтому, если они у вас не видны, просто растяните экран.

+

В правой части видно всю информацию об элементах пользовательского интерфейса. Сейчас нас интересует кнопка Sign in. Кликаем на этот элемент и смотрим информацию о кнопке:

+

UiAutomatorViewer button info

+

Здесь вы можете видеть некоторую полезную информацию:

+
    +
  1. Package – название пакета приложения, которое мы указывали в тесте. Один из способов узнать его – посмотреть через эту программу
  2. +
  3. Resource-id – здесь можно найти id элемента, чтобы потом по этому id найти кнопку и взаимодействовать с ней из теста. В нашем случае это невозможно, потому что в значении id указано, что имя ресурса было обфусцировано, то есть зашифровано. Поэтому поиск элемента по id для этого экрана невозможен
  4. +
  5. Text – один из способов найти элемент на экране – по тексту, который на нем отображается. Получается, что сейчас найти кнопку на этом экране мы можем по атрибуту text
  6. +
+ +

Developer Assistant

+

Если по какой-то причине вам неудобно пользоваться Ui Automator Viewer, или вы не смогли его запустить, то можно воспользоваться приложением Developer Assistant. Его можно скачать в Google Play.

+

После установки и запуска Developer Assistant необходимо в настройках выбрать его, как приложение-ассистент по умолчанию. Для этого кликните на кнопку Choose и следуйте инструкциям:

+

Developer Assistant Settings

+

Developer Assistant Settings

+

Developer Assistant Settings

+

Developer Assistant Settings

+

Developer Assistant Settings

+

Developer Assistant Settings

+

После настройки вы можете запускать анализ приложений. Открывайте приложение Google Play и осуществите долгое нажатие по кнопке Home:

+

Developer Assistant Google play

+

У вас появится окно с информацией о приложении, которое можно при необходимости переместить или расширить. На вкладке App есть информация о приложении – название пакета, запущенная в настоящий момент Activity и т.д.

+

Developer Assistant Google play

+

На вкладке Element можно исследовать элементы пользовательского интерфейса.

+

Developer Assistant Google play

+

Здесь есть все те же атрибуты, которые мы видели в Ui Automator Viewer.

+

Dump

+

В некоторых случаях, о которых мы поговорим дальше в этом уроке, использовать Developer Assistant не получится, поскольку он не умеет отображать информацию о системном UI (уведомления, диалоги и т.д.). Если вы оказались в такой ситуации, что возможностей Developer Assistant недостаточно, а Ui Automator Viewer запустить не удалось, то есть третий вариант – выполнить adb shell-команду uiautomator dump.

+

Для этого на эмуляторе откройте экран, информацию о котором вам нужно получить (в данном случае Google Play). Откройте консоль и выполните команду:

+
adb shell uiautomator dump
+
+

Uiautomator Dump

+

На вашем эмуляторе должен был появиться файл window_dump.xml, который можно найти через Device Explorer. Если он у вас не отображается, то выберите папку sdcard и нажмите Synchronize:

+

Uiautomator Dump

+

Если после этих шагов файл все равно не появился, то выполните еще одну команду в консоли:

+
adb pull /sdcard/window_dump.xml
+
+

После этого найдите файл на вашем компьютере через Device File Explorer и откройте его в Android Studio:

+

Uiautomator Dump

+

Этот файл представляет собой описание экрана в формате xml. Тут можно также найти все необходимые объекты, их свойства и id. Если он у вас отображается в одну строчку, то следует сделать автоформатирование, чтобы было легче читать код. Для этого нажмите комбинацию клавиш ctrl + alt + L на Windows или cmd + option + L на Mac.

+

Uiautomator Dump

+

Можно найти кнопку логина и посмотреть все ее атрибуты. Для этого нажимаем комбинацию клавиш ctrl + F (или cmd + F на Mac) и вводим текст, который установлен на кнопке «Sign in».

+

Uiautomator Dump

+

Написание теста

+

Теперь, когда мы нашли нужные нам элементы интерфейса, можем приступать к тестированию. Как обычно мы начнем с создания 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:

+

UI Button

+

В классе UiViewBuilder находится множество matcher-ов, которые вы можете использовать. Перейдя в него (удерживая ctrl, кликаем левой кнопкой мыши по названию класса) можно увидеть полный актуальный список:

+

Matchers

+

Например, можно использовать 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

+

Мы рассмотрели один вариант, когда для тестирования нужно использовать UI automator – если мы взаимодействуем со сторонним приложением. Но это не единственный случай, когда его стоит применять.

+

Давайте откроем наше приложение tutorial и перейдем на экран Notification Activity:

+

Notification Activity Button

+

Кликаем по кнопке “Show notification” – сверху отображается уведомление.

+
+

Info

+

Подробнее про уведомления (notifications) в Android можно почитать здесь.

+
+

Notification Shown

+

Давайте попробуем протестировать этот экран.

+

Сначала создадим 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

+

Открываем список уведомлений и делаем скриншот:

+

Ui automator notification

+

При помощи команды dump необходимые элементы можно найти следующим образом

+

Dump

+

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.

+

Ui automator package

+

Дело в том, что в каждом приложении могут быть свои ресурсы, и в этом случае первая часть идентификатора ресурса будет содержать имя пакета того приложения, где ресурс создан, а также приложение может использовать ресурсы системы 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.

+


+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Tutorial/Wifi_sample_test/index.html b/ru/Tutorial/Wifi_sample_test/index.html new file mode 100644 index 000000000..e56c17f0b --- /dev/null +++ b/ru/Tutorial/Wifi_sample_test/index.html @@ -0,0 +1,1352 @@ + + + + + + + + + + + + + + + + + + + + + + 5. Тестирование интернет-соединения и работа с классом Device - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Тестирование интернет-соединения и работа с классом Device

+

В этом уроке мы создадим тест, который проверяет работу экрана Internet Availability (WifiActivity)

+

Запускаем наше приложение tutorial и кликаем по кнопке Internet Availability.

+

Button Internet Availability

+

Ручное тестирование

+

Давайте сначала протестируем этот экран руками.

+

Изначально у нас есть кнопка CHECK WIFI STATUS, больше никакого текста на экране нет. На текущий момент Wifi на устройстве включен.

+

Launch Wifi Test Activity

+

Launch Wifi Test Activity

+

Кликаем на кнопку.

+

Wifi enabled

+

Эта кнопка кликабельна, после клика отображается корректный статус состояния Wifi - enabled. Отключаем Wifi.

+

Turn-off wifi

+

Кликаем на кнопку снова и проверяем статус Wifi сейчас:

+

Wifi disabled

+

Состояние определяется корректно. Последняя проверка - давайте перевернем устройство и убедимся, что текст на экране сохраняется.

+

Wifi disabled landscape

+

Текст сохраняется успешно, все тесты пройдены. Теперь нам необходимо добиться такого результата, чтобы все те же проверки выполнялись в автоматическом режиме.

+

Написание автотестов

+

Сейчас во время теста нужно будет автоматически включать и выключать интернет, а также менять ориентацию устройства на альбомную. Это выходит за рамки ответственности нашего приложения, а значит, для тестов нам придется использовать 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 на него нужно перейти. Для этого мы проделаем такие же шаги, как в уроке, в котором писали наш первый автотест:

+
    +
  1. Добавим activityRule, чтобы при запуске теста у нас открывалась MainActivity
  2. +
  3. Проверим, что кнопка для перехода на экран провеки Internet видима и кликабельна
  4. +
  5. Кликнем по кнопке "Internet Availability"
  6. +
+ +
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 + мобильная сеть). Для того чтобы посмотреть все доступные методы, можно перейти в документацию, указанную выше, но есть способ проще - после названия объекта поставить точку и посмотреть, какие методы можно вызвать у этого объекта. По их названию обычно понятно, что они делают.

+

Available methods

+

Давайте напишем тест, который выполнит все необходимые проверки, кроме переворота устройства - переворотом мы займемся чуть позже. Первым делом нужно создать 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) }
+}
+
+

Теперь добавляем шаги:

+
    +
  1. Проверяем, что кнопка видима и кликабельна
  2. +
  3. Проверяем, что заголовок не содержит текст
  4. +
  5. Кликаем по кнопке
  6. +
  7. Проверяем, что текст в заголовке стал "enabled"
  8. +
  9. Отключаем Wifi
  10. +
  11. Кликаем по кнопке
  12. +
  13. Проверяем, что текст в заголовке стал "disabled"
  14. +
+ +
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, про который вы также можете подробнее почитать в документации

+

Весь процесс теста теперь будет выглядеть следующим образом:

+
    +
  1. Устанавливаем на устройстве книжную ориентацию
  2. +
  3. Проверяем, что кнопка видима и кликабельна
  4. +
  5. Проверяем, что заголовок не содержит текст
  6. +
  7. Кликаем по кнопке
  8. +
  9. Проверяем, что текст в заголовке стал "enabled"
  10. +
  11. Отключаем Wifi
  12. +
  13. Кликаем по кнопке
  14. +
  15. Проверяем, что текст в заголовке стал "disabled"
  16. +
  17. Переворачиваем устройство
  18. +
  19. Проверяем, что текст в заголовке сохранился "disabled"
  20. +
+ +

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, научились менять статус интернет-соединения и ориентацию экрана из тестового класса. При этом тест запускается, все проверки завершаются успешно, но в нашем коде есть несколько серьезных проблем:

+
    +
  • Тест не разбит на шаги. В итоге мы имеем большое полотно кода, в котором достаточно сложно разобраться
  • +
  • Тест выполняется успешно только в том случае, если мы предварительно включили интернет на устройстве. При этом, при каждом следующем запуске тест будет падать из-за того, что внутри него Wifi отключается
  • +
+ +

В следующих уроках мы узнаем, как можно улучшить этот код и решить возникшие проблемы.

+


+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Tutorial/Working_with_adb/index.html b/ru/Tutorial/Working_with_adb/index.html new file mode 100644 index 000000000..e2c20bca5 --- /dev/null +++ b/ru/Tutorial/Working_with_adb/index.html @@ -0,0 +1,1377 @@ + + + + + + + + + + + + + + + + + + + + + + 4. Выполнение adb-команд - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+ +
+ + + +
+
+ + + + + + + +

Выполнение комманд adb

+

В прошлом уроке мы написали первый тест на Kaspresso, и на данном этапе наш тест умеет взаимодействовать с элементами интерфейса приложения, может на них каким-то образом воздействовать (например, клик по кнопке) и проверять их состояние (видимость, кликабельность и т.д.).

+

Но часто бывают случаи, когда для тестирования недостаточно использовать возможности только нашего приложения. Например, проверить работу приложения в различных внешних состояниях:

+
    +
  • При отсутствии интернета
  • +
  • Во время входящего звонка
  • +
  • При низком уровне заряда телефона
  • +
  • При смене ориентации устройства
  • +
  • И т.д.
  • +
+ +

Во всех перечисленных сценариях тест должен управлять устройством и выполнять команды, которые находятся вне зоны ответственности приложения, которое мы тестируем. В этих случаях мы можем использовать возможности Android Debug Bridge (ADB).

+

ADB - это инструмент командной строки, который позволяет взаимодействовать с девайсом посредством различных команд. С их помощью вы можете выполнять такие действия, как установка и удаление программ, получение списка установленных приложений, запуск определенной Activity, отключение интернет-соединения и многое другое.

+

Все adb команды мы можем выполнять сами через командную строку, при этом библиотека Kaspresso поддерживает работу с adb и может выполнять их в автоматическом режиме. Для того, чтобы тесты, которые работают с adb, могли выполняться, необходимо запустить adb-server.

+

Проверка java и adb

+

Процесс запуска adb-server очень простой, если на вашем компьютере корректно прописаны пути к java и adb. Но если пути не прописаны, то придется их прописывать. Поэтому первое, что мы сделаем - проверим, требуется ли какая-то дополнительная работа или у вас и так все готово для запуска adb-server.

+

Откройте командную строку.

+

На Windows - комбинация клавиш Win + R, в открывшемся окне вводим cmd и нажимаем Enter.

+

Open cmd on windows 1

+

Open cmd on windows 2

+

Сначала проверяем, что путь к java прописан корректно. Для этого пишем java -version.

+

Если все хорошо, то вы увидите вашу версию Java.

+

Java version showed

+

Если же пути прописаны некорректно, то вы увидите что-то похожее на это:

+

Java version failed

+

Теперь делаем такую же проверку для adb. Печатаем в консоли adb version.

+

Если все хорошо, то вы увидите вашу версию ADB.

+

Adb version success

+

В противном случае вы увидите примерно вот такую ошибку:

+

Adb version failed

+

Если по обоим пунктам у вас все работает, то следующий шаг можете пропустить.

+

Настройка java и 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.

+

Show jsdk path in android studio

+

Тут будет прописан путь к нужной папке - скопируйте его.

+

Теперь его нужно прописать в переменных среды, для этого кликаем win + x -> выбираем System -> Advanced System Settings -> Advanced -> Environment Variables.

+

Show system variables

+

В разделе System Variables выбираем Path и нажимаем Edit -> New -> Вставляем скопированный путь к папке с java -> Нажимаем OK.

+

Java bin path

+

Перезапускаем компьютер, чтобы изменения вступили в силу и снова проверяем команду java -version.

+

Java version success

+

Нам осталось проделать все то же самое для adb. Ищем путь к папке platform-tools, в которой лежит adb.

+

Открываем Android Studio -> Tools -> SDK Manager. В поле Android SDK Location указан путь к папке Sdk, в которой находится platform-tools.

+

Копируем этот путь и добавляем в System Variables, как мы это делали ранее с java.

+

Adb path

+

Перезапускаем компьютер и проверяем команду adb version.

+

Adb version success

+

Теперь можем приступить к запуску adb-server. Если у вас все еще команды java и adb не работают, то погуглите, вариантов решения проблемы достаточно много. Все, что нужно сделать - найти путь к java и adb и прописать их в переменные среды.

+

Пробуем различные команды

+

Перед запуском тестов, давайте посмотрим, что можно сделать с помощью adb, рассмотрим несколько команд.

+

Во-первых, можем посмотреть, какие устройства сейчас подключены к adb. Для этого вводим команду adb devices.

+

Empty devices list

+

Сейчас мы не подключили никакое устройство к adb, поэтому список пустой, давайте запустим приложение на эмуляторе и выполним команду еще раз.

+

Devices list

+

Теперь в списке устройств отображается наш эмулятор.

+

С помощью adb-команд мы можем:

+
    +
  • Перезагрузить устройство
  • +
  • Установить какое-то приложение
  • +
  • Удалить приложение
  • +
  • Загрузить файлы с телефона/на телефон
  • +
  • И многое другое
  • +
+ +

Для практики давайте удалим приложение tutorial, которое мы только что запустили. Это делается при помощи команды adb uninstall package_name

+

Uninstall app

+

Наиболее интересные задачи можно выполнять, если запустить команду adb shell. Она вызывает консоль Android (shell) для выполнения Linux-команд на устройстве.

+

Open shell console

+

Приведем несколько примеров таких команд.

+

Получение списка всех установленных приложений pm list packages.

+

List packages

+

Обратите внимание, что мы сначала запустили shell-консоль, а потом писали команды, уже находясь в ней. Поэтому на текущем этапе другие adb команды у вас работать не будут, пока вы не закроете shell-консоль через команду exit.

+

Exit shell console

+

При этом выполнять shell-команды можно и не открывая shell-консоль, для этого достаточно указывать полное наименование команды вместе с adb shell. Например, давайте попробуем сделать скриншот и сохраним его на устройстве. В Android Studio можно открыть File Explorer, в котором отображаются все файлы и папки на девайсе.

+

Device file explorer

+

Обычно скришоты сохраняют на sdcard, мы поступим так же.

+

Для создания скриншота используется команда adb shell screencap /{pathToFile}/{name_of_image.png}. В нашем случае она будет выглядеть так: adb shell screencap /sdcard/my_screen.png.

+

Create screenshot

+

В Device File Explorer кликаем правой кнопкой мыши и нажимаем Synchronize, после чего в папке отобразится созданный нами скриншот.

+

Success screenshot

+

Работаем с adb в автотестах

+

Итак, мы немного попрактиковались в работе с adb, теперь нам нужно научиться работать с ним во время прогона теста. То есть тест, который мы создадим, должен уметь запускать adb-команды и проверять работу приложения после выполнения этих команд.

+

Для того чтобы тесты могли выполнять adb-команды, необходимо на нашем компьютере запустить adb-server. Сперва нужно скачать файл adbserver-desktop.jar на официальном гитхабе Kaspresso и выполнить следующую команду в терминале:

+
java -jar <path/to/file>/adbserver-desktop.jar
+
+

Для того, чтобы в консоли был корректно прописан путь к файлу, достаточно написать команду java -jar и просто перетянуть файл adbserver-desctop.jar в консоль, путь к файлу будет подставлен автоматически.

+

Drag server

+

После ввода команды, нажмите Enter. Запустится AdbServer. При запуске теста девайс сообщит десктопу необходимые для выполнения теста adb команды.

+

Launch Server

+

Можем приступить к созданию автотеста.

+

В пакете 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() {
+
+    }
+}
+
+Чтобы выполнить adb-команду, мы можем напрямую обратиться к полю adbServer и вызвать один из методов - performAdb, performCmd или performShell. По названиям методов должно быть понятно, что они делают.

+
    +
  • `performAdb` выполняет команду adb
  • +
  • `performShell` выполняет команду shell
  • +
  • `performCmd` выполняет команду командной строки
  • +
+ +

Сейчас мы хотим вызвать 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")
+    }
+}
+
+Запускаем тест. Тест выполнен успешно. Обращаем ваше внимание, что для запуска этого теста у вас должны быть выполнены 2 условия:

+
    +
  1. Запущен adb-server
  2. +
  3. В манифесте приложения, которое вы тестируете, должно быть выдано разрешение на использование интернета
  4. +
+ +

С первым пунктом мы разобрались раньше, сейчас давайте разберемся со вторым. Каждое приложение, которое взаимодействует с интернетом, должно содержать разрешение на использование интернета. Оно прописывается в манифесте.

+

Manifest Location

+

Если вы забудете указать это разрешение, тест работать не будет.

+

Сейчас тест запускает 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, если мы хотим работать с командной строкой, пока запущен сервер, нужно запустить еще одно окно терминала и работать в нем

+
+

Undefined command

+

При выполнении этой команды внутри теста у нас будет брошено исключение 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())
+
+Обратите внимание, что если мы вызываем shell-команду при помощи 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 выводит название хоста (вашего компьютера). Если мы запустим ее в консоли, то результат будет примерно следующим:

+

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, у которого мы можем получать различную информацию об устройстве и приложении, которое мы тестируем. +
+

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Tutorial/Writing_simple_test/index.html b/ru/Tutorial/Writing_simple_test/index.html new file mode 100644 index 000000000..920bad4eb --- /dev/null +++ b/ru/Tutorial/Writing_simple_test/index.html @@ -0,0 +1,1543 @@ + + + + + + + + + + + + + + + + + + + + + + 3. Пишем первый автотест с Kaspresso - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Ваш первый тест на Kaspresso

+

Переключаемся на нужную ветку в GIT

+

В Android Studio вы можете переключаться между ветками и, таким образом, видеть разные версии проекта. Изначально, после загрузки Kaspresso вы будете находиться в главной ветке - master.

+

Master branch

+

В этой ветке находится исходный код приложения, которое мы будем покрывать тестами. В текущем и последующих уроках будет приведена пошаговая инструкция в формате codelabs по написанию автотестов. Итоговый результат со всеми написанными тестами доступен в ветке TECH-tutorial-results, вы в любой момент сможете переключиться на нее и посмотреть решение.

+

Для этого кликните на название ветки, в которой находитесь, и в поиске введите название интересующей вас ветки.

+

Switch to results

+

Ручное тестирование

+

Прежде чем приступать к написанию теста, давайте поближе познакомимся с функционалом, который мы будем покрывать автотестами. Для этого переключаемся на master-ветку.

+

Открываем выбор конфигурации (1) и выбираем tutorial (2):

+

Select tutorial

+

Проверяем, что выбран нужный девайс (1) и запускаем приложение (2):

+

Launch tutorial

+

После успешного запуска приложения мы видим основной экран приложения Tutorial.

+

Tutorial main

+

Нажимаем на кнопку с текстом "Simple test" и видим следующий экран:

+

Page object example

+

Экран состоит из: +1. Заголовка TextView +2. Поля ввода EditText +3. Кнопки Button

+
+

Info

+

Полный список виджетов в Android с подробной информацией можно найти здесь

+
+

При нажатии на кнопку текст в заголовке меняется на введенный в поле ввода.

+

Автоматическое тестирование

+

Мы вручную проверили, что результат работы приложения соответствует ожиданиям:

+
    +
  1. На главном экране есть кнопка перехода на экран SimpleTest (остальные элементы этого экрана нас сейчас не интересуют)
  2. +
  3. Эта кнопка видима
  4. +
  5. На нее можно кликнуть
  6. +
  7. При клике на нее мы переходим на экран SimpleTest
  8. +
  9. На экране SimpleTest есть элементы - заголовок, поле ввода и кнопка
  10. +
  11. Все эти элементы видимы
  12. +
  13. Заголовок содержит текст по умолчанию
  14. +
  15. Если ввести какой-то текст в поле ввода и кликнуть на кнопку, то текст в заголовке меняется на введенный
  16. +
+ +

Теперь нам нужно все те же проверки написать в коде, чтобы они осуществлялись в автоматическом режиме.

+

Чтобы покрыть приложение тестами Kaspresso, необходимо начать с подключения библиотеки Kaspresso в зависимостях проекта.

+

Подключаем Kaspresso к проекту

+

Переключаем отображение файлов проекта как Project (1) и добавляем зависимость в существующую секцию dependencies в файле build.gradle модуля Tutorial:

+

Tutorial build gradle

+
dependencies {
+    androidTestImplementation("com.kaspersky.android-components:kaspresso:1.5.1")
+    androidTestUtil("androidx.test:orchestrator:1.4.2")
+}
+
+

Написание теста начнем с создания Page object для текущего экрана.

+

Можем писать код нашего теста. Чтобы это сделать, необходимо для каждого экрана, который участвует в тесте, создать модель (класс), внутри которого объявить все элементы интерфейса (кнопки, текстовые поля и т.д.), из которых состоит экран, с которыми будет взаимодействовать тест. Такой подход называется Page Object и подробнее о нем вы можете почитать в документации.

+

В первых четырех пунктах теста мы взаимодействуем с главным экраном, поэтому первым делом необходимо создать Page Object главного экрана. +Работать мы будем в папке androidTest в модуле tutorial. Если у вас этой папки нет, то ее необходимо создать, для этого кликаем правой кнопкой мыши на папку src и выбираем пункт New -> Directory.

+

Create directory

+

Выбираем пункт androidTest/kotlin:

+

Name directory androidTest

+

Внутри папки kotlin давайте создадим отдельный пакет (package), в котором будем хранить все Page Object-ы:

+

Create package

+

Создание отдельного пакета на функциональность не влияет, мы это делаем просто для удобства, чтобы все модели экранов лежали в одном месте. Вы можете дать пакету любое имя (за некоторым исключением), но обычно в тестах используют такие же названия, как в самом приложении. Мы можем перейти в файл MainActivity, где сверху будет указано имя пакета.

+

MainActivity Package name

+

Копируем это имя и вставляем в название пакета. Конкретно в этом пакете мы будем хранить только модели экранов (Page Object-ы), поэтому в конце давайте добавим .screen.

+

Screen Package name

+

Когда мы будем добавлять другие классы в папку с тестами, то будем класть их уже в другие пакеты, но при этом первая часть их названия будет такой же com.kaspersky.kaspresso.tutorial.

+

Теперь в созданном пакете мы добавляем модель экрана (класс):

+

Create class

+

Выбираем тип Object и именуем MainScreen.

+

Create 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>() {
+}
+
+У нас возникает ошибка - класс KScreen содержит два элемента, которые нужно переопределить при наследовании. Для того чтобы сделать это быстро, в Android Studio можно нажать комбинацию клавиш ctrl + i и выбрать элементы, которые мы хотим переопределить.

+

Override methods

+

Удерживая 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.

+

Override methods

+

Чтобы тест мог с ней взаимодействовать, нужно знать id, по которому эту кнопку можно найти на экране. Эти идентификаторы присваивает разработчик при написании приложения.

+

Чтобы узнать, какой id был присвоен какому-то элементу интерфейса, можно воспользоваться инструментом, встроенным в Android Studio - LayoutInspector.

+
    +
  1. Запускаем приложение
  2. +
  3. В правом нижнем углу Android Studio выбираем пункт Layout Inspector Find bottom layout inspector
  4. +
  5. Ждем пока загрузится экран Layout inspector loaded
  6. +
  7. Если экран не загрузился, то проверьте, что у вас выбран нужный процесс Choose process
  8. +
+

Ищем пункт id - это тот идентификатор, который нас интересует.

+

Search for button id

+

Также важно понимать, с каким элементом UI мы работаем. Для этого можно перейти в макет, где элемент был объявлен, и посмотреть всю информацию о нем.

+

Find layout

+

В данном случае это элемент Button c вот таким id: simple_activity_btn

+

Find button in layout

+

Можем добавлять эту кнопку в 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 = 
+}
+
+Переменной simpleActivityButton нужно присвоить значение, она представляет собой кнопку, которую можно протестировать - за это отвечает class KButton. Вот так будет выглядеть установка значения в эту переменную, сейчас мы подробно разберем, что делает этот код.

+
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 левой кнопкой мыши.

+

Find source of KButton

+

Видим, что это класс, который наследуется от KBaseView и реализует интерфейс TextViewAssertions. Можем перейти в определение KBaseView и посмотреть всех наследников этого класса, их тут достаточно много.

+

Find kbaseview children

+

Зачем они все нужны?

+

Дело в том, что каждый элемент пользовательского интерфейса можно протестировать по-разному. К примеру, для TextView мы можем проверить, какой текст сейчас в него установлен, можем установить новый текст, а ProgressBar не содержит никакой текст, и осуществлять проверку на то, какой текст в него установлен, нет смысла.

+

Поэтому в зависимости от того, какой элемент интерфейса мы тестируем, нужно выбирать правильную реализацию KBaseView. Сейчас мы тестируем кнопку, поэтому выбрали KButton. На следующем экране мы будем тестировать заголовок (TextView) и поле ввода (EditText) и выберем соответствующие реализации KBaseView.

+

Show children which we need

+

Идем дальше, эту кнопку тест должен найти на экране по какому-то критерию. В данном случае мы осуществим поиск элемента по id, поэтому используем матчер withId, куда в качестве параметра передаем идентификатор кнопки, который мы нашли благодаря Layout Inpector.

+

Для того чтобы указать этот id, мы использовали синтаксис R.id..., где R - это класс со всеми ресурсами приложения. Благодаря нему можно находить id элементов интерфейса, строк, которые есть в проекте, картинок и т.д. При вводе названия этого класса Android Studio должна импортировать его автоматически, но иногда этого не происходит, тогда нужно ввести этот импорт вручную.

+
import com.kaspersky.kaspresso.tutorial.R
+
+

Все, теперь у нас есть модель главного экрана и эта модель содержит кнопку, которую можно тестировать. Можем приступать к написанию самого теста.

+

Добавляем SimpleActivityTest

+

В папке androidTest -> kotlin, в созданном нами пакете добавляем класс SimpleActivityTest.

+

Creating Test First part

+

Creating Test Second part

+

Новый класс был размещен в пакете screen, но мы хотели бы, чтобы в нем лежали только модели экранов, поэтому созданный тест мы переместим в корень пакета com.kaspersky.kaspresso.tutorial. Для того, чтобы это сделать, кликаем на название класса правой кнопкой мыши и выбираем Refactor -> Move

+

Move to another package

+

И убираем из названия пакета последнюю часть .screen.

+

Change package name

+

Класс тестов должен быть унаследован от класса 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 можно найти в предыдущем уроке.

+

Success passed test

+

Сейчас этот тест ничего не делает, поэтому и завершается успешно. Давайте добавим ему логики и протестируем 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() мы проверяем, что кнопка видима и по ней можно кликнуть. Запускаем и наш тест падает.

+

Feailed test

+

Дело в том, что 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()
+            }
+        }
+    }
+}
+
+

Запускаем. Все отлично, у нас тест проходит успешно, и вы можете увидеть на девайсе, что во время теста открывается нужная нам активити и закрывается после прогона.

+

Success test

+

Хорошей практикой во время написания тестов является проверка, что тест не только успешно выполняется, но и падает, если условие не выполняется. Так вы исключите ситуацию, когда тесты "зеленые", но на самом деле из-за какой-то ошибки в коде проверки вообще не выполнялись. Давайте это сделаем, проверим, что кнопка содержит некорректный текст.

+
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:

+

Title id in inspector

+

Input id in inspector

+

Button id in 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.

+

Find string in layout

+

Переходим в строковые ресурсы (файл values/strings.xml) и копируем id строки.

+

Find string in values folder

+

Теперь в методе 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.

+


+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Create_device.png b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Create_device.png new file mode 100644 index 000000000..309b069b7 Binary files /dev/null and b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Create_device.png differ diff --git a/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Device_name.png b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Device_name.png new file mode 100644 index 000000000..866006c1a Binary files /dev/null and b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Device_name.png differ diff --git a/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Github_download_button.png b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Github_download_button.png new file mode 100644 index 000000000..23b23e197 Binary files /dev/null and b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Github_download_button.png differ diff --git a/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Github_download_zip.png b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Github_download_zip.png new file mode 100644 index 000000000..ba3a8042d Binary files /dev/null and b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Github_download_zip.png differ diff --git a/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Hyper_Visor.png b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Hyper_Visor.png new file mode 100644 index 000000000..ddb5363fe Binary files /dev/null and b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Hyper_Visor.png differ diff --git a/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Hyper_Visor_next.png b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Hyper_Visor_next.png new file mode 100644 index 000000000..ef549b800 Binary files /dev/null and b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Hyper_Visor_next.png differ diff --git a/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Launch_device.png b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Launch_device.png new file mode 100644 index 000000000..6afa04120 Binary files /dev/null and b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Launch_device.png differ diff --git a/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_installer_finish.png b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_installer_finish.png new file mode 100644 index 000000000..7401966ed Binary files /dev/null and b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_installer_finish.png differ diff --git a/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_installer_next.png b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_installer_next.png new file mode 100644 index 000000000..8a8f5c610 Binary files /dev/null and b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_installer_next.png differ diff --git a/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_isntaller.png b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_isntaller.png new file mode 100644 index 000000000..4fef5bb70 Binary files /dev/null and b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/SDK_component_isntaller.png differ diff --git a/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Select_hardware.png b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Select_hardware.png new file mode 100644 index 000000000..2e43d988e Binary files /dev/null and b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Select_hardware.png differ diff --git a/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/System_Image.png b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/System_Image.png new file mode 100644 index 000000000..20616ba4e Binary files /dev/null and b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/System_Image.png differ diff --git a/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Tools_Device_Manager.png b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Tools_Device_Manager.png new file mode 100644 index 000000000..d0b82bc56 Binary files /dev/null and b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/Tools_Device_Manager.png differ diff --git a/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/clone_project.png b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/clone_project.png new file mode 100644 index 000000000..e7f2dbe2d Binary files /dev/null and b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/clone_project.png differ diff --git a/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/download_by_git.png b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/download_by_git.png new file mode 100644 index 000000000..071912c0b Binary files /dev/null and b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/download_by_git.png differ diff --git a/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/get_from_vcs.png b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/get_from_vcs.png new file mode 100644 index 000000000..cbf3813ba Binary files /dev/null and b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/get_from_vcs.png differ diff --git a/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/new_project_from_vcs.png b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/new_project_from_vcs.png new file mode 100644 index 000000000..ad6935fa7 Binary files /dev/null and b/ru/Tutorial/images/Download_Kaspresso_project_and_Android_studio/new_project_from_vcs.png differ diff --git a/ru/Tutorial/images/Running_the_first_test/device_select.png b/ru/Tutorial/images/Running_the_first_test/device_select.png new file mode 100644 index 000000000..d8a50d26f Binary files /dev/null and b/ru/Tutorial/images/Running_the_first_test/device_select.png differ diff --git a/ru/Tutorial/images/Running_the_first_test/launch_test.png b/ru/Tutorial/images/Running_the_first_test/launch_test.png new file mode 100644 index 000000000..92ec54b26 Binary files /dev/null and b/ru/Tutorial/images/Running_the_first_test/launch_test.png differ diff --git a/ru/Tutorial/images/Running_the_first_test/logcat.png b/ru/Tutorial/images/Running_the_first_test/logcat.png new file mode 100644 index 000000000..e618856b8 Binary files /dev/null and b/ru/Tutorial/images/Running_the_first_test/logcat.png differ diff --git a/ru/Tutorial/images/Running_the_first_test/logcat_search.png b/ru/Tutorial/images/Running_the_first_test/logcat_search.png new file mode 100644 index 000000000..23712adae Binary files /dev/null and b/ru/Tutorial/images/Running_the_first_test/logcat_search.png differ diff --git a/ru/Tutorial/images/Running_the_first_test/run_application.png b/ru/Tutorial/images/Running_the_first_test/run_application.png new file mode 100644 index 000000000..83adc4281 Binary files /dev/null and b/ru/Tutorial/images/Running_the_first_test/run_application.png differ diff --git a/ru/Tutorial/images/Running_the_first_test/run_simple_test.png b/ru/Tutorial/images/Running_the_first_test/run_simple_test.png new file mode 100644 index 000000000..6e33e7bae Binary files /dev/null and b/ru/Tutorial/images/Running_the_first_test/run_simple_test.png differ diff --git a/ru/Tutorial/images/Running_the_first_test/run_simple_test_1.png b/ru/Tutorial/images/Running_the_first_test/run_simple_test_1.png new file mode 100644 index 000000000..e33f7bbaa Binary files /dev/null and b/ru/Tutorial/images/Running_the_first_test/run_simple_test_1.png differ diff --git a/ru/Tutorial/images/Running_the_first_test/run_simple_test_2.png b/ru/Tutorial/images/Running_the_first_test/run_simple_test_2.png new file mode 100644 index 000000000..e33778e10 Binary files /dev/null and b/ru/Tutorial/images/Running_the_first_test/run_simple_test_2.png differ diff --git a/ru/Tutorial/images/Running_the_first_test/test_result.png b/ru/Tutorial/images/Running_the_first_test/test_result.png new file mode 100644 index 000000000..2efd131e6 Binary files /dev/null and b/ru/Tutorial/images/Running_the_first_test/test_result.png differ diff --git a/ru/Tutorial/images/adb_lesson/adb_path.png b/ru/Tutorial/images/adb_lesson/adb_path.png new file mode 100644 index 000000000..87f94c3f5 Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/adb_path.png differ diff --git a/ru/Tutorial/images/adb_lesson/adb_version_failed.png b/ru/Tutorial/images/adb_lesson/adb_version_failed.png new file mode 100644 index 000000000..05c8fe45f Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/adb_version_failed.png differ diff --git a/ru/Tutorial/images/adb_lesson/adb_version_success.png b/ru/Tutorial/images/adb_lesson/adb_version_success.png new file mode 100644 index 000000000..fc7fe75af Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/adb_version_success.png differ diff --git a/ru/Tutorial/images/adb_lesson/bin_path.png b/ru/Tutorial/images/adb_lesson/bin_path.png new file mode 100644 index 000000000..26bf43fff Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/bin_path.png differ diff --git a/ru/Tutorial/images/adb_lesson/create_screenshot.png b/ru/Tutorial/images/adb_lesson/create_screenshot.png new file mode 100644 index 000000000..8dbd6e2cd Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/create_screenshot.png differ diff --git a/ru/Tutorial/images/adb_lesson/device_file_explorer.png b/ru/Tutorial/images/adb_lesson/device_file_explorer.png new file mode 100644 index 000000000..cb961010f Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/device_file_explorer.png differ diff --git a/ru/Tutorial/images/adb_lesson/devices_list.png b/ru/Tutorial/images/adb_lesson/devices_list.png new file mode 100644 index 000000000..101eff51c Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/devices_list.png differ diff --git a/ru/Tutorial/images/adb_lesson/drag_server.png b/ru/Tutorial/images/adb_lesson/drag_server.png new file mode 100644 index 000000000..453063cbb Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/drag_server.png differ diff --git a/ru/Tutorial/images/adb_lesson/empty_devices_list.png b/ru/Tutorial/images/adb_lesson/empty_devices_list.png new file mode 100644 index 000000000..d2a4fc418 Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/empty_devices_list.png differ diff --git a/ru/Tutorial/images/adb_lesson/exit_shell_console.png b/ru/Tutorial/images/adb_lesson/exit_shell_console.png new file mode 100644 index 000000000..0a9c478d4 Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/exit_shell_console.png differ diff --git a/ru/Tutorial/images/adb_lesson/hostname.png b/ru/Tutorial/images/adb_lesson/hostname.png new file mode 100644 index 000000000..fadf4bdbe Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/hostname.png differ diff --git a/ru/Tutorial/images/adb_lesson/java_version_failed.png b/ru/Tutorial/images/adb_lesson/java_version_failed.png new file mode 100644 index 000000000..9e97cf235 Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/java_version_failed.png differ diff --git a/ru/Tutorial/images/adb_lesson/java_version_success.png b/ru/Tutorial/images/adb_lesson/java_version_success.png new file mode 100644 index 000000000..e5c8d1482 Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/java_version_success.png differ diff --git a/ru/Tutorial/images/adb_lesson/jdk_in_android_studio.png b/ru/Tutorial/images/adb_lesson/jdk_in_android_studio.png new file mode 100644 index 000000000..ab34f2798 Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/jdk_in_android_studio.png differ diff --git a/ru/Tutorial/images/adb_lesson/launch_server.png b/ru/Tutorial/images/adb_lesson/launch_server.png new file mode 100644 index 000000000..c0639bb8e Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/launch_server.png differ diff --git a/ru/Tutorial/images/adb_lesson/list_packages.png b/ru/Tutorial/images/adb_lesson/list_packages.png new file mode 100644 index 000000000..f10ae6bda Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/list_packages.png differ diff --git a/ru/Tutorial/images/adb_lesson/manifest_location.png b/ru/Tutorial/images/adb_lesson/manifest_location.png new file mode 100644 index 000000000..f68d5d998 Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/manifest_location.png differ diff --git a/ru/Tutorial/images/adb_lesson/open_shell_console.png b/ru/Tutorial/images/adb_lesson/open_shell_console.png new file mode 100644 index 000000000..d61a7c5f1 Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/open_shell_console.png differ diff --git a/ru/Tutorial/images/adb_lesson/success_screen.png b/ru/Tutorial/images/adb_lesson/success_screen.png new file mode 100644 index 000000000..3670fde11 Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/success_screen.png differ diff --git a/ru/Tutorial/images/adb_lesson/system_variables.png b/ru/Tutorial/images/adb_lesson/system_variables.png new file mode 100644 index 000000000..dff868aa5 Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/system_variables.png differ diff --git a/ru/Tutorial/images/adb_lesson/undefined_command.png b/ru/Tutorial/images/adb_lesson/undefined_command.png new file mode 100644 index 000000000..53cbc26ee Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/undefined_command.png differ diff --git a/ru/Tutorial/images/adb_lesson/uninstall_app.png b/ru/Tutorial/images/adb_lesson/uninstall_app.png new file mode 100644 index 000000000..75dabcbf7 Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/uninstall_app.png differ diff --git a/ru/Tutorial/images/adb_lesson/windows_cmd_open_1.png b/ru/Tutorial/images/adb_lesson/windows_cmd_open_1.png new file mode 100644 index 000000000..0aa81e12c Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/windows_cmd_open_1.png differ diff --git a/ru/Tutorial/images/adb_lesson/windows_cmd_open_2.png b/ru/Tutorial/images/adb_lesson/windows_cmd_open_2.png new file mode 100644 index 000000000..39c88f381 Binary files /dev/null and b/ru/Tutorial/images/adb_lesson/windows_cmd_open_2.png differ diff --git a/ru/Tutorial/images/flaky/flaky_1.png b/ru/Tutorial/images/flaky/flaky_1.png new file mode 100644 index 000000000..087ccff96 Binary files /dev/null and b/ru/Tutorial/images/flaky/flaky_1.png differ diff --git a/ru/Tutorial/images/flaky/flaky_2.png b/ru/Tutorial/images/flaky/flaky_2.png new file mode 100644 index 000000000..d87e8cb81 Binary files /dev/null and b/ru/Tutorial/images/flaky/flaky_2.png differ diff --git a/ru/Tutorial/images/flaky/flaky_3.png b/ru/Tutorial/images/flaky/flaky_3.png new file mode 100644 index 000000000..be54c0962 Binary files /dev/null and b/ru/Tutorial/images/flaky/flaky_3.png differ diff --git a/ru/Tutorial/images/flaky/flaky_4.png b/ru/Tutorial/images/flaky/flaky_4.png new file mode 100644 index 000000000..be2d0bcb7 Binary files /dev/null and b/ru/Tutorial/images/flaky/flaky_4.png differ diff --git a/ru/Tutorial/images/flaky/flaky_activity_btn.png b/ru/Tutorial/images/flaky/flaky_activity_btn.png new file mode 100644 index 000000000..84a4cac9e Binary files /dev/null and b/ru/Tutorial/images/flaky/flaky_activity_btn.png differ diff --git a/ru/Tutorial/images/logs/advanced_builder.png b/ru/Tutorial/images/logs/advanced_builder.png new file mode 100644 index 000000000..9a13e2583 Binary files /dev/null and b/ru/Tutorial/images/logs/advanced_builder.png differ diff --git a/ru/Tutorial/images/logs/after_auth.png b/ru/Tutorial/images/logs/after_auth.png new file mode 100644 index 000000000..ebbdb7aff Binary files /dev/null and b/ru/Tutorial/images/logs/after_auth.png differ diff --git a/ru/Tutorial/images/logs/create_class.png b/ru/Tutorial/images/logs/create_class.png new file mode 100644 index 000000000..1c634304f Binary files /dev/null and b/ru/Tutorial/images/logs/create_class.png differ diff --git a/ru/Tutorial/images/logs/create_package.png b/ru/Tutorial/images/logs/create_package.png new file mode 100644 index 000000000..0303b1e68 Binary files /dev/null and b/ru/Tutorial/images/logs/create_package.png differ diff --git a/ru/Tutorial/images/logs/create_package_2.png b/ru/Tutorial/images/logs/create_package_2.png new file mode 100644 index 000000000..ea4f0014e Binary files /dev/null and b/ru/Tutorial/images/logs/create_package_2.png differ diff --git a/ru/Tutorial/images/logs/custom_log.png b/ru/Tutorial/images/logs/custom_log.png new file mode 100644 index 000000000..e1884366b Binary files /dev/null and b/ru/Tutorial/images/logs/custom_log.png differ diff --git a/ru/Tutorial/images/logs/custom_log_test.png b/ru/Tutorial/images/logs/custom_log_test.png new file mode 100644 index 000000000..b39961d40 Binary files /dev/null and b/ru/Tutorial/images/logs/custom_log_test.png differ diff --git a/ru/Tutorial/images/logs/customized_builder.png b/ru/Tutorial/images/logs/customized_builder.png new file mode 100644 index 000000000..9df6b88d0 Binary files /dev/null and b/ru/Tutorial/images/logs/customized_builder.png differ diff --git a/ru/Tutorial/images/logs/kaspresso_test_tag.png b/ru/Tutorial/images/logs/kaspresso_test_tag.png new file mode 100644 index 000000000..647b11e55 Binary files /dev/null and b/ru/Tutorial/images/logs/kaspresso_test_tag.png differ diff --git a/ru/Tutorial/images/logs/logcat.png b/ru/Tutorial/images/logs/logcat.png new file mode 100644 index 000000000..75fb9ab71 Binary files /dev/null and b/ru/Tutorial/images/logs/logcat.png differ diff --git a/ru/Tutorial/images/logs/login_activity.png b/ru/Tutorial/images/logs/login_activity.png new file mode 100644 index 000000000..b0539938d Binary files /dev/null and b/ru/Tutorial/images/logs/login_activity.png differ diff --git a/ru/Tutorial/images/logs/main_screen.png b/ru/Tutorial/images/logs/main_screen.png new file mode 100644 index 000000000..aaeda9dde Binary files /dev/null and b/ru/Tutorial/images/logs/main_screen.png differ diff --git a/ru/Tutorial/images/logs/screenshots.png b/ru/Tutorial/images/logs/screenshots.png new file mode 100644 index 000000000..8ae6e61aa Binary files /dev/null and b/ru/Tutorial/images/logs/screenshots.png differ diff --git a/ru/Tutorial/images/logs/setup_password.png b/ru/Tutorial/images/logs/setup_password.png new file mode 100644 index 000000000..1170a163a Binary files /dev/null and b/ru/Tutorial/images/logs/setup_password.png differ diff --git a/ru/Tutorial/images/logs/test_case_params.png b/ru/Tutorial/images/logs/test_case_params.png new file mode 100644 index 000000000..5aad51445 Binary files /dev/null and b/ru/Tutorial/images/logs/test_case_params.png differ diff --git a/ru/Tutorial/images/logs/test_failed_1.png b/ru/Tutorial/images/logs/test_failed_1.png new file mode 100644 index 000000000..ee8d1e1a5 Binary files /dev/null and b/ru/Tutorial/images/logs/test_failed_1.png differ diff --git a/ru/Tutorial/images/permissions/call_1.png b/ru/Tutorial/images/permissions/call_1.png new file mode 100644 index 000000000..ad820d5db Binary files /dev/null and b/ru/Tutorial/images/permissions/call_1.png differ diff --git a/ru/Tutorial/images/permissions/deny_permission_settings.png b/ru/Tutorial/images/permissions/deny_permission_settings.png new file mode 100644 index 000000000..8eae4e90a Binary files /dev/null and b/ru/Tutorial/images/permissions/deny_permission_settings.png differ diff --git a/ru/Tutorial/images/permissions/device_perm_methods.png b/ru/Tutorial/images/permissions/device_perm_methods.png new file mode 100644 index 000000000..82cfa04ea Binary files /dev/null and b/ru/Tutorial/images/permissions/device_perm_methods.png differ diff --git a/ru/Tutorial/images/permissions/main_screen.png b/ru/Tutorial/images/permissions/main_screen.png new file mode 100644 index 000000000..d562478ad Binary files /dev/null and b/ru/Tutorial/images/permissions/main_screen.png differ diff --git a/ru/Tutorial/images/permissions/make_call_screen.png b/ru/Tutorial/images/permissions/make_call_screen.png new file mode 100644 index 000000000..e5dcca234 Binary files /dev/null and b/ru/Tutorial/images/permissions/make_call_screen.png differ diff --git a/ru/Tutorial/images/permissions/rename.png b/ru/Tutorial/images/permissions/rename.png new file mode 100644 index 000000000..dea224268 Binary files /dev/null and b/ru/Tutorial/images/permissions/rename.png differ diff --git a/ru/Tutorial/images/permissions/rename_2.png b/ru/Tutorial/images/permissions/rename_2.png new file mode 100644 index 000000000..b57a3c222 Binary files /dev/null and b/ru/Tutorial/images/permissions/rename_2.png differ diff --git a/ru/Tutorial/images/permissions/request_permission_1.png b/ru/Tutorial/images/permissions/request_permission_1.png new file mode 100644 index 000000000..dce36a02d Binary files /dev/null and b/ru/Tutorial/images/permissions/request_permission_1.png differ diff --git a/ru/Tutorial/images/recycler_view/layout_inspector.png b/ru/Tutorial/images/recycler_view/layout_inspector.png new file mode 100644 index 000000000..fea71e7bc Binary files /dev/null and b/ru/Tutorial/images/recycler_view/layout_inspector.png differ diff --git a/ru/Tutorial/images/recycler_view/main_screen.png b/ru/Tutorial/images/recycler_view/main_screen.png new file mode 100644 index 000000000..9b48062ec Binary files /dev/null and b/ru/Tutorial/images/recycler_view/main_screen.png differ diff --git a/ru/Tutorial/images/recycler_view/removed.png b/ru/Tutorial/images/recycler_view/removed.png new file mode 100644 index 000000000..fade38d9b Binary files /dev/null and b/ru/Tutorial/images/recycler_view/removed.png differ diff --git a/ru/Tutorial/images/recycler_view/swiped.png b/ru/Tutorial/images/recycler_view/swiped.png new file mode 100644 index 000000000..0c6578c6c Binary files /dev/null and b/ru/Tutorial/images/recycler_view/swiped.png differ diff --git a/ru/Tutorial/images/recycler_view/todo_list.png b/ru/Tutorial/images/recycler_view/todo_list.png new file mode 100644 index 000000000..b29cee6a4 Binary files /dev/null and b/ru/Tutorial/images/recycler_view/todo_list.png differ diff --git a/ru/Tutorial/images/scenario/login_activity.png b/ru/Tutorial/images/scenario/login_activity.png new file mode 100644 index 000000000..b84ef1757 Binary files /dev/null and b/ru/Tutorial/images/scenario/login_activity.png differ diff --git a/ru/Tutorial/images/scenario/main_screen_login_button.png b/ru/Tutorial/images/scenario/main_screen_login_button.png new file mode 100644 index 000000000..f6f0b00ba Binary files /dev/null and b/ru/Tutorial/images/scenario/main_screen_login_button.png differ diff --git a/ru/Tutorial/images/scenario/screen_after_login.png b/ru/Tutorial/images/scenario/screen_after_login.png new file mode 100644 index 000000000..b7f7351be Binary files /dev/null and b/ru/Tutorial/images/scenario/screen_after_login.png differ diff --git a/ru/Tutorial/images/screenshot_tests_1/Initial_state_en.png b/ru/Tutorial/images/screenshot_tests_1/Initial_state_en.png new file mode 100644 index 000000000..040e39892 Binary files /dev/null and b/ru/Tutorial/images/screenshot_tests_1/Initial_state_en.png differ diff --git a/ru/Tutorial/images/screenshot_tests_1/Initial_state_fr.png b/ru/Tutorial/images/screenshot_tests_1/Initial_state_fr.png new file mode 100644 index 000000000..f8dbbca32 Binary files /dev/null and b/ru/Tutorial/images/screenshot_tests_1/Initial_state_fr.png differ diff --git a/ru/Tutorial/images/screenshot_tests_1/create_screenshot_test.png b/ru/Tutorial/images/screenshot_tests_1/create_screenshot_test.png new file mode 100644 index 000000000..b70bb2510 Binary files /dev/null and b/ru/Tutorial/images/screenshot_tests_1/create_screenshot_test.png differ diff --git a/ru/Tutorial/images/screenshot_tests_1/fr_locale.png b/ru/Tutorial/images/screenshot_tests_1/fr_locale.png new file mode 100644 index 000000000..59fcd3b91 Binary files /dev/null and b/ru/Tutorial/images/screenshot_tests_1/fr_locale.png differ diff --git a/ru/Tutorial/images/screenshot_tests_1/french.png b/ru/Tutorial/images/screenshot_tests_1/french.png new file mode 100644 index 000000000..965c22eed Binary files /dev/null and b/ru/Tutorial/images/screenshot_tests_1/french.png differ diff --git a/ru/Tutorial/images/screenshot_tests_1/initial_en.png b/ru/Tutorial/images/screenshot_tests_1/initial_en.png new file mode 100644 index 000000000..040e39892 Binary files /dev/null and b/ru/Tutorial/images/screenshot_tests_1/initial_en.png differ diff --git a/ru/Tutorial/images/screenshot_tests_1/initial_fr.png b/ru/Tutorial/images/screenshot_tests_1/initial_fr.png new file mode 100644 index 000000000..f8dbbca32 Binary files /dev/null and b/ru/Tutorial/images/screenshot_tests_1/initial_fr.png differ diff --git a/ru/Tutorial/images/screenshot_tests_1/screenshot_test.png b/ru/Tutorial/images/screenshot_tests_1/screenshot_test.png new file mode 100644 index 000000000..fafde0c64 Binary files /dev/null and b/ru/Tutorial/images/screenshot_tests_1/screenshot_test.png differ diff --git a/ru/Tutorial/images/screenshot_tests_1/success_tests.png b/ru/Tutorial/images/screenshot_tests_1/success_tests.png new file mode 100644 index 000000000..f9956a3bc Binary files /dev/null and b/ru/Tutorial/images/screenshot_tests_1/success_tests.png differ diff --git a/ru/Tutorial/images/screenshot_tests_1/todo_on_screen.png b/ru/Tutorial/images/screenshot_tests_1/todo_on_screen.png new file mode 100644 index 000000000..9d30def3f Binary files /dev/null and b/ru/Tutorial/images/screenshot_tests_1/todo_on_screen.png differ diff --git a/ru/Tutorial/images/screenshot_tests_2/create_class.png b/ru/Tutorial/images/screenshot_tests_2/create_class.png new file mode 100644 index 000000000..b6eebf742 Binary files /dev/null and b/ru/Tutorial/images/screenshot_tests_2/create_class.png differ diff --git a/ru/Tutorial/images/screenshot_tests_2/example_1.png b/ru/Tutorial/images/screenshot_tests_2/example_1.png new file mode 100644 index 000000000..ab5f4aa77 Binary files /dev/null and b/ru/Tutorial/images/screenshot_tests_2/example_1.png differ diff --git a/ru/Tutorial/images/screenshot_tests_2/example_2.png b/ru/Tutorial/images/screenshot_tests_2/example_2.png new file mode 100644 index 000000000..b26032132 Binary files /dev/null and b/ru/Tutorial/images/screenshot_tests_2/example_2.png differ diff --git a/ru/Tutorial/images/screenshot_tests_2/example_3.png b/ru/Tutorial/images/screenshot_tests_2/example_3.png new file mode 100644 index 000000000..0bf3ba1d1 Binary files /dev/null and b/ru/Tutorial/images/screenshot_tests_2/example_3.png differ diff --git a/ru/Tutorial/images/screenshot_tests_2/example_4.png b/ru/Tutorial/images/screenshot_tests_2/example_4.png new file mode 100644 index 000000000..1dbf30df4 Binary files /dev/null and b/ru/Tutorial/images/screenshot_tests_2/example_4.png differ diff --git a/ru/Tutorial/images/screenshot_tests_2/example_5.png b/ru/Tutorial/images/screenshot_tests_2/example_5.png new file mode 100644 index 000000000..46106b5e0 Binary files /dev/null and b/ru/Tutorial/images/screenshot_tests_2/example_5.png differ diff --git a/ru/Tutorial/images/screenshot_tests_2/page_object.png b/ru/Tutorial/images/screenshot_tests_2/page_object.png new file mode 100644 index 000000000..631c30b95 Binary files /dev/null and b/ru/Tutorial/images/screenshot_tests_2/page_object.png differ diff --git a/ru/Tutorial/images/screenshot_tests_2/style.png b/ru/Tutorial/images/screenshot_tests_2/style.png new file mode 100644 index 000000000..f252d4ff6 Binary files /dev/null and b/ru/Tutorial/images/screenshot_tests_2/style.png differ diff --git a/ru/Tutorial/images/simple_test/First_tutorial_screen.png b/ru/Tutorial/images/simple_test/First_tutorial_screen.png new file mode 100644 index 000000000..cab74400f Binary files /dev/null and b/ru/Tutorial/images/simple_test/First_tutorial_screen.png differ diff --git a/ru/Tutorial/images/simple_test/Launch_tutorial.png b/ru/Tutorial/images/simple_test/Launch_tutorial.png new file mode 100644 index 000000000..07f08844d Binary files /dev/null and b/ru/Tutorial/images/simple_test/Launch_tutorial.png differ diff --git a/ru/Tutorial/images/simple_test/Layout_inspector_in_studio.png b/ru/Tutorial/images/simple_test/Layout_inspector_in_studio.png new file mode 100644 index 000000000..d8d53ee12 Binary files /dev/null and b/ru/Tutorial/images/simple_test/Layout_inspector_in_studio.png differ diff --git a/ru/Tutorial/images/simple_test/Select_tutorial.png b/ru/Tutorial/images/simple_test/Select_tutorial.png new file mode 100644 index 000000000..cd898ed00 Binary files /dev/null and b/ru/Tutorial/images/simple_test/Select_tutorial.png differ diff --git a/ru/Tutorial/images/simple_test/Tutorial_build_gradle.png b/ru/Tutorial/images/simple_test/Tutorial_build_gradle.png new file mode 100644 index 000000000..10c329ad9 Binary files /dev/null and b/ru/Tutorial/images/simple_test/Tutorial_build_gradle.png differ diff --git a/ru/Tutorial/images/simple_test/Tutorial_main.png b/ru/Tutorial/images/simple_test/Tutorial_main.png new file mode 100644 index 000000000..b38c5127a Binary files /dev/null and b/ru/Tutorial/images/simple_test/Tutorial_main.png differ diff --git a/ru/Tutorial/images/simple_test/bottom_layout_inspector.png b/ru/Tutorial/images/simple_test/bottom_layout_inspector.png new file mode 100644 index 000000000..51b6f3ce1 Binary files /dev/null and b/ru/Tutorial/images/simple_test/bottom_layout_inspector.png differ diff --git a/ru/Tutorial/images/simple_test/button_id_search.png b/ru/Tutorial/images/simple_test/button_id_search.png new file mode 100644 index 000000000..3a68a5584 Binary files /dev/null and b/ru/Tutorial/images/simple_test/button_id_search.png differ diff --git a/ru/Tutorial/images/simple_test/button_in_layout.png b/ru/Tutorial/images/simple_test/button_in_layout.png new file mode 100644 index 000000000..7f6473b10 Binary files /dev/null and b/ru/Tutorial/images/simple_test/button_in_layout.png differ diff --git a/ru/Tutorial/images/simple_test/button_inspect.png b/ru/Tutorial/images/simple_test/button_inspect.png new file mode 100644 index 000000000..8f23b309a Binary files /dev/null and b/ru/Tutorial/images/simple_test/button_inspect.png differ diff --git a/ru/Tutorial/images/simple_test/change_package.png b/ru/Tutorial/images/simple_test/change_package.png new file mode 100644 index 000000000..107c943a2 Binary files /dev/null and b/ru/Tutorial/images/simple_test/change_package.png differ diff --git a/ru/Tutorial/images/simple_test/choose_process.png b/ru/Tutorial/images/simple_test/choose_process.png new file mode 100644 index 000000000..6742613d3 Binary files /dev/null and b/ru/Tutorial/images/simple_test/choose_process.png differ diff --git a/ru/Tutorial/images/simple_test/create_class.png b/ru/Tutorial/images/simple_test/create_class.png new file mode 100644 index 000000000..c7517eec9 Binary files /dev/null and b/ru/Tutorial/images/simple_test/create_class.png differ diff --git a/ru/Tutorial/images/simple_test/create_directory.png b/ru/Tutorial/images/simple_test/create_directory.png new file mode 100644 index 000000000..3dde7300c Binary files /dev/null and b/ru/Tutorial/images/simple_test/create_directory.png differ diff --git a/ru/Tutorial/images/simple_test/create_main_screen.png b/ru/Tutorial/images/simple_test/create_main_screen.png new file mode 100644 index 000000000..71871c05c Binary files /dev/null and b/ru/Tutorial/images/simple_test/create_main_screen.png differ diff --git a/ru/Tutorial/images/simple_test/create_package.png b/ru/Tutorial/images/simple_test/create_package.png new file mode 100644 index 000000000..fec70301d Binary files /dev/null and b/ru/Tutorial/images/simple_test/create_package.png differ diff --git a/ru/Tutorial/images/simple_test/create_test_1.png b/ru/Tutorial/images/simple_test/create_test_1.png new file mode 100644 index 000000000..9fc642d55 Binary files /dev/null and b/ru/Tutorial/images/simple_test/create_test_1.png differ diff --git a/ru/Tutorial/images/simple_test/create_test_2.png b/ru/Tutorial/images/simple_test/create_test_2.png new file mode 100644 index 000000000..95633ddd9 Binary files /dev/null and b/ru/Tutorial/images/simple_test/create_test_2.png differ diff --git a/ru/Tutorial/images/simple_test/find_layout.png b/ru/Tutorial/images/simple_test/find_layout.png new file mode 100644 index 000000000..2082d2ba2 Binary files /dev/null and b/ru/Tutorial/images/simple_test/find_layout.png differ diff --git a/ru/Tutorial/images/simple_test/find_string_in_layout.png b/ru/Tutorial/images/simple_test/find_string_in_layout.png new file mode 100644 index 000000000..f61ca2c8f Binary files /dev/null and b/ru/Tutorial/images/simple_test/find_string_in_layout.png differ diff --git a/ru/Tutorial/images/simple_test/input_inspect.png b/ru/Tutorial/images/simple_test/input_inspect.png new file mode 100644 index 000000000..03b77172c Binary files /dev/null and b/ru/Tutorial/images/simple_test/input_inspect.png differ diff --git a/ru/Tutorial/images/simple_test/kbaseview_children.png b/ru/Tutorial/images/simple_test/kbaseview_children.png new file mode 100644 index 000000000..144a6e3b2 Binary files /dev/null and b/ru/Tutorial/images/simple_test/kbaseview_children.png differ diff --git a/ru/Tutorial/images/simple_test/loaded_inspector.png b/ru/Tutorial/images/simple_test/loaded_inspector.png new file mode 100644 index 000000000..9e238777f Binary files /dev/null and b/ru/Tutorial/images/simple_test/loaded_inspector.png differ diff --git a/ru/Tutorial/images/simple_test/master_branch.png b/ru/Tutorial/images/simple_test/master_branch.png new file mode 100644 index 000000000..0995a122d Binary files /dev/null and b/ru/Tutorial/images/simple_test/master_branch.png differ diff --git a/ru/Tutorial/images/simple_test/move_to_package.png b/ru/Tutorial/images/simple_test/move_to_package.png new file mode 100644 index 000000000..cdd8a1e92 Binary files /dev/null and b/ru/Tutorial/images/simple_test/move_to_package.png differ diff --git a/ru/Tutorial/images/simple_test/name_android_test.png b/ru/Tutorial/images/simple_test/name_android_test.png new file mode 100644 index 000000000..625549524 Binary files /dev/null and b/ru/Tutorial/images/simple_test/name_android_test.png differ diff --git a/ru/Tutorial/images/simple_test/needed_children.png b/ru/Tutorial/images/simple_test/needed_children.png new file mode 100644 index 000000000..91ef3350c Binary files /dev/null and b/ru/Tutorial/images/simple_test/needed_children.png differ diff --git a/ru/Tutorial/images/simple_test/override.png b/ru/Tutorial/images/simple_test/override.png new file mode 100644 index 000000000..41118ab95 Binary files /dev/null and b/ru/Tutorial/images/simple_test/override.png differ diff --git a/ru/Tutorial/images/simple_test/package_name_main_activity.png b/ru/Tutorial/images/simple_test/package_name_main_activity.png new file mode 100644 index 000000000..657219e90 Binary files /dev/null and b/ru/Tutorial/images/simple_test/package_name_main_activity.png differ diff --git a/ru/Tutorial/images/simple_test/package_name_screen.png b/ru/Tutorial/images/simple_test/package_name_screen.png new file mode 100644 index 000000000..f968667dd Binary files /dev/null and b/ru/Tutorial/images/simple_test/package_name_screen.png differ diff --git a/ru/Tutorial/images/simple_test/show_kbutton_source.png b/ru/Tutorial/images/simple_test/show_kbutton_source.png new file mode 100644 index 000000000..e0359d18a Binary files /dev/null and b/ru/Tutorial/images/simple_test/show_kbutton_source.png differ diff --git a/ru/Tutorial/images/simple_test/simple_test_button.png b/ru/Tutorial/images/simple_test/simple_test_button.png new file mode 100644 index 000000000..90c7024a8 Binary files /dev/null and b/ru/Tutorial/images/simple_test/simple_test_button.png differ diff --git a/ru/Tutorial/images/simple_test/string_in_values.png b/ru/Tutorial/images/simple_test/string_in_values.png new file mode 100644 index 000000000..e1e5178ce Binary files /dev/null and b/ru/Tutorial/images/simple_test/string_in_values.png differ diff --git a/ru/Tutorial/images/simple_test/success_1.png b/ru/Tutorial/images/simple_test/success_1.png new file mode 100644 index 000000000..d7f7abc45 Binary files /dev/null and b/ru/Tutorial/images/simple_test/success_1.png differ diff --git a/ru/Tutorial/images/simple_test/sucess_2.png b/ru/Tutorial/images/simple_test/sucess_2.png new file mode 100644 index 000000000..5d1c0160d Binary files /dev/null and b/ru/Tutorial/images/simple_test/sucess_2.png differ diff --git a/ru/Tutorial/images/simple_test/switch_to_results.png b/ru/Tutorial/images/simple_test/switch_to_results.png new file mode 100644 index 000000000..b04581b6d Binary files /dev/null and b/ru/Tutorial/images/simple_test/switch_to_results.png differ diff --git a/ru/Tutorial/images/simple_test/test_failed_1.png b/ru/Tutorial/images/simple_test/test_failed_1.png new file mode 100644 index 000000000..8250d2543 Binary files /dev/null and b/ru/Tutorial/images/simple_test/test_failed_1.png differ diff --git a/ru/Tutorial/images/simple_test/title_inspect.png b/ru/Tutorial/images/simple_test/title_inspect.png new file mode 100644 index 000000000..95ca5e262 Binary files /dev/null and b/ru/Tutorial/images/simple_test/title_inspect.png differ diff --git a/ru/Tutorial/images/steps/clear_logcat.png b/ru/Tutorial/images/steps/clear_logcat.png new file mode 100644 index 000000000..93660de8c Binary files /dev/null and b/ru/Tutorial/images/steps/clear_logcat.png differ diff --git a/ru/Tutorial/images/steps/create_filter.png b/ru/Tutorial/images/steps/create_filter.png new file mode 100644 index 000000000..ee8bacb42 Binary files /dev/null and b/ru/Tutorial/images/steps/create_filter.png differ diff --git a/ru/Tutorial/images/steps/edit_configuration.png b/ru/Tutorial/images/steps/edit_configuration.png new file mode 100644 index 000000000..4fbc0b43f Binary files /dev/null and b/ru/Tutorial/images/steps/edit_configuration.png differ diff --git a/ru/Tutorial/images/steps/log_step_1.png b/ru/Tutorial/images/steps/log_step_1.png new file mode 100644 index 000000000..e893e14fc Binary files /dev/null and b/ru/Tutorial/images/steps/log_step_1.png differ diff --git a/ru/Tutorial/images/steps/log_step_2.png b/ru/Tutorial/images/steps/log_step_2.png new file mode 100644 index 000000000..14cb2a69e Binary files /dev/null and b/ru/Tutorial/images/steps/log_step_2.png differ diff --git a/ru/Tutorial/images/steps/log_step_2_failed.png b/ru/Tutorial/images/steps/log_step_2_failed.png new file mode 100644 index 000000000..66143511e Binary files /dev/null and b/ru/Tutorial/images/steps/log_step_2_failed.png differ diff --git a/ru/Tutorial/images/steps/log_step_3.png b/ru/Tutorial/images/steps/log_step_3.png new file mode 100644 index 000000000..36e7a2a24 Binary files /dev/null and b/ru/Tutorial/images/steps/log_step_3.png differ diff --git a/ru/Tutorial/images/steps/log_with_steps.png b/ru/Tutorial/images/steps/log_with_steps.png new file mode 100644 index 000000000..a9bf7f269 Binary files /dev/null and b/ru/Tutorial/images/steps/log_with_steps.png differ diff --git a/ru/Tutorial/images/steps/logcat.png b/ru/Tutorial/images/steps/logcat.png new file mode 100644 index 000000000..790c53d9c Binary files /dev/null and b/ru/Tutorial/images/steps/logcat.png differ diff --git a/ru/Tutorial/images/steps/test_failed_with_steps.png b/ru/Tutorial/images/steps/test_failed_with_steps.png new file mode 100644 index 000000000..b81c51598 Binary files /dev/null and b/ru/Tutorial/images/steps/test_failed_with_steps.png differ diff --git a/ru/Tutorial/images/uiautomator/da_1_settings.png b/ru/Tutorial/images/uiautomator/da_1_settings.png new file mode 100644 index 000000000..127cb5d55 Binary files /dev/null and b/ru/Tutorial/images/uiautomator/da_1_settings.png differ diff --git a/ru/Tutorial/images/uiautomator/da_2_settings.png b/ru/Tutorial/images/uiautomator/da_2_settings.png new file mode 100644 index 000000000..425d630d4 Binary files /dev/null and b/ru/Tutorial/images/uiautomator/da_2_settings.png differ diff --git a/ru/Tutorial/images/uiautomator/da_3_settings.png b/ru/Tutorial/images/uiautomator/da_3_settings.png new file mode 100644 index 000000000..695657f00 Binary files /dev/null and b/ru/Tutorial/images/uiautomator/da_3_settings.png differ diff --git a/ru/Tutorial/images/uiautomator/da_4_settings.png b/ru/Tutorial/images/uiautomator/da_4_settings.png new file mode 100644 index 000000000..069b0b7d7 Binary files /dev/null and b/ru/Tutorial/images/uiautomator/da_4_settings.png differ diff --git a/ru/Tutorial/images/uiautomator/da_5_settings.png b/ru/Tutorial/images/uiautomator/da_5_settings.png new file mode 100644 index 000000000..6cd5b307d Binary files /dev/null and b/ru/Tutorial/images/uiautomator/da_5_settings.png differ diff --git a/ru/Tutorial/images/uiautomator/da_6_settings.png b/ru/Tutorial/images/uiautomator/da_6_settings.png new file mode 100644 index 000000000..8f9ab3417 Binary files /dev/null and b/ru/Tutorial/images/uiautomator/da_6_settings.png differ diff --git a/ru/Tutorial/images/uiautomator/da_gplay_1.png b/ru/Tutorial/images/uiautomator/da_gplay_1.png new file mode 100644 index 000000000..78ba8905f Binary files /dev/null and b/ru/Tutorial/images/uiautomator/da_gplay_1.png differ diff --git a/ru/Tutorial/images/uiautomator/da_gplay_2.png b/ru/Tutorial/images/uiautomator/da_gplay_2.png new file mode 100644 index 000000000..9d420ec3b Binary files /dev/null and b/ru/Tutorial/images/uiautomator/da_gplay_2.png differ diff --git a/ru/Tutorial/images/uiautomator/da_gplay_3.png b/ru/Tutorial/images/uiautomator/da_gplay_3.png new file mode 100644 index 000000000..f66267022 Binary files /dev/null and b/ru/Tutorial/images/uiautomator/da_gplay_3.png differ diff --git a/ru/Tutorial/images/uiautomator/dump_1.png b/ru/Tutorial/images/uiautomator/dump_1.png new file mode 100644 index 000000000..fc4a4f3db Binary files /dev/null and b/ru/Tutorial/images/uiautomator/dump_1.png differ diff --git a/ru/Tutorial/images/uiautomator/dump_2.png b/ru/Tutorial/images/uiautomator/dump_2.png new file mode 100644 index 000000000..0e7a6b8d0 Binary files /dev/null and b/ru/Tutorial/images/uiautomator/dump_2.png differ diff --git a/ru/Tutorial/images/uiautomator/dump_3.png b/ru/Tutorial/images/uiautomator/dump_3.png new file mode 100644 index 000000000..43753705d Binary files /dev/null and b/ru/Tutorial/images/uiautomator/dump_3.png differ diff --git a/ru/Tutorial/images/uiautomator/dump_4.png b/ru/Tutorial/images/uiautomator/dump_4.png new file mode 100644 index 000000000..be4e9ef19 Binary files /dev/null and b/ru/Tutorial/images/uiautomator/dump_4.png differ diff --git a/ru/Tutorial/images/uiautomator/dump_5.png b/ru/Tutorial/images/uiautomator/dump_5.png new file mode 100644 index 000000000..c7862a998 Binary files /dev/null and b/ru/Tutorial/images/uiautomator/dump_5.png differ diff --git a/ru/Tutorial/images/uiautomator/dump_6.png b/ru/Tutorial/images/uiautomator/dump_6.png new file mode 100644 index 000000000..b2ab44ed4 Binary files /dev/null and b/ru/Tutorial/images/uiautomator/dump_6.png differ diff --git a/ru/Tutorial/images/uiautomator/dump_7.png b/ru/Tutorial/images/uiautomator/dump_7.png new file mode 100644 index 000000000..893bde5f4 Binary files /dev/null and b/ru/Tutorial/images/uiautomator/dump_7.png differ diff --git a/ru/Tutorial/images/uiautomator/google_play_unauth.png b/ru/Tutorial/images/uiautomator/google_play_unauth.png new file mode 100644 index 000000000..94394250a Binary files /dev/null and b/ru/Tutorial/images/uiautomator/google_play_unauth.png differ diff --git a/ru/Tutorial/images/uiautomator/matchers.png b/ru/Tutorial/images/uiautomator/matchers.png new file mode 100644 index 000000000..d837cf072 Binary files /dev/null and b/ru/Tutorial/images/uiautomator/matchers.png differ diff --git a/ru/Tutorial/images/uiautomator/notification.png b/ru/Tutorial/images/uiautomator/notification.png new file mode 100644 index 000000000..95fcb06a5 Binary files /dev/null and b/ru/Tutorial/images/uiautomator/notification.png differ diff --git a/ru/Tutorial/images/uiautomator/notification_activity_btn.png b/ru/Tutorial/images/uiautomator/notification_activity_btn.png new file mode 100644 index 000000000..f10de9d54 Binary files /dev/null and b/ru/Tutorial/images/uiautomator/notification_activity_btn.png differ diff --git a/ru/Tutorial/images/uiautomator/ui_button.png b/ru/Tutorial/images/uiautomator/ui_button.png new file mode 100644 index 000000000..e280d5935 Binary files /dev/null and b/ru/Tutorial/images/uiautomator/ui_button.png differ diff --git a/ru/Tutorial/images/uiautomator/uiautomator_button.png b/ru/Tutorial/images/uiautomator/uiautomator_button.png new file mode 100644 index 000000000..42295cbd9 Binary files /dev/null and b/ru/Tutorial/images/uiautomator/uiautomator_button.png differ diff --git a/ru/Tutorial/images/uiautomator/uiautomator_notification.png b/ru/Tutorial/images/uiautomator/uiautomator_notification.png new file mode 100644 index 000000000..cf34c68ff Binary files /dev/null and b/ru/Tutorial/images/uiautomator/uiautomator_notification.png differ diff --git a/ru/Tutorial/images/uiautomator/uiautomator_package.png b/ru/Tutorial/images/uiautomator/uiautomator_package.png new file mode 100644 index 000000000..d3e4e93d9 Binary files /dev/null and b/ru/Tutorial/images/uiautomator/uiautomator_package.png differ diff --git a/ru/Tutorial/images/uiautomator/uiautomatorviewer_1.png b/ru/Tutorial/images/uiautomator/uiautomatorviewer_1.png new file mode 100644 index 000000000..aab4d6c54 Binary files /dev/null and b/ru/Tutorial/images/uiautomator/uiautomatorviewer_1.png differ diff --git a/ru/Tutorial/images/uiautomator/uiautomatorviewer_2.png b/ru/Tutorial/images/uiautomator/uiautomatorviewer_2.png new file mode 100644 index 000000000..22193d087 Binary files /dev/null and b/ru/Tutorial/images/uiautomator/uiautomatorviewer_2.png differ diff --git a/ru/Tutorial/images/wifi_test/available_methods.png b/ru/Tutorial/images/wifi_test/available_methods.png new file mode 100644 index 000000000..3bf2be02d Binary files /dev/null and b/ru/Tutorial/images/wifi_test/available_methods.png differ diff --git a/ru/Tutorial/images/wifi_test/first_launch_1.png b/ru/Tutorial/images/wifi_test/first_launch_1.png new file mode 100644 index 000000000..bfadc03b1 Binary files /dev/null and b/ru/Tutorial/images/wifi_test/first_launch_1.png differ diff --git a/ru/Tutorial/images/wifi_test/first_launch_2.png b/ru/Tutorial/images/wifi_test/first_launch_2.png new file mode 100644 index 000000000..82d833b9d Binary files /dev/null and b/ru/Tutorial/images/wifi_test/first_launch_2.png differ diff --git a/ru/Tutorial/images/wifi_test/internet_availability_button.png b/ru/Tutorial/images/wifi_test/internet_availability_button.png new file mode 100644 index 000000000..4dc1a5c8a Binary files /dev/null and b/ru/Tutorial/images/wifi_test/internet_availability_button.png differ diff --git a/ru/Tutorial/images/wifi_test/turn_off_wifi.png b/ru/Tutorial/images/wifi_test/turn_off_wifi.png new file mode 100644 index 000000000..6a0a297dc Binary files /dev/null and b/ru/Tutorial/images/wifi_test/turn_off_wifi.png differ diff --git a/ru/Tutorial/images/wifi_test/wifi_disabled.png b/ru/Tutorial/images/wifi_test/wifi_disabled.png new file mode 100644 index 000000000..6565ff88c Binary files /dev/null and b/ru/Tutorial/images/wifi_test/wifi_disabled.png differ diff --git a/ru/Tutorial/images/wifi_test/wifi_disabled_portrait.png b/ru/Tutorial/images/wifi_test/wifi_disabled_portrait.png new file mode 100644 index 000000000..16d7c7393 Binary files /dev/null and b/ru/Tutorial/images/wifi_test/wifi_disabled_portrait.png differ diff --git a/ru/Tutorial/images/wifi_test/wifi_enabled.png b/ru/Tutorial/images/wifi_test/wifi_enabled.png new file mode 100644 index 000000000..b4aeddb05 Binary files /dev/null and b/ru/Tutorial/images/wifi_test/wifi_enabled.png differ diff --git a/ru/Tutorial/index.html b/ru/Tutorial/index.html new file mode 100644 index 000000000..26874da5b --- /dev/null +++ b/ru/Tutorial/index.html @@ -0,0 +1,1151 @@ + + + + + + + + + + + + + + + + + + + + + + 1. Введение - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Введение

+

Всем привет! +
Если вы находитесь здесь, значит, вы интересуетесь автотестами под Android. Kaspresso - отличное решение, которое может помочь вам. Получить больше информации о нашем фреймворке можно здесь. +
Команда Kaspresso подготовила Tutorial в формате codelabs. Этот Tutorial призван помочь с первыми шагами в Kaspresso и ознакомиться с его основными возможностями.

+

Структура Tutorial

+

Tutorial разбит на шаги (уроки). Каждый урок начинается с краткого обзора и заканчивается промежуточными итогами и выводами.

+

Как проходить этот Tutorial?

+

Мы стремимся сделать уроки независимыми друг от друга, но это не всегда возможно. Для лучшего ознакомления с Kaspresso рекомендуем начать с первого урока и двигаться последовательно к следующим. +
Формат codelab предполагает, что вы будете сопровождать обучение практикой на своем компьютере, повторяя шаг за шагом действия из уроков. В проекте Kaspresso в папке 'tutorial' лежит код тестируемого приложения. В первом уроке будет рассказано, как его скачать. В ветке tutorial_results можно увидеть финальную реализацию всех тестов из Tutorial.

+

Что нужно знать для прохождения 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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Wiki/Espresso_as_the_basis/index.html b/ru/Wiki/Espresso_as_the_basis/index.html new file mode 100644 index 000000000..64185051d --- /dev/null +++ b/ru/Wiki/Espresso_as_the_basis/index.html @@ -0,0 +1,1215 @@ + + + + + + + + + + + + + + + + + + + + + + Espresso как основа - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Espresso как основа

+

Kaspresso основан на фреймворке Espresso от Google (если вы не знакомы с Espresso, подробности можно найти в официальной документации). +Espresso позволяет вам работать с элементами вашего приложения нативно и методом белого ящика (тестирование белого ящика). Найти нужный элемент на экране можно с помощью matcher-ов, а затем выполнить с ними различные действия или проверки.

+

Использование только фреймворка Espresso недостаточно

+

У этого фреймворка есть несколько недостатков, и невозможно покрыть все потребности в автотестировании Android только с помощью Espresso из-за отсутствия определенных фич.

+

Что мы хотим:

+
    +
  1. Хорошая читабельность. У Espresso с этим проблема из-за огромной иерархии matcher-ов. Когда у нас много matcher-ов, код становится трудно читаемым. Плохая читаемость означает сложность в дальнейшей поддержке теста.
  2. +
  3. Высокая стабильность. Espresso плохо работает с интерфейсами, элементы которых отображаются асинхронно. Можно настроить Idling, но это все равно не решит всех проблем.
  4. +
  5. Логирование. После прохождения теста с Espresso у вас нет пошаговой последовательности выполненных действий.
  6. +
  7. Скриншоты. Мы также хотим иметь несколько скриншотов в отчетах о прохождении теста.
  8. +
  9. Работа с ОС Android. В некоторых случаях нам нужно взаимодействовать с устройством, системными окнами и ОС Android. В этом случае функционала Espresso будет недостаточно. Вам понадобится UiAutomator.
  10. +
  11. Архитектура кода. Мы хотим иметь чистую архитектуру не только в основном коде, но и в наших тестах, возможность повторного использования кода, перемещение некоторых блоков в абстракции. Иметь единый стиль кода для всех разработчиков.
  12. +
+

Как Kaspresso решает все эти проблемы?

+

Читаемость

+

Kaspresso основан на Kakao - Android фреймворке для автотестов пользовательского интерфейса. Он основан на Espresso. Kakao предоставляет простой Kotlin DSL. Это делает тесты более читабельными. Вам больше не нужно использовать длинные конструкции с matcher-ами для поиска элементов на экране для взаимодействия из теста. Результат вызова метода Espresso onView() кэшируется. Затем вы можете обратиться к необходимому элементу как к свойству по ссылке. +Kakao также предоставляет реализацию паттерна Page object с объектом Screen. Вы можете описать все элементы интерфейса, с которыми будет взаимодействовать ваш тест, в одном месте (в одном объекте Screen).

+

Стабильность

+

Kaspresso обернул некоторые вызовы Espresso в более стабильную реализацию. Например, метод flakySafely().

+

Логирование

+

Kaspresso обернул некоторые вызовы Espresso не только для большей стабильности. Мы также внедрили перехватчик, который печатает больше отладочных сообщений в логи.

+

Работа с ОС Android

+

Мы создали интерфейс Device как фасад для всех интерфейсов, с которыми можно работать. UiAutomator может помочь вам только в некоторых случаях, но чаще вам нужна возможность выполнять различные команды (adb, shell). Например, с помощью команды adb emu вы можете эмулировать различные действия или события. +Тесты Espresso запускаются непосредственно на устройстве Android, поэтому нам нужен какой-то внешний сервер для отправки команд. В Kaspresso вы можете использовать AdbServer.

+

Архитектура кода

+

Используя описанную выше реализацию паттерна Page object, вы можете сделать свой код в тестовых файлах более читабельным, удобным для дальнейшей поддержки, повторно используемым и понятным. Kaspresso также предоставляет различные методы и абстракции для улучшения архитектуры (такие как step, Scenario, тестовые разделы и многое другое).

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Wiki/Executing_adb_commands/index.html b/ru/Wiki/Executing_adb_commands/index.html new file mode 100644 index 000000000..4216508ed --- /dev/null +++ b/ru/Wiki/Executing_adb_commands/index.html @@ -0,0 +1,1357 @@ + + + + + + + + + + + + + + + + + + + + + + Выполнение команд adb - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+ +
+
+ + + +
+
+ + + + + + + +

Выполнение команд adb

+

Описание

+

Как вы помните из предыдущей части, посвященной интерфейсу Device, под капотом интерфейса устройства находятся следующие сущности:

+
    +
  • Espresso
  • +
  • UI Automator
  • +
  • ADB
  • +
+ +

Внимательный читатель мог заметить, что ADB недоступен в тестах Espresso. Но используя некоторые другие фреймворки, такие как Appium, вы можете выполнять команды ADB. Поэтому мы решили добавить и эту важную функцию.
+Мы разработали специальный AdbServer для автотестов, чтобы компенсировать отсутствие этой функции. +Основная идея инструмента аналогична идее в Appium. Мы создали простую клиент-серверную систему, состоящую из двух частей:

+
    +
  • Устройство, которое запускает тест, действует как клиент
  • +
  • Desktop отправляет команды ADB для выполнения на устройстве. + Также в системе используется переадресация портов, чтобы можно было организовать сокет-туннель между Устройством и Десктопом через любой вид соединения (Wi-Fi, Bluetooth, USB и т.д.).
  • +
+

Использование

+

Алгоритм использования AdbServer:

+
    +
  1. Запустите Desktop-часть на своей рабочей станции.
    + Выполните следующую команду: java -jar <path/to/kaspresso>/artifacts/adbserver-desktop.jar в терминале
  2. +
  3. Запустите часть устройства.
    + Соберите и запустите модуль adbserver-sample. Вы должны увидеть следующий экран: +
  4. +
+

Например, введите shell input text abc в EditText приложения и нажмите кнопку Выполнить. В результате вы получите shell input text abcabc +в EditText, потому что команда ADB была выполнена и символы abc были добавлены в сфокусированный EditText.
+Вы можете заметить, что приложение использует класс AdbTerminal для выполнения команд ADB.

+

Использование в Kaspresso

+

В 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" />
+

+

Параметры и ведение журнала

+

Desktop-часть

+

Вы также можете использовать несколько специальных флагов, когда запускате 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 сообщение: неверный тип сообщения...
+
+Взгляните на формат журнала. Вы можете увидеть тип сообщения, дату и время, имя хоста и эмулятор, который выполняет команду, а также сообщение.

+
    +
  1. +

    WARN
    + Печатает сообщения об ошибках и предупреждения.

    +
  2. +
  3. +

    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)
    +
    +Также Desktop печатает имя эмулятора, в котором была выполнена конкретная команда (эта информация доступна на рабочей станции и на устройстве). +Это может быть очень полезно при отладке. Взгляните на поле 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)
    +

    +
  4. +
  5. +

    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.

    +
  6. +
  7. +

    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
    +

    +
  8. +
+

Часть Device

+

В 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)
+
+Все журналы печатаются с тегом KASPRESSO_ADBSERVER с уровнем журнала WARN.
+Если вы хотите отладить код, вы можете установить уровень журнала 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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Wiki/Images/Matchers_actions_assertions/Espresso_cheat_sheet.png b/ru/Wiki/Images/Matchers_actions_assertions/Espresso_cheat_sheet.png new file mode 100644 index 000000000..8dea00033 Binary files /dev/null and b/ru/Wiki/Images/Matchers_actions_assertions/Espresso_cheat_sheet.png differ diff --git a/ru/Wiki/Jetpack_Compose/index.html b/ru/Wiki/Jetpack_Compose/index.html new file mode 100644 index 000000000..3e3b80c0c --- /dev/null +++ b/ru/Wiki/Jetpack_Compose/index.html @@ -0,0 +1,1403 @@ + + + + + + + + + + + + + + + + + + + + + + Поддержка Compose в Kaspresso - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Поддержка Compose в Kaspresso

+

Поддержка Jetpack Compose состоит из двух частей: библиотека Kakao Compose и механизм Kaspresso Interceptors.

+

Библиотека Kakao Compose

+

Вся подробная информация доступна в 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()
+                }
+            }
+        }
+
+        // ...
+    }
+}
+
+Опять же, вся информация, связанная с DSL, доступна в документации.

+

Механизм Kaspresso Interceptors

+

Перехватчики — одно из главных преимуществ и возможностей библиотеки Kaspresso.
+Перечислим дефолтные перехватчики, которые по умолчанию работают под капотом, когда вы пишете тесты с Kaspresso.

+

Behavior interceptors (Поведенческие перехватчики)

+
    +
  1. FailureLoggingSemanticsBehaviorInterceptor
    + Создайте ясное и понятное исключение в случае сбоя теста.
  2. +
  3. FlakySafeSemanticsBehaviorInterceptor
    + Пытается повторить неудачное действие или утверждение в течение заданного времени ожидания. Все параметры этого перехватчика находятся в FlakySafetyParams.
  4. +
  5. SystemDialogSafetySemanticsBehaviorInterceptor
    + Устраняет различные системные диалоги, мешающие корректному выполнению теста.
  6. +
  7. AutoScrollSemanticsBehaviorInterceptor
    + Выполняет автопрокрутку к элементу, если элемент не виден на экране.
  8. +
  9. ElementLoaderSemanticsBehaviorInterceptor
    + Запрашивает связанный SemanticNodeInteraction, используя сохраненный Matcher, когда элемент не найден.
  10. +
+

Watcher interceptors (Перехватчики-наблюдатели)

+

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()
+        }
+    }
+)
+

+

Поддержка Robolectric

+

Вы можете запускать тесты Compose в среде JVM с помощью Robolectric.
+В качестве примера можно запустить тест ComposeSimpleFlakyTest (из модуля kaspresso-sample) на JVM прямо сейчас: +

./gradlew :samples:kaspresso-compose-support-sample:testDebugUnitTest --info --tests "com.kaspersky.kaspresso.composesupport.sample.test.ComposeSimpleFlakyTest"  
+
+Вся информация о поддержке Robolectric доступна здесь.

+

Compose совместим со всеми расширениями Kaspresso.

+

Расширения Kaspresso подразумевают использование таких конструкций, как:

+
    +
  1. flakySafely
  2. +
  3. continuously
  4. +
+

Идет поддержка некоторых конструкций: issue-317.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Wiki/Kaspresso_Allure/index.html b/ru/Wiki/Kaspresso_Allure/index.html new file mode 100644 index 000000000..47ea28e8a --- /dev/null +++ b/ru/Wiki/Kaspresso_Allure/index.html @@ -0,0 +1,1234 @@ + + + + + + + + + + + + + + + + + + + + + + Поддержка Allure в Kaspresso - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+ +
+
+ + + +
+
+ + + + + + + +

Поддержка Allure в Kaspresso

+

Что нового

+

В версии 1.3.0 Kaspresso была добавлена поддержка allure-framework. Теперь очень легко создавать красивые тестовые отчеты, используя фреймворки Kaspresso и Allure.

+

В этом выпуске семейство классов управления файлами, отвечающее за предоставление файлов для снимков экрана и журналов, было реорганизовано для лучшего использования и расширяемости. Это изменение затронуло старые классы, которые сейчас помечены как устаревшие (см. пакет com.kaspersky.kaspresso.files). Пример использования: CustomizedSimpleTest.

+

Также были добавлены следующие перехватчики:

+
    +
  1. VideoRecordingInterceptor - перехватчик записи видео.
  2. +
  3. DumpViewsInterceptor - перехватчик, который предоставляет XML-представление иерархии View в случае сбоя теста.
  4. +
+

В пакете 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:<последняя_версия>"
+}
+
+Затем используйте специальную функцию withForcedAllureSupport в вашем конструкторе TestCase или в вашем TestCaseRule, чтобы включить все доступные перехватчики, поддерживающие Allure: +
class AllureSupportTest : TestCase(
+    kaspressoBuilder = Kaspresso.Builder.withForcedAllureSupport()
+) {
+
+}
+
+Если вы хотите указать параметры или добавить больше перехватчиков, вы можете использовать функцию addAllureSupport: +
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() }
+                }
+            })
+        }
+    }
+) {
+
+}
+
+Если вам не нужны все эти перехватчики, предоставляемые withForcedAllureSupport и **addAllureSupport **, то вы можете добавить только те перехватчики, которые вам нравятся. Но обратите внимание, что AllureMapperStepInterceptor.kt является обязательным для работы службы поддержки Allure. Например, если вам не нужны видеоролики и просмотр иерархий после неудачных тестов, вы можете сделать что-то вроде: +
class AllureSupportCustomizeTest : TestCase(
+    kaspressoBuilder = Kaspresso.Builder.simple().apply {
+        stepWatcherInterceptors.addAll(
+            listOf(
+                ScreenshotStepInterceptor(screenshots),
+                AllureMapperStepInterceptor()
+            )
+        )
+        testRunWatcherInterceptors.addAll(
+            listOf(
+                DumpLogcatTestInterceptor(logcatDumper),
+                ScreenshotTestInterceptor(screenshots),
+            )
+        )
+    }
+) {
+...
+}
+
+Для просмотра, запуска и экспериментирования со всем этим функционалом вам доступен kaspresso-allure-support-sample.

+

Посмотреть результат

+

Итак, вы добавили в свою конфигурацию 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-results со всеми тестовыми ресурсами теперь находится по адресу /Users/username/Desktop.

+

Теперь мы хотим создать и просмотреть отчет. Для этого на нашей машине должен быть установлен сервер Allure. Чтобы узнать, как это сделать со всеми подробностями, следуйте документации Allure.

+

Например, чтобы установить сервер Allure на MacOS, мы можем использовать следующую команду: +

brew install allure
+
+Теперь мы готовы сгенерировать и посмотреть отчет, просто вызовите: +
allure serve /Users/username/Desktop/allure-results
+
+Затем сервер Allure создает html-страницу, представляющую отчет, и помещает ее во временный каталог в вашей системе. Вы увидите, что отчет открывается в новой вкладке вашего браузера (вкладка открывается автоматически).

+

Если вы хотите сохранить сгенерированный html-отчет в определенном каталоге для использования в будущем, вы можете просто вызвать: +

allure generate -o ~/kaspresso-allure-report /Users/username/Desktop/allure-results
+
+Чтобы посмотреть его, в своем браузере вы просто вызываете: +
allure open ~/kaspresso-allure-report
+
+После всех этих шагов вы увидите что-то вроде: +

+

Детали успешного теста: +

+

Сведения о неудачном тесте: +

+

Детали, которые вам нужно знать

+

По умолчанию, Kaspresso-Allure вводит дополнительные тайм-ауты, чтобы максимально гарантировать правильность видеозаписи. Эти тайм-ауты увеличивают время выполнения теста на 5 секунд. +Вы можете изменить эти значения, настроив videoParams в Kaspresso.Builder. См. пример выше.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Wiki/Kaspresso_Robolectric/index.html b/ru/Wiki/Kaspresso_Robolectric/index.html new file mode 100644 index 000000000..d5bb64ea7 --- /dev/null +++ b/ru/Wiki/Kaspresso_Robolectric/index.html @@ -0,0 +1,1215 @@ + + + + + + + + + + + + + + + + + + + + + + Запуск тестов Kaspresso на JVM с помощью Robolectric - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+ +
+
+ + + +
+
+ + + + + + + +

Запуск тестов Kaspresso на JVM с помощью Robolectric

+

Основная цель

+

Начиная с 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)
+    ...
+
+Это потому, что Robolectric совместим с Espresso, но не совместим с UI Automator.

+

Теперь все тесты Kaspresso могут корректно выполняться на JVM с Robolectric со следующими ограничениями:

+
    +
  1. Простая настройка вашего проекта в соответствии с руководством Robolectric.
  2. +
  3. Невозможно использовать adb-сервер, потому что в среде JVM нет такого термина, как «Рабочий стол». Тесты, использующие adb-server, будут падать на JVM с Robolectric с поясняющим сообщением об ошибке.
  4. +
  5. Невозможно работать с классами UiDevice и UiAutomation. Вот почему многие (не все!) реализации в Device будут падать на JVM с Robolectric с NotSupportedInstrumentalTestException.
  6. +
  7. Нерабочий Kautomator. Упомянутая проблема с классами UiDevice и UiAutomation затрагивает весь Kautomator. Таким образом, тесты с использованием Kaautomator будут аварийно завершать работу на JVM с Robolectric с KautomatorInUnitTestException.
  8. +
  9. Перехватчики, использующие UiDevice, UiAutomation или adb-server, автоматически отключаются на JVM с Robolectric.
  10. +
  11. DocLocScreenshotTestCase будет аварийно завершать работу на JVM с Robolectric с DocLocInUnitTestException.
  12. +
+

Использование

+

Чтобы создать тест, который может работать на устройстве/эмуляторе и на 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)

+

Мы подготовили набор инструментов и советов, чтобы приспособить ваши тесты к среде 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.

+
    +
  1. Robolectric-Espresso поддерживает Idling, но не поддерживает публикацию отложенных сообщений в Looper
  2. +
  3. Robolectric-Espresso не будет поддерживать тесты, которые запускают новые activity
  4. +
+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Wiki/Kaspresso_configuration/index.html b/ru/Wiki/Kaspresso_configuration/index.html new file mode 100644 index 000000000..4d82b2ea9 --- /dev/null +++ b/ru/Wiki/Kaspresso_configuration/index.html @@ -0,0 +1,1589 @@ + + + + + + + + + + + + + + + + + + + + + + Конфигуратор Kaspresso - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Конфигуратор Kaspresso

+

Класс 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 содержит:

+

Kakao clicks

+

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, если вы хотите добавить его, например, в свои пользовательские перехватчики.

+

Перехватчики Kaspresso на базе перехватчиков Kakao/Kautomator.

+

Эти перехватчики были введены для упрощения и единообразия использования перехватчиков Kakao и перехватчиков Kautomator.

+

Важный момент о смешении перехватчиков Kaspresso и перехватчиков Kakao/Kautomator.
+Перехватчики Kaspresso не будут работать, если вы установите свои собственные перехватчики Kakao, вызвав метод Kakao.intercept в тесте, или установите свои пользовательские перехватчики Kautomator, вызвав Kautomator.intercept в тесте.
+Если вы установите свои пользовательские перехватчики Kakao для конкретного экрана или KView и установите для аргумента isOverride значение true, то перехватчики Kaspresso не будут работать для конкретного экрана или KView. То же самое верно и для Kautomator, где разработчик взаимодействует с UiScreen и UiBaseView.

+

Перехватчики Kaspresso можно разделить на два типа:

+
    +
  1. Behavior Interceptors - перехватывают вызовы ViewInteraction, DataInteraction, WebInteraction, UiObjectInteraction, UiDeviceInteraction и выполняют свою логику.
    + Внимание, мы собираемся рассмотреть некоторые важные примечания о перехватчиках поведения в конце этого документа.
  2. +
  3. Watcher Interceptors - перехватывают вызовы ViewAction, ViewAssertion, Atom, WebAssertion, UiObjectAssertion, UiObjectAction, UiDeviceAssertion, UiDeviceAction и еще кое-что.
  4. +
+

Расширим упомянутые типы перехватчиков Kaspresso:

+
    +
  1. Behavior Interceptors
      +
    1. viewBehaviorInterceptors - перехватывают вызовы ViewInteraction#perform и ViewInteraction#check
    2. +
    3. dataBehaviorInterceptors - перехватывают вызовы DataInteraction#check
    4. +
    5. webBehaviorInterceptors - перехватывают вызовы Web.WebInteraction<R>#perform и Web.WebInteraction<R>#check
    6. +
    7. objectBehaviorInterceptors - перехватывают вызовы UiObjectInteraction#perform и UiObjectInteraction#check
    8. +
    9. deviceBehaviorInterceptors - перехватывают вызовы UiDeviceInteraction#perform и UiDeviceInteraction#check
    10. +
    +
  2. +
  3. Watcher Interceptors
      +
    1. viewActionWatcherInterceptors – выполняют какие-то действия до того, как будет вызван android.support.test.espresso.ViewAction.perform
    2. +
    3. viewAssertionWatcherInterceptors – выполняют какие-то действия до того, как будет вызван android.support.test.espresso.ViewAssertion.check
    4. +
    5. atomWatcherInterceptors – выполняют какие-то действия до того, как будет вызван android.support.test.espresso.web.model.Atom.transform
    6. +
    7. webAssertionWatcherInterceptors — выполняют какие-то действия до того, как будет вызван android.support.test.espresso.web.assertion.WebAssertion.checkResult
    8. +
    9. objectWatcherInterceptors - выполняют какие-то действия до того, как будет вызван UiObjectInteraction.perform или UiObjectInteraction.check
    10. +
    11. deviceWatcherInterceptors - выполняют какие-то действия до того, как будет вызван UiDeviceInteraction.perform или UiDeviceInteraction.check
    12. +
    +
  4. +
+

Пожалуйста, помните! Перехватчики поведения и наблюдателя работают под капотом в каждом действии (actions) и утверждении (assertions) каждого графического элемента (View) Kakao и Kautomator по умолчанию в Kaspresso.

+

Специальные перехватчики Kaspresso

+

Эти перехватчики не основаны на какой-то lib. Краткое описание:

+
    +
  1. stepWatcherInterceptors - перехватчик действий жизненного цикла Step
  2. +
  3. testRunWatcherInterceptors - перехватчик всех действий жизненного цикла Test.
  4. +
+

Как вы заметили, эти перехватчики также являются частью Watcher Interceptors.

+

BuildStepReportWatcherInterceptor

+

Этот 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

+

Экземпляр Device. Подробная информация находится на этой странице в разделе Вики.

+

AdbServer

+

Экземпляр AdbServer. Подробная информация находится на этой странице в разделе Вики.

+

Настройка Kaspresso и пример перехватчиков Kaspresso

+

Пример того, как настроить Kaspresso и как использовать перехватчики 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: ---------------------------------------------------------------------------
+
+Довольно хорошо.

+

Защита от flaky тестов

+

Если происходит сбой, Kaspresso пытается исправить его, используя большой набор разнообразных способов.
+Эта защита работает для каждого действия и проверки каждого View Kakao и Kautomator! Вам просто нужно расширить свой тестовый класс из TestCase (BaseTestCase) или установить TestCaseRule (BaseTestCaseRule) в вашем тесте.

+

Перехватчики

+

Включенные по умолчанию перехватчики:

+
    +
  1. Watcher interceptors
  2. +
  3. Behavior interceptors
  4. +
  5. Kaspresso interceptors
  6. +
  7. BuildStepReportWatcherInterceptor
  8. +
+

Все описанные выше возможности доступны благодаря этим перехватчикам.

+

Несколько слов о 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. Это:

+
    +
  1. AutoScrollViewBehaviorInterceptor
  2. +
  3. SystemDialogSafetyViewBehaviorInterceptor
  4. +
  5. FlakySafeViewBehaviorInterceptor
  6. +
+

Под капотом все действия и проверки Kakao в первую очередь вызывают FlakySafeViewBehaviorInterceptor, который вызывает SystemDialogSafetyViewBehaviorInterceptor, а тот вызывает AutoScrollViewBehaviorInterceptor.
+Если результатом обработки AutoScrollViewBehaviorInterceptor является ошибка, то SystemDialogSafetyViewBehaviorInterceptor пытается обработать полученную ошибку. Если результатом обработки SystemDialogSafetyViewBehaviorInterceptor также является ошибка, тогда FlakySafeViewBehaviorInterceptor попытается обработать полученную ошибку.
+Для упрощения обсуждаемой темы нарисовали картинку:

+

+

Дополнения основной секции

+

Разработчик также может расширить функциональность параметризованных тестов, предоставив MainSectionEnricher в BaseTestCase или BaseTestCaseRule. +Основная идея - позволить добавить дополнительные шаги тест-кейса до и после главной секции run.

+

Все, что вам нужно сделать, это:

+
    +
  1. Определите свою реализацию для интерфейса MainSectionEnricher;
  2. +
+
class LoggingMainSectionEnricher : MainSectionEnricher<TestCaseData> {
+    ...
+
+}
+
+

Здесь TestCaseData - это тот же тип данных, что и в вашей реализации BaseTestCase.

+
    +
  1. Переопределите методы beforeMainSectionRun и/или afterMainSectionRun, чтобы добавить свои действия до/после;
  2. +
+
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.

+
    +
  1. Добавьте написанные классы в свою реализацию BaseTestCase.
  2. +
+
class EnricherBaseTestCase : BaseTestCase<TestCaseDsl, TestCaseData>(
+    kaspresso = Kaspresso.Builder.default(),
+    dataProducer = { action -> TestCaseDataCreator.initData(action) },
+    mainSectionEnrichers = listOf(
+        LoggingMainSectionEnricher(),
+        AnalyticsMainSectionEnricher()
+    )
+)
+
+

После того, как это будет сделано, описанные вами действия будут выполняться до или после блока run основной секции.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Wiki/Kautomator-wrapper_over_UI_Automator/index.html b/ru/Wiki/Kautomator-wrapper_over_UI_Automator/index.html new file mode 100644 index 000000000..93ec327b5 --- /dev/null +++ b/ru/Wiki/Kautomator-wrapper_over_UI_Automator/index.html @@ -0,0 +1,1505 @@ + + + + + + + + + + + + + + + + + + + + + + Kautomator. Обертка над UI Automator - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Kautomator: оболочка над UI Automator

+

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")
+
+Это пример только для ввода и проверки текста. Поскольку у нас есть успешный опыт использования Kakao, мы решили таким же образом обернуть UI Automator и назвали его Kautomator: +
mainScreen {
+    simpleEditText {
+        replaceText("Kaspresso")
+        hasText("Kaspresso")
+    }
+}
+

+

Еще одним большим преимуществом Kautomator является возможность ускорения UI Automator.
+Взгляните на видео ниже:

+


+Левое видео — это улучшенный UI Automator, правое видео — это UI Automator по умолчанию.

+

Почему это возможно? Подробности доступны ниже.

+

Преимущества

+
    +
  • Читабельность
  • +
  • Повторное использование
  • +
  • Расширяемый DSL
  • +
  • Удивительная скорость!
  • +
+

Как это использовать

+

Создать экран

+

Создайте свой объект UiScreen, куда вы добавите UI-элементы, участвующие во взаимодействии тестов: +

class FormScreen : UiScreen<FormScreen>()
+
+UiScreen может представлять весь пользовательский интерфейс или его часть. +Если вы используете шаблон Page Object, вы можете поместить взаимодействия с Kautomator внутри Page Objects.

+

Создание UiView

+

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") }
+}
+
+Kautomator предоставляет различные типы в зависимости от типа UI-элемента:

+
    +
  • 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()
+    }
+}
+

+

Отличие от Kakao-Espresso

+

В 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-элементы

+

Если у вас есть нестандартные (кастомные) UI-элементы в ваших тестах и вы хотите создать свой собственный UiView, у нас есть UiBaseView. Просто унаследуйте этот класс и реализуйте столько дополнительных интерфейсов Action/Assertion, сколько хотите. +Вам также необходимо переопределить конструкторы, которые вам нужны.

+
class UiMyView : UiBaseView<KView>, UiMyActions, UiMyAssertions {
+    constructor(selector: UiViewSelector) : super(selector)
+    constructor(builder: UiViewBuilder.() -> Unit) : super(builder)
+}
+
+

Работа interceptor-ов

+

Если вам нужно добавить свою логику во время цепочки вызовов 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. Как это становится возможным?
+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, из-за отсутствия перехватчика скриншотов на каждом шаге. При необходимости добавьте их вручную.

+

В любом случае, это небольшое изменение для разработчика, но большой шаг для всех нас =)

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Wiki/Matchers_actions_assertions/index.html b/ru/Wiki/Matchers_actions_assertions/index.html new file mode 100644 index 000000000..022d10853 --- /dev/null +++ b/ru/Wiki/Matchers_actions_assertions/index.html @@ -0,0 +1,1053 @@ + + + + + + + + + + + + + + + + + + + + + + Доступные классы Matcher, Action и Assertion - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Доступные классы Matcher, Action и Assertion

+

Kaspresso основан на Espresso (если вы не знакомы с Espresso, ознакомьтесь с официальной документацией). +В официальной документации указаны следующие основные компоненты:

+
    +
  1. Espresso – точка входа для взаимодействия с view (через onView() и onData()). Также предоставляет API, которые не обязательно привязаны к какому-либо элементу интерфейса, например, pressBack().
  2. +
  3. ViewMatchers – набор объектов, реализующих интерфейс Matcher<? super View>. Вы можете передать один или несколько из них методу onView(), чтобы найти нужный элемент в текущей иерархии экрана.
  4. +
  5. ViewActions – коллекция объектов ViewAction, которые можно передать методу ViewInteraction.perform(), например, click(). Набор действий, которые могут быть выполнены с UI-элементами.
  6. +
  7. ViewAssertions – коллекция объектов ViewAssertion, которые можно передать методу ViewInteraction.check(). Набор проверок, которые могут быть выполнены для различных UI-элементов. В большинстве случаев используют проверки, которые принимают Matcher-ы, для проверки состояния view.
  8. +
+
// 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 наследует все лучшее от этих двух фреймворков, вам доступно все, что описано выше.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Wiki/Page_object_in_Kaspresso/index.html b/ru/Wiki/Page_object_in_Kaspresso/index.html new file mode 100644 index 000000000..778038c31 --- /dev/null +++ b/ru/Wiki/Page_object_in_Kaspresso/index.html @@ -0,0 +1,1183 @@ + + + + + + + + + + + + + + + + + + + + + + Паттерн PageObject в Kaspresso - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Паттерн Page object в Kaspresso.

+

Что такое Page object?

+

Паттерн Page object хорошо объяснен Мартином Фаулером в этой статье. Если коротко, то это тестовая абстракция, которая описывает экран с некоторыми элементами интерфейса. С этими элементами можно взаимодействовать во время тестов. В результате, описание элементов экрана будет в отдельном классе. Вам больше не нужно постоянно искать нужный UI-элемент с несколькими matcher-ами в тестах. Это можно сделать один раз, сохранив ссылку на экран.

+

Как реализован паттерн Page object в Kaspresso?

+

Kaspresso предоставляет KScreen и UiScreen в качестве реализации паттерна Page object.

+

В чем разница между KScreen и UiScreen?

+

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 (полное имя пакета приложения).

+

Преимущества паттерна Page object для рефакторинга

+

Применение этого паттерна позволяет вынести описание экрана в отдельный файл и повторно использовать экраны и ссылки на UI-элементы в разных тестах. Когда у вас есть некоторые изменения в пользовательском интерфейсе приложения, вы можете изменить только код в файле экрана без необходимости большого рефакторинга тестов.

+

Преимущества Page Object для работы в команде

+

В одних командах автотесты пишут только разработчики, в других QA инженеры. В некоторых случаях автотесты пишет кто-то, кто не знает деталей кода (исходный код есть, но плохо понятен). В этом случае разработчики могут написать сущности Screen для дальнейших автотестов. Наличие реализованных экранов (Page object Screen) помогает другому человеку писать тесты с использованием Kotlin DSL.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Wiki/Screenshot_tests/index.html b/ru/Wiki/Screenshot_tests/index.html new file mode 100644 index 000000000..fea0daadf --- /dev/null +++ b/ru/Wiki/Screenshot_tests/index.html @@ -0,0 +1,1352 @@ + + + + + + + + + + + + + + + + + + + + + + Скриншот тесты - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Скриншот тесты

+

Основная цель

+

Иногда при разработке новых функций возникает необходимость проверить, корректно ли работает приложение на всех поддерживаемых языках. Ручное изменение настроек локали может занять много времени и потребовать усилий разработчиков, 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>
+
+Подобные данные могут быть полезны для разных систем, автоматизирующих процесс локализации приложения. Система автоматизации сохраняет файл xml для каждого экрана и сравнивает его с новыми версиями, полученными при прогонах новых скриншотов. При обнаружении каких-либо отличий система дает сигнал подготовить и отправить порцию новых слов на сервер перевода.

+

Скриншоты системных диалогов/окон

+

Иногда вам нужно сделать скриншоты системных диалогов или окон. Вот почему вы должны изменить язык для всей системы. Для этого в конструкторе 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")
+            }
+        }
+    }        
+}
+
+Полный пример находится по адресу ChangeSysLanguageTestCase.

+

Расширенное использование

+

В большинстве случаев нет необходимости запускать какую-то 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 со старой системой предоставления ресурсов в качестве вторичного конструктора. +Вы можете просмотреть вторичный конструктор как пример миграции со старой системы на новую. +Кроме того, мы сохранили тесты с использованием старой системы предоставления ресурсов в примерах, чтобы убедиться, что ничего не сломано.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Wiki/Supported_Android_UI_elements/index.html b/ru/Wiki/Supported_Android_UI_elements/index.html new file mode 100644 index 000000000..bb92cf43d --- /dev/null +++ b/ru/Wiki/Supported_Android_UI_elements/index.html @@ -0,0 +1,1136 @@ + + + + + + + + + + + + + + + + + + + + + + Поддерживаемые UI-виджеты Android - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+ +
+
+ + + +
+
+ + + + + + + +

Поддерживаемые UI-виджеты Android

+

Поддерживаемые UI-виджеты Android в Kakao

+

Все поддерживаемые 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

+

Поддерживаемые UI-виджеты Android в KAutomator

+

Если вы расширяете абстрактый класс UiScreen, то вам доступны следующие элементы: +
UiView +
UiEditText +
UiTextView +
UiButton +
UiCheckbox +
UiChipGroup +
UiSwitchView +
UiScrollView +
UiBottomNavigationView

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Wiki/Working_with_Android_OS/index.html b/ru/Wiki/Working_with_Android_OS/index.html new file mode 100644 index 000000000..3effbe449 --- /dev/null +++ b/ru/Wiki/Working_with_Android_OS/index.html @@ -0,0 +1,1157 @@ + + + + + + + + + + + + + + + + + + + + + + Взаимодействие с ОС Android. Класс Device - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Взаимодействие с ОС Android. Класс Device.

+

Device — это поставщик менеджеров для всей работы вне приложения.

+

Структура

+

Все примеры находятся в device_tests. +Класс Device содержит следующие свойства:

+
    +
  1. apps позволяет устанавливать или удалять приложения. Использует команды adb install и adb uninstall. См. пример DeviceAppSampleTest.
  2. +
  3. activities — это интерфейс для работы с отображаемыми в данный момент Activity. AdbServer не требуется. См. пример DeviceActivitiesSampleTest.
  4. +
  5. files обеспечивает возможность загрузки или удаления файлов с устройства. Использует команды adb push и adb rm и не требует разрешения android.permission.WRITE_EXTERNAL_STORAGE. См. пример DeviceFilesSampleTest.
  6. +
  7. internet позволяет переключать настройки Wi-Fi и передачи данных по сети. Будьте осторожны при использовании этого интерфейса, изменения настроек Wi-Fi могут не работать с некоторыми версиями Android. См. пример DeviceNetworkSampleTest.
  8. +
  9. keyboard — это интерфейс для отправки событий клавиатуры через adb. Используйте его только тогда, когда Espresso или UiAutomator не подходят (например, экран заблокирован). См. пример DeviceKeyboardSampleTest.
  10. +
  11. location имитирует поддельное местоположение и позволяет переключать настройки GPS. См. пример DeviceLocationSampleTest.
  12. +
  13. phone позволяет эмулировать входящие звонки и принимать SMS-сообщения. Работает только на эмуляторах, так как использует команды adb emu. См. пример DevicePhoneSampleTest.
  14. +
  15. screenshots — интерфейс для скриншотов пользовательского интерфейса. Требуется разрешение android.permission.WRITE_EXTERNAL_STORAGE. См. пример DeviceScreenshotSampleTest.
  16. +
  17. accessibility - позволяет включать или отключать специальные возможности. Доступно с API 24. См. пример DeviceAccessibilitySampleTest.
  18. +
  19. permissions - предоставляет возможность выдавать или отклонять запросы разрешений через диалоговое окно разрешений Android по умолчанию. См. пример DevicePermissionsSampleTest.
  20. +
  21. hackPermissions предоставляет возможность выдавать любые разрешения без системного диалога Android по умолчанию. См. пример DeviceHackPermissionsSampleTest.
  22. +
  23. exploit позволяет менять ориентацию устройства или нажимать системные кнопки. См. пример DeviceExploitSampleTest.
  24. +
  25. language позволяет переключать язык. См. пример DeviceLanguageSampleTest.
  26. +
  27. logcat обеспечивает доступ к adb logcat. См. пример DeviceLogcatSampleTest.
    + Назначение logcat:
    + Если вы не слышали о GDPR и громких судебных процессах то вам повезло. Но, если ваше приложение работает в Европе, то очень важно включить/отключить всю аналитику/статистику согласно принятым соглашениям. + Один из самых надежных способов проверить отправку аналитики/статистики — это проверить logcat, в котором все аналитики/статистика пишут свои журналы (конечно, в режиме отладки). + Вот почему мы создали специальный класс Logcat, предоставляющий множество способов проверки logcat.
  28. +
  29. uiDevice возвращает экземпляр android.support.test.uiautomator.UiDevice. Мы не рекомендуем использовать его напрямую, потому что есть Kautomator, который предлагает более читаемый, предсказуемый и стабильный API для работы вне вашего приложения.
  30. +
+

Также 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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Wiki/how_to_write_autotests/index.html b/ru/Wiki/how_to_write_autotests/index.html new file mode 100644 index 000000000..3f3224e11 --- /dev/null +++ b/ru/Wiki/how_to_write_autotests/index.html @@ -0,0 +1,1546 @@ + + + + + + + + + + + + + + + + + + How to write autotests - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

How to write autotests

+

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.

+

Abstractions

+

How many abstractions can you have in your tests?

+

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.

+

How to determine whether View (fragment, dialog, anything) in the project has its description in some Kakao 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.

+

Is it ok that your PO contains helper methods?

+

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()
+    }
+}
+
+and +
MainScreen {
+    navigateToTasksScreen()
+}
+
+object MainScreen : KScreen<MainScreen>() {
+    //...
+    fun navigateToTasksScreen() {
+        shieldView {
+            click()
+        }
+    }
+    //...
+}
+
+I am sure that method navigateToTasksScreen() is more "talking" than the simple click on some shieldView.

+

Can 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.

+

Assert help methods inside of PO. Is it ok?

+

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
+    )
+}
+
+We prefer the first variant. But we follow the next naming convention of such methods: assert<YourCheckName>.

+

Test structure

+

Test and Test-case correlation

+

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)
+        }
+    }
+}
+
+Not bad. But can you correlate this code with the test-case easy? +No, you need to read the code of the test and the text of the test-case very attentively. It's not comfortable.
+So we want to have a structure of the test that would suggest what step of the test-case we are looking at in the particular area of the test.

+

Before/after state of a test

+

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.

+

Test structure

+

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()
+            )
+        }
+    }
+
+Let's describe the structure:
+1. before - after - run
+ In the beginning, we think about a state. After the state, we begin to consider the test body. +2. step
+ step in the test is similar to step in the test-case. That's why test reading is easier and understandable. +3. scenario
+ There are cases when some sentences of steps are absolutely identical and occur very often in tests. + For these sentences we have introduced a 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.

+

Test data for the test

+

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 {
+    // ...
+}
+
+1. init
+ Here, you prepare only sets of data without any transforms and connections. Also, you can make requests to your test server, for example.
+ It's an optional block. +2. transform
+ This construction is for transforming of our test data. In our example we join the owner and company.
+ It's an optional block. The block is enabled only after the 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!

+

Available Test DSL forms

+

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

+

Examples

+

You can have a look at examples of how to use and configure Kaspresso +and how to use different forms of DSL.

+

Sweet additional features

+

Some words about BaseTestContext method

+

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.

+

flakySafely

+

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)
+            }
+        }
+    }
+}
+
+More detailed examples are here. Please, observe a documentation about implementation details.

+

continuously

+

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()
+        }
+    }
+}
+
+The example is here.

+

compose

+

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()
+                    }
+                }
+            }
+        }
+    }
+}
+
+The example is here.
+Please, observe additional opportunities and documentation: common info, ComposeProvider and WebComposeProvider.

+

data

+

If you set your test data by init-transform methods then this test data is available by a data field.

+

testAssistants

+

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.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/Wiki/index.html b/ru/Wiki/index.html new file mode 100644 index 000000000..eaa561bdf --- /dev/null +++ b/ru/Wiki/index.html @@ -0,0 +1,1034 @@ + + + + + + + + + + + + + + + + + + + + + + Введение - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+
+ + + + + + + +

Kaspresso Вики

+

Здесь вы можете найти подробную информацию о всех возможностях Kaspresso.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/index.html b/ru/index.html new file mode 100644 index 000000000..b8365ab8c --- /dev/null +++ b/ru/index.html @@ -0,0 +1,1614 @@ + + + + + + + + + + + + + + + + + + + + О Kaspresso - Kaspresso + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + + + + + + +
+ +
+ + + + +
+
+ + + +
+
+
+ + + + + + +
+
+
+ + + + + + + +
+
+ + + + + + + +

Android Arsenal +Android Weekly +Android Weekly +MavenCentral +Build and Deploy +Telegram +Telegram

+

Kaspresso

+

Kaspresso — это фреймворк для тестирования пользовательского интерфейса Android. Он основан на Espresso и UI Automator и предоставляет широкий спектр дополнительных функций, таких как:

+
    +
  • 100% стабильность, отсутствие флаков.
  • +
  • Поддержка Jetpack Compose [Ранний доступ].
  • +
  • Значительно ускорено выполнение команд UI Automator. С Kaspresso некоторые команды UI Automator выполняются в 10 раз быстрее!
  • +
  • Отличная читаемость благодаря человеческому DSL.
  • +
  • Полезный механизм перехватчиков, позволяющий перехватывать все действия и проверки в одном месте.
  • +
  • Полное ведение журнала.
  • +
  • Возможность вызова команд ADB.
  • +
  • Философия написания тестов пользовательского интерфейса, реализованная с помощью DSL.
  • +
  • Предоставляет возможность скриншотинга.
  • +
  • Поддержка Robolectric.
  • +
  • Поддержка Allure.
  • +
+

И многое другое!

+

Kaspresso

+

Интеграция

+

Чтобы интегрировать Kaspresso в свой проект: +1. Включите репозиторий mavenCentral в корневой файл build.gradle:

+
allprojects {
+    repositories {
+        mavenCentral()
+    }
+}
+
+
    +
  1. Добавьте зависимость в build.gradle:
  2. +
+
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'
+}
+
+

Туториал Новое

+

Чтобы упростить изучение фреймворка, доступно пошаговое руководство на нашем веб-сайте.

+

Возможности Kaspresso

+

Читаемость

+

Нам нравится синтаксис, который 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())
+}
+
+Kakao: +
@Test
+fun testFirstFeature() {
+    mainScreen {
+        toFirstFeatureButton {
+            isVisible()
+            click()
+        }
+    }
+}
+
+Мы использовали тот же подход для разработки собственной оболочки над UI Automator и назвали ее Kautomator. Взгляните на код ниже:

+

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")
+
+Kautomator: +
MainScreen {
+    simpleEditText {
+        replaceText("Kaspresso")
+        hasText("Kaspresso")
+    }
+}
+
+Поскольку Kakao и Kautomator предоставляют почти идентичные API, вам не нужно заботиться о том, что находится под капотом ваших тестов, будь то Espresso или UI Automator. С 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 для обработки сбоев.

+

Благодаря перехватчикам можно делать много полезных вещей, таких как:

+
    +
  • добавлять настраиваемые действия для каждой операции фреймворка, такие как запись журнала или создание снимка экрана;
  • +
  • преодолеть ненадежные операции, повторно запустив неудачные действия, прокрутив родительский макет или закрыв системный диалог Android;
  • +
+

и многое другое (см. руководство).

+

Запись читаемых логов

+

Kaspresso пишет собственные сообщения в журнал, подробные и читабельные:

+

+

+

Возможность вызова команд ADB

+

Espresso и UI Automator не позволяют вызывать команды ADB из теста. Чтобы решить эту проблему, мы разработали AdbServer (см. вики).

+

Возможность работы с системой Android

+

Вы можете использовать классы Kaspresso для работы с системой Android.

+

Например, с помощью класса Device вы можете:

+
    +
  • отправлять/получать файлы с устройства,
  • +
  • включить/отключить сеть,
  • +
  • выдавать разрешения, как это делает пользователь,
  • +
  • эмулировать телефонные звонки,
  • +
  • делать скриншоты,
  • +
  • включать/отключать GPS,
  • +
  • устанавливать геолокацию,
  • +
  • включать/отключать специальные возможности,
  • +
  • изменять язык приложения,
  • +
  • собирать и анализировать вывод logcat.
  • +
+

(подробнее о Device class).

+

Предоставляет возможность скриншотинга

+

Если вы разрабатываете приложение, доступное по всему миру, вам необходимо локализировать его на разные языки. Когда интерфейс локализован, для переводчика важно увидеть контекст слова или фразы на конкретном экране.

+

С Kaspresso переводчики могут автоматически делать скриншот любого экрана. Это невероятно быстро, даже для устаревших экранов, и не требует дополнительного рефакторинга (см. руководство).

+

Конфигурируемость

+

Вы можете настроить любую часть Kaspresso (подробнее).

+

Поддержка Robolectric

+

Вы можете запускать свои UI-тесты в среде JVM. Кроме того, почти все перехватчики, улучшающие стабильность, читабельность и другие, будут работать. +Читать подробнее.

+

Поддержка Allure

+

Kaspresso может генерировать очень подробные Allure-отчеты для каждого теста: + +Более подробная информация доступна здесь.

+

Поддержка Jetpack Compose (ранний доступ)

+

Теперь вы можете писать свои тесты Kaspresso для экранов Jetpack Compose! DSL и все принципы одинаковы. +Таким образом, вы не увидите никакой разницы между тестами для View и для экранов Compose. +Более подробная информация доступна здесь.

+

** Имейте в виду, что это ранний доступ, который может содержать ошибки. Также возможно изменение API, но мы будем стараться этого не делать. Не стесняйтесь сообщать о багах в разделе issue, если вы столкнулись с какой-либо проблемой.**

+

Философия

+

Сам инструмент, даже идеальный, не может решить всех проблем написания UI-тестов. Важно знать, как писать тесты и как организовать весь процесс. +Наша команда имеет большой опыт внедрения автотестов в разных компаниях. Мы поделились своими знаниями на Wiki.

+

Вики

+

Для получения всей информации посетите Kaspresso wiki

+

Примеры

+

Все примеры доступны в папке samples.

+

Для большинства примеров требуется AdbServer. Чтобы запустить AdbServer, вы должны сделать следующие шаги:

+
    +
  1. Перейдите в папку Kaspresso. +
    cd ~/Workspace/Kaspresso
    +
  2. +
  3. Запустите файл adbserver-desktop.jar. +
    java -jar artifacts/adbserver-desktop.jar
    +
  4. +
+

Существующие проблемы

+

Все существующие проблемы в Kaspresso можно найти здесь.

+

Критические изменения

+

Критические изменения можно найти здесь

+

Внести свой вклад

+

Kaspresso — это проект с открытым исходным кодом, поэтому вы можете внести свой вклад. См. рекомендации).

+

Лицензия

+

Kaspresso доступен по Лицензии Apache, версия 2.0.

+ + + + + + +
+
+ + +
+ + + +
+ + + +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/ru/kaspresso.png b/ru/kaspresso.png new file mode 100644 index 000000000..74e60208a Binary files /dev/null and b/ru/kaspresso.png differ diff --git a/ru/kaspresso_old.png b/ru/kaspresso_old.png new file mode 100644 index 000000000..7c0700885 Binary files /dev/null and b/ru/kaspresso_old.png differ diff --git a/ru/users/RabotaRu.png b/ru/users/RabotaRu.png new file mode 100644 index 000000000..dc6fab061 Binary files /dev/null and b/ru/users/RabotaRu.png differ diff --git a/ru/users/aliexpress.svg b/ru/users/aliexpress.svg new file mode 100644 index 000000000..0e4b5bbe2 --- /dev/null +++ b/ru/users/aliexpress.svg @@ -0,0 +1,146 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ru/users/aloha.png b/ru/users/aloha.png new file mode 100644 index 000000000..f3cb2c92d Binary files /dev/null and b/ru/users/aloha.png differ diff --git a/ru/users/blinklist.png b/ru/users/blinklist.png new file mode 100644 index 000000000..2ce1d9ee2 Binary files /dev/null and b/ru/users/blinklist.png differ diff --git a/ru/users/cft.png b/ru/users/cft.png new file mode 100644 index 000000000..ef56ff579 Binary files /dev/null and b/ru/users/cft.png differ diff --git a/ru/users/cian.png b/ru/users/cian.png new file mode 100644 index 000000000..f10f063ab Binary files /dev/null and b/ru/users/cian.png differ diff --git a/ru/users/delivery_club.png b/ru/users/delivery_club.png new file mode 100644 index 000000000..072042d19 Binary files /dev/null and b/ru/users/delivery_club.png differ diff --git a/ru/users/dmtech.svg b/ru/users/dmtech.svg new file mode 100644 index 000000000..af60ae807 --- /dev/null +++ b/ru/users/dmtech.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ru/users/hh.png b/ru/users/hh.png new file mode 100644 index 000000000..82887c646 Binary files /dev/null and b/ru/users/hh.png differ diff --git a/ru/users/kaspersky.svg b/ru/users/kaspersky.svg new file mode 100644 index 000000000..2d2decdb2 --- /dev/null +++ b/ru/users/kaspersky.svg @@ -0,0 +1,3 @@ + + +Layer 1 \ No newline at end of file diff --git a/ru/users/letoile.svg b/ru/users/letoile.svg new file mode 100644 index 000000000..812de733c --- /dev/null +++ b/ru/users/letoile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ru/users/nexign.jpeg b/ru/users/nexign.jpeg new file mode 100644 index 000000000..e3897749a Binary files /dev/null and b/ru/users/nexign.jpeg differ diff --git a/ru/users/profi.png b/ru/users/profi.png new file mode 100644 index 000000000..75f76f4ed Binary files /dev/null and b/ru/users/profi.png differ diff --git a/ru/users/psb.jpeg b/ru/users/psb.jpeg new file mode 100644 index 000000000..6e13a082b Binary files /dev/null and b/ru/users/psb.jpeg differ diff --git a/ru/users/raiffeisen.svg b/ru/users/raiffeisen.svg new file mode 100644 index 000000000..8bb13145f --- /dev/null +++ b/ru/users/raiffeisen.svg @@ -0,0 +1,377 @@ + + + + + + + + + + + + + + + + + + + + + eJzVfVl36rrSYD/vtfgPkBECGI9gMgcSQhKSkDnsDITBSdghQAycc8/30L+9S5IH2Vi2GU737XXW +3TfIcpVUKtWkKms1Vr1JH7T7TS0tcXw08mt1tahrjVFf34zi5uhJtzsejnTUFL9ORAXohnodnKh1 +o+e9pg87/d4mfkaeltD78ZO/Gr1ENJ5ALbedUVeDNr3ReX/XOkOtV282el/Rp2K/95emj7T2C9fo +JKwBAMDDxgjeUDOimBF5PhfNbSpytHqOuhT641670/so9P+zGRXVbFQRonJWiubz6Gm5c60N3V04 +hZfy0I8TRAX6ipyoiNG8yglCXkAvHfZb42+tN6rq/ZY2HBb73b4+3IzWtG63/3e00G20vqDbwYlS +L3W6GkzwuzGK5vF0D04EsV4Yd7rti/F3U4Opy3wWt0t1DOdu2PiAqeC/cXuufvINTTfaaARDBDSY +asXz2hmNHihq/Bd/utY+OngZgDAvCQP4rfY96AKV8BylnMwp0ZwE/9h/mh1hyLhTWlI5KZfPR9Mi +rJaK/sqpPJeTeTEqAVEEkReMd2yCaH91tL83oxf9nmaQ4EAf3XT+B6aUU/iooPBG8/W4q+l3vc4I +ZiTitjwhwHm/rXXNNvx6qdvA88b/Cfa/Ro/bhv6hjWAZ+93xCDOXypvPgMiVxj8aWh3BQHI50Hq3 +/Xs8zDSsaFTKKwAvr+aj2bwYFbMGIjlrYBIwNsGAiF5HL5tgc2g1qrBAl3rno9PbNMeVqx/rnba9 +ajkxqpJ/8Ng5lfpf3vyfMUSY8Gik9cwxA8cUz6n157nzG4T1qNcu9r8R3YeY0WHZe8AT3f6H8dT+ +gZ8BiPHAmAZuqMMyVfVODwGO/Logz9R6tTuGh8d6fzw46b33I7/iZHtXG6NP4G6t1x7CViVt5GeU +vAKtlc5fZiPs0UEiAOTNP9/Nfrcz/LYA0i3W32FA3eqNFswjetn8o7VG8LbRYP91M+6MtDCgqoj8 +eu+yR2asj4ef0dt+v2sN0uhgPLImD7xK3vmvQWJ19kIAD/97gRcb3W7nQ28MPjstL/gezy1EjHfD +oIWtp2s2JPwT/v8d/X8ohm7hVfMasfORhWLyjf8OPGih3ju9NryCN45N+f73ACnM6M1nY4CaUc8S +1TPM+A+1d1CLFG/g1iPQ793+gFoAq6UBCB8a+iDU1uo2eg09ih9YsLFkqjZA2rmkFW6zwSp1kJy0 +ZEynA2XmLO/kyTOkHkf/dDUQ4ZmzXv/vHv4V3YTJPAGRGuPu6CURzVw0vrVoCvrcdECPa1YnPnqJ +/iH/faE/r83fQvQM/SkYDx//Qb9O4a8/0PZ3VI6eR59e+Gg7As/gJaTmHtuAgdBoK/IrmoEBoD/w +aGGG1FiDJ11tdEFlaWQ01SZB4GWgoLEN0XTddku15Zha/OETsSHdLuB2bHBR7YLRn9hjxoNqwR60 +NbTplqgCo3MsyyRlSJcZOULgyUOi9hCo/2W2AzB3KwuDMWjTJAPLuAHt9YzZgLgG/ey0EJ0b+j9G +w+N55QJML8bjrWj8P9/dHnRIgxjRO83xSAMrIIU7H+h64/8qmAVhofq1PsEk17We0UuMZk6AVNZj +9M/oHyTt8OP4Wm9Y/6uhD7dga94ABrA7HH3/anTHVmf0YMjo2IN9bfQzRjN0/vz/nlTNDvathDCU +ApKCkXqBaRKCXHT31H/FZHvg94SZaLff+tLaoSZpdl0kU8xPCMGfEOHWutNodrVQOyPM6v4/X/zp +hcLmX+HFAur738HkaJ6t8XDU//4vEH//KotuDhvI2kI6FARNeE79v7FvYED/beP57yDQtzZqtGHF +FjCY/NyDWW4bZleoDUD1TnlubikbzVxrjW40Pmi0TaXARzMFcOei8e/G8MsmI2kbDvojd79Gt2Nu +pZy52duDDme0KUZTq9/V7bEdnEQPxqN+9LoxBDey8z+aGyqYntEBeIR6dNj5HnexGe9aVNSloY+a +/YbejrZQtM+k09O51u6Mv6N2GA95PXe9TguIZBIOTGEhatmxOJwVNWJlgLWqa0NtFKXMAYHnLcQC +H323uup4Dum/tNaor0ebDfAXWx7zAWzmgkS/eqCH++NR9IN4lX59OzCHxkiLNlEcDIegyHAU3lw9 +RIhzDfxwk5qYWtTsjVck3vHK5Xg0gCEEvJRVFElhUhzco6bNoCLVD1yv3nDQAPZv/QPT7LRhIa1l +DgT6oWvWvlFEWVTZnUV6BIF9pwKsW6ZS4IDtroJjGTEf6X9p0VvtP6PoUbszajQ73c7oH3vRTZ6a +4MZKo/cxbnxo0Wp/YHEJDZ7mwoGJqP+Xpg+QFzcMeKPV7Qxg/Egn/QfG/wFrPvRgRfoVYNlup6dF +RzCZkF3BB+9/abYiBgZs9fW21vYQStHMRX/kfG4N/ub+GEmMM013ywF4Uur3Rre2KKVfMo8qvF+q +9Fu0aJHtp9gDp2CK9qOjHsgReyfyDmz/McKS5p6zXwNWaHWGk3IMQfxuam2yET0WTYyCuLw06Hnj +oCfP6nZrr5AtL40ubgZxCp6j6k0AFKOPG0zQUpqsnrFNsMyffpODLUQtkK3Z3P0GsBVAqHy4l9Ld +b/jVGTRBCH8l/PHqGsxgqKEROjQT3rUoanXab6JIYBTNy2OtHxvmnkxTS3l4e+jBhdWP98mFzUd7 +fXu7Rjs9vBv7w45FDIYmo1QYXlFfteXmkCLa9kVj2187tn1OcbEKYcnMPVFtBYdqcygg0v1Yb7Q7 +SH6h+CrRSL46iLxVQZyGIvzoLSwmJ94KpgPhybCEIL19KDHZ2Xv/0RQj/cKTjPSfmmbktRBEm2U/ +DgY6R7wxn02GOrVJ2NLUpHmFE80xurv+3Wlb4lCVBU5ldfzUOh+fJsj4ofYOc2xHm/9ED/XOX2iP +uk04NwDK/uW9kLTcw2H3cozFi1K4FzZyzAH7jw9JHOf4JkF2de4bXvcdnN7m+voH5z8Do5Pp5As8 +s9uw1ehqFrTAjn/5DR/3GnRb//jwDunU6g19aQudRmAl0Sp2kgs/vr+4IdLSQZ3ActbaQZ1aen/g +M+733ohrd52r49lpOG5ac5O8kA25Liiert/+GnLNDtq2fmMecj3tA2yXv3ynPwTt2hvhOKJfp66A +NE9jNClwXB2Hnw3QlholJj27ITOwpw0npCTd7T8DzukreZECOiEz1Wf00KM/aPWDegz9KIB7tMdT +uY+u9wO2tt7WgSrjXiucsMDdG72e5W17W0W4W6C4bn3TUiV+x91w0QetCZoPrK529Dl+83BZfU5E +/xL9BwVwBnr/vdN1m8SuTmDLaB0r6uA4ePSDDlaghvWVU2dPYBiOuqb2wXaTZbwF0BS9Z7xALVaY +lwZtRL9uL/y4Bu3w4Mn62a/kuRxTlXZQ8hbIj3eTuiqnBHQeWWJNUZVA0Dql9VQxx2Vl//7N/shm +Ph+d239/H2qjwH6f7n6ehntbG3Y+erTfxuqJ17uJ8guGYXrSzO3bsTFsdkbfDT+5hPqSTrrbMGPp +/hbJSERBNr/hoq6W19DEx9XGfsyxlGlfRyZmEMVQz3eQtp99/X9c0Tt3N+ylOKAxrBAYZ7cxCGGu +GB39DAysp7Ueig+H3F9Es+MNRi1tmJdg+UYoc9QcDicqnjsB9YVdj1wDl2HpPXzkxHbAqQzqp1Pp +C4GmC4pBNRv60I/ItiUEe4wWISF62zIkRGfdaTgHdXdIkCyz/3dD/xq6Rh6itz3yEJ3pkYfo7pZ9 +DJtxoL/3e74bGltp30hODANWGwwwbeQyriSeYdLoE4IH+59eXT9CyShkjBERNZqMC3l3JNG/IMPI +uac9jUpiQBnu/XDogxybKSQW2vr+xy8IRPXsjz4ZQaADsz8dBso5o4g3YPOTjFqP0A9OGEM2zXDQ +sGzdLQ9/nMqNcuXboOGQRBzk6ZsPI78y6AHdhPz8g5viyYmqHGpIvmHIyV3lLZfcu29m+EzyfCO5 +9zmS0F+ivH21KVkPrqy/8IMtae92VDh8zx9/lZevdxqH73xt13oqJneus5+xhFTeiaUz69eAJpbc +/dqOrWrCWSz1+ecG/SxxUiUPf+wMVuWhtHKFUct7l2+7fPntdA8NTE3uZjeWDzW9MD5KnlceDs9O +Yjfm08MvLjNUykq3efRz+LuklBExysrm6uFe8vOp8HYh3uTO9vdS8LOVhv6n3/BmtYjRKLFiYgBt +x93dfHnlD4ZGnhbTG9fyUKxkWVNH6eJ4fmj4K5O9KkNd3xre6s+/Uyd8Rr6J0/Oqa8WhWhrdi6/9 +rzW+vSb00EgubaLpL/xwGyCrY7w2x7E1GMzw3JyueiyrP1t/8Mjh9dqhE+uz/nL+fOWN9Tj3qmye +vHIurOZs9NeViwpG7IU12zk7XvXGuh2L60NhVffGWhWe5SVxcwPQeE13uJ46TzOwKp/xxnrtyBur +XHvkS/zGuQsrQoMRL5Xa2ZXcdeLCCytfun04ZGDNLq9Wbw4OWFjf+OPl33cIjdd0l47T++snTe7R +c2mf/zSyBtbq+rqLwtLWqNvGWIWNg+YRxorRGEtb01/E0wuENTG5tNyTvHNeTAJWuT/BUK/bJSZW +pXu5NCJSgEZsYW3or2ur9wyspVa2t56VPLEOD14kF1aChiAuy/2nWt8b6/ZSfLieW9O9sOrjN2E1 +Ed97evHCypfyR4gFGNPNLq/c3Otb3ljl2gtfeilfe851qTTcWv3K3N/YWAENhfg4Nb5gYl3XPvYv +GXONZfThoLqCsG5MUPiqhPbNXm4/dg6IcwP3dCu5Qs3AWkvHXVizN5Wve4L16Pmr5Jjr732+8nSk +2FjRbKjpln+Gua/lq6wn1vMNvcPEuv9VLx4wsD6hNFb+ZlMbek536Sz9fNbWUiNPrDf1vS0m1ovn +8mnBxoo4zYG4yN+vP6reWCvL45uPZlv1xHpfEfpMrIDm7jhz3GVN94y/H0j7DKx7ifvXh7cDT6wP +e80NGytaGyfiev1g9MDA+izzL5e3CW+sF5cffx4Ptze8sKK1eRlxV8zpfleTa48srCW+fvWz7Y31 +cj+hPx0MixgroHFP9+hPOsvAqpZjDxd8hWBtLI2OnZtnTx8/PMkIa9KFFdAM1cvlrcTr+LsOiHd0 +N9a3jesVA+tXfsOledb48wsFYxXXd+NlJ1ZOH36cLiOsacxpbllR4WLPy9kSYN0fTsjF536eYN2L +H6VcFI4dlC7XCdaX0eapjRXQAJETd8mt8s4pQpyZlIt3QjpXWfsDWEtjN1a9s5cwsG5ecc65lu8K +q2vbGCugkfbuKhXHdJefhkrzqYqw8hMS6iK/tvQ4ujoBrOKErNYPmr275Kq07XpqKmldL6jrtbvK +86nn6+NGbJs/eU6OGE/Xd4XGsLrs9RQWoYSM29hqYeUQdZhkynKzl82VVgT01M088PRzYOo2r6c9 +PXf5UlbwUy8WKI9GqrD7O+f9+slabP/y4eiK8XS0dXZyujx0PbUtmzP+pZJMKmPv1w== + + + z7L1i/3d8Trj6dl7dTN3n/Z8ql78RmWApnoVU5NSi1+xFjQ9+TS7dtd4OtplPN2O3xe3H/bxUy+i +VQobDyt6p8B4vZyqF7O3z95Pzw9Kf3YSUsL11CbaxfXnn2H9KOn9+sXjn29pmBEZT79/+ukvTfV+ +WnupAprsdSPNeP1l48li8smn9Z97Uzh6PG08ijtLqVyJSTTt8aI0WrrUvF9/518+1/+cLHk+XX24 +at/HYxd7DKLp+k79Stq/isdRB25id++IhZPLq8I3ejohj/SD+vcw9rJ+6Pl0/LaJ1NrG6nbsjdFh +J7Gx/7DRsJ/uDhLbg5DOm+3QXlG2gMM53TjbEmKpw+uHWOr+9SaWqrdvY/GnxBj9VUXuaTGWPquD +ifT4lSOv7e70v2A4N/sYoY06c6721rGpvvM4xv4QiNr3bQvrcqaz00yA6bd0BP5QxilE9SVxfaea +NpyhlT6tlXeXpXXstxJnqLly/eWwBTBiA6uceGZjXSq9pplY+VIhe+nCSkx1jBiM5p60VWdgrb36 +YD2OKWysx8d6jbYFJMd01fLyT3b8bGI97tJYt2MvNFb5ZoWm8NX+NYW1vbaG7DQbcXKre3fBwKp8 +Ip4beGOVazU21qXSB+/cnggxTWRwLRhYweME16LJwtpwYQU0DiLzm0ys2E5hYkVGyh2LwiniRjGn +e7LmWlohBQYIxo//MhbjYtz27wdojK6Xy1oYkEuXO7EQ/fRx/WvVlhsmQzvCS/QmhtdT8WShP7wg +uwD+KiLD8ATTxiSutfePL6+BzOcp4589noR6TBZANL+Sh3Lqmt5Y1TWQletPRWMQjesCDHWzvzuI +t2/dMgrwFzKf2uEq+mfZwmB5awYGc1PCiJ4OhY3CnxLqJREYdmhqd/dolfoHxGTRcgGujOCb3bm6 +9oO7kJiNMWd70PypsrqK/0Ec4fBDjKlfWnM4TO42pTJFQ4r0x3dV+Lm2SvTNeCN4WEOzi/eYMp3l +zRT+h5CUxHbMyKLt4hLCV8bBhMf/XNOuu8cM94Szij1DPJvJSRr/NB4P7WX0WkNp7/72PGgNU+d9 +QGNMErtMrvApmeHWhj+9QqwhYmiyjMc3venoxeaI43s3z1vBFF+296IX/77+8+jPXzZzEYZm81df +aKysl0NQ35/0RKZtZeamvkmv5tCL9MbaBNLLJYVqqZ5bCh09Fwc0Bmv+CDzSN+FX5PVIOHoZHlsw +JG8aHp+tGcaa9/Y8er4cMUeER4JEfQr980SHaifId4T87DPn9qTluOf2TDAnJ61snVVCTA6tjXN+ +jsk9L/tODtN6jewl1kh4rVG/MyO3k/rJnldiDc/Lm+drqYFTa3hOiRxFTCyZY0qHnIPdrf3oYHde +q3JrTkOeWiqC5vlnftq8b8Z/sxQ38ggs5rF1d9IHWpF7YkJjgrKVtGsDNsW+ewM2liohZu25+xwe +AYyoUbJ6MRY0dZQi/xjrRQ4qJnmjKS3ZS+rkNNeqon8MkYij3x4M0lg6F5gMkvnsJ3fwmDAaamyb +P4yxiT9i4Xf+1HuaqULcwzpLuojmWpYvdeBaFnj9TvdVegFSiFZrx4g2JYam8rYmGRzZPnaKWJF2 +cadd5C91HNaosk0Oh13loFcz5k8vNMOM/5gwC3zll5jDskfEtPUcY/pc9tGe+/b+DVYmxy5bb8La +qI/X/T0CxzIG2Xrh11DHVqehveaHBvugceQFCq/NtNCC3JJJULbo9IDm5P05iRZk8E0BzSl25yWa +U7pNTzQjEmawm1h4rKWd3m0ZbZmjsJazbTZj0eka72g3QIhQtq73Zvgou1bCS6bZ1re3AVF2O+zz +bM+PsvgyLpxN4U6Tg+HJ1RztrRrm4FwECuEZEu3pS6Dj6nfYeANjNrtDbEMzJET4pZr0Al0jMRyP +gMEEyYPAkXhZnTORhSkA7LVx+Y9NKeGpswqPdXk655EcjdpW58CRTLWJD9yv6Vyl+OFoOgzO8Adt +2ZwgR/h4CknC2CjgqLmVvsMpDDEsekxTiAJHOs/k3vtzshhRgIVN/GhtHsJTM9zbvDr3miGy06Yk +/OEoyHpwcS6Y+bYicDLvy/BTWNAMd49rA8vqDBcgZbnuf074d2GpxuQIErYLTy//ze5FLwzFdYRn +0EuZg17Onb4zwjvdqT33hNNxCOc8OBZ16trp7GCKX9ABbOgV/+G4jHs7E3KS3aSVTRRRCHKdQwRy +TzHRnPb9TJOL+08OES0wFnI6oamnDYQQKfB9ymvDr4c5p3Q6Zgb1bLXGiOvRS5X/kUPwoRdhaH2D +aFPXpwmXsGI7QB1nbIfJ0JRQcIQknGY2SormnWb2mdvMtoMpQZb2xDpsXi1PRT7auL2kR8Q+1vK2 +khnkG5xhvWhoT7+IaLCVDJNbxL45c2vFqdkdE23zKuZUiKHD2BS7792tSswpWbMJMSWkBmcUBRSn +Dc7cGnAGUQCE8QovkkRllvJjmLmIQLmp9BJ22E1Ld1L5JeJJZ1IIzOvxxmsD+u8+ZvgBRPKMpxvU +vLRGPe0+Xg3SgEz1h6BlQu6b4CgtgsYvQEIXgar74znD+HjlJnSg90lhMCCnsxk4HM8Tdgxo5q3o +gGK4m7adNiugUIoQJ8AE6EIMzXnQOJValVPXztRedGLsdjtR29zHE7awQdAcntnM6gcP1nlqFOqk +kEnN25DUNKMc1y4/3kO6bXhIt7uw0o2KQLHOv2GPzC3dENHA4AwQSeGl2/DL89SIhLunPINC0MQF +GFBF8CBuluaVAne2dJtPCtyFlW4Wp/kAml+63S3oFBcPp848ftvHnDa6yxi8NBkJm1y0LZ7pZ9sn +kC7DxRHlMFSeMZh1cJN3N1w5Y/65FNNldD3euyNrM5zww5LaEtfQnrOfCSNowRLXcgqDhG5tEDah +wkviWFLgfX1mx9KxcofJkMImEFB6+uE4tacJaO6sCgyFkse+B/mBgNg7yGWvm2jY+xGgTRkn9FCO +doD4LZHy0I8P01v/zDNpkGnzW//ggrHzIExTPbR+BGhsF5OpHBmRWwRtAduosaStLECtPYSNgAWp +tYfprX8vKIQF5tePD/7Kkc62Cwbkox/9laOHsGksnYvT60emckRZ6CkjyYLSjxM5NVOkOjnn/2gr +R0sKsFKz7NQOpjyCgb0yXVZCTZx579qZjNgG8iUCbE3mJp88I0DQQu3MEEYwgFICww++ktdJtN6q +/2o6TnF9XabHkDve8xiWcgrrul/M2ydjjzEmerfSsc4w+8zDjeM81FTNpabCxKGZblxz6KOmHKlx +oeICNXcK/9SbzBEaEguPX/7OU9jUXgTqZ8VnbaY6/kHQRgG8HD6YUgsVh2adKrgYujnEyb4hoTHs +jiKX8cr0tdHQnJFhDosak080eWKTGWvjjhjZ2wP9RRLRPRDSdXvx65Wc9QmZSiytput2LR+gWUw5 +n38tX8QqWpqznM+/lo8+xZ2rnC/pW8sXsUoX5yzns7B61vJRicrzlfP51/JF7NLF+cr5mFhxLV+E +Wbo4ZTmffy1fhC5dnKecz7+Wz2VyzF7O51/LR9TaAsr5kr61fATNAsr5PKQVVctH0uCmK+dzJkCz +K5EGbofd2zmmDT52Hdi+f24bGVOIAHF1TQ/KFd9pxv0td5KW8HQYlD8TNiBVXfNMPZ0pQAykqk6T +v+5zUlhdZ+evhyUVqW+bLPOZTGXbCFGUhuKfSf8xeZ0U+kBLzThDd8VKcBFf+BlygfsmNOFdNUSs +MUXCFGIGhM18xmSKHcuGnrp+L7zYqaV0d6LyLGVcIfJLiFoLkWLyejTPAZ8ribyWXpp/ch52vVc6 +T2DZ3fT5JZNxgRkjzRPr5Qhr+bhRgWV3U+SXMEUn0MbniHgqLwVAIU4LVyjmDc2Z4AWO7XHSaVaU +sOvulNCTHB9KfjWWHv2TJpA5GNI5LrnqXoOPshNsCd0o+VdUThNoK9kH494BYkdEhRFoc8Yak5MR +lfaxXaFgic7Zi3vYHx5wlMeFq0d7GvnbGFPVFPp/XSF0Kl/72GWQedXfAAfbMS52jdzuaO+UMaYJ +Mzsg8x6Gxc68t5YveA0jpKZwJUgNh64p7HkmpUeoivywUazj4JocNyji3zChBXyoIfTAyIlHQJXO +NNN0Hp7MS7SAip0picY+SZmFaN4fbAgJzRkvzukTSVSkBGsR/sVH2b98KvKL3vZMGEFFlEEA6JAq +C8atQP4JkNWjPfYmd+bcBvuDExFhH3+Q9SULsVBbWpsehmMQdS7QxbWXm0GW3WFQyR5jlSjj9qPs +PlhhO2rssr8QUiCYIuyTSn9y2OHuwII9l75j2TNlr+0+kaW6HNbCFF+GTc5pYZ4wK3UjVqWXy8Jk +MfTLsDdNgMPwpSKTJb8etXq+AohdljWRnDSznQZjClNla0qBIFKN4mH3rWeKk8VpMKz1xZDKfQqE ++MDle4bmg6AaPdeY6EzIycK6qaI3PmOSplRrfsV+ftGbiPnFsXDDCijPY44pMvmVkT3hdOiK3kgr ++UGA2+cfvbE5TTibO8Bxyoze0MImpFfxfTp99IYVF4DJrc8/OSt645kyEr4eLmz0JuLzHShUDzdd +PrL3ek0Wyc4QvUGlcIHRG7xvAmkTlOAfWB1kWjaIQNnpCld9Ei/2xy5DOmJUegWl6YQxpAdnC6mQ +3LsT/BeS7TlMZHSdzRMBck3OztJlZhCHmdxOIiyXsvXN4CxUMkRgGRtOhqBjnTOW+IXJy8K56gEl +fnMnzA9wIaZPnC5U2glV58cKm7oYOlw6IKBeXncdyEJbnJ3vZSeOhTi6mL0yz+Gt2cV5i67M8027 +XlxlXhCnLagyj0Q55itQD1GZFz4Zdq7KPMqGpovz5pzXRGXehFMYNjFyuso8n1SrRVbmRagLnTzL +6RZTmUc4baI4b9GVed5rs/DKPH9vjXEuhIIu053y0drTWQJV8mej0KmX+PvJoazOMKmXE4kXM0qB +u3mq/6mEy/v+/DYGgTKZhzz9YTEC5HKxg4fjEbMhgOb9BACBYm5Gqv6GOuWZKsX6zjde7s68D5Vi +7XEgiGrp/CuBjCLZEPsRFmOusiwj1llc3FeOEah79w4KlmnM/Xj0XAtd4Mo21YHm836NA6v1xXzq +GAMK/BZOsIuLAU29Hz3VGgI0/35EUBjKMcL8FBgzsRtBc37vOOhzbps/EZ9PT6IZJl0hL9QWaGZP +ut0RzyLZh0UUyT7/LLJIFqAtsEj2+WcxRbLCkjJvtAlXiaW8oHgkXAYCmkGITkQ5MKCAzw2EHA4f +0lsLBBTmQ9eu3EFmGRlsFJ9PwYbLX3Kk8yBZkZrYj5vxgMUIndpL1+XNmyvkWZRHq7UwuULOaYYu +yosEfe16MUV59vY06/Jmi3UGFOXN7HtOV5THTOpbbFFeJOATEwsqyiNogozGUBZjkfOt+nbIheCv +36MSP0fORZiv3/vYabWFfRKOEG1RgWdURsf4IOMMlk1z6PyEcpiTiQirVL7IZUKdAQ== + + + +SRDoIpDI6gX8a7FDVF17RqTP1vYadf+yQhGuOiKXKXnydWOK9hz70/vucPfpfub/cyoUAE0R/rr +Tn3n9vBLKBYypw+Hy9rpzeFe8uZ2p/+WzMJfx1Xoul4sPT6X2uL67tIhUU44SkzFoe8n69DUiz3r +G13GYJxld8v3tSod7XKUou1uFp9qrLK7R79iP3RpoUCTwFV2x2+cM7Bml9FN5C+sK/SCiv0GEhsr +uoyciRXdRP7hqgiLUPcUJnzK7qqKSGF1FsDh27ktrO4r9ND1od0Iq9hPTvgU+y2VGhwTK1862666 +sEboewpX5YvDN1bZXd2v7G4ly8Z6fLX8O+JzT+HqZee8wcJ67UPhi7N7F9YIXezHHx3dlZxLu4K/ +qWD9ZRQFjjd2MqH6SUXemUHM6sq/bWwehACZ3OyPjmxlCrOuybaJaqclmGdAHjq26JfqH5jfO2Hc +AmmVhPOU6TDonoNwKpxIgR3P3DV2gIV9pxfji+nMjC52cVExIHPV6/zMKzo45/V6jjEZd+vRMm1B +1+t5LV+4sN3EV9umLc60A8TVtT4f8L10Z+6gz91uE9nawWG7aW7WY84wEngXXtBFK6Fm6GWqz1wV +2w9zx0pYwgflaIffN/2g6wG8Umv9buUzncIFFvYtIGYTprDPy0vwDNvNV9jnmJxR1cewoecp7POK +IkaMm5ZYlJ6hsM8tqFBVnzsTcgGFfT5f51lkYR8z3L3Ywj6v9BRbQi+ssM/f91xYYZ/XOY/3Ke5c +hX1eVX3sg5WZC/u8QjIR5/c6F1HYx65YWWhhX7hvdM1d2Gd1pqr6PI9X5yvs81JOkV+riy7s8xoT +dfS9qMI+r6o+Z0bXQgr7vNbQ3DcLLOzzAmUcFi+ysM/LTpysWJm7sG9Wok1Z2BdAtEUV9oUr85m7 +sG+KSq95Cvu8bNKIq+p7AYV9XrLHVtILK+zzOmzBnLbYwj4agFnV5+PizlrY57XWzIOV2Qv77FWy +T2Z89M2shX1e5GBE1ecp7POq6oswC8pmLuzzmhJlqrPsVTSseX1EcrByqLsvKX4ZfgTYHf6FalaV +WsztRs1cfRVCbjhMjsXc4hdgcizqFj+vK/y8TI5wpAq8A5hmVRKEZF6WF2RthOODwxFGE3Bnr2MH ++V7gxxyTZ4K/z7BCayPnmCZSRk5CWAVhx2R7oSGFjQ+ptDDbODJRUOZ0mTwOsr9PnRrCI7FpImbm +7Xu67/+bqXiOvvyPkWcT1l4Pe/lf5FeISPPp3Jf/RcgFggH3mIWr6fPJuQidDz3f5X/2+Y3f/X9z +X/43ZRBy1sv/PIOQE/f/TVcvVZy8/C/i/uC55/1/0x8/SXt3Seoa+8iM3+ganC2uxmNv8ypUQW5w +cROggfltxucugPPP77DPCIJq+ti5OuGzu88Wce0AurOPkWnsSoAJrndkZ4aELo9DtJmnINdZ8Ugb +1z4Mzb5vjD4/AYE5WdIEbU7NR3trU51jobo5LvRq+qRa3Swu1epmoalWN2FTrQIyqavfobKsQhRi +JuY+ScFQXF8992KBkICm0YGsxDEMaLat6IJCF/vNWmlPQWN/JDHM5fXual+Pzy9D246/4p7i84YI +WtG/doadYTCZRA7QxFDZ6JQyY1Lzj7aBqRlibShn08eyQFZcwp2yDW3JwHPPcMFCVIIVaNeHKYy5 +709lXnjZFhG6Cm9hlzve910lFDNKgeFXqOuWQhRibm3M6yUU7W8wz3f0jQGFKTCNBBWXu77APONw +SJRjy1nUMeVlRK6N4lVCMfthMbLJNiZKKNZ/ggtjQu7HGe7988pMcVz9N+N+pEAt7K7v4Hv/wpnq +c9/7F7ELMb2v/ptuG7G+xjylizvrvX+Tas119d8c85qojZrusr4Z7v1zBoj9ajdmuvcv9OfbUe7L +/DSM4JvLZ8kC8aRhY6nuWZ8bob6iGL7at7HUXJ7KUfUMpgDNF1Dt+/xjl95bHsGsgMIFv/wzuhCg +eat9MRSEZgHVvmg47G/bXV7RNnSIWqqHENX3XoVUtGXj2o9pj/34OGMYbDLPBs2e/SWcaQup6jp2 +xphqjVFLxaLmo793HyHfHQzr4D/O6N17b8/HUPHtMIVUdR27+AvxPVHlqb+f7/I9WbVU6zv3bDEd +wmJ0hh/QsEKW2IWzGItcGluMLjRFLvCq9FAWY81lMTpOcWeouy08fjI/yEIJhYjzm/c+lWkBfpP/ +ic8E0UIVsof4qhaAyrotm9nrbt2fembEMyMhb12c/TJMO+cW3x24qLrbmlfRbYRVju1nu9nbmM+U +6mkvhGa5m1bU9T0BSQFSMHi7l6sd/i7Vbg9/H+n7B+Xs7WmxwLWKxULmDOWL3gxMRbTadRLNiEW5 +rqa7G3SfHDXszovTntgX4qlXm1WaoRyVecmt1iWrHlD5BDQbq9uxPqsk0LMQ0ayRa2eYWPnSbeHK +6d+4rqajq9XcWN/8Lv9L5ymszho5QKMPN5NDC7G7Rk5+/KxsMa6mW4oza+T08ZtA1QNGjNvjKCJv +b31fM7Bml/Gtg6zKvCcmVkADRP5mlz/ypf7tLRPrWkX7bLOwajZWQ3s6SwKvHtlYjy6ejpkUdtzr +SGNFawOIryaWFvaogR//ZbD7Zsh+W65+piKY6Co/V0KBlBNV0s9QnJWshzl6YQmb3dK4647A+MWX +973UHzM7k8Q6k5Mfyauu9cPmp/mp66fDia+PBmcjscuifvzHZHjSoYY1VYqN86yMVgSHcydu2WNy +ZW0xI7ch4k7V9VjY5TPWhk2qqRK3Asrjpkrc8imPY2aC2jZ0eK7qT5cDxs4aOpwmNzRoTG5vbY7K +y6AcsAj5WkKoukT2J3qm3Tc+aWD7ZDiTfpDTAef6E3KLXIS2CE/j9cj/GDRc5HbuL9/RRDta2CH4 +69Eijolgcs8LiKe9Hi3gI5Sw8PY2jsz87e6pItI4pMouSZz767qoHjF03VqgV4OgsXPAQsXTXKko +TbHvvqCzsVTxn3VoYdMUxwvzpJlh5KljyCWXOJ1I55nuQ13oI2rsD3XZwZyIWewXHIEh369wZCQc +B38ZghgptMPOrrPzUZfhCtrsA8kvdbSYTzFMfD9tzkqvIKOOiuESy4Y5LI35KYYQprrrvjWX+J+6 +LtFeQ6f49/6+QOi6xMCrbCbXkFUYs75zn14YR9xzbltgLmjsc/rIr9WpoQXelDwV0QIvOQo/MHGh +RJMWSjSZCW2iitl1WDxrSWJYs5Gqv5mlJDFsPaLjjGD6ksSw9Yguc3DaksSw9YiGIpi1JDHYyWLd +Jz1VSaK32JusR/TNuWWt0vQXDU5kP0xXkhi2HjHiU+nFpMj0Fw16e9KhSxLD1iMyPWljRBPzchlV +IS8rpKTAv3lZYYR1ddhiLysMCD8s6rJCUxGEkR9zXFbo3J7/2mWFJKQ668WAoS8rNNbG72LARVxW +iDwCdF/hgkjFSuiITH+/5yyXFXqGH9CwKvIzy66+C/NVK+dth2Ya3NwXHlqVhJ63HU6Zbce+8HCG +r1rNcuEha3LrC0jtPV3IV62mufDQtwBwwoaesThyEV+1CnXhYZh6zwVceGgRyLO4apKhZ7zw0P+2 +Q9vxmPPCw+kKyma+8NDJFu7bDt0JMDNfeOg/uYjf/Z7TXHjIHMlCLj+xLjz0nxKJ2SzgwkP/2w4j +AZ9vn6I40u9SMGJyLODCQ/9tbJrqc1946K/5XG7U7BceUuvlcduhl38z04WHbN4wjNuA1KyQFx4G +pl0v5sLDBVXkB1146A+FcqPmu/DQAcU3o2uuCw+Z6Wr4BIV2cT2ghb/w0P88JmJeIDhRHTzlhYd+ +1TE3PewRLKhazee2w4irFnfmCw/9z3lIucICLjz0ca1q6Tgjcjv9hYeeZQ3WbYe26Jyj5ML6qHWQ +1Tn/hYfhpcBcFx7axZFeB82umM3sFx76Q4l41a3NcuGhNxTTwp+rlIS+8NDDFaZOm0Ol9oa58NA/ +h9YVuZ39wkP/L49Yjse8Fx6ahV/eOR8RqmhprgsPg2XaQi48ZK0rue1wXlPduvDQH0ok3D2Fc3z/ +h0oiX8CFh0nf2w7dam3aCw+DryhkC5tZLjz0KfQQlpLY91zEhYceTEbddjiXTKMvPPQ3fqiYzXwX +HvobP3b2w5wXHloE9ywSmTI6yL7wcOpa3NkuPPSAEvZW+WkuPPSH4pPR5XHh4cyl+hFSITn/hYf+ +tx1iNIu48HDX97ZDItMWcOGhfxIVZoFFXHjo790bRJv/wkNrYCG354wXHs7se0534SFDHhq3HS4o +RRGPyee2w4jz0/qzX3jobzFGfq0u5sJD/yJdTyU9y4WHLGqS2w797bQpLjz0zz62g5BzXng4g2Uz +y4WHrNUktx3SR99zXXgYJq9zARce+usKO3hvq4uKzE3YbhXZZ9BETUymkdjhYMOyWZdW8l8ZV0TY +9yw0IN/fWUTplmly6toZ8lpzCID4N52yjMW0dRcKrk8w67BUOko+kZyEKtNi8afEOJbOrJ9zUiW/ +YvavDHVdHB7ENj70q0x6dXNNejhXDpRscvh5kumPG+tlTc1v7D2t/F6KnYwSsYPSdWap9prdXLl5 +WEactvrVu6msa5+DdPam8lPPfbUrH/tfbxefZ9pNPn/xXP65vxEGZ+83n1ed7t1x5nx8Xz9OxOv1 +YjLxp6b8ufyuJrfeB8nf+yN99Sa+ruvSSmypr/Uzq/zK51bid6V1j4iWT57Hd39Wvit8e7V3qOt7 +m9XYxsvxeUwsXHaTWy15ny/xe7t86fahxB8v9y/448uLT13v7KX18efu+nA9ddFEU48ZlZ+7P0fJ +HfXiCS1LDFffIaId3efq+vDjdJnPXGqewslYG1zyujscHf4uHVRKOwc7LfuCTHLD4nrh59pFL0ws +QLOudQX55vNu5Uff72UvYg8XZ0l7uu65jt+4lY211cfqirrVLaxWb45P199uTnbk/Np5NmmVq8JS +PR+lc5W1P8AbSVTmM1wqnaRjeuclgypKr0HVXOpOI6xIb6O31S7NbtjGMKK0BXtytjaybQFS/5tb +31zri/LRQ+Hnfj8z2jhM5uVmpnAklveh7fx0//3u9uKgnH27SOaVnb1SfuW6XXw5XS7juYqFWvKI +7G98KLN7/BBHf8WTh6lVVCFZipdPToSj183tYqeREdAC9Y5aXz95PvP4lRbr5XaKzzR+0kjvr6Ak +izR6HVToTv9L2rtb4jBcU84vr2NXic8ocgL/BKLt9/G+UTaTuAU29vUP/NxPk58vQy2F/5JWtrff +SvXE0xn//rr/sb9Viekw8jMyVDLOFN97sx5s0A+Kq01AYz1L0c/uhLb1gKMffOy8Ww946kF65eTT +fHCRwHMVjitLDdxGtOdFkup//Mq1rP5p+sFgk0dtGUNDKBc8Kpr6FsrZioh+igR483W1YQK4SuIu +KGbTHAjoiyJXaUsQrQDLxNEFOVccAdTKFhGgKx5/IF9olS/xTwNu6/eTgN0YPlMrJQ== + + + M+c3XxI8vU3hp2Iiu9mmiHbLETR8YkXlxe7N6lEqn3jd3+LvV+g9ahWHYi/U5eK6PFMAmrFE52Lh +8tRghczycDt5t4XWJrt7J18c5F7acbLFEr9vYgZD157Eg+9+ZXhw9vDwZrObmBi3v0wiPFCcIx6W +d5Cx+EDYXTz8fSLgfYBinYetS4VsisPunWj8NX56xQJAPEo06hNaEak6qhh/Z7BKiQJro9qiwD6Q +ZIoCRd6Gv7ZP9zPDwR6WB4fjRvXSUxS4rho2OPL028q2A5mdwBMBmXaK92/S7HX/Axs7xaF1iMPP +5yF6mkke3T+WETWfjQxqRYjje0jxfaHwM7dBpEBj6UY2zghWkAP0Y6wISAFcBI4Mjd+g7e91wq1a +NbdNJOn73eaTZxwFZMSzwSrWP0bWkJBa/8ygPZ2gN3t+hELFZ0lr0C8wrBMY5W4Ri6I4+N53e4YU +3D3J8ErzbI9Iht3cfbn49nXQBqNyUMBdsEw73kygBTrHl0UDgVTCHsLxXVyFRf64DGGV0KbMhNtv +V6y4q74xR+xk/mCbDN9EzWtLSsbWqLgN3Qt8Zej2HT1Jm0EYwPbejRlPIzBOnlOcE8bn7l19A+U0 +X4+QUXPnKGknhs4ytKljYhCt/RZPKX1Hyve3LwmACKrIF4dXkzDGDhjltbJt6yGykHJ/tEogjVMX +IqiVXZCMqXIS/cVZbRnchlgAfsC6pW5A1YziOmE3Ps79pqhvUu4TfY/izfzswg6Sn+c9/CDeXLl+ +Bi6Rj13fY8BWJPEIEFnIzc6mEYS+zGDaP4kaerBsWEfduwuH6bn2baxNIXFmmRzlF27pzx22NpCR +kib6ptq3b5h22Ku7G4RoUu7yy/iggDgWENYl+2scSK0vmcOhvkiwYRMB0Gw86VKTuvDbokOi+Lv9 +atKBSzvoIP9YRHiliSB+XFlE+G0TAc3G/X2Q0e5PEB0a2xc2EaSnrqC6U7EprNa3H8iyhKADZ2+j +x1g/c0SIoBdqT/7MYEtoknKG98g4ZtMhJg+f1iw6PPowA/mKNyHks4ubMKeFgYHP+r1hhABATjzu ++/PAIKcKvtvCWBu/nYE+9TbfIOo6vT1ng9EchtjfSHT6wNBGcw7ic0yJzhlZqxsLy5uIBbxl1X1v +qn0+scmrNZuvXg2iTQ3jeTC3sKnWGQIr7IpUm7oLgEW08BPRhoErsrFCAdgr/pg1HhaMzzlZq/o1 +9gKAZhMeRj8WTE3PnW5bNlV9yYRR+7Zh8OXzWovGVXu2OKhGjZcvvZSd90nXBxZxBQenGUoyxLxq +mnuRp+e02udwvgWqfblX2Oa00DB64+n3rYvTanpo+cEYxHjJaxCY08JumWZzur1vA7A5rfk53apO +DuKLvaRhlXSzN5p+77uUdPPHc+sGs8UA7JlyHNVvJp1fmiLmMK+N9lXTKbR85VfKjWqsfD8Tr3l9 +p2Y4wIXHOg7hpA1XzHIPDeexbDmbCDVyZLThFzI9yxt4NuBbo8NP9DHjcsr4WeSQTVbmrDcpZ9P2 +wsBRk+PWOF/4djy+aT7Y3LAfYI/AjnHt7qfsZ+L6XmXXfFDiqAcv49d980GFtx/Q+MEFxG4JRg1o +zOb9pE0+GvVxKW0/ABreAUceVzKGA358ySP/OSmt5L+BS47vRAQcnOjGKG8AqBaTJJ1HScE/OdDK +1ZO06dONNwyXqVrhcBfA8AgsW73mkZUswD9t9PORwJX2bsG2xWuYWOESZqToBK8mQVMUtgzEtUsO +o5EKqqRuaa+J9+JntnRZ6KrLKhUWx8uHHFXTk6dP4gHKdcYFBRFtFkB3vA0oe7ezWTy4W9t6K37m +zgYHtwe9B8TfebHwtPGK+RXFbBKtx44V7ZIshnqjF7RZsXmjAT49B2Z58xqHOVPoWzAJ8ldj6R5H +DRCFbzbIX4XfUtnI7q4B82yfkPEiD8IOBmN3loQJNg7UdcOnJUxe7Rt88PiVNLfWK6zX1xKX6SxV +1whbgBs7BDRmCIVbh161NE4swYFXEoFBgVccdRXKK392ydaydhC8cc+T/YUOW1D9DYfzZlAo8zfZ +bu7sbiufrMzj8xsrK2zNfIBCQ+DLuaO0OBKZKq4OjBHD7iPxFhIJhj1HC5sXxKhfhe5SrySkBkX+ +8HS7z0OvS8HeNyRNifDB8c6GOcOLJBZndjD+f+9GfuVUIRdVxawSzVyPu5p+qXc+Or1oKvJrK/Ir +c3AiCHe9dr+ka9qt9p/RYb81/tZ6o+hmNHNwUzw5UZVDrdVva1FyMqe85SjnmYzA4FjJdVpkPdhC +16MVDt/zx1/l5eudxuE7X9t1HyQlpPIOOkhCaiSW3P3ajq1qwlks9fnnBv0sESFPYoau80CSsvUG +cju7sXyo6YXxUfK88nB4dhK7sRO6sHOrdJtHP4e/Swri04Oysrl6uJf8fCq8XYg3ubP9vRT8bKXN +YAuIGvvYzXlmTp6aB2V51tRF02CJbbyUxmg2j7HU/etRLHX+8BiLpxtLaHKn6J+tWPpAqgIJ1l5j +iU53N5buXT3Hki0uj6a+ylRUmCvQbP5dRYW52MjS+RcVFVYVthr5txQVFcL8NxUVVkFEW/2bigpr +KZtoYRWVGVEl0Nw5PAyVBWgWATJIeeGkBof++leUF07adugvt/IKJwA2mQLANPQ9BUDi4nccCT0Q +AOPNKpIC5VgqsXfrIQBW3AKA8OFOLU506dHzQZwknCxvmFvxZmD0avwgmVZDhwtyHAWE0Zatj0pH +4rlqbHsUg9T+aOiIoJwgodn39R/8E/6p9Tnj8HI/hoxJ45iyyKXwx7UwvTKdxxFH9GEntZnExz3S +ytbRqVd6GCzaxZlt2BjWTbx9Zxyk7OYSDqVo7KDdnaQ56LOUoVjvBM7jYDNj6s0Kbx1kCPTJZOEn +i48ojOPMDWuSyH54PqBPLnyOtW0GKUzkQJqaB50cxSOO20GxSlg5elWscwWPM4Fhmj4TaKlnrnOF +WGwUt8IoQ/INXbveBcOQN445uXxghtl3+huTRyDpczv4K8bzNclx/pFdjq0WVg5NAK01V9wXwyhk +KBhC6fFYsA/2jF0IjIoNwUxn9+4Im3qIS3Yxl5pt9z9mW03PdC57yAK3jykxyzjsyDlCC1hQkTSI +HeJSmgkPG/sPGw3z9AGzFuPoAVmZUx09kFAMFXVPzBh1N4kgeEYEpooqfKkDkw77EkUEfCJkEEHO +2ERAw/ly1CJh4zuADnubXz2TDhsuIqT9z6GMkzlfOuwEBxT8mGFvszrCefuBR1FMZkBVG+smIfHJ +kRc3+Z9lIfEbDwHDBwC+KxAAoGD5jDDI/WbmzvDfFubaTMAQlpJzTYRkbpsA2NvTFwa+diPcRMwD +wAkYOCVwnoncbWZcfGUeNYdmrffXfX563iQGlCmrUuuf8Rn3uQGA7yW8AETIbQbhYORHG3MKG+RG +Jz1hhFwRIVVJppiDIAfnwRO5E9LzHNjiFI3X3FyshW0cfwBECvjCGBT5QGr66cD0yolgis7h0nG5 +YMLILqPD9B+qKxhcCaNf6Y9g90MpGm90v8rShklcrAgN4gIaoiRD0Ob4lWMvckhOO/6Q03Mt0PFg +k73CFKf5wSiv7Gfm2bcITTldmkF+0IPIVgT/QRBO89syzdfVWfe+AWCAS2zngtFayYVZUt+Ek1Z6 +h5tn76OExcyMShpnUClCXCzUhBP8mjOsGheSXzl090ft1XSUsQFluVF122sGF69L/GIzFlQzcs8s +99BwHhVu3fxLiGNfxnDGqOy2kwHJcd0hqaxgEz6hbLgiZ75ZyljOJp1oCnqpbkVKXdlqVkLoGZVq +ikVnJWllm57R2aYgUzXrQYZ+MCja+bQuLxCFty7oVNrjuzidSntBp9KCULBSaS847EagPcoTHxx2 +GmLLC4F4puW9a/Tzigbe/Egi4l6lzHgaYspNwxhPneNzrgJiMsQWVxkjx3XvBFHuyoDburrDcNFF +MNvLOAKE7gJaNeJDaRlxzm3aQTQxsbe/ZQVjrwtd7mPjoNp6r7iipLx5e3fRGfdedZY/F8REubTt +goc4bS6QV5UdO16sjKor2vHLb/Xj4Ga80jn63a6iiP0tNgctNqZDf7VXyWSyW5nOYt3LWfzykDZS +Wa+KSFA8ZEzufwDgzRfkkT0I9geRlAdcjdFHf0nkL7PoQqFPadYyaySr3Ii8nPcMxr/UNsiyWFEm +zDQ4kRx5kLx5ACjtPRw1SFgFfAkcUo2TMAxY9TjgumEdFGbEw/RTEm83a1elMfvgPYdqzB6trFBy +dz3ZnqWM9xdElBMeVSBdGaXDZv0wDtoi/45zxWvxXIWj1/YTHjHakWm8Gc3ZuLeinUC6kv/qksMN +V5K5eXJRQBYAZ8zweD9pJJiSmP8QnWigUCc6t6gf9dr0mQWqG4GmG200HuAuSr2gfXR6lcY/mh75 +JUTJfzz8h/7N5aOCqEZFRYEfCmqtwL6P495RIRGtINFZzxzoo8NOa9Tp9xr6P9FN3PZ4Xrk7OYxu +RknvOvTeisZhTHwdusOjBD4uqaOR1iO/+OgB+ufx78gviYcfOvp1if4hY/lCf5KBQa9/0K9T+OsP +tP0dlaPn0acXPtqOCNHH68gvUc1yCi/lo/kcJwlZOQoehSSJXDYvq1Zbl2pTBE4QFdRkverRRL3p +1fZORosG+YV65DhVygM+hcurAhkCOPyKmjebulSTInJyls8T2MabXm32qx5N7w56iXmeE2SAnRU4 +UYGxfqM2hVNFJRdVFICdVfEY+DxMNatEsyqXzwoYnySKnJoTs6ifoKoKaVO5rCRK0SzPqXl4htoE +heNFRY7mZE7Ok1eFHJeVBSGaA+tdEiRHmwpTEvM50pbnRFXMRVUJhsSrjrZcnlPyxrsikCifUxA8 +NZsVSVuOywkwJVWG4RlDEfNcTlYwjqwokyFLPJeXYUpAK15W1agkgrcqSCJaNz4vQYOgAkbAlBc4 +RVSlaAuNQuCyMCsEPc9njRmIQBz4G0YrZmXRJJwCw0CjzSsSWVA+C/BgOaCfoPJkprxKkAK8nJQn +8HjeYEeBU/ksngFaHEGFvZbPcpKM9p0KCKQcAMtzsizIuAGeSGiOOSBZC70E5JFhRQG4lMsR7oTZ +yXkFL0FOkQkH5fOAUJJp0lptMNisIhncIHC8AgOiyWi2ATyYNCE3L3E5GCMsfFZSRQLOxXCMNmDT +jTESNmDfSTJiHLQgApfLAW2+qTZYtVyOkFoGsgqwPHSTyimqBKsEKyjkpKgk52CxYTYAKiuIqMF4 +yYTd8sDX9W57R8yDKAWUhWXJASayhY02oERWUHKO173agLKKIGfNAWezzlcV6KbIWeersMY8WnQZ +tmCO7CnYhmIWHVKT6UuKBHtLyaKhZXPARWiqsPh5a6wtj/F3vduw1EBLkRU5Uc4C4iyXVXN4ulnY +mwrsa6NJygKHZmEvqkh8iHmqQYLtgbFaLbDVFfh/GwZwgITfMfEYDfgt2A4KCAarkw== + + + AlRQRMEGYzVYmKwWazAmEHO0zgm1JudorDRiTgE2dx5tStgH36Snooii1Ybnwcu4QZRVNDFYIAkx +INAUyUAM32hDnURj+2dhlwkwZnebTISatfDoXd7FWya8nApC0NiN5kBQvxxIBWuo0ID3mDUfswFT +C3orwCpWJwVJIyCbCsNSgOvtBpvCZgu8mwXxbQGxieKiXMuDmgaRcyIn5GB1TDIgIqPB5AQnucw2 +mgzmu15tjncn2zBqQ/AiuSjmDNRIOBB7ALchvKCiBCR9RV7KUg0yknQKnpvVJhlUtMCIoAgRFS1c +RgN+C3pLWboT/CHzCgXGbKBxmW3WgEww1pBd82p5zJWQQBYkTlIpJvxGbSonq8BAFLWsNorS1rte +bY53J9swajBQRCFPU18W0N8U9RHerEhR326wKWK3GWSzwRiEtXHZ1AfFCdozJ1Gd0Os0FOM3jclo +skZjwrDG65pUy2OiZP5Ez+WwllKd3G+0qYiHiG6UkC2R43OebTT3I+2QzyqebfS7SDgJoCm82uh3 +YfJZPid7tqF3s3kifPI5oiO92pCuzyrmq3KWTHeiCSypnJI122DUKiI9z4P6koDgWAaizqIIsi2P +LEc+ixZLUXNElFltgJfPErsLQc0qAClP7C40SUEVsmgJsQ2FGkQec4YsZ4mkMpsoCYJomFVF7zZq +5KgNOQjWyIEgebTo1sjBLhEUtLuokVtt1MgBEq8irwDMbx6ZLTlkyKpo4GBlq+R3DnwMNPKcTCSz +1WYoo64HizHa0JbkwWyXbYWBdiQYs1iyoTaUDwc/YLvI2GDk87yEGwhrgpkkEa0i8zkO7Je81dYl +wBUZW8VglqlRmRfJroU+vASmMWrIZXls+SMS4q0DglvgZbOpSzfBFJBdj0YIliZRPmCMmpMwfuLR +8EBWwWpCjJJHFEP6CwSp/Rs0aC6rmMxE2pChCsskg7ELFhveWSIYXVE3sVqT9KOsKBnsYhgH3jQC +ciK+jTYFWR0wDkkCUoKXkMvLeaoB/BEhl7XeanlA6nq3mSJGBtsuJ4C2plcV7FCBz8v2qspg+IES +sVcVLEdwBPOOVZVFpMFVx6rKPBEg1rJKADIv2qsKv2XwhelVRVYpcq6oVbWazFWVEftbS2ZNglpV +SQHPBuw8qwvwkwxukrGq9m97Ve02c1UBCMxftlfVTa2WBwXpdZXAiUPOHr2u0CaIOXsVZQFEHAgh +uiGL3Gd6Wd2Aut5taJui6ADyt1SsAESME9xc7DAiecPnsSsEyobLC5ggMri7UVnMAjgV8xvsGAk3 +CJKENY1KLFXcpObx0BQRegB9VNQDdBi4G9CgIssT/VZAjpKRg1IU89gvVkVRxZTHghGMIHDvsxgK +HgFNVFEi5jlyHvMSGh24vKKArVOsLmWRh+2hIhvX0r8i6Op8HnuhsKGNWYJkRTOGNVVzGBugBa8K +W7BYS4tIewiYb8EDE8155vM57EjkshLsABHpHJD60CBmRdQgGpsR7VIyZgF59Dk0eRBceTxkJccT +kxZ5uwiqKVdNuxetAjg6WCaDDkOdQM8itxkaFFCW0Yn1bHmsMb2jZYMitNFmttHGGLiDspqTPNtg +DhK4VGZbVsTiUIS1i4JjBTKfx462kgMa4QZJVayX0ADNNho4cqMU0avJ5BejSUB6Gk0NyE7AI/ZA +lhjiQzQgLENADyPvtUXGiJpou9JNBkYbsvRBcoFwVqPgX+WRWDF8dlEiIT2RhNOQKy7C+tFN4OtI +AozJaALvGgQGisIBohwKL6BwXA4HMsDHFYl/lCXuBnhpORT0QUBEkEEYPdpFksLD6qqkIS+Qt9xj +7Hq3vZOwB6/AVkGDUEhsRCFqWlGIS4ZfRmRVnU0gJUAEoTYFPUMNCpCLALLnj3+2JjB1vVreCSqB +V+UoePlYo32Tjgpsuygsd14RRHNIsCwibhONyBIMAdgB98OmEyIOsEzeAmZFcMyGlgfGrnfbO1lC +RYBdimK3qmFjK0DQrBHPNeNxWRTfy0ZR8CUrk7FlwSBTgeEdbSqyJnJUKBh5YDmwqegmWDH4H17h +XD6bN712IK/oaHMPjtH2Thx8MCfx6kiq4a5mDb1ptnVJqAUwS1HUX5Cs4IDjXUYb9oqNuIkxGYQF +JKlCT041ImJoyVCsuUts2DzwlmMiph3vaEPSWc052DxvRFYdbaCiVZDeMDoBBSMxDsPSzhqrZ8QF +cjnjXavNOQXvpndiGYoKCHfz5W8ywJwhBMxBI9OUR96Co82w3qgByrxKrCG6H7h/IDBE57sqUTZZ +wQqGI6cVTC/Vkgm4DeSEmBccbTyK6Mp4dbN5wTStJVB1zm6uqTHaUNT1LvKrUjDOfY56bXwyk0Yn +UqvVxod2qzc6XXQM9DFs/KVFG71ef9QYaQN4FP3QteGor2vR4Wf/b9SCXrJeWF09uixFfv0fNXid +4w== + + + \ No newline at end of file diff --git a/ru/users/revolut.svg b/ru/users/revolut.svg new file mode 100644 index 000000000..93d36c05d --- /dev/null +++ b/ru/users/revolut.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ru/users/rostelecom.png b/ru/users/rostelecom.png new file mode 100644 index 000000000..709c55527 Binary files /dev/null and b/ru/users/rostelecom.png differ diff --git a/ru/users/sberbank.svg b/ru/users/sberbank.svg new file mode 100644 index 000000000..e771f2612 --- /dev/null +++ b/ru/users/sberbank.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ru/users/squaregps.svg b/ru/users/squaregps.svg new file mode 100644 index 000000000..5cc7f4df6 --- /dev/null +++ b/ru/users/squaregps.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/ru/users/superjob.png b/ru/users/superjob.png new file mode 100644 index 000000000..582120979 Binary files /dev/null and b/ru/users/superjob.png differ diff --git a/ru/users/technology.png b/ru/users/technology.png new file mode 100644 index 000000000..3a152d1af Binary files /dev/null and b/ru/users/technology.png differ diff --git a/ru/users/tinkoff.svg b/ru/users/tinkoff.svg new file mode 100644 index 000000000..f51354736 --- /dev/null +++ b/ru/users/tinkoff.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ru/users/vivid.money.png b/ru/users/vivid.money.png new file mode 100644 index 000000000..e24669857 Binary files /dev/null and b/ru/users/vivid.money.png differ diff --git a/ru/users/vtb.svg b/ru/users/vtb.svg new file mode 100644 index 000000000..0b081dd44 --- /dev/null +++ b/ru/users/vtb.svg @@ -0,0 +1,23 @@ + + + + Group 6 + Created with Sketch. + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ru/users/x5.svg b/ru/users/x5.svg new file mode 100644 index 000000000..5fde6abcd --- /dev/null +++ b/ru/users/x5.svg @@ -0,0 +1,795 @@ + + + + + + + + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 +z/4fmc/kEg== + + + diff --git a/ru/users/yandex_zen.svg b/ru/users/yandex_zen.svg new file mode 100644 index 000000000..3abe04844 --- /dev/null +++ b/ru/users/yandex_zen.svg @@ -0,0 +1,1348 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sitemap.xml b/sitemap.xml new file mode 100644 index 000000000..a9400aa99 --- /dev/null +++ b/sitemap.xml @@ -0,0 +1,299 @@ + + + + None./ + 2023-08-21 + daily + + + + + + NoneHome/Breaking-changes/ + 2023-08-21 + daily + + + + + + NoneHome/Contribution_guide/ + 2023-08-21 + daily + + + + + + NoneHome/Kaspresso%20users/ + 2023-08-21 + daily + + + + + + NoneHome/Kaspresso-in-articles/ + 2023-08-21 + daily + + + + + + NoneHome/Kaspresso-in-videos/ + 2023-08-21 + daily + + + + + + NoneIssues/Storage_issue/ + 2023-08-21 + daily + + + + + + NoneIssues/ + 2023-08-21 + daily + + + + + + NoneTutorial/Android_permissions/ + 2023-08-21 + daily + + + + + + NoneTutorial/Download_Kaspresso_project_and_Android_studio/ + 2023-08-21 + daily + + + + + + NoneTutorial/FlakySafely/ + 2023-08-21 + daily + + + + + + NoneTutorial/Logger_and_screenshot/ + 2023-08-21 + daily + + + + + + NoneTutorial/Logger_and_screenshots/ + 2023-08-21 + daily + + + + + + NoneTutorial/Recyclerview/ + 2023-08-21 + daily + + + + + + NoneTutorial/Scenario/ + 2023-08-21 + daily + + + + + + NoneTutorial/Screenshot_tests_1/ + 2023-08-21 + daily + + + + + + NoneTutorial/Screenshot_tests_2/ + 2023-08-21 + daily + + + + + + NoneTutorial/Steps_and_sections/ + 2023-08-21 + daily + + + + + + NoneTutorial/UiAutomator/ + 2023-08-21 + daily + + + + + + NoneTutorial/Wifi_sample_test/ + 2023-08-21 + daily + + + + + + NoneTutorial/Working_with_adb/ + 2023-08-21 + daily + + + + + + NoneTutorial/Writing_simple_test/ + 2023-08-21 + daily + + + + + + NoneTutorial/ + 2023-08-21 + daily + + + + + + NoneWiki/Espresso_as_the_basis/ + 2023-08-21 + daily + + + + + + NoneWiki/Executing_adb_commands/ + 2023-08-21 + daily + + + + + + NoneWiki/Jetpack_Compose/ + 2023-08-21 + daily + + + + + + NoneWiki/Kaspresso_Allure/ + 2023-08-21 + daily + + + + + + NoneWiki/Kaspresso_Robolectric/ + 2023-08-21 + daily + + + + + + NoneWiki/Kaspresso_configuration/ + 2023-08-21 + daily + + + + + + NoneWiki/Kautomator-wrapper_over_UI_Automator/ + 2023-08-21 + daily + + + + + + NoneWiki/Matchers_actions_assertions/ + 2023-08-21 + daily + + + + + + NoneWiki/Page_object_in_Kaspresso/ + 2023-08-21 + daily + + + + + + NoneWiki/Screenshot_tests/ + 2023-08-21 + daily + + + + + + NoneWiki/Supported_Android_UI_elements/ + 2023-08-21 + daily + + + + + + NoneWiki/Working_with_Android_OS/ + 2023-08-21 + daily + + + + + + NoneWiki/how_to_write_autotests/ + 2023-08-21 + daily + + + + + + NoneWiki/ + 2023-08-21 + daily + + + + + \ No newline at end of file diff --git a/sitemap.xml.gz b/sitemap.xml.gz new file mode 100644 index 000000000..318ab8f11 Binary files /dev/null and b/sitemap.xml.gz differ diff --git a/users/RabotaRu.png b/users/RabotaRu.png new file mode 100644 index 000000000..dc6fab061 Binary files /dev/null and b/users/RabotaRu.png differ diff --git a/users/aliexpress.svg b/users/aliexpress.svg new file mode 100644 index 000000000..0e4b5bbe2 --- /dev/null +++ b/users/aliexpress.svg @@ -0,0 +1,146 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/users/aloha.png b/users/aloha.png new file mode 100644 index 000000000..f3cb2c92d Binary files /dev/null and b/users/aloha.png differ diff --git a/users/blinklist.png b/users/blinklist.png new file mode 100644 index 000000000..2ce1d9ee2 Binary files /dev/null and b/users/blinklist.png differ diff --git a/users/cft.png b/users/cft.png new file mode 100644 index 000000000..ef56ff579 Binary files /dev/null and b/users/cft.png differ diff --git a/users/cian.png b/users/cian.png new file mode 100644 index 000000000..f10f063ab Binary files /dev/null and b/users/cian.png differ diff --git a/users/delivery_club.png b/users/delivery_club.png new file mode 100644 index 000000000..072042d19 Binary files /dev/null and b/users/delivery_club.png differ diff --git a/users/dmtech.svg b/users/dmtech.svg new file mode 100644 index 000000000..af60ae807 --- /dev/null +++ b/users/dmtech.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/users/hh.png b/users/hh.png new file mode 100644 index 000000000..82887c646 Binary files /dev/null and b/users/hh.png differ diff --git a/users/kaspersky.svg b/users/kaspersky.svg new file mode 100644 index 000000000..2d2decdb2 --- /dev/null +++ b/users/kaspersky.svg @@ -0,0 +1,3 @@ + + +Layer 1 \ No newline at end of file diff --git a/users/letoile.svg b/users/letoile.svg new file mode 100644 index 000000000..812de733c --- /dev/null +++ b/users/letoile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/users/nexign.jpeg b/users/nexign.jpeg new file mode 100644 index 000000000..e3897749a Binary files /dev/null and b/users/nexign.jpeg differ diff --git a/users/profi.png b/users/profi.png new file mode 100644 index 000000000..75f76f4ed Binary files /dev/null and b/users/profi.png differ diff --git a/users/psb.jpeg b/users/psb.jpeg new file mode 100644 index 000000000..6e13a082b Binary files /dev/null and b/users/psb.jpeg differ diff --git a/users/raiffeisen.svg b/users/raiffeisen.svg new file mode 100644 index 000000000..8bb13145f --- /dev/null +++ b/users/raiffeisen.svg @@ -0,0 +1,377 @@ + + + + + + + + + + + + + + + + + + + + + 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 +4w== + + + \ No newline at end of file diff --git a/users/revolut.svg b/users/revolut.svg new file mode 100644 index 000000000..93d36c05d --- /dev/null +++ b/users/revolut.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/users/rostelecom.png b/users/rostelecom.png new file mode 100644 index 000000000..709c55527 Binary files /dev/null and b/users/rostelecom.png differ diff --git a/users/sberbank.svg b/users/sberbank.svg new file mode 100644 index 000000000..e771f2612 --- /dev/null +++ b/users/sberbank.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/users/squaregps.svg b/users/squaregps.svg new file mode 100644 index 000000000..5cc7f4df6 --- /dev/null +++ b/users/squaregps.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/users/superjob.png b/users/superjob.png new file mode 100644 index 000000000..582120979 Binary files /dev/null and b/users/superjob.png differ diff --git a/users/technology.png b/users/technology.png new file mode 100644 index 000000000..3a152d1af Binary files /dev/null and b/users/technology.png differ diff --git a/users/tinkoff.svg b/users/tinkoff.svg new file mode 100644 index 000000000..f51354736 --- /dev/null +++ b/users/tinkoff.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/users/vivid.money.png b/users/vivid.money.png new file mode 100644 index 000000000..e24669857 Binary files /dev/null and b/users/vivid.money.png differ diff --git a/users/vtb.svg b/users/vtb.svg new file mode 100644 index 000000000..0b081dd44 --- /dev/null +++ b/users/vtb.svg @@ -0,0 +1,23 @@ + + + + Group 6 + Created with Sketch. + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/users/x5.svg b/users/x5.svg new file mode 100644 index 000000000..5fde6abcd --- /dev/null +++ b/users/x5.svg @@ -0,0 +1,795 @@ + + + + + + + + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 +z/4fmc/kEg== + + + diff --git a/users/yandex_zen.svg b/users/yandex_zen.svg new file mode 100644 index 000000000..3abe04844 --- /dev/null +++ b/users/yandex_zen.svg @@ -0,0 +1,1348 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file