×Закрыть

Пишемо свої Gradle плагіни для автоматизації тестування

Я займаю позицію Lead Software Engineer in Test в EPAM. У цій статті розглянемо, як писати свої Gradle плагіни і які проблеми цим інструментом можна вирішити. У галузі автоматизації тестування, як, напевно, і в розробці загалом, часто зустрічаються рутинні задачі, які спочатку виконуються вручну, а потім люди втомлюються і якось їх автоматизують. Для цього зазвичай у хід ідуть скриптові мови програмування, які є в арсеналі у виконавця: Python, Bash, Excel VBA — кого вже як життя покидало.

Переважно ці рішення є ефективними, але але якщо у вас тести написані на Java і проект збирається за допомогою Gradle, то для деяких таких задач варто розглянути варіант написання свого Gradle плагіна, а саме для тих, які логічно було би прив’язати до компіляції проекту. У цій статті я наведу приклади такого рішення, які застосовую у власній практиці.

Наведені приклади також можна застосувати і до збірок Maven — там теж є плагіни, тільки Maven плагіни мають бути обов’язково окремими проектами, тоді як у Gradle є можливість їх реалізувати прямо в проекті з тестами, помістивши до магічної директорії buildSrc. Це, щоправда, має сенс, якщо плагіни тісно пов’язані з даним Gradle проектом і не будуть використовуватись в інших. Так ми уникаємо проблем із версіонністю і потребою в додаткових збірках на CI.

Контекст

Отже, уявімо, що ми маємо System Under Test у вигляді простого веб-застосунку за назвою Reminders. Там можна додавати, редагувати і видаляти нагадування. Кожне нагадування має три атрибути: текст, час і прапорець, чи виконано. Також ми маємо і UI тести, написані на Java з використанням TestNG і Selenide.

Код тестів і самого застосунку у фінальному вигляді можна подивитися тут.

Тепер розглянемо два Gradle плагіни, які могли би бути корисні для тестів.

Test Format Check Plugin

Такий вигляд у нас має тест зі створення нагадування:

@Test(description = "Create reminder",
        dataProvider = "createReminderData",
        groups = {Groups.SMOKE, Teams.ALPHA, Sprints._1})
@TestCase("REM-132")
public void createReminder(Reminder reminder) {
    NavigationSteps.openRemindersApp();
    ReminderSteps.addReminder(reminder);
    ReminderSteps.checkLastReminder(reminder);
}

Абстрагуймося від деталей реалізації і звернімо увагу на мета-дані тесту — список груп (зокрема, приналежність до команди і спринта), а також анотацію @TestCase, яка містить ідентифікатор задачі в якійсь трекінг-системі, наприклад JIRA. Схожий список атрибутів має переважна більшість фреймворків у тому чи іншому вигляді, і що більший проект (а точніше, що більше автоматизаторів одночасно працюють над ним), то гостріше стоїть потреба у перевірці наявності цих атрибутів і відповідності певному формату. Це нам дасть упевненість у завтрашньому дні вибірках за цими атрибутами як зараз, так і через кілька років, коли ми вже фізично не зможемо руками перевірити всі тести.

Таку перевірку можна робити вручну на code review, можна написати окремий скрипт, а можна включити просто до процесу збірки проекту, наприклад, наступним етапом після компіляції. Тоді кожен автоматизатор отримає найшвидший зворотний зв’язок про стан мета-даних своїх тестів, просто зібравши проект локально після своїх змін. Для цього нам і знадобиться Gradle плагін.

Спочатку робимо в корені проекту директорію buildSrc, в якій створюємо Gradle модуль, тобто свій build.gradle (build.gradle.kts) файл і директорію src, куди покладемо сам код плагіна. В нашому випадку це один клас, написаний на Kotlin. Власне, тут ви якраз можете потренуватись в будь-якій мові, яка компілюється в JVM bytecode, що я і зробив.

Директорія buildSrc у проекті:

Файл build.gradle.kts має такий вигляд:

plugins {
    kotlin("jvm") version "1.3.50"
    `java-gradle-plugin`
}

gradlePlugin {
    plugins {
        create("testFormatCheck") {
            id = "reminder-test-format-checker"
            implementationClass = "com.example.plugins.TestFormatCheckPlugin"
        }
    }
}

repositories {
    jcenter()
}

dependencies {
    compile("com.github.javaparser:javaparser-core:3.14.7")
}

Єдиною бібліотекою, потрібною для нашого плагіна, є javaparser. Вона вміє парсити Java код в абстрактне дерево, а також генерувати його, але ця функціональність у нашому випадку не потрібна. В блоці gradlePlugin ми даємо плагіну унікальний id і вказуємо клас імплементації, який має реалізувати інтерфейс org.gradle.api.Plugin.

І ось так виглядатиме наш клас TestFormatCheckPlugin:

package com.example.plugins

import com.github.javaparser.ast.body.MethodDeclaration
import com.github.javaparser.ast.expr.AnnotationExpr
import com.github.javaparser.ast.expr.MemberValuePair
import com.github.javaparser.ast.expr.NormalAnnotationExpr
import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr
import com.github.javaparser.utils.SourceRoot
import org.gradle.api.Plugin
import org.gradle.api.Project
import java.io.File

class TestFormatCheckPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        project.task("checkTestFormat") { task ->
            task.doLast {
                val errors = arrayListOf<String>()
                val packagePath = File("${project.projectDir}/src/test/java/com/example/tests").toPath()
                SourceRoot(packagePath)
                        .tryToParse("")
                        .filter { it.isSuccessful }
                        .map { it.result.get() }
                        .forEach { compilationUnit ->
                            compilationUnit.findAll(MethodDeclaration::class.java).forEach { method ->
                                if (method.isTest()) {
                                    errors.addAll(findErrorsWithTestCase(method))
                                    errors.addAll(findErrorsWithMandatoryTestProperties(method))
                                }
                            }
                        }
                if (errors.isEmpty()) {
                    println("Checking tests format passed")
                } else {
                    println("===================================================")
                    println("Errors in tests format:")
                    errors.forEach { println("> $it") }
                    println("===================================================")
                    throw RuntimeException("Checking tests format failed. See errors in log.")
                }
            }
        }
    }

private fun MethodDeclaration.isTest(): Boolean =
            this.findFirst(AnnotationExpr::class.java) { it.nameAsString == "Test" }.isPresent

    /**
     * TestCase annotation should be present and match certain format
     */
    private fun findErrorsWithTestCase(method: MethodDeclaration): List<String> {
        val result = arrayListOf<String>()
        val testCase = method.findFirst(SingleMemberAnnotationExpr::class.java) { it.nameAsString == "TestCase" }
        if (testCase.isPresent) {
            val testCaseValue = testCase.get().memberValue.asStringLiteralExpr().value
            if (!testCaseValue.matches(Regex("REM-\\d+"))) {
                result.add("@TestCase annotation does not match format 'REM-1234' for test " +
                        "${method.nameAsString}. Actual value: '$testCaseValue'")
            }
        } else {
            result.add("No @TestCase annotation found for test ${method.nameAsString}")
        }
        return result
    }

    /**
     * Mandatory attributes are description, team and sprint
     */
    private fun findErrorsWithMandatoryTestProperties(method: MethodDeclaration): List<String> {
        val result = arrayListOf<String>()
        val test = method.findFirst(NormalAnnotationExpr::class.java) { it.nameAsString == "Test" }.get()
        if (!test.findFirst(MemberValuePair::class.java) { it.nameAsString == "description" }.isPresent) {
            result.add("Description not found in @Test for method ${method.nameAsString}")
        }
        val groups = test.findFirst(MemberValuePair::class.java) { it.nameAsString == "groups" }
        if (groups.isPresent) {
            if (!groups.get().value.toString().contains("Teams.")) {
                result.add("No Team found for test method ${method.nameAsString}")
            }
            if (!groups.get().value.toString().contains("Sprints.")) {
                result.add("No Sprint found for test method ${method.nameAsString}")
            }
        } else {
            result.add("Groups not found in @Test for method ${method.nameAsString}")
        }
        return result
    }
}

Розглянемо, що тут взагалі відбувається. Реалізовуючи інтерфейс Plugin, ми маємо реалізувати метод apply(Project), де можемо розширити логіку проекту, як нам заманеться. А саме, ми створюємо task під назвою «checkTestFormat» і пишемо його конфігурацію. Зокрема, нам потрібно просто виконати якийсь блок коду, для цього використовуємо властивість doLast {...} і описуємо в ній ту логіку, яка має відпрацювати, коли буде викликана задача checkTestFormat.

Логіка ж задачі така: використовуючи javaparser,ми перебираємо всі Java файли з тестами, фільтруємо методи за анотацією @Test, потім збираємо список невідповідностей атрибутів (наприклад, наявність команди в групах тесту), і якщо список помилок не порожній, то виводимо його в консоль і валимо збірку викиданням RuntimeException.

Залишилося кілька штрихів. Застосовуємо плагін у нашому проекті: для цього в нашому основному build.gradle.kts файлі в блоці plugins додаємо id нашого плагіна:

plugins {
   java
   `reminder-test-format-checker`
}

Тепер ми можемо його викликати напряму в командному рядку:

$ gradlew checkTestFormat

Але так нецікаво. Краще додати його в обов’язковому порядку після компіляції. Тоді при запуску тестів навіть локально ця перевірка не дасть пропустити жоден обов’язковий атрибут тесту. Для цього додаємо таке в основний build.gradle.kts файл:

tasks.compileTestJava {
   finalizedBy("checkTestFormat")
}

Тепер в консолі будемо бачити таке повідомлення, якщо з атрибутами наших тестів все гаразд:

> Task :checkTestFormat
Checking tests format passed

BUILD SUCCESSFUL in 2s

І наприклад таке, якщо щось пішло не так:

> Task :checkTestFormat FAILED
===================================================
Errors in tests format:
> No Team found for test method createReminder
> No @TestCase annotation found for test updateReminder
> No Sprint found for test method updateReminder
> @TestCase annotation does not match format ’REM-1234′ for test deleteReminder. Actual value: ’REM-’
===================================================

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ’:checkTestFormat’.
> Checking tests format failed. See errors in log.

* Try:
Run with —stacktrace option to get the stack trace. Run with —info or —debug option to get more log output. Run with —scan to get full insights.

* Get more help at https://help.gradle.org

BUILD FAILED in 2s

Звичайно, подібний плагін також можна застосувати і до тестів у форматі BDD. Там навіть жодної бібліотеки не треба підключати: можна просто читати текстові файли з тестами і перевіряти відповідні атрибути за допомогою RegEx або іншим способом.

Swagger Codegen Plugin

У нас UI тести, але добре було б мати можливість викликати API напряму, щоб, наприклад, приготувати певні передумови для тесту, або і взагалі писати API тести. Отож, ми вирішуємо, що нам це потрібно, але як це правильно зробити? Напевно, перше, що спадає на думку, — підключити Rest-Assured, і вперед. На щастя, людство винайшло Swagger, тож якщо девелопери зробили вам таке щастя і підключили його, то ви будь-якої миті можете подивитись API специфікацію у читабельному вигляді і, відповідно, написати тести на потрібні вам точки доступу.

Але виявляється, що існує ще більш просунутий варіант. Людство не зупинилось на досягнутому і винайшло також Swagger Codegen — інструмент, який дозволяє згенерувати код API клієнта (точки доступу і сутності) на основі API специфікації, яку генерує Swagger. Він підтримує різні мови і бібліотеки для API клієнтів, але нас, зокрема, цікавлять Java і Rest-Assured. Існує command-line інструмент, але оскільки він написаний на Java, то нам зручніше викликати його програмно, підключивши як бібліотеку. Аби зрозуміти, де найкраще викликати цю генерацію, поміркуймо, які саме переваги вона нам дає. Крім того, що цей код не треба писати самому, Swagger Codegen дає можливість постійно перегенеровувати API клієнт, тож якщо ми будемо це робити щоразу безпосередньо перед компіляцією проекту, і в нас змінились сутності або точки доступу, які ми смикаємо в тестах, то компіляція впаде, і ми одразу будемо змушені виправити невідповідність. Знову ж таки, отримуємо швидший фідбек, і нам не треба дізнаватись про зміни в API, розбираючи тести, що впали — вони навіть не запустяться.

Отже, дописуємо ще один плагін. Для цього у файлі buildSrc/build.gradle.kts додаємо інформацію про нього і його залежності:

gradlePlugin {
    plugins {
        ...
        create("codegen") {
            id = "reminder-codegen"
            implementationClass = "com.example.plugins.SwaggerCodeGenPlugin"
        }
    }
}

dependencies {
    ...
    compile("io.swagger:swagger-codegen:2.4.7")
}

Тепер пишемо клас реалізації:

package com.example.plugins

import io.swagger.codegen.DefaultGenerator
import io.swagger.codegen.config.CodegenConfigurator
import org.gradle.api.Plugin
import org.gradle.api.Project
import java.io.File

const val SWAGGER_JSON_URL = "http://localhost:8080/v2/api-docs"
const val INVOKER_PACKAGE = "com.example.services.api.client"
const val API_PACKAGE = "com.example.services.api.controllers"
const val MODEL_PACKAGE = "com.example.entities.generated"

class SwaggerCodeGenPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        project.task("generateApi") { task ->
            task.doLast {
                // Clean up temp dir and existing API client packages
                File("${project.projectDir}/temp").deleteRecursively()
                deletePackageDirectories("${project.projectDir}",
                        listOf(INVOKER_PACKAGE, API_PACKAGE, MODEL_PACKAGE))

                // Configure and run code generation
                val config = CodegenConfigurator()
                config.inputSpec = SWAGGER_JSON_URL
                config.outputDir = "${project.projectDir}/temp"
                config.lang = "java"
                config.library = "rest-assured"
                config.additionalProperties = mapOf(
                        "invokerPackage" to INVOKER_PACKAGE,
                        "apiPackage" to API_PACKAGE,
                        "modelPackage" to MODEL_PACKAGE,
                        "dateLibrary" to "java8",
                        "hideGenerationTimestamp" to "true"
                )
                DefaultGenerator().opts(config.toClientOptInput()).generate()

                // Copy generated files to the corresponding directory in sources
                File("${project.projectDir}/temp/src/main/java")
                        .copyRecursively(File("${project.projectDir}/src/test/java"))

                // Clean up temp directory
                File("${project.projectDir}/temp").deleteRecursively()
            }
        }
    }
}

fun deletePackageDirectories(projectPath: String, packages: List<String>) {
    packages.forEach { pack ->
        File("$projectPath/src/test/java/${pack.replace(".", "/")}").deleteRecursively()
    }
}

Додамо плагін до блока plugins в нашому основному build.gradle.kts файлі:

plugins {
    ...
    `reminder-codegen`
}

У нас з’явився таск generateApi, і ми можемо його викликати:

$ gradlew generateApi

Він нам згенерує файли моделі (в нашому випадку це тільки Reminder), API клієнта і наших точок доступу (в нашому випадку ReminderControllerApi):

Непоганий результат, але ми ще не можемо смикати API в тестах, треба трохи допиляти напилком дописати код. Створюємо клас «степів» у пакеті steps.api:

package com.example.steps.api;

import com.example.entities.generated.Reminder;
import io.restassured.response.ResponseBody;

public class RemindersApiSteps extends BaseApiSteps {
    public static void addReminder(Reminder reminder) {
        apiClient
                .reminderController()
                .createUpdateUsingPUT()
                .body(reminder)
                .execute(ResponseBody::prettyPrint);
    }
}

У свою чергу абстрактний клас BaseApiSteps міститиме конфігурацію apiClient:

package com.example.steps.api;

import com.example.services.api.client.ApiClient;
import io.restassured.builder.RequestSpecBuilder;
import io.restassured.filter.log.ErrorLoggingFilter;

import static com.example.services.api.client.ApiClient.Config.apiConfig;
import static com.example.services.api.client.ApiClient.api;
import static io.restassured.config.RestAssuredConfig.config;

public abstract class BaseApiSteps {

    protected static ApiClient apiClient = api(apiConfig().reqSpecSupplier(
            () -> new RequestSpecBuilder()
                    .setConfig(config())
                    .addFilter(new ErrorLoggingFilter())
                    .setBaseUri("http://localhost:8080")
    ));
}

Тепер ми можемо використати додавання reminder-а через API як передумову для тесту з видалення, який виглядатиме приблизно так:

@Test(description = "Delete reminder",
        dataProvider = "deleteReminderData",
        groups = {Teams.GAMMA, Sprints._1})
@TestCase("REM-171")
public void deleteReminder(Reminder reminder) {
    RemindersApiSteps.addReminder(reminder);
    NavigationSteps.openRemindersApp();
    ReminderSteps.deleteLastReminder();
    CommonUiSteps.refreshPage();
    ReminderSteps.checkReminderIsAbsent(reminder);
}

Ще лишилося вирішити, де саме викликати генерацію класів API клієнта. В даному випадку я би все ж не включав її як обов’язкову перед стадією компіляції, адже якщо, наприклад, наш System Under Test зараз недоступний, то ламати в цей час збірку проекту у когось на комп’ютері не виглядає справедливим. В даному випадку доцільніше викликати генерацію явно:

$ gradlew generateApi test

Таку конфігурацію варто застосувати на CI після кожного коміта в Back-end: або генерація + компіляція тестів, або генерація + smoke API тести.

Висновок

Наведені приклади Gradle плагінів, як, напевно, і майже всі Gradle плагіни, можна було би замінити якимось скриптом, який лежить за межами проекту. Але коли в такій задачі з’являється потреба прив’язати її до якоїсь стадії збирання проекту, варто замислитись над перенесенням скрипта до плагіна.

Поділіться, будь ласка, в коментарях досвідом написання своїх Gradle плагінів: як позитивним, так і негативним. Буду радий розширенню арсеналу.

LinkedIn

2 комментария

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

Дякую! Коли мав відповідний проект, не мав часу це зробити, але зараз подосліджував.

Подписаться на комментарии