Когда я писал свой первый gradle-плагин, я проверял его работоспособность следующим образом:

  1. Опубликовал версию n в plugins.gradle.org

  2. Проверил опубликованный плагин вручную на тестовом проекте

  3. Нашел ошибку/доработал, увеличил версию n=n+1, затем снова пункт 1

Такой вот PDD (Publish Driven Development). Сегодня поговорим о том, как писать эффективные тесты на собственные gradle плагины.

Сложности тестирования

Почему в начале я тестировал gradle-плагин именно через публикацию? Потому что я не знал, как тестировать иначе. Я не знал, как опубликовать gradle-плагин в maven local repository. Я не знал, как написать unit-тест для gradle-плагина. Поэтому я пошел самым легким путем. Тем, что наверняка сработает. Это, кстати, одна из причин, почему люди не пишут unit-тесты: они не знают, как это делать. В случае с gradle-плагином сложностей добавляет тот факт, что gradle - не самая простая система сборки. Обычно на вход подается некоторая часть build-скрипта, а на выходе какое-то действие, усложняющее работу build-скрипта. Повторить и проверить то же самое в unit-тестах сходу сложно.

Берешь и пишешь тест

Хорош ныть. Берешь и пишешь, чего сложного то. Какая там сигнатура у основного метода плагина?

@Override
public void apply(final Project project) {

То есть нужно просто подготовить объект project, передать его в метод apply, затем проверить, все ли верно отработало в этом apply. Тогда тестовый метод будет выглядеть следующим образом:

@Test
public void someSimpleTest() {
  Project project = Mockito.spy(Project.class);    
  Mockito.when(project.getExtensions()).thenReturn(...);
  Mockito.when(project. ...).thenReturn(...);
  ...
  new MyPlugin().apply(project);
  Mockito.verify(project, Mockito.times(1)).getExtensions();
  Mockito.verify(project). ... ;
  ...
}

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

Специальная подготовка проекта

Кто немножечко углублялся в тему тестирования gradle-плагинов, наверняка сталкивался с классом org.gradle.testfixtures.ProjectBuilder. Он позволяет подготовить project более естественным для плагина путем. Без тонких знаний реализации метода apply вашего плагина. В самом примитивном варианте можно сделать вот так:

Project project = ProjectBuilder.builder().build()

и этого будет достаточно, чтобы дальше применять ваш плагин и проверять, как он отработал. Можно будет через полученный экземпляр project проверить, были ли созданы ожидаемые Task-и или Extension-ы, например. Или даже, если ваш плагин зависит от project.afterEvaluate(...), то можно будет запустить evaluate проекта прямо в тесте с помощью:

project.evaluationDependsOn(":");

Такой подход позволяет более гибко и независимо от реализации apply настроить проект перед проверкой вашего плагина. Простой пример:

@Test
fun configurePluginWithEmptyExtension() {
  val projectFolder = tmpFolder.newFolder()
  val project = ProjectBuilder.builder()
    .withProjectDir(projectFolder)
    .build()
  project.pluginManager.apply("com.nikialeksey.arspell")
  project.evaluationDependsOn(":")
  MatcherAssert.assertThat(
    project.extensions.getByType(ArspellExtension::class.java),
    IsNull.notNullValue()
  )
  MatcherAssert.assertThat(
    project.tasks.getByName("arspell"),
    IsNull.notNullValue()
  )
}

Проверки из реальной жизни

После написания unit-тестов на gradle-плагин все равно остается соблазн проверить плагин в работе вручную “на всякий” сразу после публикации. Потому что часто вместе с плагином идет какой-нибудь хитрый DSL (Domain Specific Language) у его Extensions, который с одной стороны нужно правильно реализовать, и с другой у него есть несколько способов быть вызванным. Кроме того, ваш плагин может быть использован в gradle-скриптах написанных на разных языках: kotlin или groovy. В groovy может быть использованы различные способы настройки extension-ов:

myPlugin {
  myArgument = "myValue"
}

или так:

myPlugin.myArgument = "myValue"

или даже так (это, конечно, зависит от реализации extension-а):

myPlugin {
  setMyArgument("myValue")
}

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

Composite builds

На помощь к нам приходят composite builds. Они позволяют использовать локально один gradle-проект в другом gradle-проекте без публикации в какие-либо репозитории. Поэтому предлагаю следующее: под каждую проверку плагина из “реальной жизни” (в скрипте kotlin, в скрипте на groovy, в скрипте с разным использованием одного и того же extension) подготовить отдельный gradle-проект, и в каждый из этих тестовых gradle-проектов включить проект, содержащий наш тестируемый плагин. Вот так это будет выглядеть в файловой системе:

- myPlugin/ <!-- Our plugin main module -->
- myPlugin-module-1/ <!-- Our plugin additional module -->
- myPlugin-module-2/ <!-- Our plugin additional module -->
- myPlugin-tests/ <!-- The plugin integration tests -->
  - kts/ <!-- For kts scripts -->
    - build.gradle.kts
    - settings.gradle.kts <!-- Here we should include our plugin -->
  - groovy/ <!-- For groovy scripts -->
    - build.gradle
    - settings.gradle <!-- Here we should include our plugin -->
- build.gradle
- settings.gradle    

Файлы ./myPlugin-tests/kts/settings.gradle.kts, ./myPlugin-tests/groovy/settings.gradle выглядят так:

includeBuild("../../") // include main plugin module

Теперь достаточно проверить, что build проектов ./myPlugin-tests/kts и ./myPlugin-tests/groovy прошел успешно, или то, что запускается какая-нибудь задача вашего плагина. Это будет означать, что скрипты с различным использованием extension-ов вашего плагина хотя бы компилируются/интерпретируются, и, вероятно, передают необходимые входные данные в плагин. Такие проверки можно написать в виде bash-скриптов или используя org.gradle.testkit.runner.GradleRunner, но я выбрал более простой путь - .github/workflows (тут будет пример из реального проекта, поэтому вот его исходник):

jobs:
  build:
    steps:
      - name: Build with Gradle
        run: ./gradlew check
      - name: Check arspell plugin usage with groovy
        working-directory: ./arspell-plugin-gradle-test/groovy/
        run: ./gradlew arspell
      - name: Check arspell plugin usage with kts
        working-directory: ./arspell-plugin-gradle-test/kts/
        run: ./gradlew arspell

Параметр working-directory помог выполнить нужные проверки без лишних сложностей с cd.

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

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


  1. ermadmi78
    06.01.2022 13:55
    +8

    На мой вкус для плагинов лучше писать полноценные интеграционные тесты. Это легко делается с помощью GradleRunner:

            val buildResult = GradleRunner.create()
                .withProjectDir(tempProjectPath.toFile())
                .withPluginClasspath()
                .withArguments("build", "--stacktrace")
                .build()

    Вот пример интеграционного теста для Gradle плагина (кодогенерация), который ищет подготовленные тесткейсы в ресурсах проекта, копирует их во временную директорию, и выполняет полноценную сборку gradle build с помощью GradleRunner. Затем он сравнивает результат сборки с эталонным результатом в ресурсах проекта.

    Есть только один минус у этого подхода - GradleRunner стартует gradle демон, что очень не быстро. Соответственно первый тесткейс у меня выполняется порядка 40 секунд, последующие по 1-2 секунды.


    1. nikialeksey Автор
      06.01.2022 14:16
      +2

      Спасибо! Это следующий этап после тестирования с помощью Composite builds :) Выглядит элегантно и позволяет делать более гибкие проверки результата тестирования. Обязательно попробую в своих проектах.


  1. js605451
    06.01.2022 18:45
    +3

    Project project = Mockito.spy(Project.class);    
    Mockito.when(project.getExtensions()).thenReturn(...);
    Mockito.when(project. ...).thenReturn(...);

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

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