diff --git a/docs/Tutorial/Logger_and_screenshot.en.md b/docs/Tutorial/Logger_and_screenshot.en.md new file mode 100644 index 000000000..da4c59aab --- /dev/null +++ b/docs/Tutorial/Logger_and_screenshot.en.md @@ -0,0 +1,567 @@ +# 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` + +```kotlin +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() + + @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: + +```kotlin +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 + +```kotlin +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() + + @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 + +```kotlin +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() + + @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 + +```kotlin +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 + +```kotlin +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() + + @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]( https://kotlinlang.org/docs/strings.html#string-templates) + +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. + +```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( + private val username: String, + private val password: String +) : Scenario() { + + override val steps: TestContext.() -> 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` + +```kotlin +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() + + @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](https://kasperskylab.github.io/Kaspresso/Wiki/Kaspresso_configuration/). + +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: + +```kotlin +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. + +```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( + private val username: String, + private val password: String +) : Scenario() { + + override val steps: TestContext.() -> 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]( https://kasperskylab.github.io/Kaspresso/Wiki/Kaspresso_configuration/) + +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: + +```kotlin +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 + +```kotlin +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. diff --git a/docs/Tutorial/Logger_and_screenshot.ru.md b/docs/Tutorial/Logger_and_screenshot.ru.md new file mode 100644 index 000000000..117a03b43 --- /dev/null +++ b/docs/Tutorial/Logger_and_screenshot.ru.md @@ -0,0 +1,712 @@ +# Логирование и скриншоты + +В этом уроке мы научимся выявлять причины падающих тестов путем добавления дополнительных логов и скриншотов. + +Вспомним пример, который уже использовался в одном из предыдущих уроков. Открываем приложение tutorial + +Tutorial main screen + +и кликаем на кнопку `Login Activity` + +Login Activity + +На этом экране можно ввести логин и пароль, и, если они будут корректные, то откроется экран после авторизации. Корректными в данном случае считаются: логин длиной от трех символов, пароль – от шести. + +Screen after auth + +## Внешняя система для тестовых данных + +Мы уже писали тесты для этого экрана, они находятся в классе `LoginActivityTest` + +```kotlin +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() + + @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 + +Как мы уже говорили ранее – здесь мы будем только моделировать ситуацию, когда данные для теста получаем из внешней системы. В созданном классе у нас будет два метода: один из них возвращает логин, другой – пароль. В реальных проектах эти данные мы бы запрашивали с сервера. Сейчас мы сами укажем, какие логин и пароль вернет система, но представляем, что для нас это «черный ящик», и мы не знаем, какие значения будут получены. + +Добавляем в этом классе два метода. Пусть они возвращают корректные логин и пароль: + +```kotlin +package com.kaspersky.kaspresso.tutorial.data + +object TestData { + + fun generateUsername(): String = "Admin" + + fun generatePassword(): String = "123456" +} +``` +Теперь давайте создадим отдельный класс теста, в котором будем выполнять проверку успешного логина с помощью данных, полученных от класса `TestData`. Тестовый класс назовем `LoginActivityGeneratedDataTest`. Можем скопировать проверку успешного логина из класса `LoginActivityTest` + +```kotlin +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() + + @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` + +```kotlin +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() + + @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`, чтобы он возвращал неверные значения + +```kotlin +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 можно почитать в [официальной документации]("https://developer.android.com/studio/debug/logcat") + +Например, в нашем случае мы хотим в журнале показать данные, которые использовались при авторизации - это простое информационное сообщение, которое не говорит об ошибках в работе программы или каких-то предупреждениях, а также не используется для отладки, поэтому нам подойдет уровень логирования `info` - метод `Log.i()`. + +В качестве параметра этому методу нужно передать два аргумента типа String - две строчки: + +
    +
  1. Тэг. По этому тэгу мы будем искать нужное нам сообщение в журнале.
  2. +
  3. Текст сообщения
  4. +
+ +Раньше необходимые сообщения в журнале мы искали по тэгу "KASPRESSO", можем указать его в качестве тэга, а в качестве сообщения выведем данные, использованные при авторизации. + +Логин и пароль у нас генерируются перед шагом `step("Try to login with correct username and password")` можем в этом месте вывести в лог сообщение о том, какие именно данные были сгенерированы + +```kotlin +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() + + @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 + Подробнее о том, как формировать строку с использованием переменных и методов, можно почитать в [документации]( https://kotlinlang.org/docs/strings.html#string-templates) + +Давайте запустим тест еще раз и посмотрим логи: + +Custom Log + +После `TEST SECTION` видно наш лог, который выводится с тэгом `KASPRESSO`. В этом логе видно, что сгенерированные данные некорректные (пароль слишком короткий), а значит тест падает из-за внешней системы, и решать проблему нужно именно в ней. + +Если вы не хотите смотреть полностью весь лог, и вас интересуют только сообщения, добавленные вами, то вы можете использовать любой другой тэг. Для таких ситуаций удобно использовать тэг "KASPRESSO_TEST", тогда ваши логи будут отображаться в общем журнале вместе с другими сообщениями, если отфильтровать их по тэгу "KASPRESSO", при этом вы в любой момент сможете оставить только ваши сообщения, отфильтровав их по тэгу "KASPRESSO_TEST" + +```kotlin +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() + + @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`, вызвав метод с необходимым уровнем логирования. При использовании этого метода больше не нужно передавать тэг, достаточно указать только текст сообщения. + +В нашем случае логирование выглядело бы следующим образом: + +```kotlin +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() + + @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` мы добавим скриншоты, чтобы потом проанализировать все, что происходило на экране. + +```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( + private val username: String, + private val password: String +) : Scenario() { + + override val steps: TestContext.() -> 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` + +```kotlin +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() + + @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](https://kasperskylab.github.io/Kaspresso/ru/Wiki/Kaspresso_configuration/). + +Сейчас нас интересует добавление скриншотов, если тесты упали. Самый простой вариант сделать это – использовать `advanced` builder вместо `simple`. Делается это следующим образом: + +```kotlin +class LoginActivityGeneratedDataTest : TestCase( + kaspressoBuilder = Kaspresso.Builder.advanced() +) + +``` +В этом случае вызов методов, которые делают скриншоты, можно убрать, они будут сохранены автоматически, если тест упадет. + +!!! info + Обратите внимание, что разрешения на доступ к файловой системе нужны обязательно, без них скриншоты сохранены не будут + +```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( + private val username: String, + private val password: String +) : Scenario() { + + override val steps: TestContext.() -> 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 достаточно много, подробнее о них вы можете почитать в [документации](https://kasperskylab.github.io/Kaspresso/ru/Wiki/Kaspresso_configuration/) + +Нас интересует добавление скриншотов, за это отвечают классы `ScreenshotStepWatcherInterceptor`, `ScreenshotFailStepWatcherInterceptor` и `TestRunnerScreenshotWatcherInterceptor`. + +
    +
  • ScreenshotStepWatcherInterceptor – добавляет скриншоты независимо от того, шаг завершился с ошибкой или нет +
  • +
  • ScreenshotFailStepWatcherInterceptor – добавляет скриншот только того шага, который завершился с ошибкой +
  • +
  • TestRunnerScreenshotWatcherInterceptor – добавляет скриншот, если произошла ошибка в секции before или after +
  • +
+ +Если тест падает, то удобно смотреть не только шаг, на котором произошла ошибка, но и предыдущие – таким образом разобраться в проблеме бывает гораздо проще. Поэтому мы добавим первый вариант `Interceptor`-а, который скриншотит все шаги, независимо от результата. Делается это следующим образом: + +```kotlin +class LoginActivityGeneratedDataTest : TestCase( + kaspressoBuilder = Kaspresso.Builder.simple().apply { + stepWatcherInterceptors.add(ScreenshotStepWatcherInterceptor(screenshots)) + } +) +``` +Здесь мы сначала получаем builder по умолчанию `Kaspresso.Builder.simple()`, вызываем у него метод `apply` + +```kotlin + kaspressoBuilder = Kaspresso.Builder.simple().apply { + + } +``` + +и в фигурных скобках добавляем все необходимые настройки. + +В данном случае мы получаем все `Interceptor`-ы, которые перехватывают событие выполнения шагов (`step`) + +```kotlin + stepWatcherInterceptors +``` + +и добавляем туда `ScreenshotStepWatcherInterceptor`. + +```kotlin + stepWatcherInterceptors.add(ScreenshotStepWatcherInterceptor(...)) +``` + +Этому `interseptor`-у в качестве параметра конструктора нужно передать реализацию интерфейса `Screenshots`, то есть экземпляр класса, который реализует данный интерфейс и, соответственно, умеет делать скриншоты. Такой объект уже есть в `Kaspresso.Builder`, называется он `screenshots`. Мы вызвали функцию `apply` у `Kaspresso.Builder`, поэтому, находясь внутри этой функции, мы можем напрямую обращаться к переменным и методам данного `builder`-а. Обращаемся к переменной `screenshots`, передавая ее в качестве параметра. + +```kotlin +class LoginActivityGeneratedDataTest : TestCase( + kaspressoBuilder = Kaspresso.Builder.simple().apply { + stepWatcherInterceptors.add(ScreenshotStepWatcherInterceptor(screenshots)) + } +) +``` + +Теперь, когда мы добавили данный `Interceptor`, после каждого шага теста, независимо от результата его выполнения, на устройстве будут сохранены скриншоты. + +Запускаем. Тест завершился неудачно, и на устройстве были сохранены скриншоты + +Customized Builder + +Давайте вернем корректную реализацию класса `TestData` + +```kotlin +package com.kaspersky.kaspresso.tutorial.data + +object TestData { + + fun generateUsername(): String = "Admin" + + fun generatePassword(): String = "123456" +} + +``` + +Запустим тест еще раз. Тест пройден успешно, и все скриншоты сохранены на устройстве. + +!!! info + Обратите внимание на то, что скриншоты сохраняются на тестируемом устройстве. Поэтому, если вы делаете скриншоты для каждого шага независимо от результата, то размер артефактов после прогона тестов может быть очень большим. Это может стать проблемой, особенно если ваши тесты запускаются на CI. Поэтому злоупотреблять скриншотами не следует, используйте их сохранение только в случае необходимости. + +## Итог + +В этом уроке мы узнали, как в наши тесты добавить логирование и скриншоты. Узнали, в каких случаях стандартных логов бывает недостаточно, научились настраивать `Kaspresso.Builder`, добавляя в него различные `Interceptor`-ы. + + +
diff --git a/docs/Tutorial/Logger_and_screenshots.en.md b/docs/Tutorial/Logger_and_screenshots.en.md index fe60a4fc5..e69de29bb 100644 --- a/docs/Tutorial/Logger_and_screenshots.en.md +++ b/docs/Tutorial/Logger_and_screenshots.en.md @@ -1,566 +0,0 @@ -# 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` - -```kotlin -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() - - @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: - -```kotlin -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 - -```kotlin -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() - - @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 - -```kotlin -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() - - @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 - -```kotlin -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 - -```kotlin -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() - - @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]( https://kotlinlang.org/docs/strings.html#string-templates) - -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. - -```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( - private val username: String, - private val password: String -) : Scenario() { - - override val steps: TestContext.() -> 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` - -```kotlin -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() - - @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](https://kasperskylab.github.io/Kaspresso/Wiki/Kaspresso_configuration/). - -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: - -```kotlin -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. - -```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( - private val username: String, - private val password: String -) : Scenario() { - - override val steps: TestContext.() -> 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]( https://kasperskylab.github.io/Kaspresso/Wiki/Kaspresso_configuration/) - -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: - -```kotlin -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 - -```kotlin -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. diff --git a/docs/Tutorial/Logger_and_screenshots.ru.md b/docs/Tutorial/Logger_and_screenshots.ru.md index a7dc497f0..e69de29bb 100644 --- a/docs/Tutorial/Logger_and_screenshots.ru.md +++ b/docs/Tutorial/Logger_and_screenshots.ru.md @@ -1,570 +0,0 @@ -# Логирование и скриншоты - -В этом уроке мы научимся выявлять причины падающих тестов путем добавления дополнительных логов и скриншотов. - -Вспомним пример, который уже использовался в одном из предыдущих уроков. Открываем приложение tutorial - -Tutorial main screen - -и кликаем на кнопку `Login Activity` - -Login Activity - -На этом экране можно ввести логин и пароль, и, если они будут корректные, то откроется экран после авторизации. Корректными в данном случае считаются: логин длиной от трех символов, пароль – от шести. - -Screen after auth - -## Внешняя система для тестовых данных - -Мы уже писали тесты для этого экрана, они находятся в классе `LoginActivityTest` - -```kotlin -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() - - @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) - } - } - } -} -``` - -В этом тесте мы сами создаем логин и пароль, с которыми будем авторизоваться. Но бывают случаи, когда данные для теста мы получаем из какой-то внешней системы. Например, в проекте может быть какой-то сервис, который генерирует логин и пароль для входа, возвращает нам, и мы их используем для тестирования. - -Давайте смоделируем эту ситуацию. Создадим класс, который возвращает данные для входа – логин и пароль. - -В пакете `com.kaspersky.kaspresso.tutorial` создадим еще один пакет `data` - -Create package 1 - -Create package 2 - -В созданном пакете добавляем класс `TestData`, тип выбираем `Object` - -Create class - -Как мы уже говорили ранее – здесь мы будем только моделировать ситуацию, когда данные для теста получаем из внешней системы. В созданном классе у нас будет два метода: один из них возвращает логин, другой – пароль. В реальных проектах эти данные мы бы запрашивали с сервера, и менять внутреннюю реализацию возможности у нас бы не было. То есть сейчас мы сами укажем, какие логин и пароль вернет система, но представляем, что для нас это «черный ящик», и мы не знаем, какие значения будут получены. - -Добавляем в этом классе два метода и пусть они возвращают корректные логин и пароль: - -```kotlin -package com.kaspersky.kaspresso.tutorial.data - -object TestData { - - fun generateUsername(): String = "Admin" - - fun generatePassword(): String = "123456" -} -``` -Теперь давайте создадим отдельный класс теста, в котором будем выполнять проверку успешного логина с помощью данных, полученных от класса `TestData`. Тестовый класс назовем `LoginActivityGeneratedDataTest`. Можем скопировать проверку успешного логина из класса `LoginActivityTest` - -```kotlin -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() - - @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` - -```kotlin -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() - - @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`, чтобы он возвращал неверные значения - -```kotlin -package com.kaspersky.kaspresso.tutorial.data - -object TestData { - - fun generateUsername(): String = "Adm" - - fun generatePassword(): String = "123" -} - -``` -Запускаем тест еще раз. На этот раз тест падает. - -Мы уже говорили о том, что в реальных проектах влиять на внешнюю систему мы не можем, и иногда она может возвращать некорректные данные, из-за чего тест будет падать. Если тест упал, то нужно провести анализ и определить, в чем была проблема: в тестах, в неправильно работающем приложении или во внешней системе. Давайте попробуем определить это из логов. Открываем Logcat и фильтруем лог по тэгу `KASPRESSO` - -Test failed - -Что мы отсюда видим? Попытка залогиниться прошла успешно, а проверка на то, что после успешного логина открыт нужный экран – провалилась. - -При этом, отсюда совершенно неясно, почему проблема возникла. Мы не видим, с какими данными была попытка залогиниться, действительно ли они корректные, и непонятно, как решать возникшую проблему. Результат был бы более понятный, если бы в логах содержалась информация – какие конкретно логин и пароль были использованы во время тестирования. - -## Добавление логов - -Если нам нужно добавить какую-то свою информацию в логи, то можем использовать объект `testLogger`, у которого необходимо вызвать метод `i` (от слова `info)`, и в качестве параметра передать текст, который нужно залогировать. - -Логин и пароль у нас генерируются перед шагом ` step("Try to login with correct username and password")` можем в этом месте вывести в лог сообщение о том, какие именно данные были сгенерированы - -```kotlin -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() - - @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) - } - } - } -} - -``` - -В этой строчке `testLogger.i("Generated data. Username: $username, Password: $password") -` мы вызываем метод `i` у объекта `testLogger`, в качестве параметра передаем строку `"Generated data. Username: $username, Password: $password")`, где вместо `$username` и `$password` будут подставлены значения переменных логин и пароль. - -!!! info - Подробнее о том, как формировать строку с использованием переменных и методов, можно почитать в [документации]( https://kotlinlang.org/docs/strings.html#string-templates) - -Давайте запустим тест еще раз и посмотрим логи: - -Custom Log - -После `TEST SECTION` видно наш лог, который выводится с тэгом `KASPRESSO_TEST`. В этом логе видно, что сгенерированные данные некорректные (пароль слишком короткий), а значит тест падает из-за внешней системы, и решать проблему нужно именно в ней. - -Если вы не хотите смотреть полностью весь лог, и вас интересуют только сообщения, добавленные вами, то можете отфильтровать журнал по тэгу `KASPRESSO_TEST` - -Kaspresso test tag - -## Скриншоты - -Логи действительно очень полезны при анализе тестов и поиске ошибок, но бывают случаи, когда гораздо проще найти проблему по скриншотам. Если бы во время теста на каждом шаге сохранялся скриншот, и потом мы могли бы посмотреть их в какой-то папке, то поиск ошибок был бы намного проще. - -В Kaspresso есть возможность во время теста делать скриншоты на любом шаге, для этого достаточно вызвать метод `device.screenshots.take("file_name")`. Вместо `file_name` нужно указать название файла скриншота, по которому вы сможете его найти. Давайте в каждый шаг `LoginScenario` мы добавим скриншоты, чтобы потом проанализировать все, что происходило на экране. - -```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( - private val username: String, - private val password: String -) : Scenario() { - - override val steps: TestContext.() -> 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` - -```kotlin -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() - - @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`, который по умолчанию принимает значение `Kaspresso.Builder.simple()`. - -Test Case Params - -!!! info - Чтобы посмотреть параметры, которые принимает метод или конструктор, можно кликнуть левой кнопкой мыши внутри круглых скобок и нажать комбинацию клавиш `ctrl + P` (или `cmd + P` на Mac) - -Мы можем добавить множество разных настроек, подробнее о которых можно почитать в [Wiki](https://kasperskylab.github.io/Kaspresso/Wiki/Kaspresso_configuration/). - -Сейчас нас интересует добавление скриншотов, если тесты упали. Самый простой вариант сделать это – использовать `advanced` builder вместо `simple`. Делается это следующим образом: - -```kotlin -class LoginActivityGeneratedDataTest : TestCase( - kaspressoBuilder = Kaspresso.Builder.advanced() -) - -``` -В этом случае вызов методов, которые делают скриншоты, можно убрать, они будут сохранены автоматически, если тест упадет. - -!!! info - Обратите внимание, что разрешения на доступ к файловой системе нужны обязательно, без них скриншоты сохранены не будут - -```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( - private val username: String, - private val password: String -) : Scenario() { - - override val steps: TestContext.() -> 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`-ам. `Interceptors` — это классы, которые перехватывают вызываемые нами действия и добавляют в них какую-то функциональность. Таких классов в Kaspresso достаточно много, подробнее о них вы можете почитать в [документации]( https://kasperskylab.github.io/Kaspresso/Wiki/Kaspresso_configuration/) - -Нас интересует добавление скриншотов, за это отвечают классы `ScreenshotStepWatcherInterceptor`, `ScreenshotFailStepWatcherInterceptor` и `TestRunnerScreenshotWatcherInterceptor`. - -
    -
  • ScreenshotStepWatcherInterceptor – добавляет скриншоты независимо от того, шаг завершился с ошибкой или нет -
  • -
  • ScreenshotFailStepWatcherInterceptor – добавляет скриншот только того шага, который завершился с ошибкой -
  • -
  • TestRunnerScreenshotWatcherInterceptor – добавляет скриншот, если произошла ошибка в секции `before` или `after` -
  • -
- -Если тест падает, то удобно смотреть не только шаг, на котором произошла ошибка, но и предыдущие – таким образом разобраться в проблеме бывает гораздо проще. Поэтому мы добавим первый вариант `Interceptor`-а, который скриншотит все шаги, независимо от результата. Делается это следующим образом: - -```kotlin -class LoginActivityGeneratedDataTest : TestCase( - kaspressoBuilder = Kaspresso.Builder.simple().apply { - stepWatcherInterceptors.add(ScreenshotStepWatcherInterceptor(screenshots)) - } -) -``` -Здесь мы сначала получаем builder по умолчанию, вызываем у него метод `apply` и в фигурных скобках добавляем все необходимые настройки. В данном случае мы получаем все `Interceptor`-ы, которые перехватывают событие выполнения шагов (`step`) и добавляем туда `ScreenshotStepWatcherInterceptor`, передавая ему в конструктор объект `screenshots`. - -Теперь, когда мы добавили данный `Interceptor`, после каждого шага теста, независимо от результата его выполнения, на устройстве будут сохранены скриншоты. - -Запускаем. Тест завершился неудачно, и на устройстве были сохранены скриншоты - -Customized Builder - - -Давайте вернем корректную реализацию класса `TestData` - -```kotlin -package com.kaspersky.kaspresso.tutorial.data - -object TestData { - - fun generateUsername(): String = "Admin" - - fun generatePassword(): String = "123456" -} - -``` - -Запустим тест еще раз. Тест пройден успешно, и все скриншоты сохранены на устройстве. - -## Итог - -В этом уроке мы узнали, как в наши тесты добавить логирование и скриншоты. Узнали, в каких случаях стандартных логов бывает недостаточно, научились настраивать `Kaspresso.Builder`, добавляя в него различные `Interceptor`-ы. -Также мы рассмотрели способы, как создавать скриншоты вручную, и как этот процесс можно автоматизировать. - - -
diff --git a/docs/Tutorial/Screenshot_tests_1.en.md b/docs/Tutorial/Screenshot_tests_1.en.md new file mode 100644 index 000000000..2ce5d2aef --- /dev/null +++ b/docs/Tutorial/Screenshot_tests_1.en.md @@ -0,0 +1,243 @@ +# 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]( https://developer.android.com/reference/android/app/Activity) или [Fragment]( https://developer.android.com/guide/fragments), скриншоты которого хотим получить. + +Во-вторых, мы хотим получить снимки всех возможных состояний экрана для каждой локали, поэтому добавлять проверки элементов или выполнять шаги, имитирующие действия пользователя, как мы делали ранее, мы не будем. Наша цель – + +
    +
  1. Открыть экран
  2. +
  3. Установить нужное состояние
  4. +
  5. Сделать скриншот
  6. +
  7. При необходимости изменить состояние и снова сделать скриншот
  8. +
+ +Дальше нужно поменять локаль и повторить все перечисленные действия. + +Подробнее про состояния (или стейты, как их часто называют), и как их правильно устанавливать, мы поговорим позже, а сейчас напишем простой screenshot-тест, который откроет экран LoginActivity, сделает скриншот, затем сменит язык на устройстве на французский и снова сделает скриншот. + +## Простой screenshot-тест + +Создание screenshot-теста начинается так же, как мы делали ранее – в папке тестов создаем новый класс. Классы для скриншотов обычно называются с окончанием `Screenshots`. Давайте все скриншот-тесты будем хранить в отдельном пакете, назовем его screenshot_tests. + +В этом пакете создаем класс `LoginActivityScreenshots` + +Creating screenshot test class + +У тестов, которые мы сейчас будем создавать, есть особенности: во-первых, они должны запускаться для разных локалей, во-вторых, полученные скриншоты должны быть размещены в удобной структуре папок – для каждого языка своя папка. По этим причинам тестовый класс мы унаследуем от класса `DocLocScreenshotTestCase`, а не от `TestCase`, как мы это делали ранее + +```kotlin +package com.kaspersky.kaspresso.tutorial.screenshot_tests + +import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase + +class LoginActivityScreenshots : DocLocScreenshotTestCase() { + +} + +``` + +В качестве параметра конструктору нужно передать список локалей, для которых будут делаться скриншоты. В данном случае нас интересует английский и французский языки, устанавливаем их. Делается это следующим образом: + +```kotlin +package com.kaspersky.kaspresso.tutorial.screenshot_tests + +import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase + +class LoginActivityScreenshots : DocLocScreenshotTestCase(locales = "en, fr") { + +} + +``` +Порядок, в котором будут перечислены языки, не имеет значения, тест будет запущен для каждого языка поочерёдно. + +Как мы говорили ранее, здесь мы не будем проходить весь процесс от старта приложения до открытия необходимого экрана. Вместо этого мы сразу создадим `Rule`, в котором укажем, что при старте теста должен быть открыт экран `LoginActivity` + +```kotlin +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() +} + +``` + +В этом классе мы можем использовать такие же методы, какие использовали в других тестах. Давайте создадим один step, в котором проверим только исходное состояние экрана. Назовем метод takeScreenshots() + +```kotlin +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() + + @Test + fun takeScreenshots() = run { + step("Take screenshots initial state") { + + } + } +} + +``` + +Для того чтобы сделать скриншоты, и чтобы эти скриншоты были сохранены в правильные папки на устройстве, необходимо вызвать метод `captureScreenshot`. В качестве параметра методу необходимо передать название файла, это может быть любая строка – по этому имени вы сможете найти скриншот на устройстве. + +```kotlin +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() + + @Test + fun takeScreenshots() = run { + step("Take screenshots initial state") { + captureScreenshot("Initial state") + } + } +} + +``` + +Разрешение на доступ к файлам здесь давать не нужно, это реализовано «под капотом». На данном этапе мы сделали все, что нужно, чтобы получить скриншоты экрана и посмотреть, как выглядит приложение на разных локалях, но желательно сделать еще одно изменение. + +Сейчас у нас открывается нужный экран, и сразу делается скриншот, поэтому есть вероятность, что какие-то данные на экране не успеют загрузиться, и снимок будет сделан до того, как мы увидим нужные нам элементы. + +Чтобы решить эту проблему, давайте в Page Object `Login Screen` мы добавим метод, который дождется загрузки всех необходимых элементов интерфейса. В этом методе мы просто для всех объектов сделаем проверку на `isVisible`. Это проверка в своей реализации использует `flakySafely`, поэтому даже если данные мгновенно загружены не будут, то тест будет ждать, пока условие не выполнится в течение нескольких секунд. + +Добавляем метод, назовем его `waitForScreen`: + +```kotlin +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() { + + 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() + } +} + +``` +В тестовом классе можем вызвать этот метод перед тем, как сделать скриншот: + +```kotlin +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() + + @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]( https://issuetracker.google.com/issues/246092030). Его уже пофиксили, как только опубликуют соответствующий релиз, внесем изменения в Kaspresso. + + +## Итог + +В данном уроке мы рассмотрели: зачем нужны скриншот-тесты, как их писать и где смотреть результаты выполнения тестов. + +Тема screenshot-тестов довольно обширная, и для более комфортного освоения мы ее разбили на несколько частей. Для более углубленного изучения переходите к следующему уроку + + + diff --git a/docs/Tutorial/Screenshot_tests_1.ru.md b/docs/Tutorial/Screenshot_tests_1.ru.md new file mode 100644 index 000000000..c9f4ffcf0 --- /dev/null +++ b/docs/Tutorial/Screenshot_tests_1.ru.md @@ -0,0 +1,221 @@ +# 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]( https://developer.android.com/reference/android/app/Activity) или [Fragment]( https://developer.android.com/guide/fragments), скриншоты которого хотим получить. + +Во-вторых, мы не будем добавлять проверки элементов или выполнять шаги, имитирующие действия пользователя, как мы делали ранее. Наши цели – + +
    +
  1. Открыть экран
  2. +
  3. Установить нужное состояние
  4. +
  5. Сделать скриншот
  6. +
  7. При необходимости изменить состояние и снова сделать скриншот
  8. +
+ +Дальше нужно поменять локаль и повторить все перечисленные действия. + +Подробнее про состояния (или стейты, как их часто называют), и как их правильно устанавливать, мы поговорим в следующем уроке, а сейчас напишем простой screenshot-тест, который откроет экран LoginActivity, сделает скриншот, затем сменит язык на устройстве на французский и снова сделает скриншот. + +## Простой screenshot-тест + +Создание screenshot-теста начинается так же, как мы делали ранее – в папке тестов создаем новый класс. Классы для скриншотов обычно называются с окончанием `Screenshots`. Давайте все скриншот-тесты будем хранить в отдельном пакете, назовем его screenshot_tests. + +В этом пакете создаем класс `LoginActivityScreenshots` + +Creating screenshot test class + +У тестов, которые мы сейчас будем создавать, есть особенности: во-первых, они должны запускаться для разных локалей, во-вторых, полученные скриншоты должны быть размещены в удобной структуре папок – для каждого языка своя папка. По этим причинам тестовый класс мы унаследуем от класса `DocLocScreenshotTestCase`, а не от `TestCase`, как мы это делали ранее + +```kotlin +package com.kaspersky.kaspresso.tutorial.screenshot_tests + +import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase + +class LoginActivityScreenshots : DocLocScreenshotTestCase() { + +} + +``` + +В качестве параметра конструктору нужно передать список локалей, для которых будут делаться скриншоты. В данном случае нас интересует английский и французский языки, устанавливаем их. Делается это следующим образом: + +```kotlin +package com.kaspersky.kaspresso.tutorial.screenshot_tests + +import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase + +class LoginActivityScreenshots : DocLocScreenshotTestCase(locales = "en, fr") { + +} + +``` +Порядок, в котором будут перечислены языки, не имеет значения, тест будет запущен для каждого языка поочерёдно. + +Как мы говорили ранее, здесь мы не будем проходить весь процесс от старта приложения до открытия необходимого экрана. Вместо этого мы сразу создадим `Rule`, в котором укажем, что при старте теста должен быть открыт экран `LoginActivity` + +```kotlin +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() +} + +``` + +В этом классе мы можем использовать такие же методы, какие использовали в других тестах. Давайте создадим один step, в котором проверим только исходное состояние экрана. Назовем метод takeScreenshots() + +```kotlin +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() + + @Test + fun takeScreenshots() = run { + step("Take initial state screenshots") { + + } + } +} + +``` + +Чтобы сделать скриншоты и сохранить их в правильные папки на устройстве, необходимо вызвать метод `captureScreenshot`. В качестве параметра методу необходимо передать название файла, это может быть любая строка – по этому имени вы сможете найти скриншот на устройстве. + +```kotlin +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() + + @Test + fun takeScreenshots() = run { + step("Take initial state screenshots") { + captureScreenshot("Initial state") + } + } +} + +``` + +Разрешение на доступ к файлам здесь давать не нужно, это реализовано «под капотом». На данном этапе мы сделали все необходимое, чтобы получить скриншоты экрана и посмотреть, как выглядит приложение на разных локалях, но желательно сделать еще одно изменение. + +Сейчас у нас открывается нужный экран, и сразу делается скриншот, поэтому есть вероятность, что какие-то данные на экране не успеют загрузиться, и снимок будет сделан до того, как мы увидим нужные нам элементы. + +Чтобы решить эту проблему, перед тем, как делать скриншот, мы дождемся загрузки всех необходимых элементов интерфейса. Для всех объектов `LoginScreen` мы сделаем проверку на `isVisible`. Это проверка в своей реализации использует `flakySafely`, поэтому даже если данные мгновенно загружены не будут, то тест будет ждать, пока условие не выполнится в течение нескольких секунд. + +```kotlin +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() + + @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]( https://issuetracker.google.com/issues/246092030). Его уже пофиксили, как только опубликуют соответствующий релиз, внесем изменения в Kaspresso. + + +## Итог + +В данном уроке мы рассмотрели: зачем нужны скриншот-тесты, как их писать и где смотреть результаты выполнения тестов. + +Тема screenshot-тестов довольно обширная, и для более комфортного освоения мы ее разбили на несколько частей. В следующем уроке мы более подробно разберем тему стейтов, как их правильно устанавливать, и что нужно учитывать при разработке приложения, чтобы его можно было покрыть тестами. + + + diff --git a/docs/Tutorial/Screenshot_tests_2.en.md b/docs/Tutorial/Screenshot_tests_2.en.md new file mode 100644 index 000000000..b319c634b --- /dev/null +++ b/docs/Tutorial/Screenshot_tests_2.en.md @@ -0,0 +1,890 @@ +# Screenshot-тесты. Часть 2. Установка стейтов и работа с ViewModel. + +Если в вашем приложении планируется использование screenshot-тестов, то этот момент нужно учитывать не только при написании тестов, но также при разработке приложения. В сегодняшнем уроке мы поближе познакомимся с установкой стейтов, внесем изменения в код приложения, чтобы его можно было покрыть тестами, и напишем первый скриншот тест, в котором будет работа с ViewModel. + +## Предварительные знания + +Если вы ранее не разрабатывали приложения под Android, то сегодняшний урок может быть сложным для понимания. Поэтому мы настоятельно рекомендуем перед прохождением данного урока ознакомиться со следующими темами: + +1. [Фрагменты](https://developer.android.com/guide/fragments) – что это, и как с ними работать +2. [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel) и шаблон проектирования MVVM +3. [StateFlow](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow) +4. [Библиотека Mockk](https://mockk.io/) + +## Обзор тестируемого приложения + +В приложении, которое мы сегодня будем покрывать тестами, имитируется загрузка данных. При клике на кнопку начинается загрузка, отображается прогресс бар, после окончания загрузки, в зависимости от результата, мы увидим либо загруженные данные, либо сообщение об ошибке. + +Откройте приложение 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` и передаем список языков в качестве параметра конструктору, сделаем скриншоты для английской и французской локалей + +```kotlin +package com.kaspersky.kaspresso.tutorial.screenshot_tests + +import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase + +class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") { + +} + +``` +Как мы говорили ранее – screenshot-тесты должны быть максимально легковесными, чтобы их прохождение занимало как можно меньше времени, поэтому вместо открытия главного экрана и перехода на экран загрузки данных пользователя, мы сразу будем открывать `LoadUserActivity`, создаем соответствующее правило. + +```kotlin +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() +} + +``` + +Для того чтобы получить все состояния экрана мы будем, как и раньше, имитировать действия пользователя – кликать по кнопке и ждать получения результата. Создаем `PageObject` этого экрана. В пакете `com.kaspersky.kaspresso.tutorial.screen` добавляем класс `LoadUserScreen`, тип `Object` + +Create page object + +Наследумся от `KScreen` и добавляем все необходимые UI-элементы: кнопка загрузки, ProgressBar, TextView с именем пользователя и TextView с текстом ошибки + + +```kotlin +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() { + + 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` + +```kotlin +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() + + @Test + fun takeScreenshots() { + + } +} + +``` + +Первым делом давайте дождемся, пока кнопка отобразится на экране и сделаем первый скриншот исходного состояния экрана + +```kotlin +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() + + @Test + fun takeScreenshots() { + LoadUserScreen { + loadingButton.isVisible() + captureScreenshot("Initial state") + } + } +} + +``` +Далее необходимо кликнуть по кнопке и сохранить снимок экрана в состоянии загрузки + +```kotlin +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() + + @Test + fun takeScreenshots() { + LoadUserScreen { + loadingButton.isVisible() + captureScreenshot("Initial state") + loadingButton.click() + progressBarLoading.isVisible() + captureScreenshot("Progress state") + } + } +} + +``` + +Следующий этап – отображение данных о пользователе (стейт Content) + +```kotlin +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() + + @Test + fun takeScreenshots() { + LoadUserScreen { + loadingButton.isVisible() + captureScreenshot("Initial state") + loadingButton.click() + progressBarLoading.isVisible() + captureScreenshot("Progress state") + username.isVisible() + captureScreenshot("Content state") + } + } +} + +``` +Теперь нам нужно получить состояние ошибки. В реальных приложениях можно было бы, например, выключить интернет на устройстве и выполнить запрос. В текущей реализации приложения мы лишь имитируем работу с интернетом, и для получения ошибки можно еще дважды попробовать загрузить данные пользователя. Имейте в виду, что это временная реализация, позже мы ее исправим. + +```kotlin +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() + + @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 + +```kotlin +binding.loadingButton.setOnClickListener { + viewModel.loadUser() +} +``` + +Логика загрузки реализована внутри ViewModel. Откройте класс `LoadUserViewModel` из пакета `com.kaspersky.kaspresso.tutorial.user`. + +При вызове этого метода ViewModel меняет стейт экрана: при старте загрузки устанавливает стейт Progress, после окончания загрузки в зависимости от результата она устанавливает стейт Error или Content. + +```kotlin +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` + +```kotlin +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, у которого будем устанавливать стейт + +```kotlin +class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") { + + val viewModel = LoadUserViewModel() + +… +} + +``` +Теперь в эту ViewModel внутри тестового метода мы будем устанавливать новый стейт. Давайте попробуем установить какое-то новое значение в переменную `state`. + +!!! info +Далее мы будем работать с объектами StateFlow и MutableStateFlow, если вы не знаете, что это, и как с ними работать, обязательно прочитайте [документацию]( https://developer.android.com/kotlin/flow/stateflow-and-sharedflow) + +```kotlin +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() + + @Test + fun takeScreenshots() { + LoadUserScreen { + viewModel.state.value = State.Initial + … + } + } +} + +``` +У нас возникает ошибка. Дело в том, что переменная `state` внутри ViewModel имеет тип `StateFlow`, который является неизменяемым. То есть установить новый стейт в этот объект не получится. Если вы посмотрите в код `LoadUserViewModel`, то увидите, что все новые значения стейта устанавливаются в переменную с нижним подчеркиванием `_state`, у которой тип `MutableStateFlow` + +```kotlin +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, который будет изменяемый, и в который мы сможем устанавливать любые значения. + +```kotlin +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.Initial) + val viewModel = LoadUserViewModel() + + @get:Rule + val activityRule = activityScenarioRule() + + @Test + fun takeScreenshots() { + LoadUserScreen { + _state.value = State.Initial + … + } + } +} + +``` +Теперь нужно сделать так, чтобы в тот момент, когда фрагмент подписывается на `viewModel.state` вместо настоящей реализации подставлялся наш только что созданный объект. Сделать это можно при помощи библиотеки Mockk. Если вы не работали с этой библиотекой ранее, советуем почитать [официальную документацию]( https://mockk.io) +Для использования данной библиотеки необходимо добавить зависимости в файл `build.gradle` + +```kotlin + +androidTestImplementation("io.mockk:mockk-android:1.13.3") +``` + +!!! info +Если после синхронизации и запуска проекта у вас возникают ошибки, следуйте инструкциям в журнале ошибок. В случае, если разобраться не получилось, переключитесь на ветку `TECH-tutorial-results` и сверьте файл `build.gradle` из этой ветки с вашим + +Теперь внутренняя реализация ViewModel нас не интересует. Все, что нам нужно – чтобы фрагмент подписывался на `state` из ViewModel, а ему возвращался тот объект, который мы создали внутри тестового класса. Делается это следующим образом: + +```kotlin +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.Initial) + val viewModel = mockk(relaxed = true){ + every { state } returns _state + } + + … +} + +``` + +То, что мы сделали, называется мокированием. Мы «замокали» ViewModel таким образом, что если кто-то будет работать с ней и обратится к ее полю `state`, то ему вернется созданный нами объект `_state`. Настоящая реализация `LoadUserViewModel` в тестах использоваться не будет. + +Теперь нам не нужно имитировать действия пользователя для получения различных состояний экрана. Вместо этого, мы будем устанавливать стейт в переменную `_state` и затем делать скриншот. + +```kotlin +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.Initial) + val viewModel = mockk(relaxed = true) { + every { state } returns _state + } + + @get:Rule + val activityRule = activityScenarioRule() + + @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` + +```kotlin +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]( https://developer.android.com/guide/navigation/migrate#move). При таком подходе все экраны создаются на фрагментах, а активити служит лишь контейнером для них. Если вы хотите узнать больше о преимуществах этого подхода, то мы советуем почитать документацию. Что нужно понимать сейчас – внешний вид экрана и взаимодействие с ViewModel реализовано внутри `LoadUserFragment`, а `LoadUserActivity` представляет собой контейнер, в который мы этот фрагмент установили. Следовательно, изменения нужно делать именно внутри фрагмента. + +Открываем `LoadUserFragment` + +```kotlin +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` + +```kotlin +companion object { + + fun newInstance(): LoadUserFragment = LoadUserFragment() +} + +``` +В этом методе мы просто создаем объект `LoadUserFragment`. Давайте добавим еще один метод, который будет принимать в качестве параметра замоканную вьюмодель и устанавливать это значение в поле фрагмента. Этот метод мы будем использовать в тестах, поэтому давайте назовем его `newTestInstance` + +```kotlin +companion object { + + fun newInstance(): LoadUserFragment = LoadUserFragment() + + fun newTestInstance( + mockedViewModel: LoadUserViewModel + ): LoadUserFragment = LoadUserFragment().apply { + viewModel = mockedViewModel + } +} + +``` +Теперь для создания фрагмента в активити мы будем вызывать метод `newInstance`, что мы сейчас и делаем + +```kotlin +if (savedInstanceState == null) { + supportFragmentManager.beginTransaction() + .replace(R.id.fragment_container, LoadUserFragment.newInstance()) + .commit() +} + +``` +А для создания фрагмента внутри скриншот-тестов будем вызывать метод `newTestInstance`. + +На данном этапе в методе `onViewCreated` мы присваиваем значение переменной `viewModel` независимо от того, используется этот фрагмент для скриншот-тестов или нет. Давайте это исправим, добавим поле `isForScreenshots` типа `Boolean`, по умолчанию установим значение `false`, а в методе `newTestInstance` установим значение `true`. + +```kotlin +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` + +```kotlin +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()` приведет к падению теста, так как у нее данный метод не реализован. Поэтому вызов любых методов вьюмодели мы также будем выполнять только в том случае, если этот фрагмент используется не для скриншот-тестов: + +```kotlin +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` из вьюмодели + +```kotlin +val _state = MutableStateFlow(State.Initial) +val viewModel = mockk(relaxed = true) { + every { state } returns _state +} + +``` +Поэтому, когда мы обратимся к полю `viewModel.state` из фрагмента в методе `observeViewModel` + +```kotlin +viewModel.state.collect { state -> + when (state) { + is State.Content -> { + … + +``` +то ошибки не будет, вместо настоящей реализации будет использовано значение из переменной `_state`, созданной внутри теста. + +## Тестирование фрагментов + +Теперь, для того чтобы написать скриншот тест, нам нужно внутри этого теста создать экземпляр фрагмента, передав ему замоканную вьюмодель в качестве параметра. Но если вы посмотрите на текущий код, то увидите, что мы вообще не создаем здесь никаких фрагментов + +```kotlin +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.Initial) + val viewModel = mockk(relaxed = true) { + every { state } returns _state + } + + @get:Rule + val activityRule = activityScenarioRule() + + @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 + +```kotlin +debugImplementation("androidx.fragment:fragment-testing-manifest:1.6.0"){ + isTransitive = false +} +androidTestImplementation("androidx.fragment:fragment-testing:1.6.0") + +``` + +После синхронизации проекта открываем класс `LoadUserScreenshots` и удаляем из него `activityRule`, запускать активити нам больше не нужно. + +Для того чтобы запустить фрагмент, необходимо вызвать метод `launchFragmentInContainer` и в фигурных скобках создать фрагмент, который нужно отобразить +```kotlin +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.Initial) + val viewModel = mockk(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` можно следующим образом: + +```kotlin +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.Initial) + val viewModel = mockk(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-тестами. diff --git a/docs/Tutorial/Screenshot_tests_2.ru.md b/docs/Tutorial/Screenshot_tests_2.ru.md new file mode 100644 index 000000000..a5f96da53 --- /dev/null +++ b/docs/Tutorial/Screenshot_tests_2.ru.md @@ -0,0 +1,891 @@ +# Screenshot-тесты. Часть 2. Установка стейтов и работа с ViewModel. + +Если в вашем приложении планируется использование screenshot-тестов, то этот момент нужно учитывать не только при написании тестов, но также при разработке приложения. В сегодняшнем уроке мы поближе познакомимся с установкой стейтов, внесем изменения в код приложения, чтобы его можно было покрыть тестами, и напишем первый скриншот тест, в котором будет работа с ViewModel. + +## Предварительные знания + +Если вы ранее не разрабатывали приложения под Android, то сегодняшний урок может быть сложным для понимания. Поэтому мы настоятельно рекомендуем перед прохождением данного урока ознакомиться со следующими темами: + +1. [Фрагменты](https://developer.android.com/guide/fragments) – что это, и как с ними работать +2. [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel) и шаблон проектирования MVVM +3. [StateFlow](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow) +4. [Библиотека Mockk](https://mockk.io/) +5. [Kotlin coroutines](https://kotlinlang.org/docs/coroutines-overview.html) + +## Обзор тестируемого приложения + +В приложении, которое мы сегодня будем покрывать тестами, имитируется загрузка данных. При клике на кнопку начинается загрузка, отображается прогресс бар, после окончания загрузки, в зависимости от результата, мы увидим либо загруженные данные, либо сообщение об ошибке. + +Откройте приложение 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` и передаем список языков в качестве параметра конструктору, сделаем скриншоты для английской и французской локалей + +```kotlin +package com.kaspersky.kaspresso.tutorial.screenshot_tests + +import com.kaspersky.kaspresso.testcases.api.testcase.DocLocScreenshotTestCase + +class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") { + +} + +``` +Как мы говорили ранее – screenshot-тесты должны быть максимально легковесными, чтобы их прохождение занимало как можно меньше времени, поэтому вместо открытия главного экрана и перехода на экран загрузки данных пользователя, мы сразу будем открывать `LoadUserActivity`, создаем соответствующее правило. + +```kotlin +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() +} + +``` + +Для того чтобы получить все состояния экрана мы будем, как и раньше, имитировать действия пользователя – кликать по кнопке и ждать получения результата. Создаем `PageObject` этого экрана. В пакете `com.kaspersky.kaspresso.tutorial.screen` добавляем класс `LoadUserScreen`, тип `Object` + +Create page object + +Наследумся от `KScreen` и добавляем все необходимые UI-элементы: кнопка загрузки, ProgressBar, TextView с именем пользователя и TextView с текстом ошибки + + +```kotlin +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() { + + 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` + +```kotlin +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() + + @Test + fun takeScreenshots() { + + } +} + +``` + +Первым делом давайте дождемся, пока кнопка отобразится на экране и сделаем первый скриншот исходного состояния экрана + +```kotlin +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() + + @Test + fun takeScreenshots() { + LoadUserScreen { + loadingButton.isVisible() + captureScreenshot("Initial state") + } + } +} + +``` +Далее необходимо кликнуть по кнопке и сохранить снимок экрана в состоянии загрузки + +```kotlin +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() + + @Test + fun takeScreenshots() { + LoadUserScreen { + loadingButton.isVisible() + captureScreenshot("Initial state") + loadingButton.click() + progressBarLoading.isVisible() + captureScreenshot("Progress state") + } + } +} + +``` + +Следующий этап – отображение данных о пользователе (стейт Content) + +```kotlin +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() + + @Test + fun takeScreenshots() { + LoadUserScreen { + loadingButton.isVisible() + captureScreenshot("Initial state") + loadingButton.click() + progressBarLoading.isVisible() + captureScreenshot("Progress state") + username.isVisible() + captureScreenshot("Content state") + } + } +} + +``` +Теперь нам нужно получить состояние ошибки. В реальных приложениях можно было бы, например, выключить интернет на устройстве и выполнить запрос. В текущей реализации приложения мы лишь имитируем работу с интернетом, и для получения ошибки можно еще дважды попробовать загрузить данные пользователя. Имейте в виду, что это временная реализация, позже мы ее исправим. + +```kotlin +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() + + @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 + +```kotlin +binding.loadingButton.setOnClickListener { + viewModel.loadUser() +} +``` + +Логика загрузки реализована внутри ViewModel. Откройте класс `LoadUserViewModel` из пакета `com.kaspersky.kaspresso.tutorial.user`. + +При вызове этого метода ViewModel меняет стейт экрана: при старте загрузки устанавливает стейт Progress, после окончания загрузки в зависимости от результата она устанавливает стейт Error или Content. + +```kotlin +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` + +```kotlin +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, у которого будем устанавливать стейт + +```kotlin +class LoadUserScreenshots : DocLocScreenshotTestCase(locales = "en, fr") { + + val viewModel = LoadUserViewModel() + +… +} + +``` +Теперь в эту ViewModel внутри тестового метода мы будем устанавливать новый стейт. Давайте попробуем установить какое-то новое значение в переменную `state`. + +!!! info + Далее мы будем работать с объектами StateFlow и MutableStateFlow, если вы не знаете, что это, и как с ними работать, обязательно прочитайте [документацию]( https://developer.android.com/kotlin/flow/stateflow-and-sharedflow) + +```kotlin +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() + + @Test + fun takeScreenshots() { + LoadUserScreen { + viewModel.state.value = State.Initial + … + } + } +} + +``` +У нас возникает ошибка. Дело в том, что переменная `state` внутри ViewModel имеет тип `StateFlow`, который является неизменяемым. То есть установить новый стейт в этот объект не получится. Если вы посмотрите в код `LoadUserViewModel`, то увидите, что все новые значения стейта устанавливаются в переменную с нижним подчеркиванием `_state`, у которой тип `MutableStateFlow` + +```kotlin +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, который будет изменяемый, и в который мы сможем устанавливать любые значения. + +```kotlin +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.Initial) + val viewModel = LoadUserViewModel() + + @get:Rule + val activityRule = activityScenarioRule() + + @Test + fun takeScreenshots() { + LoadUserScreen { + _state.value = State.Initial + … + } + } +} + +``` +Теперь нужно сделать так, чтобы в тот момент, когда фрагмент подписывается на `viewModel.state` вместо настоящей реализации подставлялся наш только что созданный объект. Сделать это можно при помощи библиотеки Mockk. Если вы не работали с этой библиотекой ранее, советуем почитать [официальную документацию]( https://mockk.io) +Для использования данной библиотеки необходимо добавить зависимости в файл `build.gradle` + +```kotlin + +androidTestImplementation("io.mockk:mockk-android:1.13.3") +``` + +!!! info + Если после синхронизации и запуска проекта у вас возникают ошибки, следуйте инструкциям в журнале ошибок. В случае, если разобраться не получилось, переключитесь на ветку `TECH-tutorial-results` и сверьте файл `build.gradle` из этой ветки с вашим + +Теперь внутренняя реализация ViewModel нас не интересует. Все, что нам нужно – чтобы фрагмент подписывался на `state` из ViewModel, а ему возвращался тот объект, который мы создали внутри тестового класса. Делается это следующим образом: + +```kotlin +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.Initial) + val viewModel = mockk(relaxed = true){ + every { state } returns _state + } + + … +} + +``` + +То, что мы сделали, называется мокированием. Мы «замокали» ViewModel таким образом, что если кто-то будет работать с ней и обратится к ее полю `state`, то ему вернется созданный нами объект `_state`. Настоящая реализация `LoadUserViewModel` в тестах использоваться не будет. + +Теперь нам не нужно имитировать действия пользователя для получения различных состояний экрана. Вместо этого, мы будем устанавливать стейт в переменную `_state` и затем делать скриншот. + +```kotlin +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.Initial) + val viewModel = mockk(relaxed = true) { + every { state } returns _state + } + + @get:Rule + val activityRule = activityScenarioRule() + + @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` + +```kotlin +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]( https://developer.android.com/guide/navigation/migrate#move). При таком подходе все экраны создаются на фрагментах, а активити служит лишь контейнером для них. Если вы хотите узнать больше о преимуществах этого подхода, то мы советуем почитать документацию. Что нужно понимать сейчас – внешний вид экрана и взаимодействие с ViewModel реализовано внутри `LoadUserFragment`, а `LoadUserActivity` представляет собой контейнер, в который мы этот фрагмент установили. Следовательно, изменения нужно делать именно внутри фрагмента. + +Открываем `LoadUserFragment` + +```kotlin +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` + +```kotlin +companion object { + + fun newInstance(): LoadUserFragment = LoadUserFragment() +} + +``` +В этом методе мы просто создаем объект `LoadUserFragment`. Давайте добавим еще один метод, который будет принимать в качестве параметра замоканную вьюмодель и устанавливать это значение в поле фрагмента. Этот метод мы будем использовать в тестах, поэтому давайте назовем его `newTestInstance` + +```kotlin +companion object { + + fun newInstance(): LoadUserFragment = LoadUserFragment() + + fun newTestInstance( + mockedViewModel: LoadUserViewModel + ): LoadUserFragment = LoadUserFragment().apply { + viewModel = mockedViewModel + } +} + +``` +Теперь для создания фрагмента в активити мы будем вызывать метод `newInstance`, что мы сейчас и делаем + +```kotlin +if (savedInstanceState == null) { + supportFragmentManager.beginTransaction() + .replace(R.id.fragment_container, LoadUserFragment.newInstance()) + .commit() +} + +``` +А для создания фрагмента внутри скриншот-тестов будем вызывать метод `newTestInstance`. + +На данном этапе в методе `onViewCreated` мы присваиваем значение переменной `viewModel` независимо от того, используется этот фрагмент для скриншот-тестов или нет. Давайте это исправим, добавим поле `isForScreenshots` типа `Boolean`, по умолчанию установим значение `false`, а в методе `newTestInstance` установим значение `true`. + +```kotlin +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` + +```kotlin +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()` приведет к падению теста, так как у нее данный метод не реализован. Поэтому вызов любых методов вьюмодели мы также будем выполнять только в том случае, если этот фрагмент используется не для скриншот-тестов: + +```kotlin +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` из вьюмодели + +```kotlin +val _state = MutableStateFlow(State.Initial) +val viewModel = mockk(relaxed = true) { + every { state } returns _state +} + +``` +Поэтому, когда мы обратимся к полю `viewModel.state` из фрагмента в методе `observeViewModel` + +```kotlin +viewModel.state.collect { state -> + when (state) { + is State.Content -> { + … + +``` +то ошибки не будет, вместо настоящей реализации будет использовано значение из переменной `_state`, созданной внутри теста. + +## Тестирование фрагментов + +Теперь, для того чтобы написать скриншот тест, нам нужно внутри этого теста создать экземпляр фрагмента, передав ему замоканную вьюмодель в качестве параметра. Но если вы посмотрите на текущий код, то увидите, что мы вообще не создаем здесь никаких фрагментов + +```kotlin +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.Initial) + val viewModel = mockk(relaxed = true) { + every { state } returns _state + } + + @get:Rule + val activityRule = activityScenarioRule() + + @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 + +```kotlin +debugImplementation("androidx.fragment:fragment-testing-manifest:1.6.0"){ + isTransitive = false +} +androidTestImplementation("androidx.fragment:fragment-testing:1.6.0") + +``` + +После синхронизации проекта открываем класс `LoadUserScreenshots` и удаляем из него `activityRule`, запускать активити нам больше не нужно. + +Для того чтобы запустить фрагмент, необходимо вызвать метод `launchFragmentInContainer` и в фигурных скобках создать фрагмент, который нужно отобразить +```kotlin +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.Initial) + val viewModel = mockk(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` можно следующим образом: + +```kotlin +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.Initial) + val viewModel = mockk(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-тестами. diff --git a/docs/Tutorial/images/logs/custom_log.png b/docs/Tutorial/images/logs/custom_log.png index db8009d8d..e1884366b 100644 Binary files a/docs/Tutorial/images/logs/custom_log.png and b/docs/Tutorial/images/logs/custom_log.png differ diff --git a/docs/Tutorial/images/logs/custom_log_test.png b/docs/Tutorial/images/logs/custom_log_test.png new file mode 100644 index 000000000..b39961d40 Binary files /dev/null and b/docs/Tutorial/images/logs/custom_log_test.png differ diff --git a/docs/Tutorial/images/screenshot_tests_1/Initial_state_en.png b/docs/Tutorial/images/screenshot_tests_1/Initial_state_en.png new file mode 100644 index 000000000..040e39892 Binary files /dev/null and b/docs/Tutorial/images/screenshot_tests_1/Initial_state_en.png differ diff --git a/docs/Tutorial/images/screenshot_tests_1/Initial_state_fr.png b/docs/Tutorial/images/screenshot_tests_1/Initial_state_fr.png new file mode 100644 index 000000000..f8dbbca32 Binary files /dev/null and b/docs/Tutorial/images/screenshot_tests_1/Initial_state_fr.png differ diff --git a/docs/Tutorial/images/screenshot_tests_1/create_screenshot_test.png b/docs/Tutorial/images/screenshot_tests_1/create_screenshot_test.png new file mode 100644 index 000000000..b70bb2510 Binary files /dev/null and b/docs/Tutorial/images/screenshot_tests_1/create_screenshot_test.png differ diff --git a/docs/Tutorial/images/screenshot_tests_1/fr_locale.png b/docs/Tutorial/images/screenshot_tests_1/fr_locale.png new file mode 100644 index 000000000..59fcd3b91 Binary files /dev/null and b/docs/Tutorial/images/screenshot_tests_1/fr_locale.png differ diff --git a/docs/Tutorial/images/screenshot_tests_1/french.png b/docs/Tutorial/images/screenshot_tests_1/french.png new file mode 100644 index 000000000..965c22eed Binary files /dev/null and b/docs/Tutorial/images/screenshot_tests_1/french.png differ diff --git a/docs/Tutorial/images/screenshot_tests_1/initial_en.png b/docs/Tutorial/images/screenshot_tests_1/initial_en.png new file mode 100644 index 000000000..040e39892 Binary files /dev/null and b/docs/Tutorial/images/screenshot_tests_1/initial_en.png differ diff --git a/docs/Tutorial/images/screenshot_tests_1/initial_fr.png b/docs/Tutorial/images/screenshot_tests_1/initial_fr.png new file mode 100644 index 000000000..f8dbbca32 Binary files /dev/null and b/docs/Tutorial/images/screenshot_tests_1/initial_fr.png differ diff --git a/docs/Tutorial/images/screenshot_tests_1/screenshot_test.png b/docs/Tutorial/images/screenshot_tests_1/screenshot_test.png new file mode 100644 index 000000000..fafde0c64 Binary files /dev/null and b/docs/Tutorial/images/screenshot_tests_1/screenshot_test.png differ diff --git a/docs/Tutorial/images/screenshot_tests_1/success_tests.png b/docs/Tutorial/images/screenshot_tests_1/success_tests.png new file mode 100644 index 000000000..f9956a3bc Binary files /dev/null and b/docs/Tutorial/images/screenshot_tests_1/success_tests.png differ diff --git a/docs/Tutorial/images/screenshot_tests_1/todo_on_screen.png b/docs/Tutorial/images/screenshot_tests_1/todo_on_screen.png new file mode 100644 index 000000000..9d30def3f Binary files /dev/null and b/docs/Tutorial/images/screenshot_tests_1/todo_on_screen.png differ diff --git a/docs/Tutorial/images/screenshot_tests_2/create_class.png b/docs/Tutorial/images/screenshot_tests_2/create_class.png new file mode 100644 index 000000000..b6eebf742 Binary files /dev/null and b/docs/Tutorial/images/screenshot_tests_2/create_class.png differ diff --git a/docs/Tutorial/images/screenshot_tests_2/example_1.png b/docs/Tutorial/images/screenshot_tests_2/example_1.png new file mode 100644 index 000000000..ab5f4aa77 Binary files /dev/null and b/docs/Tutorial/images/screenshot_tests_2/example_1.png differ diff --git a/docs/Tutorial/images/screenshot_tests_2/example_2.png b/docs/Tutorial/images/screenshot_tests_2/example_2.png new file mode 100644 index 000000000..b26032132 Binary files /dev/null and b/docs/Tutorial/images/screenshot_tests_2/example_2.png differ diff --git a/docs/Tutorial/images/screenshot_tests_2/example_3.png b/docs/Tutorial/images/screenshot_tests_2/example_3.png new file mode 100644 index 000000000..0bf3ba1d1 Binary files /dev/null and b/docs/Tutorial/images/screenshot_tests_2/example_3.png differ diff --git a/docs/Tutorial/images/screenshot_tests_2/example_4.png b/docs/Tutorial/images/screenshot_tests_2/example_4.png new file mode 100644 index 000000000..1dbf30df4 Binary files /dev/null and b/docs/Tutorial/images/screenshot_tests_2/example_4.png differ diff --git a/docs/Tutorial/images/screenshot_tests_2/example_5.png b/docs/Tutorial/images/screenshot_tests_2/example_5.png new file mode 100644 index 000000000..46106b5e0 Binary files /dev/null and b/docs/Tutorial/images/screenshot_tests_2/example_5.png differ diff --git a/docs/Tutorial/images/screenshot_tests_2/page_object.png b/docs/Tutorial/images/screenshot_tests_2/page_object.png new file mode 100644 index 000000000..631c30b95 Binary files /dev/null and b/docs/Tutorial/images/screenshot_tests_2/page_object.png differ diff --git a/docs/Tutorial/images/screenshot_tests_2/style.png b/docs/Tutorial/images/screenshot_tests_2/style.png new file mode 100644 index 000000000..f252d4ff6 Binary files /dev/null and b/docs/Tutorial/images/screenshot_tests_2/style.png differ diff --git a/mkdocs.yml b/mkdocs.yml index ff03eac60..3463f87c6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -88,6 +88,8 @@ plugins: 10. Работа с Android permissions: 10. Working with Android permissions 11. RecyclerView: 11. RecyclerView. Testing list of elements 12. Логирование и скриншоты: 12. Logger and screenshots + 13. Screenshot-тесты. Часть 1. Простой screenshot тест: 13. Screenshot-tests. Part 1. Simple screenshot-test + 14. Screenshot-тесты. Часть 2. Установка стейтов и работа с ViewModel: 14. Screenshot-tests. Part 2. Working with ViewModel and setting states Wiki: Wiki Introduction: Introduction PageObject in Kaspresso: PageObject in Kaspresso @@ -126,6 +128,8 @@ plugins: 10. Работа с Android permissions: 10. Работа с Android permissions 11. RecyclerView: 11. RecyclerView 12. Логирование и скриншоты: 12. Логирование и скриншоты + 13. Screenshot-тесты. Часть 1. Простой screenshot тест: 13. Screenshot-тесты. Часть 1. Простой screenshot тест + 14. Screenshot-тесты. Часть 2. Установка стейтов и работа с ViewModel: 14. Screenshot-тесты. Часть 2. Установка стейтов и работа с ViewModel Wiki: Вики Introduction: Введение PageObject in Kaspresso: Паттерн PageObject в Kaspresso @@ -163,7 +167,9 @@ nav: - 9. flakySafely: Tutorial/FlakySafely.md - 10. Работа с Android permissions: Tutorial/Android_permissions.md - 11. RecyclerView: Tutorial/Recyclerview.md - - 12. Логирование и скриншоты: Tutorial/Logger_and_screenshots.md + - 12. Логирование и скриншоты: Tutorial/Logger_and_screenshot.md + - 13. Screenshot-тесты. Часть 1. Простой screenshot тест: Tutorial/Screenshot_tests_1.md + - 14. Screenshot-тесты. Часть 2. Установка стейтов и работа с ViewModel: Tutorial/Screenshot_tests_2.md - Wiki: - Introduction: Wiki/index.md - PageObject in Kaspresso: Wiki/Page_object_in_Kaspresso.md