Русский
Русский
English
Статистика
Реклама

Unit-testing

Прокачиваем Android проект с GitHub Actions. Часть 2

04.12.2020 10:17:32 | Автор: admin

Запуск UI-тестов на GitHub Actions

Продолжаем разбираться с автоматизацией Android проекта на GitHub Actions, в этой части:

  • Заведем новый проект под UI-тесты в Firebase Test Lab

  • Настроим интеграцию GitHub Actions и Test Lab

  • Посмотрим, как можно запускать UI-тесты в workflow на CI/CD.

Если пропустили первую часть рассказа, где разбирались с Unit-тестами в Android проекте, можно начать с нее.

Чтобы запустить unit-тесты, нам достаточно иметь настроенное Java-окружение. Все тесты проходят очень быстро внутри JVM, всё просто, и почти исключена ситуация появления flacky-тестов. Таких тестов должно быть 70-80% от общего количества тестов в проекте, в первую очередь стоит покрывать ими бизнес-логику.

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

С UI-тестами всё непросто с самого начала. Во-первых, их нужно написать. Звучит банально, но уже на этом этапе у большинства заканчивается энтузиазм. Потом, когда критическая масса UI-тестов написана, нужно придумать, как это богатство запускать и поддерживать в рабочем состоянии в условиях постоянных А/Б-тестов и частых изменений интерфейса. Нужно решать, где они будут запускаться - реальные устройства или эмуляторы и кто будет владеть этими устройствами - своя ферма телефонов или пользоваться услугами сторонних сервисов. В общем, тема не из лёгких.

Мы пойдем по самому простому и удобному пути - Firebase Test Lab

Firebase Test Lab - это сервис от Google, предоставляющий возможность запускать тесты на реальных устройствах или эмуляторах. На момент написания поста бесплатный тариф предлагает 10 тестов в день на эмуляторах и 5 на реальных устройствах. В платном тарифе цена сейчас 1$ за телефоно-час эмулятора, можно и заплатить за такое удобство.

Весь процесс запуска тестов в Test Lab можно описать следующими шагами:

  1. Делаем checkoutна нужный коммит и устанавливаем Java-окружение

  2. Проводим unit-тестирование. Если на этом шаге ошибка, то нет смысла тратить время на UI-тестирование.

  3. Собираем специальными Gradle-тасками артефакты для UI-тестирования

  4. Выкачиваем APK-артефакты, которые собираемся отправить на тестирование.

  5. Авторизуемся в Firebase Test Lab с помощью персонального токена.

  6. Используя специальную command line утилиту gcloud, скармливаемв Test Lab собранные ранее APK.

  7. Ждём, когда тесты пройдут, и смотрим результаты в workflow GitHub Actions.

Но для начала заведём на Firebase проект под приложение и сгенерируем себе токен для доступа к нему из GitHub.

Заходим наhttps://console.firebase.google.comи авторизуемся под своим Google-аккаунтом.

Далее следуем по понятному визарду, нажимаем Создать проект.

Указываем название

Дальше отключаем Google-аналитику или оставляем всё как есть, на Test Lab это никак не повлияет. Если вам нужна аналитика, оставьте и на следующем шаге примите условия пользовательского соглашения.

Когда проект создастся, приступаем к генерации токена для доступа GitHub Actions к Test Lab.

Идём в Настройки проекта (Project settings), затем на вкладку Сервисные аккаунты (Service accounts). Там выбираем Управление правами доступа для сервисных аккаунтов (Manage service account permissions).

Теперь необходимо добавить сервисный аккаунт с теми правами, которые мы планируем использовать на CI/CD в GitHub Actions. Для запуска UI-тестов достаточно прав типа Редактор. Подробнее тут.

Заполняем предлагаемые поля

Выбираем тип Редактор. Если неправильно настроить права доступа для сервисного аккаунта, то на шаге авторизации в Firebase мы получим ошибку 403.

ERROR: (gcloud.firebase.test.android.run) Unable to access the test environment catalog: ResponseError 403: Not authorized for project ***

На третьем шаге можно просто нажать кнопку Готово

Мы только что добавили сервисный аккаунт для CI/CD и теперь готовы получить токен. Выбираем Создать ключ (Create key).

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

Фокус в том, что в таком виде JSON у нас не получится использовать. Придётся закодировать его через Base64.

Пути два:

1) В консоли вводим

base64 github-actions-sample-key.json > base64-key.txt

Где github-actions-sample-key.json - это название скачанного на предыдущем шаге JSON, а base64-key файл, в который будет записан результат кодирования.

2) Делаем всё наhttps://www.base64encode.org/

Возвращается в GitHub проект и записываем результат в Secrets в проект на GitHub.

После добавления ключа в секреты обязательно удалите или перенесите в надёжное место ключ с Firebase и base64-key.

Теперь необходимо добавить в секреты Project ID с экрана общих настроек проекта в Firebase. Не перепутайте с Project number.

Отлично, всё готово к интеграции GitHub Actions и Test Lab. Создаём новый workflow в директории giithub/workflows.

Если прямо сейчас запустить workflow, то на прогонах UI-тестов в Test Lab мы получим ошибку.

ERROR: (gcloud.firebase.test.android.run) User [github-actions-ci-cd@***.iam.gserviceaccount.com] does not have permission to access project [***:initializeSettings] (or it may not exist): Cloud Tool Results API has not been used in project 254361894337 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/toolresults.googleapis.com/overview?project=254361894337 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.

Вообще-то можно пройти по ссылке из ошибки и включить APItoolresults.googleapis.com, но сейчас посмотрим, как можно управлять вообще любыми API в своём проекте. Нажимаем Enable APIs and services.

Тут можно управлять API в проекте и смотреть статистику использования. Находим Cloud Tool Result API и включаем.

Ну теперь-то уж точно всё, пора запускать workflow.

name: UI_tests_on_release on:  pull_request:    branches:      - 'main' jobs:  assemble_ui_test_artifacts:    if: startsWith(github.head_ref, 'release/') == true    name: Build artifacts    runs-on: ubuntu-20.04    steps:      - uses: actions/checkout@v2      - uses: actions/setup-java@v1        with: {java-version: 1.8}       - name: Build APK for UI test after Unit tests        run: |          ./gradlew test          ./gradlew assembleDebug          ./gradlew assembleDebugAndroidTest       - name: Upload app-debug APK        uses: actions/upload-artifact@v2        with:          name: app-debug          path: app/build/outputs/apk/debug/app-debug.apk       - name: Upload app-debug-androidTest APK        uses: actions/upload-artifact@v2        with:          name: app-debug-androidTest          path: app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk   run_ui_tests_on_firebase:    runs-on: ubuntu-20.04    needs: assemble_ui_test_artifacts    steps:      - uses: actions/checkout@v2      - name: Download app-debug APK        uses: actions/download-artifact@v1        with:          name: app-debug       - name: Download app-debug-androidTest APK        uses: actions/download-artifact@v1        with:          name: app-debug-androidTest       - name: Firebase auth with gcloud        uses: google-github-actions/setup-gcloud@master        with:          version: '290.0.1'          service_account_key: ${{ secrets.FIREBASE_KEY }}          project_id: ${{ secrets.FIREBASE_PROJECT_ID }}       - name: Run Instrumentation Tests in Firebase Test Lab        run: |          gcloud firebase test android models list          gcloud firebase test android run --type instrumentation --use-orchestrator --app app-debug/app-debug.apk --test app-debug-androidTest/app-debug-androidTest.apk --device model=Pixel2,version=28,locale=en,orientation=portrait

Разбираемся, что тут вообще происходит

Шаг 1

name: UI_tests_on_release on:  pull_request:    branches:      - 'main' jobs:  assemble_ui_test_artifacts:    if: startsWith(github.head_ref, 'release/') == true    name: Build artifacts    runs-on: ubuntu-20.04    steps:      - uses: actions/checkout@v2      - uses: actions/setup-java@v1        with: {java-version: 1.8}

Тут точно такие же установки для запуска workflow, что и раньше. Pull request в ветку main из ветки, название которой начинается на release/.

Далее делаем checkout и устанавливаем окружение Java 8.

Шаг 2

- name: Build APK for UI test after Unit tests  run: |    ./gradlew test    ./gradlew assembleDebug    ./gradlew assembleDebugAndroidTest - name: Upload app-debug APK  uses: actions/upload-artifact@v2  with:    name: app-debug    path: app/build/outputs/apk/debug/app-debug.apk - name: Upload app-debug-androidTest APK  uses: actions/upload-artifact@v2  with:    name: app-debug-androidTest    path: app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk

На этом шаге мы прогоняем unit-тесты и после собираем два APK - app-debug.apk и app-debug-androidTest.apk. Почему два? Да просто один APK - это собственно приложение для тестирования, а второй APK содержит instrumentation-тесты, они оба нам понадобятся.

Дальше достаём полученные артефакты по пути и имени в upload-artifact@v2.

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

Шаг 3

run_ui_tests_on_firebase:  runs-on: ubuntu-20.04  needs: assemble_ui_test_artifacts  steps:    - uses: actions/checkout@v2    - name: Download app-debug APK      uses: actions/download-artifact@v1      with:        name: app-debug     - name: Download app-debug-androidTest APK      uses: actions/download-artifact@v1      with:        name: app-debug-androidTest

Вторая Job в тестовом workflow запускается не параллельно с первой (assemble_ui_test_artifacts), а ждёт, пока та успешно завершится.

Это указано в строчке.

needs: assemble_ui_test_artifacts

Дальше воспользуемся готовым action download-artifact@v1 и достанем по имени те два APK которые собирали в прошлой Job.

Шаг 4

Добрались до самого интересного - пора передавать артефакты в Test Lab для тестирования.

- name: Firebase auth with gcloud  uses: google-github-actions/setup-gcloud@master  with:    version: '290.0.1'    service_account_key: ${{ secrets.FIREBASE_KEY }}    project_id: ${{ secrets.FIREBASE_PROJECT_ID }} - name: Run Instrumentation Tests in Firebase Test Lab  run: |    gcloud firebase test android models list    gcloud firebase test android run --type instrumentation --use-orchestrator --app app-debug/app-debug.apk --test app-debug-androidTest/app-debug-androidTest.apk --device model=Pixel2,version=28,locale=en,orientation=portrait

Сначала выполняем action setup-gcloud, передаем в аргументах ID проекта и тот самый Base64 ключ, который хранится в секретах, всё по инструкции.

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

Перваяgcloud firebase test android models listвыведет таблицу из доступных устройств с названиями и версиями SDK. Для тестирования это не требуется, просто удобно посмотреть и выбрать подходящее устройство.

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

Запускаем workflow и смотрим, что получилось.

Отлично, всё работает, самый базовый сценарий запуска UI-тестов реализован! Подробные результаты тестирования можно посмотреть на вкладке Test Lab проекта. Или можно убрать из секретов Project ID (иначе в ссылке будут звездочки вместо ID) и переходить на результаты сразу из логов.

Что ещё можно улучшить в сценарии

Можно включить orchestrator, добавив ключ --use-orchestrator.

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

--num-flaky-test-attempts - для задания количества попыток перезапуска для Flaky тестов.

--network-profile - для задания профиля сети при тестировании. Можно потестировать на медленном соединении, к примеру.

Полный список ключей с описанием тут:

https://cloud.google.com/sdk/gcloud/reference/firebase/test/android/run

Еще можно включить шардинг для запускаемых тестов

https://firebase.google.com/docs/test-lab/android/instrumentation-test#sharding

https://github.com/Flank/flank

Идея для самостоятельного изучения если интересно прямо сейчас что-то посмотреть:

1) Попробовать запустить UI-тесты не в Test Lab а в эмуляторе, поднимаемом на MacOS. Можно посмотретьhttps://github.com/ReactiveCircus/android-emulator-runner

2) Настроить матрицу тестирования для UI-тестов. Запускать тесты не на одной версии Android SDK а на каждой из заданных в условиях.


На этом с запуском UI-тестов из GitHub Actions всё :)

Подробнее..

Быстрый старт гайд по автоматизированному тестированию для Android-разработчика. JVM

14.12.2020 14:08:42 | Автор: admin

Привет! Меня зовут Сергей Иванов, я ведущий разработчик Android в Redmadrobot. С 2016 использую автотесты различных категорий и успел в этом набить немало шишек. Именно поэтому решил поделиться опытом. Возможно, что кому-то статья поможет систематизировать знания или начать применять эту практику в работе.

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

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

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

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

  • базовые понятие автоматизированного тестирования;

  • категории тестов их специфика на Android;

  • как писать тестируемый код;

  • как и какие инструменты использовать для тестирования;

  • как писать полезные и поддерживаемые тесты;

  • что тестировать;

  • как и когда применять методологию Test Driven Development.


При производстве приложений автотесты помогают:

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

  2. Локализовать проблему. Чем более низкоуровневым является тест, тем более точно он способен указать на причину ошибки.

  3. Ускорить разработку. Это вытекает из предыдущих пунктов и из того, что благодаря автотестам разработка разных частей фичи может быть оперативно разделена на несколько разработчиков. Установив контракты между компонентами приложения, разработчик может разработать свой компонент и проверить его корректность при отсутствии остальных (например, при полном отсутствии UI).

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

Но есть и проблемы:

  1. Нужно время на внедрение, написание и поддержку.

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

Важные базовые понятия автоматизированного тестирования

System Under Test (SUT) тестируемая система. В зависимости от типа теста системой могут быть разные сущности (о них подробнее написал в разделе категории тестов).

Для различия уровня тестирования по использованию знаний о SUT существуют понятия:

Black box testing тестирование SUT без знания о деталях его внутреннего устройства.

White box testing тестирование SUT с учётом деталей его внутреннего устройства.

Выделяют также Gray box testing, комбинацию подходов, но ради упрощения он будет опущен.

Для обеспечения базового качества автотестов важно соблюдать некоторые правила написания. Роберт Мартин сформулировал в книге "Clean Code" глобальные принципы F.I.R.S.T.

Fast тесты должны выполняться быстро.

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

Repeatable тесты должны выполняться с одинаковым результатом независимо от среды выполнения.

Self-validating тесты должны однозначно сообщать о том, успешно их прохождение или нет.

Timely тесты должны создаваться своевременно. Unit-тесты пишутся непосредственно перед кодом продукта.

Структура теста состоит как минимум из двух логических блоков:

  • cовершение действия над SUT,

  • проверка результата действия.

Проверка результата заключается в оценке:

  • состояния SUT или выданного ею результата,

  • cостояний взаимодействующих с SUT объектов,

  • поведения (набор и порядок вызовов функций других объектов, которые должен совершить SUT, переданные в них аргументы).

При необходимости также добавляются блоки подготовки и сброса тестового окружения, отчасти связанные с первыми тремя принципам F.I.R.S.T.

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

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

Зачастую для настройки окружения применяются тестовые дублеры.

Test doubles (Тестовые дублёры) фиктивные объекты, заменяющие реальные объекты, от которых зависит SUT, для достижения целей теста.

Тестовые дублеры позволяют:

  • зафиксировать тестовое окружение, имитируя неважные, нереализованные, нестабильные или медленные внешние объекты (например, БД или сервер),

  • совершать проверки своих вызовов (обращений к функциям, свойствам).

Самая популярная классификация включает 5 видов тестовых дублеров, различных по своим свойствам: Dummy, Fake, Stub, Spy, Mock.

Stub объект, который при вызовах его функций или свойств возвращает предустановленные (hardcoded) результаты, а не выполняет код реального объекта. Если же функция не имеет возвращаемого значения, то вызов просто игнорируется.

Mock объект, позволяющий проверять поведение SUT путём отслеживания обращений к функциям и свойствам объекта: были ли в ходе теста вызваны функции мока, в правильном ли порядке, ожидаемые ли аргументы были в них переданы и т.д. Может также включать функциональность Stub.

Почитать об этих и остальных видах дублеров можно в первоисточнике.

Эта классификация не является стандартом, и в фреймворках для создания тестовых дублёров часто ради удобства API несколько типов обобщают термином Mock. А вот чем они на самом деле будут являться, зависит от их последующей конфигурации и применения в тесте. Например, при использовании фреймворка Mockito, экземпляр тестового дублера может быть создан как Dummy, а потом превращен в Stub и в Mock.

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

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

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

Категории тестов

Есть разные версии категоризации тестов, по разным характеристикам, поэтомусуществует некоторая путаница.

Покажу основные категории уровней тестов, на которых тестируется система, на примере одного из самых распространенных вариантов пирамиды тестирования:

Unit-тесты проверяют корректность работы отдельного unit-а (модуля). Unit-ом (то есть SUT данного типа тестирования) может быть класс, функция или совокупность классов.

Integration-тесты (в приложении) проверяют корректность взаимодействия модулей или наборов этих модулей (компонентов). Определение SUT данной категории является еще более расплывчатым, т.к. в свою очередь зависит от того, что считается модулем.

Грань между Unit- и Integration-тестированием довольно тонкая. Интеграционными тестами, в зависимости от масштаба и контекста, в принципе могут называть тесты, проверяющие взаимодействие чего-либо с чем-либо с определенной долей абстракции: приложение(клиент)-сервер, приложение-приложение, приложение-ОС и др. Но в дальнейшем я буду говорить об интеграционном тестировании в рамках приложения.

End-to-end-тесты (E2E) интеграционные тесты, которые воздействуют на приложение и проверяют результат его работы через самый высокоуровневый интерфейс (UI), то есть на уровне пользователя. Использование тестовых дублеров на этом уровне исключено, а значит обязательно используются именно реальные сервер, БД и т.д.

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

Вернёмся к категориям. В Android сложность категоризации автотестов усугубляется еще и тем, что они могут работать на JVM или в Instrumentation-среде (эмулятор или реальное устройство). Последние называют инструментальными.

Чтобы было удобнее ориентироваться в видах тестов, не путаясь в терминологии, предлагаю такую категоризацию для мобильного приложения на Android:

JVM Integration tests интеграционные тесты, проверяющие взаимодействие модулей или совокупностей модулей без использования Instrumentation. Характеризуются они высокой скоростью исполнения, сравнимой с Unit-тестами, также выполняющимися на JVM.

Instrumentation Integration non-UI tests интеграционные тесты, исполняемые уже в реальной Android-среде, но без UI.

Component UI tests интеграционные инструментальные тесты с использованием UI и фиктивных сервера и БД, если таковые требуются. Тест может состоять как из одного экрана, запущенного в изоляции, так и из нескольких экранов с соблюдением их реального флоу.

E2E UI tests интеграционные инструментальные UI-тесты без тестовых дублеров только с реальным флоу экранов. Максимально приближены к ручным тестам.

Если Unit-тесты являются сильно завязанными на детали реализации, очень быстро выполняются, относительно легко пишутся и наиболее точно при поломке указывают на причину ошибки, то в случае E2E UI ситуация противоположная. Изменение этих характеристик происходит постепенно от низа к верху пирамиды.

При переходе от тестов на JVM к тестам на Instrumentation из-за использования настоящей Android-среды происходит резкое падение скорости выполнения этих тестов. Это становится серьезным ограничением. Особенно когда тесты необходимо запускать часто и много раз подряд. Поэтому к написанию инструментальных тестов следует прибегать лишь в случаях, когда использование настоящих Android-зависимостей действительно необходимо.

UI-тесты

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

Часто они оказываются нестабильны в своём поведении и могут то выполняться, то падать, даже если не вносилось никаких изменений в реализацию (нестабильные тесты называют Flaky). Мало того, UI-тесты могут совершенно по-разному себя вести на разных устройствах, эмуляторах и версиях Android. Когда же UI-тесты являются еще и E2E, добавляется хрупкость и снижается скорость выполнения из-за реальных внешних зависимостей. Причем в случае ошибки найти её причину бывает затруднительно, поскольку проверки в таких тестах осуществляются на уровне состояния UI. В таких ситуациях выгоднее обойтись силами QA-инженеров.

Конечно, UI-тесты способны приносить и весьма существенную пользу. Мобильные приложения имеют свойство разрастаться, и в какой-то момент их ручное регрессионное тестирование выходит за адекватные временные рамки. Тогда часть проверок может быть делегирована E2E UI-тестам, что при удачном исполнении может здорово сократить время тестирования.

Поэтому, для написания UI-тестов желательно иметь разработчиков или QA-инженеров-автоматизаторов, которые будут заниматься именно ими бльшую часть времени.

Unit-тесты

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

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

А заменять дублером следует только то, что действительно необходимо для приемлемой изоляции SUT в конкретном случае. Иногда (но далеко не всегда!) бывает оптимальнее сделать переиспользуемый рукописный дублер, чем конфигурировать его фреймворком для создания дублеров в множестве мест.

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

Подытожим

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

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

  • Лучше делать акцент на быстро выполняющиеся тесты. Так, после Unit-тестов рекомендую проверять JVM Integration-тестами интеграцию в том масштабе, который можно комфортно обеспечить без использования Instrumentation от ViewModel до слоя данных.

Дальше я буду говорить преимущественно о тестах на JVM. Но некоторые моменты актуальны и для остальных категорий.

Инструментарий

Раньше для написания JVM-тестов наши разработчики использовали фреймворки Junit 4 и Junit 5, но потом переключились на молодой перспективный Spek 2. Junit 4 нужен для инструментальных тестов с другими фреймворками они не работают.

Для проверок (assert) используем AssertJ отличную библиотеку с богатым набором читабельных ассертов и удобных дополнительных функций.

Для создания тестовых дублеров применяем Mockito-Kotlin 2 Mockito 2, адаптированный для Kotlin.

Для стаббинга и мокирования сервера MockWebServer библиотеку от Square, рассчитанную на работу с OkHttp.

Фреймворки PowerMock и Robolectric не используем из соображений скорости выполнения тестов и их надёжности. Кроме того, эти фреймворки поощряют плохо пахнущий код это дополнительные зависимости, без которых вполне можно обойтись. Для этого код должен быть тестируемым.

Дизайн кода

Признаки нетестируемого кода:

  • Наличие неявных зависимостей, сильная связанность. Это затрудняет изолированное unit-тестирование, тестирование на раннем этапе развития фичи, распараллеливание разработки. Использование статических функций, создание сложных объектов внутри класса, ServiceLocator исключают возможность использования тестовых дублеров.

  • Обилие Android-зависимостей. Они требуют Instrumentation или объемную подготовку среды на JVM с тестовыми дублерами, если их использование вообще возможно (см. прошлый пункт).

  • Наличие явного управления асинхронным и многопоточным поведением. Если результат работы SUT зависит от выполнения асинхронной работы, особенно порученной другому потоку (или нескольким), то не получится просто так гарантировать правильность и стабильность выполнения тестов. Тест может совершить проверки и завершиться раньше, чем асинхронная работа будет выполнена, и результат не будет соответствовать желаемому. При этом принудительное ожидание в тестах (в первую очередь на JVM) плохая практика, поскольку нарушается принцип Fast.

Пример
class ExampleViewModel constructor(val context: Context) : BaseViewModel() {    private lateinit var timer: CountDownTimer    fun onTimeAccepted(seconds: Long) {        val milliseconds = MILLISECONDS.convert(seconds, SECONDS)        // Неявная зависимость, Android-зависимость, запуск асинхронной работы        timer = object : CountDownTimer(milliseconds, 1000L) {            override fun onTick(millisUntilFinished: Long) {                showTimeLeft(millisUntilFinished)            }            override fun onFinish() {                // Неявная зависимость. Вызов статической функции с Android-зависимостью                WorkManager.getInstance(context)                    .cancelUniqueWork(SeriousWorker.NAME)            }        }        timer.start()    }

Как сделать код тестируемым

Следовать принципам SOLID, использовать слоистую архитектуру. Грамотное разделение и реализация сущностей позволит писать изолированные тесты именно на интересующую часть функционала, не допускать чрезмерного разрастания тестового файла и, при необходимости, осуществлять распараллеливание разработки. DI позволит подменять настоящие реализации тестовыми дублёрами.

Стремиться к чистоте функций. Это функции, которые:

  1. При одинаковом наборе входных данных возвращают одинаковый результат.

  2. Не имеют побочных эффектов, т.е. не модифицируют внешние переменные (класса, глобальные) и переданные в качестве входных данных параметры.

Пример теста такой функции:

val result = formatter.toUppercase("адвокат")assertThat(result).isEqualTo("АДВОКАТ")

Минимизировать количество Android-зависимостей. Часто прямое использование Android-зависимостей в SUT не является необходимым. Тогда их следует выносить вовне, оперируя в SUT типами, поддерживающимися на JVM.

Самая распространенная Android-зависимость в потенциально тестируемых классах ресурсы, и их выносить из, скажем, ViewModel, ну, совсем не хочется. В таком случае можно внедрить Resources во ViewModel, чтобы стаббить конкретные ресурсы (их id актуальны на JVM) и проверять конкретные значения:

mock<Resources> { on { getString(R.string.error_no_internet) } doReturn "Нет интернета" }

Но лучше поместить Resources во Wrapper, предоставляющий только необходимый функционал работы с ресурсами, и сделать его тестовую реализацию. Это избавит SUT от прямой зависимости от фреймворка и упростит подготовку окружения в тестах:

interface ResourceProvider {    fun getString(@StringRes res: Int, vararg args: Any): String}class ApplicationResourceProvider(private val resources: Resources) : ResourceProvider {    override fun getString(res: Int, vararg args: Any): String {        return resources.getString(res, *args)    }}class TestResourceProvider : ResourceProvider {    override fun getString(res: Int, vararg args: Any): String = "$res"}

При таком поведении TestResourceProvider по умолчанию правильность строки в ожидаемом результате можно сверять по id ресурса:

val string = TestResourceProvider().getString(R.string.error_no_internet)assertThat(string).isEqualTo(R.string.error_no_internet.toString())

В общем случае лучше вообще не заменять дублерами типы, принадлежащие сторонним библиотекам и фреймворкам. Это может привести к проблемам при обновлении их API. Обезопасить себя можно также с помощью Wrapper. Подробнее ситуация разобрана в статье Dont Mock Types You Dont Own.

Использовать Wrapper-ы для статический функций, управления асинхронным и многопоточным поведением. Существует немало стандартных статических функций или Android-зависимостей в виде таких функций. Если нужно иметь с ними дело, то следует помещать их во Wrapper-ы и внедрять в SUT для последующей подмены.

Это поможет и при работе с асинхронностью и многопоточностью: инкапсулирующий управление ими Wrapper можно заменить тестовым дублером, который позволит проверяемому коду выполняться в одном потоке и синхронно вызвать асинхронный код. Для RxJava и Kotlin Coroutines есть стандартные решения от их авторов.

Дизайн тестов

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

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

Spoiler
public void testSubClassSerializerInvokedForBaseClassFieldsHoldingArrayOfSubClassInstances() {    Gson gson = new GsonBuilder()            .registerTypeAdapter(Base.class, new BaseSerializer())            .registerTypeAdapter(Sub.class, new SubSerializer())            .create();    ClassWithBaseArrayField target = new ClassWithBaseArrayField(new Base[] {new Sub(), new Sub()});    JsonObject json = (JsonObject) gson.toJsonTree(target);    JsonArray array = json.get("base").getAsJsonArray();    for (JsonElement element : array) {        JsonElement serializerKey = element.getAsJsonObject().get(Base.SERIALIZER_KEY);        assertEquals(SubSerializer.NAME, serializerKey.getAsString());    }}

Чтобы достичь желаемого эффекта от тестов, необходимо уделить внимание качеству их дизайна.

Наименование теста и разделение на блоки

Чтобы сделать содержимое теста более читабельным, его следует разделять на блоки соответствующих этапов. Я выбрал BDD-стиль, где есть этапы:

  • Given настройка SUT и среды;

  • When действие, инициирующее работу SUT, результат работы которой нужно проверить;

  • Then проверка результатов на соответствие ожиданиям.

Пример разделения тела теста:

@Testfun `when create - while has 1 interval from beginning of day and ending not in end of day - should return enabled and disabled items`() {    // given    val intervalStart = createDateTime(BEGINNING_OF_DAY)    val intervalEnd = createDateTime("2019-01-01T18:00:00Z")    val intervals = listOf(        ArchiveInterval(startDate = intervalStart, endDate = intervalEnd)    )    // when    val result = progressItemsfactory.createItemsForIntervalsWithinDay(intervals)    // then    val expected = listOf(        SeekBarProgressItem.createEnabled(intervalStart, intervalEnd),        SeekBarProgressItem.createDisabled(intervalEnd, createDateTime(END_OF_DAY))    )    assertThat(result).isEqualTo(expected)}

Лицо теста его название. Оно должно быть читабельным и ёмко передавать информацию о содержимом, чтобы для ориентации не приходилось каждый раз анализировать это самое содержимое.

В тестах на JVM Kotlin позволяет использовать пробел и дефис при обрамлении названия функции обратными кавычками. Это здорово повышает читабельность. В инструментальных тестах это не работает, поэтому текст пишется в CamelCase, а вместо дефисов используются нижние подчеркивания.

Для тестов на Junit применим следующий паттерн именования в простых случаях:

  • when - should

    when аналогично блоку When;

    should аналогично блоку Then.

В более сложных случаях, когда есть дополнительные условия:

  • when - while/and - should , где

    while предусловие до вызова целевой функции SUT;

    and условие после вызова функции SUT.

Пример:

@Testfun `when doesValueSatisfyRegex - while value is incorrect - should return false`() {

Так имя теста написано в виде требования, и в случае падения будет сразу видно, какой сценарий отработал некорректно:

Фреймворк Spek 2 выводит всё это на новый уровень. Он предоставляет из коробки DSL в стиле Gherkin (BDD).

object GetCameraGroupsInteractorTest : Spek({    Feature("Transform cached cameras to groups of cameras") {        ...        Scenario("subscribe while has non-grouped camera and unsorted by groups order cameras") {            ...            Given("non-grouped camera and unsorted by groups order cameras") {                ...            }            When("subscribe") {                ...            }            Then("should return four groups") {                ...            }            ...        }    }})

Блоки Given, When, Then подтесты глобального теста, описанного с помощью блока Scenario. Теперь нет необходимости ставить всё описание в названии, можно просто расположить все части в соответствующих блоках.

Результат выполнения имеет иерархический вид:

Эти блоки могут присутствовать внутри Scenario в любом количестве, а для придания еще более человекочитаемого вида можно использовать блок And. Теперь насыщенный сценарий можно оформить, не растянув при этом название теста далеко за границу экрана:

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

Чтобы добиться схожего разделения и отображения результатов с помощью Junit 5, понадобилось бы написать в тестах много бойлерплейта с аннотациями.

Устранение лишнего кода

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

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

В Spek 2 вместо создания полностью отдельных тестов, если они концептуально относятся к одному сценарию, разделение проверок можно сделать с помощью блоков Then/And внутри Scenario:

...Then("should return four groups") {...}And("they should be alphabetically sorted") {...}And("other group should contain one camera") {...}And("other group should be the last") {...}...

В Junit 4 такой возможности нет. На помощь приходит механизм SoftAssertions из AssertJ, который гарантирует выполнение всех assert в тесте. Например:

// thenassertSoftly {    it.assertThat(capabilityState)        .describedAs("Capability state")        .isInstanceOf(Available::class.java)    it.assertThat((capabilityState as Available).disclaimer)        .describedAs("Disclaimer")        .isNull()}

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

3. Использовать обобщающие конструкции тестового фреймворка для одинаковой настройки окружения, если настройка повторяется для большого количества тестов, находящихся на одном уровне иерархии (например, beforeEachScenario и afterEachScenario в случае Spek 2). Если настройка одинакова для нескольких тестовых файлов, можно использовать Extension для Junit 5, Rule для Junit 4, а для Spek 2 подобного механизма из коробки нет, поэтому нужно обходиться конструкциями before/after.

4. Объемные схожие настройки тестового окружения следует также выносить в отдельную функцию.

5. Использовать статические импорты для повсеместно применяемых функций вроде функций проверок AssertJ и Mockito.

6. Если создание вспомогательных объектов объемное, используется в разных тестовых файлах и с разными параметрами, следует завести генератор с дефолтными значениями:

Пример генератора
object DeviceGenerator {    fun createDevice(        description: String? = null,        deviceGroups: List<String> = emptyList(),        deviceType: DeviceType = DeviceType.CAMERA,        offset: Int = 0,        id: String = "",        photoUrl: String? = null,        isActive: Boolean = false,        isFavorite: Boolean = false,        isPublic: Boolean = false,        model: String? = null,        vendor: String? = null,        title: String = "",        serialNumber: String = "",        streamData: StreamData? = null    ): Device {        return Device(            description = description,            deviceGroups = deviceGroups,            deviceType = deviceType,            offset = offset,            id = id,            photoUrl = photoUrl,            isActive = isActive,            isFavorite = isFavorite,            isPublic = isPublic,            model = model,            vendor = vendor,            title = title,            serialNumber = serialNumber,            streamData = streamData        )    }}Given("initial favorite camera") {    val devices = listOf(        createDevice(id = deviceId, isFavorite = true)    )    ...}

Очень важно не переборщить с созданием вспомогательных функций и архитектурных изысков, поскольку KISS и единообразие в автотестах важнее, чем DRY. Когда все тесты в проекте написаны однотипно и прозрачно, они гораздо лучше воспринимаются.

Тесты как документация

Когда предыдущие пункты соблюдены, тесты уже можно применять как документацию, свернув тестовые функции в IDE.

Для сворачивания и разворачивания всех блоков кода в файле в случае Mac используются комбинации клавиш Shift + + - и Shift + + +, для управления конкретным блоком + - и + + соответственно.

В тестах на Junit 4 можно сделать еще лучше, сгруппировав тесты по регионам, ведь их тоже можно сворачивать.

Пример

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

Если в файле с тестами присутствуют некоторые вспомогательные свойства или функции, их также стоит поместить в регион. Это поспособствует улучшению фокусировки внимания на названиях тестовых функций.

Наконец пример тестов на Spek 2 в режиме документации

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

Она лучше обычной текстовой, поскольку в отличие от тестов, обычную документацию можно забыть актуализировать. Чем тесты более высокоуровневые, тем более близкими к составленным аналитиком функциональным требованиям будут их названия. Это будет заметно в разделе "JVM Integration Testing".

Параметрические тесты

Если нужно протестировать корректность работы SUT с разнообразным набором входных данных, но при этом основная реализация тестов меняться не должна, можно использовать параметрический тест.

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

В документации Spek 2 не написано о возможности написания параметрических тестов, хотя она есть, и писать их проще, чем в Junit 4 и Junit 5. Для этих целей удобно использовать стиль тестов Specification.

Пример параметрического теста в Speck 2
class OrientationTypeTest : Spek({    describe("Orientation type") {        mapOf(            -1 to Unknown,            -239 to Unknown,            361 to Unknown,            2048 to Unknown,            340 to Portrait,            350 to Portrait,            360 to Portrait,            0 to Portrait,            ...        ).forEach { (tiltAngle, expectedOrientation) ->            describe("get orientation by tilt angle $tiltAngle") {                val result = OrientationType.getOrientation(tiltAngle)                it("return $expectedOrientation type") {                    assertThat(result).isEqualTo(expectedOrientation)                }            }        }    }})

Результат выполнения:

Снижение хрупкости non-UI тестов

Я писал, что степень хрупкости unit-тестов при изменениях исходного кода, обусловленную их привязкой к деталям реализации модуля, можно снизить. Это применимо для всех non-UI тестов.

Написание тестов в стиле White box искушает расширять видимость функций/свойств SUT для проверок или установки состояний. Это простой путь, который влечет за собой не только увеличение хрупкости тестов, но и нарушение инкапсуляции SUT.

Избежать этого помогут правила. Можно сказать, что взаимодействие с SUT будет в стиле Black box.

  1. Тестировать следует только публичные функции. Если SUT имеет приватную функцию, логику которой нужно протестировать, делать это следует через связанную с ней публичную функцию. Если сделать это проблематично, то, возможно, код приватной функции так сложен, что должен быть вынесен в отдельный класс и протестирован напрямую.

  2. Нужно стараться делать функции чистыми. Об этом я говорил выше.

  3. Проверки в тесте следует осуществлять по возвращаемому значению вызываемой публичной функции, публичным свойствам или, в крайнем случае, по взаимодействию с mock-объектами (с помощью функции verify() и механизма ArgumentCaptor в Mockito)

  4. Делать только необходимые проверки в рамках теста. Например, если в тесте проверяется, что при вызове функции A у SUT происходит вызов функции X у другого класса, то не следует до кучи проверять значения её публичных полей, особо не имеющих отношения к делу, и что у SUT не будет более никаких взаимодействий с другими функциями связанного класса (функция verifyNoMoreInteractions() в Mockito).

  5. Если для проведения определенного теста невозможно привести SUT в требуемое предварительное состояние с помощью аргументов целевой функции, моков/стабов или изменения полей, то следует вызвать другие публичные функции, вызов которых приводит SUT в интересующее состояние в условиях реальной работы приложения. Например, вызвать функции onLoginInputChanged и onPasswordInputChanged для подготовки теста onEnterButtonClick во ViewModel

Существует аннотация-маркер @VisibleForTesting для выделения функций/свойств, модификатор доступа которых расширили для тестирования. Благодаря этому маркеру Lint подскажет разработчику, обратившемуся к функции/свойству в таком месте исходного кода, в котором они на самом деле не должны быть доступны, что видимость функции расширена только для тестирования. Несмотря на возможность использования такого маркера, прибегать к расширению видимости всё равно не рекомендуется.

Тестирование асинхронного кода с RxJava

Лучше избегать прямого управления асинхронным и многопоточным поведением в SUT. Для тестирования же кода, использующего RxJava или Coroutines, применяются специфичные решения. Сейчас в большинстве наших проектов используется RxJava, поэтому расскажу именно про нее.

Для тестирования SUT, осуществляющей планирование Rx-операций, нужно произвести замену реализаций Scheduler-ов так, чтобы весь код выполнялся в одном потоке. Также важно иметь в виду, что на JVM нельзя использовать AndroidSchedulers.mainThread().

В большинстве случаев все Scheduler-ы достаточно заменить на Schedulers.trampoline(). В случаях, когда нужен больший контроль над временем события, лучше использовать io.reactivex.schedulers.TestScheduler с его функциями triggerActions(), advanceTimeBy(), advanceTimeTo().

Замену реализаций можно совершить двумя способами:

  • RxPlugins (RxJavaPlugins & RxAndroidPlugins);

  • Подход Schedulers Injection.

Первый способ официальный и может быть применен независимо от того, как спроектирована SUT. Он имеет не самое удачное API и неприятные нюансы работы, усложняющие применение в некоторых ситуациях (например, когда внутри тестового файла в одних тестах нужно использовать Schedulers.trampoline(), а в других TestScheduler).

Суть подхода Schedulers Injection заключается в следующем: экземпляры Scheduler-ов попадают в SUT через конструктор, благодаря чему в тесте они могут быть заменены на иные реализации. Этот подход является очень прозрачным и гибким. Также он останется неизменным независимо от выбранного тестового фреймворка (Junit 4, Junit 5, Spek 2) чего нельзя сказать об RxPlugins, которыми придется в каждом управлять по-своему.

Из минусов Shedulers Injection можно выделить необходимость внедрения дополнительного аргумента в SUT и необходимость использования вместо rx-операторов с Sheduler по умолчанию (таких как delay()) их перегруженные варианты с явным указанием Scheduler.

Есть две неплохие статьи на тему обоих подходов: раз, два. Но там упомянуты не все нюансы RxPlugins.

Я предпочитаю второй подход. Чтобы упростить внедрение и подмену реализаций в тесте, я написал SchedulersProvider:

Реализация и применение SchedulersProvider
interface SchedulersProvider {    fun ui(): Scheduler    fun io(): Scheduler    fun computation(): Scheduler}class SchedulersProviderImpl @Inject constructor() : SchedulersProvider {    override fun ui(): Scheduler = AndroidSchedulers.mainThread()    override fun io(): Scheduler = Schedulers.io()    override fun computation(): Scheduler = Schedulers.computation()}fun <T> Single<T>.scheduleIoToUi(schedulers: SchedulersProvider): Single<T> {    return subscribeOn(schedulers.io()).observeOn(schedulers.ui())}// другие необходимые функции-расширения...

Его применение в коде:

class AuthViewModel(    ...    private val schedulers: SchedulersProvider) : BaseViewModel() {    ...    loginInteractor        .invoke(login, password)        .scheduleIoToUi(schedulers)    ...

А вот и его тестовая реализация с Scheduler-ами по умолчанию, вместо которых при надобности можно передать TestScheduler:

class TestSchedulersProvider(    private val backgroundScheduler: Scheduler = Schedulers.trampoline(),    private val uiScheduler: Scheduler = Schedulers.trampoline()) : SchedulersProvider {    override fun ui(): Scheduler = uiScheduler    override fun io(): Scheduler = backgroundScheduler    override fun computation(): Scheduler = backgroundScheduler}

Применение в тесте:

authViewModel = AuthViewModel(    ...    router = mock(),    schedulers = TestSchedulersProvider(),    loginInteractor = loginInteractor,    ...)

Вообще, RxJava из коробки имеет и другие полезные инструменты для тестирования (TestObserver, TestSubscriber), но они не входят в рамки статьи.

JVM Integration Testing

JVM Integration-тесты проверяют взаимодействие модулей или совокупностей модулей на JVM. Какие именно связки стоит тестировать, зависит от конкретных случаев.

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

Тест взаимодействует с SUT через ViewModel, инициируя действия и проверяя результат.

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

  • android.content.res.Resources или собственный Wrapper. Обычно достаточно стаба, обеспечивающего исправный возврат строк из ресурсов.

  • androidx.arch.core.executor.TaskExecutor. Требуется в любых тестах на JVM, у которых SUT использует LiveData, поскольку стандартная реализация имеет Android-зависимость. Подробнее можно почитать в этой статье. Google предлагает готовое решение этой проблемы в форме Rule лишь для Junit 4, поэтому для Spek 2 и Junit 5 использую рукописный класс, содержащий код из того самого решения:

object TestLiveDataExecutionController {    fun enableTestMode() {        ArchTaskExecutor.getInstance()            .setDelegate(object : TaskExecutor() {                override fun executeOnDiskIO(runnable: Runnable) = runnable.run()                override fun postToMainThread(runnable: Runnable) = runnable.run()                override fun isMainThread(): Boolean = true            })    }    fun disableTestMode() {        ArchTaskExecutor.getInstance().setDelegate(null)    }}

Соответствующие функции достаточно вызывать перед первым и после последнего теста в тестовом файле. Пример применения в Spek 2:

object DeviceDetailViewModelIntegrationTest : Spek({    beforeGroup { TestLiveDataExecutionController.enableTestMode() }    afterGroup { TestLiveDataExecutionController.disableTestMode() }...
  • Сервер. Для имитации сервера используется MockWebServer от создателей OkHttp. Он позволяет предустанавливать ответы на конкретные запросы, проверять состав запросов, факты их вызова и др.

  • Interceptors с Android-зависимостями. Не следует пренебрегать добавлением интерцепторов в тестовую конфигурацию клиента OkHttp, соблюдая тот же порядок, что и в настоящем клиенте, чтобы серверные запросы и ответы правильно обрабатывались. Однако некоторые интерцепторы могут иметь Android-зависимости их следует подменить. Например, это могут быть интерцепторы логирования. Интерцепторы последовательно передают данные друг другу и эту цепочку нельзя прерывать, поэтому фиктивный интерцептор должен выполнять это минимальное требование:

// StubInterceptorInterceptor { chain ->    return@Interceptor chain.proceed(chain.request().newBuilder().build())}
  • Персистентные хранилища данных (SharedPreferences, Room и т.д.)

Базовая логика управления тестовым сетевым окружением сконцентрирована в классе BaseTestNetworkEnvironment. Он используется на JVM и в Instrumentation. За специфическую конфигурацию под каждую из сред отвечают его классы-наследники: JvmTestNetworkEnvironment и InstrumentationTestNetworkEnvironment.

Сервер запускается при создании экземпляра *NetworkEnvironment до запуска теста и отключается функцией shutdownServer() после завершения теста (в случае Gherkin-стиля Spek 2 до и после Scenario соответственно).

Для удобной настройки ответов на конкретные запросы используется функция dispatchResponses. При необходимости к mockServer можно обратиться напрямую.

Реализация BaseTestNetworkEnvironment
abstract class BaseTestNetworkEnvironment {    companion object {        private const val BASE_URL = "/"        private const val ENDPOINT_TITLE = "Mock server"    }    val mockServer: MockWebServer = MockWebServer().also {         it.startSilently()     }    // класс, специфичный для инфраструктуры проекта    protected val mockNetworkConfig: NetworkConfig    init {        val mockWebServerUrl = mockServer.url(BASE_URL).toString()        mockNetworkConfig = TestNetworkConfigFactory.create(mockWebServerUrl, BASE_URL)    }    /**     * Используется для предустановки фиктивных ответов на конкретные запросы к [MockWebServer].     *     * [pathAndResponsePairs] пара путь запроса - ответ на запрос.     *     * Если [MockWebServer] получит запрос по пути, которого нет среди ключей [pathAndResponsePairs],     * то будет возвращена ошибка [HttpURLConnection.HTTP_NOT_FOUND].     */    fun dispatchResponses(vararg pathAndResponsePairs: Pair<String, MockResponse>) {        val pathAndResponseMap = pathAndResponsePairs.toMap()        val dispatcher = object : Dispatcher() {            override fun dispatch(request: RecordedRequest): MockResponse {                val mockResponse = request.path?.let {                   pathAndResponseMap[it]                 }                return mockResponse ?: mockResponse(HttpURLConnection.HTTP_NOT_FOUND)            }        }        mockServer.dispatcher = dispatcher    }    fun shutdownServer() {        mockServer.shutdown()    }    /**     * Запуск сервера с отключенными логами     */    private fun MockWebServer.startSilently() {        Logger.getLogger(this::class.java.name).level = Level.WARNING        start()    }}

Содержимое JvmTestNetworkEnvironment уже сильно зависит от специфики конкретного проекта, но цель его неизменна заменить некоторые сущности локального сетевого окружения тестовыми дублерами, чтобы код работал на JVM.

Пример реализации JvmTestNetworkEnvironment
// Если не передавать в конструктор класса специфические экземпляры тестовых дублеров, то будут использоваться// стабы с минимальным предустановленным поведением, необходимым для функционирования сетевого флоу.class JvmTestNetworkEnvironment(    val mockPersistentStorage: PersistentStorage = mockPersistentStorageWithMockedAccessToken(),    val mockResources: ResourceProvider = TestResourceProvider()) : BaseTestNetworkEnvironment() {    private val nonAuthZoneApiHolderProvider: NonAuthZoneApiHolderProvider    private val authZoneApiHolderProvider: AuthZoneApiHolderProvider    init {        val moshiFactory = MoshiFactory()        val serverErrorConverter = ServerErrorConverter(moshiFactory, mockResources)        val stubInterceptorProvider = StubInterceptorProvider()        val interceptorFactory = InterceptorFactory(            ErrorInterceptorProvider(serverErrorConverter).get(),            AuthInterceptorProvider(mockPersistentStorage).get(),            stubInterceptorProvider.get(),            stubInterceptorProvider.get()        )        nonAuthZoneApiHolderProvider = NonAuthZoneApiHolderProvider(            interceptorFactory,            moshiFactory,            mockNetworkConfig        )        authZoneApiHolderProvider = AuthZoneApiHolderProvider(            interceptorFactory,            moshiFactory,            UserAuthenticator(),            mockNetworkConfig        )    }    fun provideNonAuthZoneApiHolder() = nonAuthZoneApiHolderProvider.get()    fun provideAuthZoneApiHolder() = authZoneApiHolderProvider.get()}

Функции для упрощения создания серверных ответов:

fun mockResponse(code: Int, body: String): MockResponse = MockResponse().setResponseCode(code).setBody(body)fun mockResponse(code: Int): MockResponse = MockResponse().setResponseCode(code)fun mockSuccessResponse(body: String): MockResponse = MockResponse().setBody(body)

Тела фиктивных серверных ответов сгруппированы по object-ам, соответствующим разным запросам. Это делает тестовые файлы чище и позволяет переиспользовать ответы и значения их полей в разных тестах. Одни и те же ответы используются тестами на JVM и Instrumentation (в том числе UI).

После добавления комментария "language=JSON" IDE подсвечивает синтаксис JSON. Подробнее о Language injections можно почитать тут.

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

Пример object с фиктивными серверными ответами
object LoginResponses {    const val INVALID_CREDENTIALS_ERROR_DESCRIPTION = "Неверный логин или пароль"        fun invalidCredentialsErrorJson(        errorDescription: String = INVALID_CREDENTIALS_ERROR_DESCRIPTION    ): String {        // language=JSON        return """            {              "error": {                "code": "invalid_credentials",                "description": "$errorDescription",                "title": "Введены неверные данные"              }            }            """.trimIndent()    }...}

Схожим образом вынесены и пути запросов:

const val LOGIN_REQUEST_PATH = "/auth/login"object GetCameraRequest {    const val DEVICE_ID = "1337"    const val GET_CAMERA_REQUEST_PATH = "/devices/camera/$DEVICE_ID"}...

Общие для JVM и Instrumentation файлы должны находиться в директории, доступной обоим окружениям. Доступ настраивается в build.gradle:

android {    sourceSets {        // Instrumentation        androidTest {            java.srcDirs += 'src/androidTest/kotlin'            java.srcDirs += 'src/commonTest/kotlin'        }        // JVM        test {            java.srcDirs += 'src/test/kotlin'            java.srcDirs += 'src/commonTest/kotlin'        }    }}

Взаимодействие View и ViewModel построено особым способом, благодаря которому очень удобно писать unit-тесты ViewModel и integration-тесты. Публичные функции ViewModel представляют события со стороны View (обычно они соответствуют действиям со стороны пользователя) и именуются в событийном стиле:

ViewModel воздействует на View посредством двух LiveData:

  • state описание состояния View

  • events однократные события, не сохраняющиеся в state

Этот подход в более удобном виде реализован в нашей библиотеке.

Пример организации ViewModel, ViewState и ViewEvents
class AuthViewModel(...) {    val state = MutableLiveData<AuthViewState>()    val events = EventsQueue<ViewEvent>()    ...}sealed class AuthViewState {    object Loading : AuthViewState()    data class Content(        val login: String = "",        val password: String = "",        val loginFieldState: InputFieldState = Default,        val passwordFieldState: InputFieldState = Default,        val enterButtonState: EnterButtonState = Disabled    ) : AuthViewState() {        sealed class InputFieldState {            object Default : InputFieldState()            object Error : InputFieldState()            object Blocked : InputFieldState()        }...    }}class EventsQueue<T> : MutableLiveData<Queue<T>>() {    fun onNext(value: T) {        val events = getValue() ?: LinkedList()        events.add(value)        setValue(events)    }}// ViewEvents:interface ViewEventdata class ShowSnackbarError(val message: String) : ViewEventclass OpenPlayStoreApp : ViewEvent...
Наконец, пример JVM Integration-теста
object AuthViewModelIntegrationTest : Spek({    Feature("Login") {        // region Fields and functions        lateinit var authViewModel: AuthViewModel        lateinit var networkEnvironment: JvmTestNetworkEnvironment        val login = "log"        val password = "pass"        fun setUpServerScenario() {            networkEnvironment = JvmTestNetworkEnvironment()            val authRepository = networkEnvironment.let {                AuthRepositoryImpl(                    nonAuthApi = it.provideNonAuthZoneApiHolder(),                    authApi = it.provideAuthZoneApiHolder(),                    persistentStorage = it.mockPersistentStorage,                    inMemoryStorage = InMemoryStorage()                )            }            val clientInfo = ClientInfo(...)            val loginInteractor = LoginInteractor(authRepository, clientInfo)            authViewModel = AuthViewModel(                resources = networkEnvironment.mockResources,                schedulers = TestSchedulersProvider(),                loginInteractor = loginInteractor                analytics = mock()            )        }        beforeFeature { TestLiveDataExecutionController.enableTestMode() }        afterFeature { TestLiveDataExecutionController.disableTestMode() }        beforeEachScenario { setUpServerScenario() }        afterEachScenario { networkEnvironment.shutdownServer() }        // endregion        Scenario("input credentials") {...}        Scenario("click enter button and receive invalid_credentials error from server") {            Given("invalid_credentials error on server") {                networkEnvironment.dispatchResponses(                    LOGIN_REQUEST_PATH to mockResponse(HTTP_UNAUTHORIZED, invalidCredentialsErrorJson())                )            }            When("enter not blank credentials") {                authViewModel.onCredentialsChanged(login, password)            }            And("click enter button") {                authViewModel.onEnterButtonClick(login, password)            }            Then("reset password, mark login and password input fields as invalid and disable enter button") {                val state = authViewModel.state.value                val expectedState = Content(                    login = login,                    password = "",                    loginFieldState = Content.InputFieldState.Error,                    passwordFieldState = Content.InputFieldState.Error,                    enterButtonState = Content.EnterButtonState.Disabled                )                assertThat(state).isEqualTo(expectedState)            }            And("create snackbar error event with message from server") {                val expectedEvent = authViewModel.events.value!!.peek()                assertThat(expectedEvent).isEqualTo(ShowSnackbarError(INVALID_CREDENTIALS_ERROR_DESCRIPTION))            }        }        ...    }    ...})

Так тестируется основная логика пользовательских сценариев. Эти сценарии с теми же данными затем могут быть проверены в UI-тестах.

Что в итоге нужно тестировать?

Не нужно тестировать чужие библиотеки это ответственность разработчиков библиотек (исследовательское тестирование исключение). Тестировать нужно свой код.

Unit-тесты следует писать на логику, в которой есть реальная вероятность совершения ошибки. Это могут быть ViewModel, Interactor, Repository, функции форматирования (денег, дат и т.д.) и другие стандартные и нестандартные сущности. Тривиальную логику тестировать не стоит. Но нужно следить за изменением непокрытой тестами логики, если она при очередном изменении перестанет быть тривиальной, то тогда её нужно протестировать.

100%-е покрытие кода тестами несёт с собой вред: трата лишнего времени на написание бесполезных тестов, боль при изменении реализации, при поддержке бесполезных тестов, иллюзия хорошо протестированной системы. Процент покрытия не отражает реальной картины того, насколько хорошо система протестирована.

Я предпочитаю не устанавливать минимальный порог тестового покрытия для нового кода. Однако для разработчика всё же может быть полезной точечная проверка покрытия SUT, над которой он работает.

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

При исправлении бага в коде, который не покрыт тестами, следует его ими покрыть.

JVM Integration-тесты от ViewModel до слоя данных следует писать для каждого экрана. Менее масштабные JVM Integration при надобности. Возможны случаи, когда большинство модулей, включая ViewModel, сами по себе являются слишком простыми, чтобы их стоило покрывать unit-тестами. Однако создание масштабного JVM integration-теста на всю цепочку будет очень кстати, тем более что пишутся такие тесты достаточно просто и однотипно.

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

Тесты Instrumentation Integration non-UI только когда нужно проверить что-то, что нельзя адекватно проверить на JVM.

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

Test Driven Development

Можно подумать, что о написании тестов уже известно достаточно и пора идти в бой, но есть еще один момент Вы, вероятно, собрались написать очередную фичу и затем покрыть её тестами? Замечательная идея. Именно так и стоит делать, пока навык написания тестов не будет более менее отработан. Такой подход называют Test Last. Конечно же, среди пишущих тесты разработчиков он наиболее распространен. Но он имеет серьезные недостатки:

  • несмотря на знания о том, каким должен быть тестируемый код, всё равно может получиться реализация, протестировать которую сходу не получится. Может понадобиться рефакторинг, чтобы появилась возможность написать тесты и сделать их не корявыми :)

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

  • тесты остаются на последнюю очередь и на них зачастую не остается времени.

Решить эти проблемы можно, используя принцип Test First, придуманным Кентом Беком. Он основан на идее: "Never write a single line of code unless you have a failing automated test" (не стоит писать код реализации, пока для него не написан падающий тест).

На базе этого принципа Кент Бек создал методологию Test Driven Development (TDD, разработка через тестирование). Согласно ей, разработка должна вестись итеративно, путем цикличного повторения шагов Red-Green-Refactor (микро-цикл):

  • написать тест на логику, которую предстоит реализовать, и убедиться, что он падает;

  • написать простейшую реализацию, чтобы тест выполнился успешно;

  • провести рефакторинг реализации, не сломав тесты.

Подразумевается, что в итерации падающий тест должен быть только один.

Позже Роберт Мартин развил TDD, сформулировав Three Laws of TDD (нано-цикл):

  • перед написанием какого-либо кода реализации необходимо написать падающий тест;

  • тест не должен содержать больше, чем нужно для его падения или провала компиляции;

  • не нужно писать больше кода реализации, чем достаточно для того, чтобы падающий тест прошел.

Из-за второго правила работа сводится к поочерёдному написанию строчки реализации SUT за строчкой теста. Нано-циклы заставляют фокусироваться на очень маленькой подзадаче. Это также помогает, когда не знаешь, с чего начать реализацию.

Со временем Робертом были сформулированы еще два более масштабных цикла. Про всех них можно почитать в его статье.

Технику применения нано-циклов я использую далеко не всегда. Частые перескакивания в сложной задаче приводят к потере фокуса и снижают продуктивность. Несмотря на то, что более крупные циклы должны помогать этого избегать. Считаю, что эту технику лучше применять точечно и комфортный размер шага следует выбирать каждому самостоятельно. Жесткое следование всем правилам на практике не всегда идёт на пользу, но полезно при обучении.

Я несколько отступился от канонов и нашел эффективным такой алгоритм работы при реализации новой фичи:

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

2. Создать SUT, описать его интерфейс.

  • Если функции должны возвращать какой-то результат, можно писать в их теле TODO(), чтобы код мог скомпилироваться, тогда при вызове функции тест будет прерван эксепшеном. Другой вариант хардкодить возврат простого объекта или null. Так тесты смогут совершить проверки после вызова функции, но тут лучше быть поаккуратнее.

fun doSomething(): Boolean { TODO() }

3. Создать тестовый файл для SUT, объявить тесты-требования.

  • Описать столько кейсов, сколько получится. Нормально, если в ходе написания реализации на ум придут еще какие-то кейсы.

    В пустые тесты/блоки можно добавлять вызов функции fail() (из Junit или AssertJ), чтобы не забыть реализовать какой-то из тестов, поскольку пустой тест при запуске выдает положительный результат.

@Testfun `when invoke - should do something`() {    fail { "not implemented" }}

4. Реализовать тест(ы)

  • Методология подразумевает написание только одного теста и необходимой реализации SUT для его прохождения за микроцикл, но это может быть не продуктивно, если несколько тестов-требований тесно связаны. Вполне нормально написать несколько тестов и потом перейти к реализации. Если же тестов у SUT получается много и они направлены на проверку разных аспектов её работы, то написание всех тестов перед началом реализации будет приводить к потере фокуса с подзадач, которые предстоит в ходе реализации решить. Как именно поступать, стоит определять в зависимости от конкретного случая и от того, как получается комфортнее.

5. Реализовать SUT, чтобы реализованные тесты успешно выполнились.

  • По умолчанию в момент времени стоит фокусироваться на прохождении одного конкретного теста.

6. Отрефакторить SUT, сохранив успешность выполнения реализованных тестов.

7. Если остались нереализованные тесты, перейти к пункту #4.

Алгоритм доработки SUT, которая уже покрыта тестами:

  1. Объявить новые тесты согласно новым требованиям,

  2. Реализовать новые тесты,

  3. Реализовать доработку в SUT, чтобы новые тесты выполнились успешно

  4. Если старые тесты упали:

    • Они актуальны при новых требованиях исправить реализацию SUT и/или эти тесты,

    • Они неактуальны удалить.

  5. Отрефакторить SUT, сохранив успешность выполнения реализованных тестов,

  6. Если остались нереализованные тесты, перейти к пункту 2.

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

Приступаем к реализации по принципу Test Last, но как только достаточное представление сформируется, делаем паузу и переходим к тестам, продолжая дальнейшую разработку уже по TDD. Незачем также применять TDD для реализации тривиальной логики, если вы не собираетесь покрывать её тестами.

По итогу получаем от подхода следующие преимущества:

  • Предварительное написание тестов вынуждает реализовывать SUT заведомо тестируемой. Тесты оказываются слабо связанными с деталями реализации.

  • Тесты являются спецификацией SUT (если при этом соблюдать правила их именования). Часто они вытекают напрямую из функциональных требований к задаче. Сфокусированное перечисление тестов-требований до начала реализации помогает лучше понять и запомнить требования, лучше продумать детали интерфейса SUT. Увеличивается вероятность заблаговременного выявления всех необходимых краевых кейсов. Это само по себе уже помогает допускать меньше ошибок в будущей реализации, а возможность запуска готовых тестов в любой момент дает уверенность в том, что реализация осуществляется правильно.

  • Наличие тестов делает рефакторинг реализации безопасным. После каждого изменения реализации можно быстро прогнать все тесты SUT и в случае обнаружения поломки сразу же её устранить. Время, затрачиваемое на отладку, очень сильно сокращается.

  • На тесты хватает времени, ведь они неотъемлемая часть процесса разработки

  • Все эти факторы в совокупности сокращают время, затрачиваемое на разработку и на развитие приложения в будущем.

  • Приятно видеть, как красные тесты один за другим превращаются в зелёные

TDD это в первую очередь подход к разработке. Методология замечательно показывает себя при реализации SUT с unit- и JVM integration-тестами, поскольку их можно быстро и часто запускать. С Instrumentation non-UI-тестами применять её можно, но из-за длительности запуска придется запускать тесты реже. Применять же TDD с UI-тестами крайне не рекомендуется.

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

Заключение

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

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

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

Полезные материалы

Подробнее..

Почему большинство юнит тестов пустая трата времени? (перевод статьи)

17.05.2021 16:18:04 | Автор: admin

Автор: James O Coplien

Перевод: Епишев Александр

1.1 Наши дни

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

Постепенно объектное ориентирование штурмом захватывает мир, а мир проектирования, и вовсе, переворачивает вверх дном. Во-первых, проектируемые блоки превратились из вычислительных единиц в маленькие гетерогенные композиты, называемые объектами и совмещающие в себе несколько программных артефактов, включая и функции, и данные внутри одной обертки. Объектная парадигма использовала классы для оборачивания нескольких функций вместе с общими для этих функций спецификациями данных. Подобный класс становился шаблоном, из которого в ран-тайме создавались объекты. В таком вычислительном контексте, необходимая функция определяется во время выполнения и не может быть вызвана из исходного кода, как это было возможно с FORTRAN. Это привело к невозможности разобраться во взаимосвязях поведения кода в ран-тайме лишь за счет анализа. Теперь вам необходимо запустить программу для того, чтобы получить малейшее представление о том, что же в ней происходит.

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

Классы превратились в объекты анализа, и, в определенной степени, проектирования. Популярной техникой дизайна стали CRC-карты (обычно представляющие Классы, Ответственности и Кооперацию), каждый класс в которых представлен отдельным человеком. Объектная ориентация начала ассоциироваться с антропоморфным дизайном. Классы, к тому же, превратились в единицы администрирования, дизайна и программирования, а их антропоморфная суть усилила стремление каждого создателя класса его протестировать. Поскольку у некоторых методов класса сохранилась такая же контекстуализация, как и у функции FORTRAN, у программистов возникла необходимость предоставлять контекст перед выполнением метода (помните, что мы не тестируем классы и, даже, не тестируем тестовые объекты, единицей функционального теста является метод). Юнит тесты обеспечивали выполнение сценариев драйверами. Моки - контекст состояния окружения (энва) и других методов, от которых зависел тестируемый метод. При подготовке к тесту, тестовые окружения поставляли необходимые средства для создания каждого объекта в его правильном состоянии.

1.2 Лекарство хуже болезни

Конечно же, юнит-тестирование не является проблемой исключительно объектно-ориентированного программирования, de rigueur (лат. "крайней необходимостью"), скорее всего, его сделала комбинация объектной-ориентированности, эджайла, разработки программного обеспечения, а также рост инструментов и вычислительных мощностей. Как консультант, я часто слышу вопросы о юнит-тестировании, включая следующий от одного из своих клиентов, Ричарда Якобса (Richard Jacobs) из Sogeti (Sogeti Nederland B.V.):

Второй вопрос касается юнит-тестов. Если я правильно припоминаю, вы говорили, что юнит-тесты - это пустая трата времени. Во-первых, я был удивлен. Тем не менее, сегодня моя команда сообщила, что их тесты сложнее, чем сам код. (Это не та команда, которая изначально написала код и юнит тесты. Поэтому некоторые тесты застают их врасплох. Текущая команда более высоко квалифицирована и дисциплинирована.) В таком случае, по моему, это пустая трата времени... Когда я ежедневно программировал, то создавал действительно тестируемый код, однако, почти никогда не писал никакие юнит тесты. При этом я заслуживал признание за свой качественный код и почти безошибочное программное обеспечение. Мне хотелось бы разобраться, ПОЧЕМУ такой вариант работал в моем случае?

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

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

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

(Здесь, триллион - не риторический прием, а цифра, основанная на различных возможных состояниях, с учетом того, что средний размер объекта представляет собой четыре слова, и, по консервативной оценке, вы используете 16-битные слова).

1.3 Тесты ради тестов и спроектированные тесты

У меня был клиент из Северной Европы, разработчики которого должны были предоставить 40% покрытия кода, для, так называемого, 1-го уровня зрелости программного обеспечения, 60% для 2-го уровня и 80% для 3-го, хотя были и стремящиеся к 100%. Без проблем! Как вы могли бы предположить, достаточно сложная процедура с ветвлениями и циклами стала бы вызовом, однако, это всего лишь вопрос принципа divide et impera (разделяй и властвуй). Большие функции, для которых 80% покрытие было невозможным, разбивались на множество более мелких, для которых 80% уже было тривиальным. Такой подход повысил общий корпоративный показатель зрелости команд всего лишь за один год, потому как вы обязательно получаете то, что поощряете. Конечно же, это также означало, что функции больше не инкапсулировали алгоритмы. Невозможным оказалось понимание контекста выполняемой строки, точнее тех, которые предшествуют и следуют за ней во время выполнения, поскольку эти строки кода больше не имеют прямого отношения к той, которая нас интересует. Такой переход в последовательности теперь происходил благодаря вызову полиморфной функции - гипер-галактической GOTO. Даже если всё, что вас беспокоит, - это покрытие решений (branch coverage), это больше не имеет значения.

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

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

Задумайтесь на секунду о вычислительной сложности этой задачи. Под 100% покрытием, я подразумеваю проверку всех возможных комбинаций всех возможных ветвлений, проходящих через все методы класса, которые воспроизводят все возможные конфигурации битов данных, доступные этим методам, в каждой инструкции машинного языка во время выполнения программы. Все остальное - это эвристика, о корректности которой нельзя сделать никаких формальных заявлений. Число возможных путей выполнения с помощью функции невелико: скажем, 10. Перекрестное произведение этих путей с возможными конфигурациями состояний всех глобальных данных (включая данные экземпляра, которые для области видимости метода являются глобальными) и формальных параметров в действительности же очень велико. Перекрестное произведение этого числа с возможной последовательностью методов внутри класса представляется счетно-бесконечным. Если вы возьмете несколько типичных чисел, то быстро осознаете, насколько вам повезло, если получите покрытие лучше, чем 1 из 1012.

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

Если вы пишете тест с целью покрытия как можно большего количества возможных сценариев, тогда можете уже выделять ферму машин для прогона тестов и отслеживания последних результатов 24/7.

Помните, однако, что автоматизированный хлам - это всё ещё хлам. И те из вас, у кого есть корпоративная Lean-программа, могли заметить, что основы производственной системы Toyota, которые лежали в основе Scrum, очень сильно противились автоматизации интеллектуальных задач (http://personeltest.ru/away/www.computer.org/portal/web/buildyourcareer/Agile Careers/-/blogs/autonomation). Более эффективно - это постоянно удерживать человека процессе, что становится еще более очевидным при исследовательском тестировании. Если вы собираетесь что-то автоматизировать, автоматизируйте что-нибудь ценное. Автоматизировать необходимо рутинные вещи. Возможно даже, вы получите еще больше прибыли от инвестиций, если автоматизируете интеграционные тесты, тесты для проверки регрессионных багов, а также системные, вместо того, чтобы заниматься автоматизацией юнит тестов.

Более разумный подход уменьшает объем тестового кода за счет формального проектирования тестов: то есть, формальной проверки граничных условий, большего количества тестов белого-ящика и т.д. Для этого необходимо, чтобы программный юнит проектировался как тестируемый. Вот как это делают инженеры по аппаратному обеспечению: разработчики предоставляют контрольные точки, способные считывать значения c J-Tag микросхем, для доступа к внутренним значениям сигналов микросхем - это равносильно доступу к значениям между промежуточными вычислениями, содержащимися в вычислительном юните. Я настоятельно рекомендую делать подобное на системном уровне, на котором должно быть сосредоточено основное внимание тестирования; я никогда не видел, чтобы кто-то достигал подобного на уровне юнита. Без таких приемов вы ограничиваете себя юнит-тестированием черного ящика.

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

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

1.4 Убеждение, что тесты умнее кода, говорит о скрытом страхе или плохом процессе

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

Если ваши кодеры предоставляют в юнит-тестах больше строк, чем в исходном коде, это, вероятно, означает одно из следующего. У них может быть параноидальное отношение к корректности; паранойя вытесняет ясное мышление и инновации, которые служат предзнаменованием высокого качества. Им может не хватать аналитических ментальных инструментов или дисциплины мышления, и хотелось бы, чтобы машина думала за них. Машины хорошо справляются с повторением механических задач, однако дизайн тестов по-прежнему требует тщательного обдумывания. Или может случиться так, что в вашем процессе частая интеграция невозможна из-за плохо налаженных процессов разработки или неподходящих инструментов. Программисты делают всё возможное, чтобы компенсировать подобную ситуацию за счет тестов там, где они хотя бы в какой-то степени обретают контроль над собственной судьбой.

Если у вас большой объем юнит-тестов, оцените обратную связь в процессе разработки. Интегрируйте код чаще; сократите время сборки и интеграции; сократите количество юнит тестирования и перейдите больше к интеграционному.

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

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

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

1.5 У тестов с низким уровнем риска низкая (даже потенциально отрицательная) отдача

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

Разберем тривиальный пример. Цель тестирования - предоставить информацию о вашей программе. (Тестирование само по себе не повышает качество; это делают программирование и проектирование. Тестирование лишь сообщает об упущениях команды в создании правильного проектирования и соответствующей реализации.) Большинство программистов хотят услышать информацию о том, что их программный компонент работает. Поэтому, как только в проекте трехлетней давности была создана первая функция, тут же для нее был написан и юнит тест. Тест ни разу не падал. Вопрос: Много ли информации содержится в этом тесте? Другими словами, если 1 - это успешно выполненный тест, а 0 - упавший, тогда сколько будет информации в следующей строке результатов:

11111111111111111111111111111111

Существует несколько возможных ответов, обусловленных видом применяемого формализма, хотя большинство из них не верны. Наивный ответ - 32, однако, это биты данных, а не информации. Возможно, вы информационный теоретик и скажете, что количество битов информации в однородной двоичной строке равносильно двоичному логарифму длины этой строки, которая в данном случае равна 5. Однако это не то, что я хочу знать: в конце концов хотелось бы понять, сколько информации можно получить после одноразового прогона такого теста. Информация основывается на вероятности. Если вероятность успешного прохождения теста равняется 100%, тогда, по определению теории информации, этой информации нет вообще. Ни в одной из единиц указанной выше строки не содержится почти никакой информации. (Если бы строка была бесконечно длинной, то в каждом тестовом прогоне было бы ровно ноль битов информации.)

Далее, сколько бит информации в следующей строке тестовых прогонов?

1011011000110101101000110101101

Ответ... намного больше. Вероятно, 32. Это означает, что в каждом тесте содержится намного больше информации. Если мы изначально не способны предсказать, пройдет ли тест успешно или нет, тогда каждый запуск теста содержит полный бит информации, и добиться чего-то лучшего вы не сможете. Видите ли, разработчики любят поддерживать тесты, которые проходят успешно, потому что это подогревает их эго и уровень комфорта. Однако, информация поступает от упавших тестов. (Конечно же, мы могли бы взять другую крайность:

00000000000000000000000000000000

в которой, фактически, нет никакой информации, в том числе, даже о процессе улучшения качества.)

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

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

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

Если у вас есть подобные тесты - это второй претендент на удаление.

Третий набор для удаления - тавтологические тесты. Я сталкиваюсь с ними чаще, чем вы можете себе представить, особенно среди последователей, так называемой, разработки через тестирование (TDD). (Кстати, проверка this на ненулевое/не пустое (non-null) значение при входе в метод, не является тавтологической, и может быть очень информативной. Однако, как и в случае с большинством юнит тестов, лучше сделать ассершн, чем пичкать свой тестовый фреймворк подобными проверками. Подробнее об этом ниже.)

Во многих компаниях, единственные тесты с бизнес-ценностью - это те, в основании которых лежат бизнес-требования. Большинство же юнит тестов основываются на фантазиях программистов о том, как должна работать функция: на их надеждах, стереотипах, а иногда и желаниях, как все должно было бы быть. У всего этого нет подтвержденной ценности. В 1970-х и 1980-х годах существовали методологии, опирающиеся на прослеживаемость (tracebility), и стремящиеся сократить системные требования вплоть до уровня юнитов. В общем, это NP-трудная (нелинейная полиномиальная) задача (если только вы не выполняете чисто процедурную декомпозицию), поэтому я очень скептичен в отношении всех, кто говорит, что способен её решить. В итоге, единственный вопрос, который следовало бы задавать каждому тесту: Если тест упадет, какое из бизнес-требований будет нарушено? В большинстве случаев, ответ: Я не знаю. Если вы не понимаете ценность теста, тогда, теоретически, он может иметь нулевую ценность для бизнеса. У теста есть стоимость: поддержка, время вычислений, администрирование и так далее. Значит, у теста может быть чистая отрицательная ценность. И это четвертая категория тестов, которые необходимо удалять. Такие тесты, не смотря на их способность что-то проверять, в действительности ничего не проверяют.

Если вы не можете объяснить, как сбой какого-либо юнит теста отражается на рисках, следует подумать о возможном удалении такого теста. При отсутствии формальных критериев корректности, существуют более эффективные техники устранения изъянов в качестве, такие как исследовательское тестирование и методы Монте-Карло. (Они великолепны, и я рассматриваю их как отдельную категорию в отличие от того, о чем рассказываю здесь.) Не используйте юнит тесты для подобных проверок.

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

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

1.6 Сложное - сложно

Существует следующая дилемма: большая часть интересных показателей о качестве определенных программ находиться в распределении результатов тестирования, несмотря на то, что традиционные подходы к статистике, всё же, предоставляют ложную информацию. Так, в 99,99% всех случаев тест может быть успешным, но однажды упав за десять тысяч раз, он убьет вас. Опять же, заимствуя аналогию из мира железа, для уменьшения вероятности ошибки до сколь угодно низкого уровня, вы можете всё проектировать с учетом заданной вероятности отказа или же провести анализ наихудшего случая (WCA). Специалисты по аппаратному обеспечению обычно используют WCA при проектировании асинхронных систем для защиты от сбоев в сигналах, выходящих за пределы проектных параметров: один сбой на 100 миллионов раз. В области аппаратного обеспечения, сказали бы, что коэффициент качества (FIT rate) такого модуля равняется 10 - десять отказов на триллион (Failures In a Trillion).

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

Приятно читать статью, проливающую свет на причину моего успеха (и остальной части моей команды). Возможно, Вы уже знаете, что я инженер по авионике, чья карьера началась с разработки встраиваемого программного обеспечения, и, отчасти, разработки оборудования. И вот с таким образом мышления, ориентированным на особенности работы оборудования, я начал тестировать свое программное обеспечение. (Команда состояла из четырех человек: 3-х инженеров-электриков из Делфтского университета (включая меня, в качестве специалиста по авионике) и одного инженера-программиста (из Гаагского университета). Мы были очень дисциплинированы в разработке систем безопасности для банков, пенитенциарных учреждений, пожарных, полицейских участков, служб экстренной помощи, химических заводов и т.д. В каждом из случаев всё должно было правильно заработать с первого раза.)

Обычно, при наличии разумных предположений, вы можете провести WCA аппаратного обеспечения, из-за легко прослеживаемых причинно-следственных связей: можно взять схему и разобраться в причинах изменения состояний в элементах памяти. Состояния в машине фон Неймана изменяются в результате побочных эффектов (side effects) выполнения функции, и, как правило, отследить причину такого изменения нереально, даже если некое состояние является достижимым. Объектная ориентация еще больше усугубляет ситуацию. Невозможно понять, какая из инструкций последней изменила состояние, используемое программой.

Большинство программистов убеждены, что построчное покрытие исходного кода, или, по крайней мере, покрытие ветвлений является вполне достаточным. Нет. С точки зрения теории вычислений, покрытие наихудшего случая означает анализ всевозможных комбинаций в последовательностях работы машинного языка, при котором гарантируется достижение каждой инструкции, а также - воспроизведение каждой возможной конфигурации битов данных в каждом из значений счетчика команд выполняемой программы. (Недостаточна и симуляция состояния среды выполнения только лишь для модуля или класса, содержащего тестируемую функцию или метод: как правило, любое изменение в каком-либо месте может проявиться в любом другом месте программы, а поэтому, потребует повторного тестирования всей программы. Формальное доказательство предложено в статье: Перри и Кайзера (Perry and Kaiser), Адекватное тестирование и объектно-ориентированное программирование (Adequate Testing and Objectoriented Programming), Журнал объектно-ориентированного программирования 2 (5), январь 1990 г., стр. 13). Даже взяв небольшую программу, мы уже попадаем в такое тестовое окружение, количество комбинаций в котором намного превышает количество молекул во Вселенной. (Мое определение понятия покрытие кода - это процент всех возможных пар, {Счетчик команд, Состояние системы}, воспроизводимых вашим набором тестов; все остальное - эвристика, которую, очевидно, вам сложно будет как-либо обосновать). Большинство выпускников бакалавриата смогут распознать проблему остановки (Halting Problem) в большинстве вариантов подобных задачах и поймут, что это невозможно.

1.7 Меньше - это больше или вы не шизофреник

Вот еще одна проблема, которая имеет особое отношение к первоначальному вопросу моего клиента. Наивный тестировщик пытается извлечь множество данных из результатов тестирования, при этом постоянно поддерживая все существующие тесты или даже добавляя новые; это приводит к точно такой же ситуации, в которой оказался мой клиент, когда сложность тестов (объемы кода или какие-только-хотите-метрики) начинает превосходить сложность исходного кода. Тестируемые классы - это код. Тесты - это код. Разработчики пишут код. Когда разработчики пишут код, они допускают около трех ошибок, непосредственно влияющих на систему, на каждые тысячу строк кода. Если бы мы случайным образом выбрали участки кода с подобными ошибками у моего клиента, включая тесты, то обнаружили бы, что в тестах содержиться код, который приводит к неправильным результатам чаще, чем реальный баг, останавливающий выполнение кода!

Некоторые мне говорят, что подобное не имеет к ним отношения, поскольку они уделяют значительно больше внимания тестам, чем исходному коду. Во-первых, это просто вздор. (Меня действительно смешат утверждающие, что, с одной стороны, они способны забывать о своих ранее сделанных предположениях во время создания изначального кода, и, с другой, те, кто может привнести свежий и независимый взгляд во время тестирования. Как первые, так и вторые должны быть шизофрениками.) Посмотрите, что делают разработчики при запуске тест-сьютов: они их запускают, но не думают (кстати, это же относится и к большей части Agile манифеста). На моей первой работе в Дании был проект, в значительной степени построенный на XP методологии и юнит тестировании. Я всячески пытался собрать билд на своей локальной машине, и после долгой борьбы с Maven и другими инструментами, наконец-то, мне это удалось. Каким же было разочарование, когда я обнаружил, что юнит-тесты не проходят. Пришлось обратиться к своим коллегам, которые сказали: О, так тебе нужно запустить Maven с вот этим флагом, он отключает вот эти тесты - из-за изменений эти тесты уже не работают, поэтому их необходимо отключить.

Если у вас 200, 2000, или 10 000 тестов, вы не будете тратить время на тщательное исследование и (кхе-кхе) рефакторинг каждого из них каждый раз, когда тест падает. Самая распространенная практика, которую я наблюдал, работая в стартапе еще в 2005 году, - это просто переписать результат старых тестов (ожидаемый результат или результаты вычислений такого теста) новыми результатами. С психологической перспективы, зеленый статус - это вознаграждение. Современные быстрые машины создают иллюзию возможности замены мышления программиста; их скорость намекает на исключение моей необходимости мыслить. Ведь, в любом же случае, если клиент сообщит об ошибке, я, в свою очередь, сформулирую гипотезу о ее действительной причине, внесу изменения, исправляющие поведение системы, и, в результате, с легкостью смогу себя убедить, что функция, в которую я добавил исправление, теперь работает правильно. То есть я просто переписываю результат выполнения этой функции. Однако, подобное - просто лженаука, основанная на колдовстве, связь с которым - причинность. В таком случае, необходимо повторно запустить все регрессионные и системные тесты.

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

1.8 Вы платите за поддержку тестов и качество!

Суть в том, что код - это часть вашей системной архитектуры. Тесты - это модули. Тот факт, что кто-то может не писать тесты, не освобождает его от ответственности заниматься проектированием и техническим обслуживанием возрастающего количества модулей. Одна из методик, которую часто путают с юнит-тестированием, но использующая последнее в качестве техники - это разработка через тестирование (TDD). Считается, что она улучшает метрики сцепления и связности (coupling and coherence), хотя, эмпирические данные свидетельствуют об обратном (одна из статей, опровергающих подобное представление на эмпирических основаниях принадлежит Янзену и Саледиану (Janzen and Saledian), Действительно ли разработка через тестирование улучшает качество проектирования программного обеспечения? IEEE Software 25(2), март/апрель 2008 г., стр. 77 - 84.) Еще хуже то, что таким образом, в качестве запланированного изменения, вы уже вводите связанность (coupling) между каждым модулем и сопровождающими их тестами. У вас появляется необходимость относиться к тестам так же как и к системным модулям. Даже если вы удаляете их перед релизом, это никак не сказывается на необходимости их обслуживать. (Подобное удаление может быть даже достаточно плохой идеей, но об этом дальше.)

Я обратил внимание, что подавляющая часть юнит-тестов, особенно созданных с помощью JUnit, представляет собой замаскированные утверждения (assertions). Когда же я создаю какое-нибудь восхитительное программное обеспечение, то засыпаю его подобными утверждениями, описывая ожидаемые мной результаты, и по отношению к тем, кто вызывает мои функции, и в отношении самих функций, которые обязаны что-то предоставить своим клиентам. Такие проверки дорабатываются в том же артефакте, где находится и остальная часть моего кода. Множество энвайерментов предоставляют средства административной стерилизации этих тестов во время релиза.

Более профессиональный подход - оставить эти асершены в коде даже после релиза, автоматически отправлять отчеты об ошибках от имени конечного пользователя и, возможно, пытаться перезапускать приложение каждый раз, когда подобная проверка провалена. В одном из ранее упомянутых стартапов, мой босс настаивал, чтобы мы такого не делали. Я указал, что отрицательный результат проверок означает: в программе что-то пошло совсем не так, и, скорее всего, данная программа выдаст неправильный результат. Даже мельчайшая ошибка в создаваемом нами продукте, может обойтись клиенту в 5 миллионов долларов дополнительных доработок. На что он ответил: Для компании намного важнее избегать видимости чего-то неправильно сделанного, чем останавливаться еще до получения ошибочных результатов. Я ушел из этой компании. Возможно, сегодня вы один из её клиентов.

Превратите юнит-тесты в утверждения (assertions). Используйте их для обеспечения отказоустойчивости архитектуры высокодоступных систем. Это решает проблему поддержки множества дополнительных программных модулей, которые оценивают ход выполнения (программы) и проверяют правильность (её) поведения; это одна часть юнит-тестирования. Другая - это драйвер, выполняющий сам код: в этом отношении, рассчитывайте, на свои стресс-, интеграционные и системные тесты.

Почти последнее, существуют такие юнит-тесты, которые повторяют системные, интеграционные или другие виды тестов. На заре вычислений, когда компьютеры были медленными, вместо того, чтобы дожидаться запуска системных тестов, юнит-тесты предоставляли разработчику более быструю обратную связь о том, сломало ли их изменение код. Сегодня, когда появились более дешевые и мощные компьютеры, этот аргумент кажется менее убедительным. Каждый раз, внося изменения в свое приложение Scrum Knowsy, я тестирую его на системном уровне. Разработчики должны непрерывно интегрироваться и, так же непрерывно проводить тестирование системы, а не сосредотачиваться на своих юнит-тестах и откладывать интеграцию, даже на час. Так что избавляйтесь от юнит-тестов, которые дублируют то, что уже делают системные тесты. Если системный уровень обходится слишком дорого, создайте наборы интеграционных тестов. Рекс (Rex) считает, что следующим большим скачком в тестировании будет разработка таких юнит, интеграционных и системных тестов, которые устраняют случайные упущения и дублирование.

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

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

1.9 Это процесс, глупец или лихорадка зеленого статуса

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

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

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

Системные тесты почти сразу же погружают вас в подобную позицию размышления. Разумеется, вам все еще нужна будет более подробная информация, для этого на помощь приходит отладка (debugging). Отладка - это использование инструментов и устройств, помогающих изолировать ошибку. Отладка - не тестирование. Во-первых, она представляет собой ad-hoc (интуитивную активность) и, во-вторых, выполняется на основании последовательного перехода от ошибки к ошибке. Юнит тесты могут быть полезным инструментом отладки. На собственном опыте я обнаружил, что лучше всего работает комбинация различных инструментов, среди которых наиболее эффективными являются наборы с невалидными данными и доступ к глобальному контексту, включающий все значения данных и случайную трассировку стека.

1.10 Подводим итоги

Вернемся к моему клиенту из компании Sogeti. Вначале, я упоминал его высказывание:

Когда я ежедневно программировал, то создавал действительно тестируемый код, однако почти никогда не писал никакие юнит тесты. При этом я заслуживал признание за свой качественный код и почти безошибочное программное обеспечение. Мне хотелось бы разобраться, ПОЧЕМУ такой вариант работал в моем случае?

Возможно, Ричард - это один из тех редких людей, которые знают, как думать самому, вместо того, чтобы позволять компьютеру думать за него - будь то проектирование системы или дизайн более низкого уровня. Я чаще наблюдаю подобную ситуацию в Восточноевропейских странах, где отсутствие общедоступного компьютерного оборудования заставляло людей мыслить. Просто не хватало компьютеров. Когда я впервые посетил Сербию в 2004 году, студенты из ФОН (факультета информатики) могли получить доступ к компьютеру для выхода в интернет один раз в неделю. Расплата за ошибку высока: если запуск вашего кода не отработает, придется ждать еще неделю, чтобы повторить свою попытку.

К счастью, я вырос именно в такой культуре программирования, мой код записывался на перфокартах, которые отдавались оператору для установки в очередь машины, а затем, через сутки, собирались результаты. Такой формат действительно заставлял вас или же задуматься - или же, потерпеть неудачу. У Ричарда из Sogeti было аналогичное воспитание: у них была неделя на подготовку кода и всего один час на его запуск. Всё должно было делаться правильно с первого раза. В любом случае, обдуманный проект должен оценивать возможные риски, связанные с затратами, и устранять их по одному в каждой итерации, уделяя особое внимание постоянно растущей ценности. Одна из моих любимых циничных цитат: Я считаю, что недели программирования и тестирования могут сэкономить мне часы планирования. Что меня больше всего беспокоит в культуре раннего провала (fail-fast), так это не столько понятие провала, сколько слово раннее. Много лет назад мой босс Нил Халлер мне сказал, что отладка - это не то, что вы делаете, сидя перед своей программой с отладчиком; это то, что вы делаете, откинувшись на спинку стула и глядя в потолок, или обсуждение ошибки с командой. Однако многие, якобы ярые приверженцы эджайл методологий, ставят процессы и JUnit выше людей и взаимодействий.

Лучший пример, услышанный мной в прошлом году, был от моей коллеги, Нэнси Гитинджи (Nancy Githinji), управлявшей вместе со своим мужем IT-компанией в Кении; сейчас они оба работают в Microsoft. Последний раз, посещая свой дом (в прошлом году), она познакомилась с детьми, которые проживают в джунглях и пишут программы. Они могут приезжать раз в месяц в город, чтобы получить доступ к компьютеру и апробировать свой код. Я хочу нанять этих детей!

Мне, как стороннику эджайла (да и просто из принципа), немного больно признавать, что Рекс оказался прав, как, впрочем-то это было и ранее , достаточно красноречиво сказав: В этой культуре раннего провала (fail fast) есть нечто небрежное, она побуждает швырнуть кучу спагетти на стену, особо даже не задумываясь отчасти, из-за чрезмерной уверенности в заниженных рисках, предоставляемых юнит-тестами. Культура раннего провала может хорошо работать при очень высокой дисциплине, подкрепленной здоровым скептицизмом, однако редко можно встретить такое отношение в динамичном IT-бизнесе. Иногда ошибки требуют обдумывания, а последнее требует больше времени, чем результаты, достигаемые ранним провалом. Как только что напомнила моя жена Гертруда: Никто не хочет, чтобы ошибки затягивались на долго

Если вы наймете профессионального менеджера или консультанта по тестированию, он сможет помочь вам разобраться в вопросах более масштабной картины тестирования: интеграционным и системным тестированием, а также подходящим для них инструментарием и процессами. Это важно. Но не забывайте о позиции продакт овнера, бизнес-аналитика или менеджера в Scrum: управление рисками находится в центре их деятельности, и, возможно, именно поэтому Джефф Сазерленд говорит, что заказчик должен разрабатывать (а в лучшем случае и проектировать) системные тесты для использования в качестве входных данных до или во время планирования спринта.

Что касается Интернета: грустно и откровенно страшно, что там так много всего. Много советов, но очень мало из них подкреплено теорией, данными или даже моделью того, почему необходимо довериться тому или иному совету. Хорошее тестирование немыслимо без скептицизма. Относитесь скептически к себе: измеряйте, доказывайте, делайте повторные попытки. Ради всего святого, отнесись ко мне скептически.

Пишите мне свои комментарии на jcoplien@gmail.com с копией Рексу вначале этого письма.

В заключение:

  • Сохраняйте регрессионные тесты до года, большинство из них должны быть тестами системного уровня, а не юнит-тестами.

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

  • Исключая предыдущее заявление, если у X есть определенная бизнес-ценность и вы можете протестировать X системным или же юнит-тестом, используйте системный: контекст - это всё.

  • Разрабатывайте тест более тщательно, чем код.

  • Превратите большинство юнит-тестов в утверждения (assertions).

  • Удалите тесты, которые за год ни разу не падали.

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

  • Если вы обнаруживаете, что определенные тестируемые функции тривиальны, еще раз перепроверьте способы стимуляции производительности разработчиков. Одобрение за покрытие или другие бессмысленные метрики могут привести к быстрому разрушению архитектуры.

  • Будьте скромны в отношении способностей тестов. Тесты не улучшают качество: это делают разработчики.

Подробнее..

Прокачиваем Android проект с GitHub Actions. Часть 1

03.12.2020 10:23:07 | Автор: admin

Привет!

Это пост для тех, кто заинтересовался возможностями GitHub Actions, но никогда не имел опыта реальной настройки build-систем. Примеры будут полезны как для прокачки собственного pet-проекта, так и для понимания, как настраивается CI/CD, если по работе нет связанных с этим задач.

Что будет рассмотрено:

  • Основные понятия для построения CI/CD на GitHub Actions.

  • Настроим работающий workflow который запускает Unit-тесты при создании pull request.

  • Добавим бейджики со статусом созданных workflow в репозиторий.

  • Настроим работающий workflow для сборки релизных артефактов APK и AAB.

  • Научимся безопасно подписывать ключом релизный APK.

GitHub Actions был выбран для примеров, потому что позволяет не углубляясь в инфраструктурные сложности с развёртыванием своего собственного CI-сервера буквально за день собрать работающий пайплайн для прогона тестов, подписи приложения и даже загрузки в Google Play. Кроме того, у GitHub Actions полная интеграция с GitHub, очень легко взаимодействовать с репозиторием. Для открытых репозиториев услуга бесплатная, для закрытых предусмотрены разные тарифные планы.

Но главное преимущество GitHub Actions состоит в возможности переиспользовать готовые блоки бизнес-логики (actions), причём не только свои собственные. На большинство самых распространённых задач уже скорее всего есть свой Action, который вы можете включить в свой пайплайн! Какие экшены уже написаны участниками сообщества, можно посмотреть наhttps://github.com/marketplace?type=actions

Примеры будут настраиваться на самом простом проекте с одной пустой Activity из шаблонов Android Studio и на новом пустом репозитории в GitHub.

Общие слова про Github Actions

Если кто-то представляет себе, как собирают автомобили на заводах, это неплохая иллюстрация к тому, чем вообще занимается CI/CD.

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

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

Основные понятия

Вот так по блокам можно представить, как структурирован workflow в Github Actions.

Runner

Это развёрнутый в облаке от GitHub или self-hosted сервер с настроенным окружением икоторый может запускать workflow внутри себя.

Workflow

Это независимый процесс, автоматически запускаемый на GitHub Actions в отдельном контейнере по получению Event. Каждый workflow описывается отдельным YAML-файлом.

Состоит из более мелких структурных единиц исполнения - Jobs.

Job

Составная часть workflow, в свою очередь состоит из отдельных шагов Steps. Jobs могут быть настроены на параллельное и последовательное выполнение.

Step

Еще более мелкая единица исполнения скрипта, состоит из набора команд или действий.

Actions

Самая маленькая структурная единица исполнения скрипта workflow. Action может делать в принципе всё что угодно, например, проставлять теги с версией приложения в Git или отправлять собранный AAB в Google Play.

Можно писать как собственный Action, так и пользоваться готовыми. Action по сути выступает наравне с другими командами внутри Step.

Самые распространённые Action - это checkout на коммит и установка Java-окружения. По умолчанию, если специально не встать на нужный коммит, Job ничего не знает о проекте, из которого он запущен.

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

- uses: actions/checkout@v1- uses: actions/setup-java@v1

Event

Внутренние или внешние события, которые запускают workflow. Commit, pull request, comment, tag - все эти события могут быть использованы в ваших скриптах как триггер для старта каких-то действий. Еще workflow может быть настроен на ручной запуск (https://github.blog/changelog/2020-07-06-github-actions-manual-triggers-with-workflow_dispatch/) и запуск по cron расписанию (https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows#scheduled-events)

Hello, world!

Всё, что связано в GitHub Actions, располагается на вкладке Actions в репозитории.

При создании нового workflow GitHub пытается проанализировать содержимое репозитория и предлагает шаблон на выбор. На самые популярные сценарии сборки и деплоя можно найти заготовки.

Все workflow конфигурируются через файлы в формате YAML, это фактически стандарт для CI/CD-систем.

Чтобы GitHub Actions начала выполнять таски, необходимо положить их в определённым образом названную директорию в корне проекта github/workflows.

Добавлять и редактировать конфиги можно как в Android Studio, так и в самом GitHub на вкладке Actions. Так и поступим.

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

GitHub сразу же подставляет самый простой скрипт, который делает checkout на новый коммит, выводит в консоль несколько строк и заканчивает работу. Что-то ещё проще придумать сложно, но даже в этом примере есть что посмотреть.

# This is a basic workflow to help you get started with Actionsname: CI# Controls when the action will run. Triggers the workflow on push or pull request # events but only for the develop branch on: push: branches: [ main ] pull_request: branches: [ main ]# A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: # This workflow contains a single job called "build" build: # The type of runner that the job will run on runs-on: ubuntu-latest# Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v2# Runs a single command using the runners shell - name: Run a one-line script run: echo Hello, world!# Runs a set of commands using the runners shell - name: Run a multi-line script run: | echo Add other actions to build, echo test, and deploy your project.

Сделать коммит с новым скриптом можно прямо из веб-интерфейса GitHub.

Но когда этот workflow будет отрабатывать? Ведь мы раньше упоминали, что в GitHub Actions все workflow запускаются не сами по себе, а только при получении того eventа, который прописан в самом yml-скрипте.

on: push: branches: [ main ] pull_request: branches: [ main ]

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

Стойте, так мы ведь только что сделали коммит с новым hello_world.yml, получается, он уже должен был сработать? Совершенно верно, можно прямо сейчас зайти в раздел actions и посмотреть результат работы скрипта.

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

Запуск unit-тестов на каждый pull request в main

Первый YAML-скрипт мы создавали в веб-интерфейсе GitHub, теперь сделаем то же самое в Android Studio.

Чтобы увидеть директорию с YAML-файлами, нужно переключить режим просмотра на Project (если вдруг у вас был выбран режим Android).

Находим директорию workflows и создаём новый файл с типом YML. Назовём его, к примеру, run_unit_tests.yml.

Пока что всё, что мы хотим от скрипта, - это запускать unit-тесты на каждом pull request в ветку main. Можно скопировать целиком код из примера, всё должно работать. Если GitHub покажет, что в YAML ошибка, то проверить в первую очередь стоит правильность форматирования и количество отступов у блоков, так как формат чувствителен к этому.

name: PR_unit_testson:  pull_request:    branches:  - 'main'jobs:  Unit-test:  name: Run unit tests on PR in main  runs-on: ubuntu-20.04  steps:    - uses: actions/checkout@v2    - uses: actions/setup-java@v1      with: {java-version: 1.8}    - name: Run unit tests      run: ./gradlew test

actions/checkout@v2иactions/setup-java@v1подготавливают окружение для запуска тестов, первый выкачивает репозиторий и встаёт на нужный коммит, а второй устанавливает Java 8 - окружение. Это те самые Actions, которые даже упоминаются в названии, самые маленькие исполняемые единицы workflow. Можно рассматривать их как подключаемые к вашему workflow внешние библиотеки. Если интересно, что именно делают эти Actions, переходите по ссылкамhttps://github.com/actions/checkouthttps://github.com/actions/setup-java

run: ./gradlew test запускает тесты с помощью gradle wrapper.

Запускать можно всё то же самое, что и в консоли, доступны все команды shell. Можно ещё написать свой собственный shell-скрипт и просто запустить его в этом месте, например, так: run: ./run_unit_tests.sh

Тут открывается простор для автоматизации всего что только можно. Если раньше вы никогда самостоятельно не писали shell-скрипты, рекомендую прочитать книгу The Linux Command Line: a Complete introduction от William Shotts, оченьхорошеевведение в shell-автоматизацию.

Готово! Создаем любой пулл-реквест в ветку main и смотрим во вкладке Actions, что получилось.

Я специально испортил один unit-тест, чтобы показать ещё одну базовую настройку вашего CI/CD-пайплайна - запрет на merge в ветку main c поломанными тестами. Всё логично: если ваш новый коммит что-то поломает в бизнес-логике приложения, то автоматика не даст сделать по ошибке merge. Или по крайней мере предупредит о проблеме.

Настраивается это очень просто: заходим в Settings репозитория, вводим в Branch name pattern паттерн для тех веток, для которых хотим создать новое правило безопасности. В нашем случае можно ввести main. Далее проставляем галочки в нужных условиях правила и сохраняем.

Всё готово, вы только что создали своё первое правило для merge в своем репозитории.Смотрим теперь, как поведёт себя автоматика с pull-request, в котором поломаны тесты.

Работает! При желании можно запретить merge с проблемами даже для администраторов, там же в настройках merge protection rule.

Если хочется прямо сейчас самостоятельно что-то настроить, то вот несложное задание. Gradle task test, который мы запускали, генерит небольшой отчет по результатам запуска unit-тестов, всё лежит в app/build/reports/tests/testDebugUnitTest/

Попробуйте самостоятельно добавить после шагаRun unit tests ещё один шаг, который выкачивает отчет по тестированию.

Подсказка - использоватьactions/upload-artifact@v2

На этом часть про запуск unit-тестов закончена, дальше настроим сборку и подпись релизного APK.

Задачу сформулируем так:подготавливать нам APK и AAB и подписывать ключом из keystore. Причём сборку мы будем запускать только на pull request в main из веток с именем, начинающимся сrelease/

Задача стала чуть сложнее, поэтому будем рассматривать ее по шагам.

Шаг 1. Собираем APK и AAB. Пока не подписываем

name: Test_and_build_artifacts_on_releaseon: pull_request:   branches:     - 'main'jobs: build_apk_aab:   if: startsWith(github.head_ref, 'release/') == true   name: Build release artifacts   runs-on: ubuntu-20.04   steps:     - uses: actions/checkout@v2     - uses: actions/setup-java@v1       with: {java-version: 1.8}     - name: Build release APK and AAB after test       run: |         ./gradlew test         ./gradlew assembleRelease         ./gradlew bundleRelease     - name: Upload APK       uses: actions/upload-artifact@v2       with:         name: app-release.apk         path: app/build/outputs/apk/release/app-release-unsigned.apk     - name: Upload AAB Bundle       uses: actions/upload-artifact@v2       with:         name: app-release.aab         path: app/build/outputs/bundle/release/app-release.aab

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

if: startsWith(github.head_ref, 'release/') == true

Этот блок команд запускает, используя Gradle wrapper, тесты, а затем сборку APK и AAB. Обратите внимание, вертикальная черта позволяет запускать несколько shell-команд в одном блоке run.

run: |  ./gradlew test  ./gradlew assembleRelease --stacktrace  ./gradlew bundleRelease

Следующий шаг достанет после сборки APK и оставит его в виде артефакта в GItHub. Если этого не сделать, все временные файлы будут удалены после завершения workflow. Стоит обратить внимание, что APK остаётся неподписанным, мы просто не сконфигурировали пока ничего для этого. В таком виде APK его ещё нельзя выложить в Google Play, как настроить автоматическое подписание, будет рассказано дальше.

Подробнее про Actionupload-artifact@v2 можно посмотреть тут. Основное, что может этот Action, - это выкачать файл по имени либо целиком директорию и упаковать в zip-архив.

- name: Upload APK  uses: actions/upload-artifact@v2  with:    name: app-release.apk    path: app/build/outputs/apk/release/app-release-unsigned.apk

Аналогичным образом достаем и AAB-файл.

Шаг 2. Подписываем APK

Сначала немного теории, как и зачем вообще подписывать APK.

Цифровая подпись необходима для того, чтобы Google Play мог идентифицировать разработчика и в дальнейшем только он мог обновлять приложение, это крайне важная вещь в процессе размещения проекта в магазине приложений.

В целях безопасности цифровая подпись хранится не в открытом виде, а в специальном хранилище типа key value - файле с расширением jks или keystore. Сам файл хранилища стоит держать в надёжном месте, это, можно сказать, паспорт вашего приложения.

Как создать keystore

Если вы уже выложили своё приложение в Google Play, то ключ у вас точно есть. Если же нет - ниже простая инструкция.

Вариантов два - создать через консоль или через IDE.

  1. Консоль

$ keytool -genkey -v -keystore my_app_keystore.keystore -alias app_sign_key -keyalg RSA -keysize 2048 -validity 10000

my_app_keystore.keystore- это название самого хранилища, которое мы создаем.

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

10000- время жизни ключа в днях (примерно 27 лет).

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

2)В Android Studio

В меню студии заходим в Build -> Generate Signed Bundle / APK.

Дальше Next -> Create New и вводим всё то же самое: пароли, имя хранилища и имя ключа в хранилище.

Чтобы собранный APK успешно подписать ключом из хранилища, необходимо этот самый ключ достать по имени (key alias), предварительно получив доступ через пароль к хранилищу (store password) и пароль непосредственно к ключу (key password). Это происходит в рамках специального Gradle task, всё будет далее автоматизировано.

Дополнительная информация

https://developer.android.com/studio/build/building-cmdline#gradle_signing

https://developer.android.com/studio/publish/app-signing#secure-shared-keystore

https://developer.android.com/studio/publish/app-signing#sign-auto

И тут возникает два вопроса.

  1. Где хранить пароли от хранилища, не в открытом же виде прописывать их в конфигах?

  2. Как и куда выкладывать само хранилище ключей для открытого проекта?

Для хранения секретных данных, например, таких как идентификаторы приложения в Facebook, VK или Firebase, сервис GitHub предлагает механизмSecrets.

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

После добавления секретов к ним можно обращаться через специальный синтаксис прямо из YAML-скриптов. Например, вот так мы запишем EXAMPLE_API_KEY_1 в переменную окружения и затем в Gradle-скрипте, которому она понадобится, достанем её через System.getenv('EXAMPLE_API_KEY_1')

env:  API_KEY: ${{ secrets.EXAMPLE_API_KEY }}

Отлично, часть проблемы решена, но куда положить само хранилище?

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

Ничего кроме хранилища мы не собираемся помещать в новый приватный репозиторий. Мы будем клонировать его прямо в наш основной репозиторий в директорию app/keystore перед подписью APK-файла и доставать из него ключ с помощью паролей, который поместим в секцию Secrets в основном репозитории. Вот так будет выглядеть структура проекта на CI после клонирования репозитория с ключом в проект с основным проектом.

Звучит не очень сложно, смотрим, как такое настроить в GitHub Actions.

  1. Создаемприватныйрепозиторий и помещаем туда только хранилище ключей.

  2. Генерируем Personal access token для доступа к приватному репозиторию с хранилищем.

    Делаем всё по этой инструкции и не забываем сразу же скопировать сгенерированный токен. Этот токен будет играть роль логина и пароля при клонировании репозитория с хранилищем. Важно не запутаться и генерировать токен именно в том аккаунте, где расположен наш приватный репозиторий.

  3. Добавляем Personal access token из предыдущего шага в секреты основного проекта под любым именем, например KEYSTORE_ACCESS_TOKEN.

  4. Добавляем все пароли и key_alias от хранилища.

    Добавляем название аккаунта и имя приватного репозитория туда же в секреты основного проекта через слеш, что-то вроде another-account/secret-repo. Это понадобится нам дальше, когда будем клонировать репозиторий с ключом в YAML-скрипте.

  5. Оформляем workflow для сборки APK и AAB в YAML-файле.

name: Test_and_build_signed_artifacts_on_releaseon:  pull_request:    branches:      - 'main'env:  KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}  RELEASE_SIGN_KEY_ALIAS: ${{ secrets.RELEASE_SIGN_KEY_ALIAS }}  RELEASE_SIGN_KEY_PASSWORD: ${{ secrets.RELEASE_SIGN_KEY_PASSWORD }}jobs:  build_apk_aab:    if: startsWith(github.head_ref, 'release/') == true    name: Build release artifacts    runs-on: ubuntu-20.04    steps:      - uses: actions/checkout@v2      - uses: actions/setup-java@v1        with: {java-version: 1.8}      - name: Checkout keystore repo        uses: actions/checkout@v2        with:          repository: ${{ secrets.KEYSTORE_GIT_REPOSITORY }}          token: ${{ secrets.KEYSTORE_ACCESS_TOKEN }}          path: app/keystore      - name: Run tests and build release artifacts        run: |          ./gradlew test          ./gradlew assembleRelease --stacktrace          ./gradlew bundleRelease      - name: Upload signed APK        uses: actions/upload-artifact@v2        with:          name: app-release.apk          path: app/build/outputs/apk/release/app-release.apk      - name: Upload AAB Bundle        uses: actions/upload-artifact@v2        with:          name: app-release.aab          path: app/build/outputs/bundle/release/app-release.aab

За основу был взят workflow, который был описан ранее. Запускается так же на pull request в main, только из веток, начинающихся на release/*. Вы можете поменять так, как вам удобно, это просто для иллюстрации возможностей.

Что тут добавилось? Во-первых, в начале workflow записываются переменные окружения, вот тут:

env:  KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}  RELEASE_SIGN_KEY_ALIAS: ${{ secrets.RELEASE_SIGN_KEY_ALIAS }}  RELEASE_SIGN_KEY_PASSWORD: ${{ secrets.RELEASE_SIGN_KEY_PASSWORD }}

Далее последовательно делаем два checkout - сначала на коммит в созданном pull request (это было и раньше), потом делаем checkout приватного репозитория с хранилищем.

- name: Checkout keystore repo  uses: actions/checkout@v2  with:    repository: ${{ secrets.KEYSTORE_GIT_REPOSITORY }}    token: ${{ secrets.KEYSTORE_ACCESS_TOKEN }}    path: app/keystore

Тут уже есть особенности. Необходимо передать в checkout@v2 аргумент, в какой репозиторий стучаться (repository), токен для доступа к нему (token) и path. Path - это путь внутри директории с основным проектом, куда нужно сложить файлы. Мы хотим получить хранилище в app/keystore. В принципе, не обязательно именно такой путь, главное указать выбранный путь в Gradle, чтобы он понимал, где искать хранилище. Полную документацию по checkout@v2 можно почитать тут.

Дальше всё уже знакомое. Запускаем тесты и сборку релизной версии артефактов. На этом с workflow всё, дальше начинаем подготавливать build.gradle проекта.

Редактируем build.gradle

signingConfigs {   release {       def keystoreProperties = new Properties()       def keystorePropsFile = file("keystore/keystore_config")       if (keystorePropsFile.exists()) {           file("keystore/keystore_config").withInputStream { keystoreProperties.load(it) }           storeFile file("$keystoreProperties.storeFile")           storePassword "$keystoreProperties.storePassword"           keyAlias "$keystoreProperties.keyAlias"           keyPassword "$keystoreProperties.keyPassword"       } else {           storeFile file("keystore/my_app_keystore")           storePassword System.getenv('KEYSTORE_PASSWORD')           keyAlias System.getenv('RELEASE_SIGN_KEY_ALIAS')           keyPassword System.getenv('RELEASE_SIGN_KEY_PASSWORD')       }   }}buildTypes {   release {       signingConfig signingConfigs.release       minifyEnabled false       proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'   }}

Идея заключается в том, что в заранее указанную директорию (app/keystore/) на CI автоматически добавится хранилище, а у себя локально мы можем без опаски хранить его в структуре проекта и даже положить туда файл с паролем в открытом виде. Это если нам хочется собирать и подписывать APK локально.

Главное при этом добавить в gitignore всё содержимое app/keystore, чтобы случайно секретная информация не утекла с очередным коммитом.

*.iml.gradle/local.properties/.idea/caches/.idea/libraries/.idea/modules.xml/.idea/workspace.xml/.idea/navEditor.xml/.idea/assetWizardSettings.xml.DS_Store/build/captures.externalNativeBuild.cxxlocal.properties/keystore # <-- вот эту строчку мы добавили

Чтобы Gradle понимал, где ему брать my_app_keystore в случае запуска assembleRelease локально и на CI, делаем нехитрую проверку. Сначала ищем keystore_config в директории keystore. Не нашли - делаем вывод, что нас запустили на CI и пароль следует брать не из keystore_config-файла, а из переменных окружения.

keystore_config - тут стандартный способ хранить в открытом виде пароли, внутри он состоит из пар key=value. Всё то же самое, что мы записывали в секреты на GitHub, но в открытом виде.

storeFile=keystore/my_app_keystorestorePassword=654321keyAlias=sign_apk_keykeyPassword=123456

Само зашифрованное хранилище кладём рядом, в той же директории.

Если потребности подписывать APK локально нет или хочется вручную запускать процесс через Generate Signed Bundle / APK, выбирая каждый раз нужный keystore, то можно всё упростить и оставить только часть про System.getenv()

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

Пробуем запустить на CI

Отлично! То, что нам нужно, - готовый к релизу артефакт, собранный автоматически на GitHub Actions.

Запускаем локально.

В Android Studio переходим в терминал, запускаем.

./gradlew assembleRelease

После успешного завершения подписанный APK будет ждать нас в app/build/outputs/apk/release.

На этом со сборкой артефактов можно закончить, самые базовые кейсы рассмотрены.

Чтобы потренироваться с этим, предлагаю вам самостоятельно настроить подпись для debug-сборок.

Иеще одно самостоятельное задание для заинтересовавшихся: чтобы сделать проект еще красивее, по проставлению в git tag версии приложения (например, v1.0.0) собирать APK, подписывать и складывать в разделе релизов прямо в GitHub репозитории с правильным названием, включающим версию из tag.

Подсказка - удобно использоватьhttps://github.com/actions/create-release, там в описании есть похожий на нашу задачу workflow.

Выводим на README.MD статус выполнения workflow

Здорово смотрятся бейджи со статусом прохождения этапов CI на главной странице репозитория. Наверняка вы много раз видели похожие бейджики с процентом покрытия тестами кода, статусом сборки и так далее. Давайте прямо сейчас сделаем такие для статуса прохождения unit-тестов, для этого у нас уже всё есть.

Итак, у нас уже есть несколько workflow. Документацияочень подробная и с примерами.

ОткрываемREADME.mdи пишем что-то вроде этого, подставляя своё реальное имя на GitHub, название текущего репозитория и имя workflow, для которого хочется иметь бейджик.

Feature branch Unit tests status![PR_unit_tests](http://personeltest.ru/aways/github.com/{your_github_acc_name}/{repository_name}/workflows/PR_unit_tests/badge.svg)Main branch status![main](http://personeltest.ru/aways/github.com/{your_github_acc_name}/{repository_name}/workflows/Hello_world/badge.svg)

Сохраняем и смотрим результат.

По-моему, круто всего для двух минут настройки. Теперь всегда будет видно текущий статус прогона тестов. А ещё можно будет добавить статус сборки APK для релизных веток, что-нибудь от статического анализатора кода вроде Sonarcube, в общем, всё, что пожелаете.

На этом первая часть рассказа про GitHub Actions заканчивается.За короткое время мы смогли настроить очень неплохую автоматизацию для проекта.

В следующей части продолжим тему тестирования и посмотрим как настроить запуск UI тестов в Firebase Test Lab. Не пропустите, будет интересно.

Подробнее..

Основы Flutter для начинающих (Часть IX)

11.06.2021 16:11:37 | Автор: admin

Flutter позволяет вам писать простые и понятные тесты для разных частей приложения.

Сегодня мы попробуем написать несколько unit тестов, которые используются для тестирования классов, методов и отдельных функций.

Также мы попробуем использовать библиотеку Mockito, которая позволяет создавать фейковые реализации.

Ну что ж, приступаем к тестированию!

Наш план
  • Часть 1- введение в разработку, первое приложение, понятие состояния;

  • Часть 2- файл pubspec.yaml и использование flutter в командной строке;

  • Часть 3- BottomNavigationBar и Navigator;

  • Часть 4- MVC. Мы будем использовать именно этот паттерн, как один из самых простых;

  • Часть 5- http пакет. Создание Repository класса, первые запросы, вывод списка постов;

  • Часть 6- работа с формами, текстовые поля и создание поста.

  • Часть 7- работа с картинками, вывод картинок в виде сетки, получение картинок из сети, добавление своих в приложение;

  • Часть 8 - создание своей темы, добавление кастомных шрифтов и анимации;

  • Часть 9 (текущая статья) - немного о тестировании;

Добавления необходимых зависимостей

Нам понадобиться два дополнительных пакета mockito и build_runner, поэтому добавим их:

# зависимости для разработки# в данном случае подключено тестированиеdev_dependencies:  flutter_test:    sdk: flutter  mockito: ^5.0.10  build_runner: ^2.0.4

Теперь мы можем приступать к тестированию

Пишем первый тест

В качестве объекта тестирования будет небольшой класс Stack:

class Stack<T> {  final stack = <T>[];    void push(T t) {    stack.add(t);  }    T? pop() {    if (isEmpty) {      return null;    }    return stack.removeLast();  }    bool get isEmpty => stack.isEmpty; }

Обратите внимание: класс Stack является обобщенным.

В корневой директории нашего проекта есть папка test, которая предназначена для тестов.

Создадим в ней новый файл stack_test.dart:

import 'package:flutter_test/flutter_test.dart';import 'package:json_placeholder_app/helpers/stack.dart';void main() {  // группа тестов  group("Stack", () {    // первый тест на пустой стек    test("Stack should be empty", () {      // expect принимает текущее значение       // и сравнивает его с правильным      // если значения не совпадают, тест не пройден      expect(Stack().isEmpty, true);    });    test("Stack shouldn't be empty", () {      final stack = Stack<int>();      stack.push(5);      expect(stack.isEmpty, false);    });    test("Stack should be popped", () {      final stack = Stack<int>();      stack.push(5);      expect(stack.pop(), 5);    });    test("Stack should be work correctly", () {      final stack = Stack<int>();      stack.push(1);      stack.push(2);      stack.push(5);      expect(stack.pop(), 5);      expect(stack.pop(), 2);      expect(stack.isEmpty, false);    });  });}

Довольно просто! Не правда ли?

На самом деле, это один из типов тестирования, который называется unit (модульное).

Также Flutter поддерживает:

  • Widget тестирование

  • Интеграционное тестирование

В данной статье мы рассмотрим только unit тестирование.

Давайте выполним наши тесты командой flutter test test/stack_test.dart:

Успешно!

Тестируем получение постов

Сначала видоизменим метод fetchPosts:

Future<PostList> fetchPosts({http.Client? client}) async {  // сначала создаем URL, по которому  // мы будем делать запрос  final url = Uri.parse("$SERVER/posts");  // делаем GET запрос  final response =  (client == null) ? await http.get(url) : await client.get(url);  // проверяем статус ответа  if (response.statusCode == 200) {    // если все ок то возвращаем посты    // json.decode парсит ответ    return PostList.fromJson(json.decode(response.body));  } else {    // в противном случае вызываем исключение    throw Exception("failed request");  }}

Теперь переходим к написанию самого теста.

Мы будем использовать mockito для создания фейкового http.Client'а

Создадим файл post_test.dart в папке tests:

import 'package:flutter_test/flutter_test.dart';import 'package:http/http.dart' as http;import 'package:json_placeholder_app/data/repository.dart';import 'package:json_placeholder_app/models/post.dart';import 'package:mockito/annotations.dart';import 'package:mockito/mockito.dart';// данный файл будет сгенерированimport 'post_test.mocks.dart';// аннотация mockito@GenerateMocks([http.Client])void main() {  // создаем наш репозиторий  final repo = Repository();  group("fetchPosts", () {      test('returns posts if the http call completes successfully', () async {        // создаем фейковый клиент        final client = MockClient();        // ответ на запрос        when(client.get(Uri.parse('https://jsonplaceholder.typicode.com/posts')))            .thenAnswer((_) async => http.Response('[{"userId": 1, "id": 2, "title": "Title", "content": "Content"}]', 200));        // проверяем корректность работы fetchPosts        // при удачном выполнении        final postList = await repo.fetchPosts(client: client);        expect(postList, isA<PostList>());        expect(postList.posts.length, 1);        expect(postList.posts.first.title, "Title");      });      test('throws an exception if the http call completes with an error', () {        final client = MockClient();        // генерация ошибки        when(client.get(Uri.parse('https://jsonplaceholder.typicode.com/posts')))            .thenAnswer((_) async => http.Response('Not Found', 404));        // проверка на исключение        expect(repo.fetchPosts(client: client), throwsException);      });  });}

Перед запуском теста необходимо сгенерировать post_test.mocks.dart файл:

flutter pub run build_runner build

После этого выполняем наши тесты командой flutter test test/post_test.dart:

Вуаля!

Заключение

Мы разобрали один из самых простых и известных типов тестирования - unit (модульное).

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

Полезные ссылки:

Всем хорошего кода!

Подробнее..

Перевод Не мокайте то, чем вы не владеете

25.04.2021 22:21:50 | Автор: admin

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

Веб-приложения зачастую созданы для обработки HTTP-запросов. Обычно объекты используются для инкапсуляции данных запроса. В зависимости от фреймворка у нас может быть такой интерфейс, как

interface HttpRequest{    public function get(string $name): string;    // ...}

или даже конкретный класс, такой как

class HttpRequest{    public function get(string $name): string    {        // ...    }    // ...}

которые мы можем (и должны) использовать для доступа к данным запроса.

В symfony, например, есть Symfony\Component\HttpFoundation\Request::get(). В качестве примера мы не будем беспокоиться о том, какой тип HTTP-запроса мы обрабатываем (GET, POST или другой). Вместо этого давайте сосредоточимся на неявных API, таких как HttpRequest::get(), и проблемах, которые они создают.

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

class SomeController{    public function execute(HttpRequest $request): HttpResponse    {        $id     = $request->get('id');        $amount = $request->get('amount');        $price  = $request->get('price');        // ...    }}

Мы не будем спорить о том, должен ли контроллер иметь один action-метод или несколько (подсказка: у него должен быть только один (eng видео)). Дело в том, что контроллеру необходимо извлекать и обрабатывать данные из HTTP-запроса.

Когда мы заменяем объект HttpRequest на тестовую заглушку (stub) или mock-объект для тестирования SomeController изолированно от сети и от фреймворка, мы сталкиваемся с проблемой множественных вызовов одного и того же метода get() с разными аргументами, которые представляют собой просто строки: 'id', 'amount' и 'price'.

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

Для тестирования SomeController изолированно от реального объекта HttpRequest мы можем использовать тестовую заглушку (stub) в unit тесте с PHPUnit примерно так:

$request = $this->createStub(HttpRequest::class);$request->method('get')        ->willReturnOnConsecutiveCalls(              '1',              '2',              '3',          );$controller = new SomeController;$controller->execute($request);

Если мы также хотим проверить связь между SomeController и объектом HttpRequest, нам понадобится mock-объект, для которого мы должны настроить ожидаемые значения в нашем тесте:

$request = $this->createMock(HttpRequest::class);$request->expects($this->exactly(3))        ->method('get')        ->withConsecutive(            ['id'],            ['amount'],            ['price']        )        ->willReturnOnConsecutiveCalls(            '1',            '2',            '3',        );$controller = new SomeController;$controller->execute($request);

Код, показанный выше, немного трудно читать, это запах кода (прим пер. на русском почитать можно тут).

Мы заявляем, что HttpRequest::get() необходимо вызывать три раза: сначала с аргументом id, затем с amount и, наконец, с price.

Если мы изменим реализацию SomeController::execute(), например изменим порядок вызовов HttpRequest::get(), наш тест завершится ошибкой. Это говорит нам о том, что мы слишком сильно связали наш тестовый код с рабочим кодом. Это еще один запах.

Настоящая проблема заключается в том, что мы работаем с HTTP-запросом, используя неявный API, где мы передаем строковый аргумент, определяющий имя параметра HTTP, в общий метод get(). И, что еще хуже, мы имитируем тип, которым не владеем: HttpRequest предоставляется фреймворком, а не находится под нашим контролем.

Мудрость не мокайте то, что вам не принадлежит берет свое начало в сообществе Лондонской школы разработки, основанной на тестировании. Как написали Стив Фриман и Нат Прайс в 2009 году в статье Развитие объектно-ориентированного программного обеспечения с помощью тестов:

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

Но если мы не должны мокать то, что нам не принадлежит, то как нам изолировать наш код от стороннего кода? Стив Фриман и Нат Прайс продолжили:

Мы [...] проектируем интерфейсы для сервисов, которые нужны для наших объектов, - интерфейсов, которые будут определяться в терминах домена наших объектов, а не внешней библиотеки. Мы пишем слой адаптера [...], который использует третье-сторонний API для реализации этих интерфейсов [...] "

Давайте применим это к нашему коду:

interface SomeRequestInterface{    public function getId(): string;    public function getAmount(): string;    public function getPrice(): string;}

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

Создать тестового двойника для SomeRequestInterface очень просто:

$request = $this->createStub(SomeRequestInterface::class);$request->method('getId')        ->willReturn(1);$request->method('getAmount')        ->willReturn(2);$request->method('getPrice')        ->willReturn(3);

С точки зрения фреймворка, стандартный объект HTTP-запроса является правильной абстракцией, потому что это работа фреймворка - представлять входящий HTTP-запрос в виде объекта. Однако это не должно мешать нам поступать правильно. Мы можем сопоставить общий объект HTTP-запроса фреймворка с нашим конкретным объектом запроса. Нам даже не нужен отдельный маппер. Мы можем просто обернуть общий запрос:

class SomeRequest implements SomeRequestInterface{    private HttpRequest $request;    public function __construct(HttpRequest $request)    {        $this->request = $request;    }    public function getId(): string    {        return $this->request->get('id');    }    public function getAmount(): string    {        return $this->request->get('amount');    }    public function getPrice(): string    {        return $this->request->get('price');    }}

И вот как мы заставляем этот код работать вместе:

class SomeController{    public function execute(HttpRequest $request)    {        return $this->executable->execute(            new SomeRequest($request)        )    }}

Даже если SomeController является подклассом базового класса контроллера, предоставляемого фреймворком, ваш фактический код остаётся независимым от HTTP абстракции фреймворка.

Вы, конечно, должны будете делать свою обертку request'a, специфичную для каждого контроллера. Вашему коду нужны определенные заголовки? Создайте метод, чтобы просто получить их. Вашему коду нужен загруженный файл? Создайте метод для получения именно этого.

Полный HTTP-запрос может содержать заголовки, значения, возможно, загруженные файлы, тело POST и т. д. Настройка тестовой заглушки или mock'а для всего этого, пока вы не владеете интерфейсом, мешает вам выполнить работу. Определение собственного интерфейса значительно упрощает задачу.

Подробнее..

Подсказки по написанию тестов в приложениях на Go

23.04.2021 14:18:12 | Автор: admin

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

Используем интерфейсы при разработке

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

package yourpackage import (    "context"     "github.com/go-redis/redis/v8") func CheckLen(ctx context.Context, client *redis.Client, key string) bool {    val, err := client.Get(ctx, key).Result()    if err != nil {    return false    }    return len(val) < 10  }

Тесты для неё могут выглядеть примерно так:

package yourpackage import (    "context"    "testing"     "github.com/go-redis/redis/v8") func TestCheckLen(t *testing.T) {    ctx := context.Background()    rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})    err := rdb.Set(ctx, "some_key", "value", 0).Err()    if err != nil {    t.Fatalf("redis return error: %s", err)    }     got := CheckLen(ctx, rdb, "some_key")    if !got {    t.Errorf("CheckLen return %v; want true", got)    }}

Но как проверить ситуацию, когда Redis возвращает ошибку? Или что делать, если мы не хотим добавлять Redis в наш CI? То есть как нам замокать вызов Redis? И ответ на эти вопросы используйте интерфейсы!

Перепишем наш код с использованием интерфейсов:

package yourpackage import (    "context"     "github.com/go-redis/redis/v8") type Storage interface {    Set(ctx context.Context, key string, v interface{}) error    Get(ctx context.Context, key string) (string, error)} type RedisStorage struct {    Redis *redis.Client} func (rs *RedisStorage) Set(ctx context.Context, key string, v interface{}) error {    return rs.Redis.Set(ctx, key, v, 0).Err()} func (rs *RedisStorage) Get(ctx context.Context, key string) (string, error) {    return rs.Redis.Get(ctx, key).Result()} func CheckLen(ctx context.Context, storage Storage, key string) bool {    val, err := storage.Get(ctx, key)    if err != nil {    return false    }    return len(val) < 10}

Интерфейсы не только упрощают написание тестов, но и в процессе разработки облегчают переход на другие технологии, например, замену Redis на Memcached. Тест же с использованием мока будет выглядеть примерно так:

package yourpackage import (    "context"    "testing") type testRedis struct{} func (t *testRedis) Get(ctx context.Context, key string) (string, error) {    return "value", nil}func (t *testRedis) Set(ctx context.Context, key string, v interface{}) error {    return nil} func TestCheckLen(t *testing.T) {   ctx := context.Background()    storage := &testRedis{}     got := CheckLen(ctx, storage, "some_key")    if !got {    t.Errorf("CheckLen return %v; want true", got)    }}

Используем генераторы моков

Понятное дело, что для каждого случая писать свой мок немного избыточно. Можно попробовать написать универсальный мок. А можно попробовать его сгенерировать на основе интерфейса. Существует множество генераторов моков. Нам нравится https://github.com/vektra/mockery.

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

mockery --recursive=true --inpackage --name=Storage

И дальше используем его в тестах следующим образом:

package yourpackageimport (    "context"    "testing"     mock "github.com/stretchr/testify/mock") func TestCheckLen(t *testing.T) {    ctx := context.Background()     storage := new(MockStorage)    storage.On("Get", mock.Anything, "some_key").Return("value", nil)     got := CheckLen(ctx, storage, "some_key")    if !got {    t.Errorf("CheckLen return %v; want true", got)    }

Перехватываем логирование

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

package yourpackage import (    log "github.com/sirupsen/logrus") func Minus(a, b int) int {    log.Infof("Minus(%v, %v)", a, b)    return a - b} func Plus(a, b int) int {    log.Infof("Plus(%v, %v)", a, b)    return a + b} func Mul(a, b int) int {    log.Infof("Mul(%v, %v)", a, b)    return a + b // тут ошибка}

И тесты к этому коду:

package yourpackage import "testing" func TestPlus(t *testing.T) {    a, b, expected := 3, 2, 5    got := Plus(a, b)    if got != expected {    t.Errorf("Plus(%v, %v) return %v; want %v", a, b, got, expected)    }} func TestMinus(t *testing.T) {    a, b, expected := 3, 2, 1    got := Minus(a, b)    if got != expected {    t.Errorf("Minus(%v, %v) return %v; want %v", a, b, got, expected)    }} func TestMul(t *testing.T) {    a, b, expected := 3, 2, 6    got := Mul(a, b)    if got != expected {    t.Errorf("Mul(%v, %v) return %v; want %v", a, b, got, expected)    }}

При запуске тестов мы видим, помимо ошибки, ещё логирование от других тестов:

time="2021-03-22T22:09:54+03:00" level=info msg="Plus(3, 2)"time="2021-03-22T22:09:54+03:00" level=info msg="Minus(3, 2)"time="2021-03-22T22:09:54+03:00" level=info msg="Mul(3, 2)"--- FAIL: TestMul (0.00s)yourpackage_test.go:55: Mul(3, 2) return 5; want 6FAILFAILgotest2/yourpackage 0.002sFAIL

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

package yourpackage import (    "io"    "testing"     "github.com/sirupsen/logrus") type logCapturer struct {    *testing.T    origOut io.Writer} func (tl logCapturer) Write(p []byte) (n int, err error) {    tl.Logf((string)(p))    return len(p), nil} func (tl logCapturer) Release() {    logrus.SetOutput(tl.origOut)} func CaptureLog(t *testing.T) *logCapturer {    lc := logCapturer{T: t, origOut: logrus.StandardLogger().Out}    if !testing.Verbose() {    logrus.SetOutput(lc)    }    return &lc} func TestPlus(t *testing.T) {    defer CaptureLog(t).Release()    a, b, expected := 3, 2, 5    got := Plus(a, b)    if got != expected {    t.Errorf("Plus(%v, %v) return %v; want %v", a, b, got, expected)    }} func TestMinus(t *testing.T) {    defer CaptureLog(t).Release()    a, b, expected := 3, 2, 5    got := Minus(a, b)    if got != expected {    t.Errorf("Minus(%v, %v) return %v; want %v", a, b, got, expected)    }} func TestMul(t *testing.T) {    defer CaptureLog(t).Release()    a, b, expected := 3, 2, 5    got := Mul(a, b)    if got != expected {    t.Errorf("Mul(%v, %v) return %v; want %v", a, b, got, expected)    }}

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

--- FAIL: TestMul (0.00s)yourpackage_test.go:16: time="2021-03-22T22:10:52+03:00" level=info msg="Mul(3, 2)"yourpackage_test.go:55: Mul(3, 2) return 5; want 6FAILFAILgotest2/yourpackage 0.002sFAIL

Здесь приведён пример для Logrus, но нечто похожее можно сделать с любой библиотекой логирования. Например, для библиотеки Zap есть отдельный модуль, который облегчает тестирование.

Считаем покрытие правильно

В Go всегда была какая-то странность с подсчётом покрытия кода тестами. Сначала нельзя было построить отчёт по покрытию для всех пакетов, написанных в рамках приложения. До сих пор в некоторых репозиториях можно встретить скрипты, похожие на этот, которые используются для запуска тестов по всем пакетам и объединения информации о покрытии в один отчёт. Сейчас с этим всё хорошо, но есть неочевидный момент.

Допустим, наш проект состоит из трёх пакетов. И мы хотим для них посчитать покрытие. Обращаемся за помощью к утилите cover, которая скажет нам примерно следующее:

$ go tool cover -helpUsage of 'go tool cover':Given a coverage profile produced by 'go test':    go test -coverprofile=c.out...Display coverage percentages to stdout for each function:    go tool cover -func=c.out

Пробуем:

$ go test -coverprofile=c.out ./...ok  gotestcover/minus   0.001s  coverage: 100.0% of statements?   gotestcover/mul [no test files]ok  gotestcover/plus    0.001s  coverage: 100.0% of statements

Уже из этого вывода видно, что у нас два пакета покрыты на 100 % и для одного пакета нет тестовых файлов. Получим отчёт о покрытии:

$ go tool cover -func=c.outgotestcover/minus/minus.go:4:   Minus       100.0%gotestcover/plus/plus.go:4: Plus        100.0%total:                      (statements)100.0%

Но тут что-то не так. В отчёте говорится о полном покрытии тестами. Хотя мы знаем, что это не так. Это всё потому, что при подсчёте покрытия не учитывается пакет, в котором нет тестов. Его не будет и в HTML-отчёте. И кажется, что это не всегда может быть корректным, потому что зачастую мы хотим знать покрытие всего кода, а не только того, для которого мы написали тесты. Это можно исправить, указав специальный параметр при запуске тестов:

go test -coverpkg=./... -coverprofile=c.out ./

И теперь отчёт выдаёт ожидаемый процент покрытия тестами:

$ go tool cover -func=c.outgotestcover/minus/minus.go:4:   Minus       100.0%gotestcover/mul/mul.go:4:   Mul         0.0%gotestcover/plus/plus.go:4: Plus        100.0%total:                      (statements)66.7%

Считаем покрытие при тестировании приложения как черного ящика

Писать тесты на Go довольно-таки сложно. И если вы разрабатываете какой-нибудь веб-сервис, то иногда бывает проще написать тесты на другом языке, например, на Python, и тестировать приложение как чёрный ящик.

Но возникает вопрос, а можно ли посчитать покрытие для такого способа тестирования? Да, посчитать можно. В целом, идея довольно проста. Пишем подобный тест:

func TestRunMain(t *testing.T) {    main()}

Запускаем его, потом интеграционные тесты, и завершаем наш тест. Звучит просто, но есть несколько нюансов. Зачастую надо сделать так, чтобы этот тест не запускался со всеми остальными тестами. Он особый, и для него должна быть отдельная логика запуска. Ещё функция main не должна приводить к выходу с ненулевым кодом возврата. И надо реализовать способ выхода из main по сигналу, не завершая при этом сам тест. То есть в целом надо реализовать для нашего web-сервиса graceful shutdown, что несложно сделать, и это в целом полезно. Давайте на примере реализуем небольшой web-сервис, протестируем его с помощью curl, и посчитаем покрытие тестами.

Сервис наш будет выглядеть следующим образом (взято с https://gobyexample.com/http-servers):

package main import (    "context"    "fmt"    "net/http"    "os"    "os/signal"    "time") func hello(w http.ResponseWriter, req *http.Request) {    fmt.Fprintf(w, "hello\n")} func headers(w http.ResponseWriter, req *http.Request) {    for name, headers := range req.Header {    for _, h := range headers {    fmt.Fprintf(w, "%v: %v\n", name, h)    }    }} func main() {    http.HandleFunc("/hello", hello)    http.HandleFunc("/headers", headers)     // Приложим некоторые усилия, чтобы приложение завершилось с нулевым кодом выхода    // Это важно для тестов, и в целом приятно    server := &http.Server{Addr: ":8090", Handler: nil}    // Запускаем приложение в отдельной горутине    go func() {    server.ListenAndServe()    }()     // А в текущей ждём сигнала об остановке приложения    quit := make(chan os.Signal, 1)    signal.Notify(quit, os.Interrupt)    <-quit    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)    defer cancel()    server.Shutdown(ctx)}

И тест к нему:

// +build testrunmain package main import "testing" func TestRunMain(t *testing.T) {    main()}

Комментарий +build testrunmain говорит о том, что тест будет запускаться только в случае, если передан соответствующий tag. Запускаем наш тест:

$ go test -v -tags testrunmain -coverpkg=./... -coverprofile=c.out  ./...=== RUN   TestRunMain

Тестируем с помощью curl:

$ curl 127.0.0.1:8090/hellohello

И завершаем наше тестирование, нажав Ctrl+C:

$ go test -v -tags testrunmain -coverpkg=./... -coverprofile=c.out  ./...=== RUN   TestRunMain^C--- PASS: TestRunMain (100.92s)PASScoverage: 80.0% of statements in ./...ok  gobintest   100.926s    coverage: 80.0% of statements in ./

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

$ go tool cover -func=c.outgobintest/main.go:12:   hello       100.0%gobintest/main.go:16:   headers     0.0%gobintest/main.go:24:   main        100.0%total:              (statements)80.0%

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

Хотите узнать больше о тестировании в Go? Вот ещё несколько интересных статей на хабре: один, два, три.

Подробнее..

Как я на собеседование готовился 1

14.12.2020 06:13:47 | Автор: admin
Иногда так случается, что хочется сменить работу. В таком случае, у нас есть несколько вариантов:

  1. 1) Потребовать прибавки, чтобы получить дополнительную мотивацию работать
  2. 2) Потребовать отпуск, чтобы отдохнуть от надоевших задач и набраться сил.
  3. 3) Сменить работу, выложив резюме, в надежде, что Вы получите предложение, которое будет Вас удовлетворять.


Если Вы все таки выбрали 3й вариант, то Вы поступили, как я. И я получил предложение мечты: родной стек(последнее время, я был вынужден сменить любимый C#, на php, так ещё и bitrix framework), финтех и с прибавкой к зарплате. Конечно же, первый делом, я решил подновить знания. Этот текст это в первую очередь, моя шпаргалка, составленная с текста вакансии на должность Middle .NET Engineer моей мечты.


ORM Entity Framework / NHibernate


Entity framework и NHibernate это фреймворки, которые используют технологию ORM(объектно-ориентировочного отображения(маппинга)для сопоставления объектов(классов) с таблицами в БД. То есть ORM, условно, прослойка между кодом и базой данных, которая позволяет созданые в программе объекты складывать/получать в/из бд.

Плюсы EF


  • Позволяет создать таблицу кодом или же используя EF Designer и сгенерировать новую базу данных
  • Вы можете автоматизировать создание объектов, а так же отслеживать изменения этих объектов, чем упростить процес обновления базы данных
  • Использование единого синтаксиса(LINQ) для любых коллекций объектов, будь то данные из БД, или просто список; он достаточно быстр, при правильном использовании, а так же в меру лаконичен.
  • EF может заменить огромные куски кода, которые Вы бы писали самостоятельно.
  • Сокращает время разработки.
  • Поддерживает асинхронные операции с базами данных.

Минусы EF


  • Использование нетрадиционного подхода обработки данных, доступного не с каждой базой данных.
  • При любом изменении в схеме БД, EF откажется работать; потребуются изменения в коде.
  • SQL-код генерируются в некотролируемом виде, мы должны доверять разработчикам самого EF.
  • Слабо подходит для больших доменных моделей.


Плюсы NHibernate


  • Большие возможности маппинга.
  • Поддержка кэша второго уровня.
  • Отличная реализация Unit Of Work.
  • Вы можете использовать почти любую базу данных.
  • Очень популярный в корпоративной среде.
  • Поддерживает различные стратегии генерации идентификаторов из базы данных.

Минусы NHibernate


  • Большое время подготовки метаданных.
  • Написание сопоставления, может быть очень утомительным, ведь XML не самый дружелюбный язык.
  • Сложен в освоении, так как сложен сам процес поиска документации.


Writing unit tests frameworks


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

Популярные фрейморки:
  • xUnit.net
  • MS Test
  • NUnit

Начиная с Visual Studio 2019 специально для тестов были добавлены три типа проектов: xUnit Test Project(.NET Core), MSTest Test Project(.NET Core) и NUnit Test Project(.NET Core)

Я буду рассматривать xUnit
Тесты в xUnit определяются в виде методов, к которым применяются атрибуты Fact, Theory.
Fact- это отдельный тест, у которого нет параметров. Theory тест, принимающий параметры, может иметь несколько сценариев.
Пример
[Fact]    public void Should_do_somthing(){...}    [Theory]    [InlineData(20, 180, 80, good)]    [InlineData(20, 180, 50, bad)]    public void Should_measure_weight(int age, int height, decimal weight, string expected){...}



Существует целая парадиграма тестирования, которую xUnit реализует в полной мере: Arrange-Act-Assert.
  • Arrange: устанавливает начальные условия для выполнения теста
  • Act: выполняет тест (обычно представляет одну строку кода)
  • Assert: верифицирует результат теста


Arrange и Act это обычный код на C#, а Assert это отдельный класс с набором статических методов для проверки результатов.

Основные методы
  • All(collection, action): метод подтверждает, что все элементы коллекции collection соответствуют действию action
  • Contains(expectedSubString, actualString): метод подтверждает, что строка actualString содержит expectedSubString
  • DoesNotContain(expectedSubString, actualString): метод подтверждает, что строка actualString не содержит строку expectedSubString
  • DoesNotMatch(expectedRegexPattern, actualString): метод подтверждает, что строка actualString не соответствует регулярному выражению expectedRegexPattern
  • Matches(expectedRegexPattern, actualString): метод подтверждает, что строка actualString соответствует регулярному выражению expectedRegexPattern
  • Equal(expected, result): метод сравнивает результат теста в виде значения result и ожидаемое значение expected и подтверждает их равенство
  • NotEqual(expected, result): метод сравнивает результат теста в виде значения result и ожидаемое значение expected и подтверждает их неравенство
  • Empty(collection): метод подтверждает, что коллекция collection пустая
  • NotEmpty(collection): метод подтверждает, что коллекция collection не пустая
  • True(result): метод подтверждает, что результат теста равен true
  • False(result): метод подтверждает, что результат теста равен false
  • IsType(expected, result): метод подтверждает, что результат теста имеет тип expected
  • IsNotType(expected, result): метод подтверждает, что результат теста не представляет тип expected
  • IsNull(result): метод подтверждает, что результат теста имеет значение null
  • IsNotNull(result): метод подтверждает, что результат теста не равен null
  • InRange(result, low, high): метод подтверждает, что результат теста находится в диапазоне между low и high
  • NotInRange(result, low, high): метод подтверждает, что результат теста не принадлежит диапазону от low до high
  • Same(expected, actual): метод подтверждает, что ссылки expected и actual указывают на один и тот же объект в памяти
  • NotSame(expected, actual): метод подтверждает, что ссылки expected и actual указывают на разные объекты в памяти
  • Throws(exception, expression): метод подтверждает, что выражение expression генерирует исключение exception



Таким образом, мы прошли 2 из 7 пунктов резюме.
В следующей части:
DI frameworks
Application designs understanding (n-Tier, Onion).

Спасибо, что дочитали, таким образом, я понимаю, что не один я готовлюсь к чему-либо ;)

Материалы: раз, два, три
Подробнее..
Категории: Net , Orm , Unit-testing

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru