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

Мы используем библиотеку для описания бизнес-логики в коде в виде конечных автоматов с возможностью визуализации VisualFSM. Подробнее о работе библиотеки вы можете прочитать в статье автора библиотеки Василия Рылова.

Так выглядит граф конечного автомата, сформированного с помощью VisualFSM:

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

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

Концепция по формированию графа с покрытием следующая:

  1. Собираем два файла:

    1. Первый — с информацией о всех возможных переходах конечного автомата.

    2. Второй — с информацией о всех переходах, которые были вызваны во время работы инструментальных тестов.

  2. На основе информации из двух файлов формируем граф в dot-формате с отмеченным красным переходами, которые не были задействованы, и состояниями, которые не были достигнуты.

  3. С помощью пакета утилит graphviz отрендерим dot-файл.

В итоге мы получим графическое изображение с результатом анализа тестового покрытия:

Информацию со всеми переходами мы будем формировать в качестве csv-файла со строками вида:

[Имя перехода],[Имя State, из которого происходит переход],[Имя State, в который происходит переход]

Файл создадим при помощи VisualFSM. Она умеет генерировать такой файл при помощи KSP. Подробнее про то, как включить генерацию csv-файла со всеми переходами, можно прочитать в readme на github.

Информацию с осуществлёнными переходами также будем формировать в виде csv-файла со строками такого же вида, как и в файле со всеми переходами.

Файл будем формировать следующим образом:

Создадим интерфейс FSMCoverageLogger с двумя реализациями FSMCoverageLoggerStub и FileFSMCoverageLogger.

abstract class FSMCoverageLogger {

    abstract fun <STATE : State> logTransition(
        baseStateClass: KClass<STATE>,
        actionClass: KClass<out Action<STATE>>,
        transitionClass: KClass<out Transition<STATE, STATE>>,
        useTransitionName: Boolean,
    )

}

FSMCoverageLoggerStub — реализация-заглушка, которую мы будем использовать в production-коде. FileFSMCoverageLogger — реализация для записи информации о переходах в файл. Её мы будем использовать в тестовом коде.

class FileFSMCoverageLogger : FSMCoverageLogger() {

    private val instrumentation = InstrumentationRegistry.getInstrumentation()

    @Suppress("UNCHECKED_CAST")
    override fun <STATE : State> logTransition(
        baseStateClass: KClass<STATE>,
        actionClass: KClass<out Action<STATE>>,
        transitionClass: KClass<out Transition<STATE, STATE>>,
        useTransitionName: Boolean,
    ) {
        val directory = provide(File("ui_fsm_coverage"))

        val file = directory.resolve("${baseStateClass.simpleName}CoveredTransitions.csv")
        if (!file.exists()) {
            file.createNewFile()
        }

        val edgeName = VisualFSM.getEdgeName(transitionClass)

        val fromState = transitionClass.supertypes.first().arguments
            .first().type?.classifier as KClass<out State>
        val toState = transitionClass.supertypes.first().arguments
            .last().type?.classifier as KClass<out State>

        val fromStateName = fromState.getSimpleNameWithSealedName(baseStateClass)
        val toStateName = toState.getSimpleNameWithSealedName(baseStateClass)
        val textToWrite = "${edgeName},$fromStateName,$toStateName"

        writeLineIfNotExists(file, textToWrite)
    }

    private fun provide(dest: File): File {
        return provideNew(dest)
    }

    private fun provideNew(dest: File): File {
        val dir = instrumentation.targetContext.applicationContext.filesDir.resolve(dest)
        return dir.createDirIfNeeded()
    }

    /**
     * Creates a directory if it does not exist with all needed parent dirs, then grants RWX permissions.
     */
    private fun File.createDirIfNeeded(): File = apply {
        if (!exists()) {
            mkdirs()
            grantRwxPermissions()
        }
    }

    private fun File.grantRwxPermissions(): File = apply {
        setReadable(true, false)
        setWritable(true, false)
        setExecutable(true, false)
    }

    private fun writeLineIfNotExists(file: File, line: String) {
        if (!file.readLines().contains(line)) {
            file.appendText(line + "\n")
        }
    }

    private fun <STATE : State> KClass<out STATE>.getSimpleNameWithSealedName(baseStateClass: KClass<out STATE>): String {
        return this.qualifiedName!!.substringAfterLast("${baseStateClass.simpleName}.")
    }
}

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

abstract class BaseTransitionCallbacks<STATE : State>(
    private val fsmCoverageLogger: FSMCoverageLogger,
    private val baseStateClass: KClass<STATE>,
    private val useTransitionName: Boolean = true,
) : TransitionCallbacks<STATE> {

    @CallSuper
    override fun onTransitionSelected(action: Action<STATE>, transition: Transition<STATE, STATE>, currentState: STATE) {
        fsmCoverageLogger.logTransition(baseStateClass, action::class, transition::class, useTransitionName)
    }
}

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

После прогона UI-тестов нам будем необходимо вытянуть файл с информацией об осуществлённых переходах из устройства, на котором они выполнялись.

Для получения графа с покрытием в dot-формате напишем python-скрипт.

На вход скрипт будет принимать:

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

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

В скрипте найдём все состояния и переходы, которые не были задействованы во время выполнения теста. И сформируем граф, в котором такие состояния и переходы будут отмечены красным цветом.

Для преобразования графа из формата dot в графический формат svg будем использовать пакет утилит graphviz. Напишем bash-скрипт, который будет преобразовывать все находящиеся в каталоге файлы в svg и складывать в подкаталог с именем «svg»:

#!/bin/bash

mkdir "$1"/svg

for filename in "$1"/*.dot; do
    dot -Tsvg "$filename" > "$1/svg/$(basename $filename .dot).svg"
done

Теперь напишем bash-скрипт, который запустит UI-тесты и последовательно выполнит перечисленные выше шаги:

#!/bin/bash

./gradlew connectedDebugAndroidTest

rm -rf ui_fsm_coverage
mkdir ui_fsm_coverage
mv ./app/build/generated/ksp/debug/resources/ru/kontur/mobile/visualfsm/sample_android/feature/auth/fsm/* ./ui_fsm_coverage

adb pull /data/user/0/ru.kontur.mobile.visualfsm.sample_android/files/ui_fsm_coverage

python3 ui_fsm_coverage.py -input ui_fsm_coverage -output ui_fsm_coverage_results

bash dot-to-svg.sh ui_fsm_coverage_results

Теперь проверим работу скрипта на примере.

Напишем UI-тест, в котором мы зарегистрируем нового пользователя.

Запустим скрипт формирования графа — и получаем следующий результат:

Из графа видно, что все состояния, которые были связаны с регистрацией пользователя, были задействованы (Registration, ConfirmationRequested, AsyncWorkState.Registering).

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

Видим, что теперь состояния, отвечающие за логин (AsyncWorkState.Authenticating, UserAuthorized), тоже были задействованы при прохождении теста.

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

Подробно реализацию можно посмотреть в ветке coverage-graph репозитория VisualFSM-Sample-Android.

Скрипты из статьи вы можете использовать в своём CI для полной автоматизации получения графа покрытия.

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

  • Оценить уровень покрытия бизнес-сценариев инструментальными тестами.

  • Понять, какие именно сценарии не были покрыты.

  • Проводить регресс-тестирования с меньшими усилиями.

Комментарии (0)