Я проверил рецепт 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
- Построить анализатор котлина
 - Установить контекст выполнения; Мне пришлось выбрать, и в памяти была самой простой.
 - Cowerplate для получения единого компиляционного блока из потока
 - Бросить в
K.CompilationUnitПотому что мы знаем лучше - Явно позвоните посетителю, чтобы посетить
 - Утверждайте рецепт переместил файл
 
Вышеуказанное работает, но требует глубокого понимания того, как работает 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
- Получите сканер и посетите исходные файлы, чтобы вычислить корень
 - Посетите посетителя и посетите исходные файлы, чтобы переместить файл
 
Это сработало, но я признаю, что это было счастливое предположение. Более вовлеченные рецепты потребуют более глубокого знания о том, как работает 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>
Это приносит пару новых занятий в проект:

В документации говорится, что ваш тестовый класс должен наследовать от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
            )
        }
    },
)
- Запустите рецепт
 kotlinпреобразовать строку вSourceSpecs- Я использую котлин, но
javaТо же самое для регулярных проектов Java - Разрешить настройку спецификации источника
 - Настройте путь
 - Крючок после запуска рецепта
 - Утверждают, что рецепт обновил путь в соответствии с ожиданиями
 
Возможно, вы заметили, что переписанный код не указывает, какой рецепт он тестирует. Это ответственность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
}
- Установите, сколько циклов должен работать рецепт
 - Установить на 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
}
- Почему два цикла по умолчанию? Разве в большинстве случаев не должно быть достаточно?
 - Почему
cycles - 1по умолчанию? 
Моя вторая критика заключается в том, как предоставленные классы тестирования заставляют вас структурировать ваши тесты. Мне нравится структурировать их на три части:
- Дано: Опишите начальное состояние
 - Когда: выполнить проверенный код
 - Затем: утверждать, что окончательное состояние соответствует тому, что я ожидаю.
 
С абстракциями Openrewrite структура сильно отличается от вышеперечисленного.
Заключение
В этом посте я мигрировал свойдля этого случаяТестовый код, чтобы полагаться на предоставленные классы Openrewrite. Несмотря на то, что они не освобождаются от критики, они предлагают солидный слой абстракции и делают тесты более поддерживаемыми.
Полный исходный код для этого поста можно найти на GitHub:
https://github.com/ajavageek/flatten-kotlin-recipe?embedable=true
Идти дальше:
- Тестирование рецептов
 - Циклы исполнения
 - Как разрешить ожидаемое несоответствие цикла рецептов
 
Первоначально опубликовано вJava Geek22 июня 2025 года
Оригинал