Я проверил рецепт OpenRewrite: ошибки, которые я сделал, и как их исправить

Я проверил рецепт OpenRewrite: ошибки, которые я сделал, и как их исправить

27 июня 2025 г.

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

Наивный подход

Я первоначально подошел к тестированию рецепта очень наивно, если не сказать больше. Как объяснено вПервый постЯ использовал API с низким уровнем Openrewrite. Вот что я написал:

// Given
val parser = KotlinParser.builder().build()                                      //1
val cu = parser.parse(
    InMemoryExecutionContext(),                                                  //2
    sourceCode
).findFirst()                                                                    //3
 .orElseThrow { IllegalStateException("Failed to parse Kotlin file") }           //3
val originalPath = Paths.get(originalPath)
val modifiedCu = (cu as K.CompilationUnit).withSourcePath(originalPath)          //4

// When
val recipe = FlattenStructure(configuredRootPackage)
val result = recipe.visitor.visit(modifiedCu, InMemoryExecutionContext())        //5

// Then
val expectedPath = Paths.get(expectedPath)
assertEquals(expectedPath, (result as SourceFile).sourcePath)                    //6
  1. Построить анализатор котлина
  2. Установить контекст выполнения; Мне пришлось выбрать, и в памяти была самой простой.
  3. Cowerplate для получения единого компиляционного блока из потока
  4. Бросить вK.CompilationUnitПотому что мы знаем лучше
  5. Явно позвоните посетителю, чтобы посетить
  6. Утверждайте рецепт переместил файл

Вышеуказанное работает, но требует глубокого понимания того, как работает Openrewrite. У меня не было такого понимания, но это было достаточно хорошо. Это вернулось, чтобы укусить меня, когда я улучшил рецепт, чтобы вычислить корень.

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

// When
val recipe = FlattenStructure()
val context = InMemoryExecutionContext()
val acc = AtomicReference//String?(null)
recipe.getScanner(acc).visit(modifiedCu1, context)                               //1
recipe.getScanner(acc).visit(modifiedCu2, context)                               //1
val result1 = recipe.getVisitor(acc).visit(modifiedCu1, context)                 //2
val result2 = recipe.getVisitor(acc).visit(modifiedCu2, context)                 //2
  1. Получите сканер и посетите исходные файлы, чтобы вычислить корень
  2. Посетите посетителя и посетите исходные файлы, чтобы переместить файл

Это сработало, но я признаю, что это было счастливое предположение. Более вовлеченные рецепты потребуют более глубокого знания о том, как работает OpenRewrite, с большими потенциальными ошибками. К счастью, Openrewrite предоставляет средства для поддержания кода тестирования на правильном уровне абстракции.

Номинальный подход

Номинальный подход включает в себя пару классов из коробки; Это требует новой зависимости. Я не делал этого раньше, так что сейчас хорошее время: давайте представим <abbr title = "Билл материала"> Bom </abbr>, чтобы выравнивать все зависимости Openrewrite:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.openrewrite.recipe</groupId>
            <artifactId>rewrite-recipe-bom</artifactId>
            <version>3.9.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

Теперь можно добавить зависимость без версии, так как Maven решает ее от вышеуказанного HOD.

<dependency>
    <groupId>org.openrewrite</groupId>
    <artifactId>rewrite-test</artifactId>
    <scope>test</scope>
</dependency>

Это приносит пару новых занятий в проект:

OpenRewrite recipes test class diagram

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

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

Мы можем переписать предыдущий фрагмент:

rewriteRun(                                                                     //1
    kotlin(sourceCode1) { spec ->                                               //2-3-4
        spec.path("src/main/kotlin/ch/frankel/blog/foo/Foo.kt")                 //5
        spec.afterRecipe {                                                      //6
            assertEquals(                                                       //7
                Paths.get("src/main/kotlin/foo/Foo.kt"),
                it.sourcePath
            )
        }
    },
    kotlin(sourceCode2) { spec ->                                               //2-3-4
        spec.path("src/main/kotlin/org/frankel/blog/bar/Bar.kt")                //5
        spec.afterRecipe {                                                      //6
            assertEquals(                                                       //7
                Paths.get("src/main/kotlin/bar/Bar.kt"),
                it.sourcePath
            )
        }
    },
)
  1. Запустите рецепт
  2. kotlinпреобразовать строку вSourceSpecs
  3. Я использую котлин, ноjavaТо же самое для регулярных проектов Java
  4. Разрешить настройку спецификации источника
  5. Настройте путь
  6. Крючок после запуска рецепта
  7. Утверждают, что рецепт обновил путь в соответствии с ожиданиями

Возможно, вы заметили, что переписанный код не указывает, какой рецепт он тестирует. Это ответственностьRewriteTest.defaults()метод

class FlattenStructureComputeRootPackageTest : RewriteTest {

    override fun defaults(spec: RecipeSpec) {
        spec.recipe(FlattenStructure())
    }

    // Rest of the class
}

Не забывайте циклы

Если вы следовали приведенным выше инструкциям, есть высокая вероятность, что ваш тест не пройдет с этим сообщением об ошибке:

java.lang.AssertionError: Expected recipe to complete in 0 cycle, but took 1 cycle. This usually indicates the recipe is making changes after it should have stabilized.

Нам нужно обратиться к документации, чтобы понять это загадочное сообщение:

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

-Циклы исполнения

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

override fun defaults(spec: RecipeSpec) {
    spec.recipe(FlattenStructure())
        .cycles(1)                                                              //1
        .expectedCyclesThatMakeChanges(1)                                       //2
}
  1. Установите, сколько циклов должен работать рецепт
  2. Установить на 0, если рецепт не ожидается внести изменения

Критика

Мне нравится то, что приносят классы тестирования OpenWrite, но у меня есть две критики.

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

public class RecipeSpec {

    @Nullable
    Integer cycles;

    int getCycles() {
        return cycles == null ? 2 : cycles;                                     //1
    }

    int getExpectedCyclesThatMakeChanges(int cycles) {
        return expectedCyclesThatMakeChanges == null ? cycles - 1 :             //2
                expectedCyclesThatMakeChanges;
    }

    // Rest of the class body
}
  1. Почему два цикла по умолчанию? Разве в большинстве случаев не должно быть достаточно?
  2. Почемуcycles - 1по умолчанию?

Моя вторая критика заключается в том, как предоставленные классы тестирования заставляют вас структурировать ваши тесты. Мне нравится структурировать их на три части:

  1. Дано: Опишите начальное состояние
  2. Когда: выполнить проверенный код
  3. Затем: утверждать, что окончательное состояние соответствует тому, что я ожидаю.

С абстракциями Openrewrite структура сильно отличается от вышеперечисленного.

Заключение

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

Полный исходный код для этого поста можно найти на GitHub:

https://github.com/ajavageek/flatten-kotlin-recipe?embedable=true

Идти дальше:

  • Тестирование рецептов
  • Циклы исполнения
  • Как разрешить ожидаемое несоответствие цикла рецептов

Первоначально опубликовано вJava Geek22 июня 2025 года


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE