matrei commented on code in PR #15538:
URL: https://github.com/apache/grails-core/pull/15538#discussion_r3015441242


##########
grails-doc/src/en/guide/testing/testPhases.adoc:
##########
@@ -0,0 +1,199 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+////
+
+A Grails application has two test phases by default: `unit` (the standard 
`test` source set) and `integration` (added automatically by the Grails Gradle 
plugin). You can define additional test phases - such as `functionalTest`, 
`smokeTest`, or `performanceTest` - using the `testPhases` extension in your 
`build.gradle`.

Review Comment:
   Should we be consistent and use the phases natural names for all examples?
   - `unit`
   - `integration`
   - `functional`
   - `smoke`
   - `performance`



##########
settings.gradle:
##########
@@ -398,7 +398,8 @@ include(
         'grails-test-examples-scaffolding',
         'grails-test-examples-scaffolding-fields',
         'grails-test-examples-views-functional-tests',
-        'grails-test-examples-views-functional-tests-plugin'
+        'grails-test-examples-views-functional-tests-plugin',
+        'grails-test-examples-test-phases'

Review Comment:
   Sort alphabetically?



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/TestPhasesGradlePlugin.groovy:
##########
@@ -0,0 +1,177 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.gradle.plugin.core
+
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+
+import org.gradle.api.NamedDomainObjectContainer
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.artifacts.ConfigurationContainer
+import org.gradle.api.artifacts.dsl.DependencyHandler
+import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.SourceSetContainer
+import org.gradle.api.tasks.SourceSetOutput
+import org.gradle.api.tasks.TaskContainer
+import org.gradle.api.tasks.TaskProvider
+import org.gradle.api.tasks.testing.Test
+import org.gradle.api.tasks.testing.TestReport
+import org.gradle.language.base.plugins.LifecycleBasePlugin
+import org.gradle.plugins.ide.idea.model.IdeaModel
+import org.gradle.plugins.ide.idea.model.IdeaModule
+
+import org.grails.gradle.plugin.util.SourceSets
+
+import static 
org.gradle.api.plugins.JavaPlugin.TEST_IMPLEMENTATION_CONFIGURATION_NAME
+import static 
org.gradle.api.plugins.JavaPlugin.TEST_RUNTIME_ONLY_CONFIGURATION_NAME
+import static org.gradle.api.plugins.JavaPlugin.TEST_TASK_NAME
+import static org.gradle.api.tasks.SourceSet.TEST_SOURCE_SET_NAME
+
+/**
+ * Gradle plugin that provides a {@code testPhases} extension for defining
+ * additional test phases (e.g. integrationTest, functionalTest).
+ *
+ * <p>Each {@link TestPhase} added to the container automatically gets its own
+ * source set, dependency configurations, {@link Test} task, and merged test
+ * report contribution.</p>
+ *
+ * @since 7.1
+ */
+@CompileStatic
+class TestPhasesGradlePlugin implements Plugin<Project> {
+
+    static final String EXTENSION_NAME = 'testPhases'
+    static final String MERGE_TEST_REPORTS_TASK_NAME = 'mergeTestReports'
+
+    private NamedDomainObjectContainer<TestPhase> testPhases
+
+    @Override
+    void apply(Project project) {
+        testPhases = project.container(TestPhase)
+        project.extensions.add(EXTENSION_NAME, testPhases)
+
+        registerMergeTestReports(project)
+
+        testPhases.configureEach { TestPhase phase ->
+            configureTestPhase(project, phase)
+        }
+    }
+
+    private void configureTestPhase(Project project, TestPhase phase) {
+        String phaseName = phase.name
+        String implConfigName = "${phaseName}Implementation"
+        String runtimeOnlyConfigName = "${phaseName}RuntimeOnly"
+
+        File[] sourceDirs = findTestPhaseSources(project, phase)
+        List<File> acceptedSourceDirs = []
+
+        final SourceSetContainer sourceSets = 
SourceSets.findSourceSets(project)
+        final SourceSetOutput mainSourceSetOutput = 
SourceSets.findMainSourceSet(project).output
+        final SourceSetOutput testSourceSetOutput = 
SourceSets.findSourceSet(project, TEST_SOURCE_SET_NAME).output
+        final SourceSet phaseSourceSet = sourceSets.create(phaseName)
+        phaseSourceSet.compileClasspath += mainSourceSetOutput + 
testSourceSetOutput
+        phaseSourceSet.runtimeClasspath += mainSourceSetOutput + 
testSourceSetOutput
+
+        if (sourceDirs != null) {
+            for (File srcDir in sourceDirs) {
+                registerSourceDir(phaseSourceSet, srcDir)
+                acceptedSourceDirs.add(srcDir)
+            }
+        }
+
+        final File resources = new File(project.projectDir, 'grails-app/conf')
+        phaseSourceSet.resources.srcDir(resources)
+
+        final DependencyHandler dependencies = project.dependencies
+        dependencies.add(implConfigName, mainSourceSetOutput)
+        dependencies.add(implConfigName, testSourceSetOutput)
+
+        final ConfigurationContainer configurations = project.configurations
+        configurations.named(implConfigName).configure {
+            
it.extendsFrom(configurations.named(TEST_IMPLEMENTATION_CONFIGURATION_NAME).get())
+        }
+        configurations.named(runtimeOnlyConfigName).configure {
+            
it.extendsFrom(configurations.named(TEST_RUNTIME_ONLY_CONFIGURATION_NAME).get())
+        }
+
+        final TaskContainer tasks = project.tasks
+        final TaskProvider<Test> testTask = tasks.register(phaseName, Test)
+        testTask.configure {
+            it.group = LifecycleBasePlugin.VERIFICATION_GROUP
+            it.testClassesDirs = phaseSourceSet.output.classesDirs
+            it.classpath = phaseSourceSet.runtimeClasspath
+            it.shouldRunAfter(TEST_TASK_NAME)
+            it.finalizedBy(MERGE_TEST_REPORTS_TASK_NAME)
+            it.reports.html.required.set(false)
+            it.maxParallelForks = 1
+            it.testLogging {
+                events('passed')
+            }
+            it.systemProperty(phase.systemPropertyName, true)
+        }
+        tasks.named('check').configure {
+            it.dependsOn(testTask)
+        }
+
+        addPhaseToMergeTestReports(project, phaseName)
+
+        if (phase.ideaIntegration) {
+            final File[] files = acceptedSourceDirs.toArray(new 
File[acceptedSourceDirs.size()])
+            integrateIdea(project, files)
+        }
+    }
+
+    private void registerMergeTestReports(Project project) {
+        project.tasks.register(MERGE_TEST_REPORTS_TASK_NAME, TestReport) {
+            it.mustRunAfter(project.tasks.withType(Test).toArray())
+            
it.destinationDirectory.set(project.layout.buildDirectory.dir('reports/tests'))
+            it.testResults.from(
+                    
project.files(project.layout.buildDirectory.dir('test-results/test/binary'))
+            )
+        }
+    }
+
+    private void addPhaseToMergeTestReports(Project project, String phaseName) 
{

Review Comment:
   static?



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/TestPhasesGradlePlugin.groovy:
##########
@@ -0,0 +1,177 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.gradle.plugin.core
+
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+
+import org.gradle.api.NamedDomainObjectContainer
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.artifacts.ConfigurationContainer
+import org.gradle.api.artifacts.dsl.DependencyHandler
+import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.SourceSetContainer
+import org.gradle.api.tasks.SourceSetOutput
+import org.gradle.api.tasks.TaskContainer
+import org.gradle.api.tasks.TaskProvider
+import org.gradle.api.tasks.testing.Test
+import org.gradle.api.tasks.testing.TestReport
+import org.gradle.language.base.plugins.LifecycleBasePlugin
+import org.gradle.plugins.ide.idea.model.IdeaModel
+import org.gradle.plugins.ide.idea.model.IdeaModule
+
+import org.grails.gradle.plugin.util.SourceSets
+
+import static 
org.gradle.api.plugins.JavaPlugin.TEST_IMPLEMENTATION_CONFIGURATION_NAME
+import static 
org.gradle.api.plugins.JavaPlugin.TEST_RUNTIME_ONLY_CONFIGURATION_NAME
+import static org.gradle.api.plugins.JavaPlugin.TEST_TASK_NAME
+import static org.gradle.api.tasks.SourceSet.TEST_SOURCE_SET_NAME
+
+/**
+ * Gradle plugin that provides a {@code testPhases} extension for defining
+ * additional test phases (e.g. integrationTest, functionalTest).
+ *
+ * <p>Each {@link TestPhase} added to the container automatically gets its own
+ * source set, dependency configurations, {@link Test} task, and merged test
+ * report contribution.</p>
+ *
+ * @since 7.1
+ */
+@CompileStatic
+class TestPhasesGradlePlugin implements Plugin<Project> {
+
+    static final String EXTENSION_NAME = 'testPhases'
+    static final String MERGE_TEST_REPORTS_TASK_NAME = 'mergeTestReports'
+
+    private NamedDomainObjectContainer<TestPhase> testPhases
+
+    @Override
+    void apply(Project project) {
+        testPhases = project.container(TestPhase)
+        project.extensions.add(EXTENSION_NAME, testPhases)
+
+        registerMergeTestReports(project)
+
+        testPhases.configureEach { TestPhase phase ->
+            configureTestPhase(project, phase)
+        }
+    }
+
+    private void configureTestPhase(Project project, TestPhase phase) {
+        String phaseName = phase.name
+        String implConfigName = "${phaseName}Implementation"
+        String runtimeOnlyConfigName = "${phaseName}RuntimeOnly"
+
+        File[] sourceDirs = findTestPhaseSources(project, phase)
+        List<File> acceptedSourceDirs = []
+
+        final SourceSetContainer sourceSets = 
SourceSets.findSourceSets(project)
+        final SourceSetOutput mainSourceSetOutput = 
SourceSets.findMainSourceSet(project).output
+        final SourceSetOutput testSourceSetOutput = 
SourceSets.findSourceSet(project, TEST_SOURCE_SET_NAME).output
+        final SourceSet phaseSourceSet = sourceSets.create(phaseName)
+        phaseSourceSet.compileClasspath += mainSourceSetOutput + 
testSourceSetOutput
+        phaseSourceSet.runtimeClasspath += mainSourceSetOutput + 
testSourceSetOutput
+
+        if (sourceDirs != null) {
+            for (File srcDir in sourceDirs) {
+                registerSourceDir(phaseSourceSet, srcDir)
+                acceptedSourceDirs.add(srcDir)
+            }
+        }
+
+        final File resources = new File(project.projectDir, 'grails-app/conf')
+        phaseSourceSet.resources.srcDir(resources)
+
+        final DependencyHandler dependencies = project.dependencies
+        dependencies.add(implConfigName, mainSourceSetOutput)
+        dependencies.add(implConfigName, testSourceSetOutput)
+
+        final ConfigurationContainer configurations = project.configurations
+        configurations.named(implConfigName).configure {
+            
it.extendsFrom(configurations.named(TEST_IMPLEMENTATION_CONFIGURATION_NAME).get())
+        }
+        configurations.named(runtimeOnlyConfigName).configure {
+            
it.extendsFrom(configurations.named(TEST_RUNTIME_ONLY_CONFIGURATION_NAME).get())
+        }
+
+        final TaskContainer tasks = project.tasks
+        final TaskProvider<Test> testTask = tasks.register(phaseName, Test)
+        testTask.configure {
+            it.group = LifecycleBasePlugin.VERIFICATION_GROUP
+            it.testClassesDirs = phaseSourceSet.output.classesDirs
+            it.classpath = phaseSourceSet.runtimeClasspath
+            it.shouldRunAfter(TEST_TASK_NAME)
+            it.finalizedBy(MERGE_TEST_REPORTS_TASK_NAME)
+            it.reports.html.required.set(false)
+            it.maxParallelForks = 1
+            it.testLogging {
+                events('passed')
+            }
+            it.systemProperty(phase.systemPropertyName, true)
+        }
+        tasks.named('check').configure {
+            it.dependsOn(testTask)
+        }
+
+        addPhaseToMergeTestReports(project, phaseName)
+
+        if (phase.ideaIntegration) {
+            final File[] files = acceptedSourceDirs.toArray(new 
File[acceptedSourceDirs.size()])
+            integrateIdea(project, files)
+        }
+    }
+
+    private void registerMergeTestReports(Project project) {
+        project.tasks.register(MERGE_TEST_REPORTS_TASK_NAME, TestReport) {
+            it.mustRunAfter(project.tasks.withType(Test).toArray())
+            
it.destinationDirectory.set(project.layout.buildDirectory.dir('reports/tests'))
+            it.testResults.from(
+                    
project.files(project.layout.buildDirectory.dir('test-results/test/binary'))
+            )
+        }
+    }
+
+    private void addPhaseToMergeTestReports(Project project, String phaseName) 
{
+        project.tasks.named(MERGE_TEST_REPORTS_TASK_NAME, 
TestReport).configure {
+            it.testResults.from(
+                    
project.files(project.layout.buildDirectory.dir("test-results/${phaseName}/binary"))
+            )
+        }
+    }
+
+    @CompileDynamic
+    private void registerSourceDir(SourceSet sourceSet, File srcDir) {

Review Comment:
   static?



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/TestPhasesGradlePlugin.groovy:
##########
@@ -0,0 +1,177 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.gradle.plugin.core
+
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+
+import org.gradle.api.NamedDomainObjectContainer
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.artifacts.ConfigurationContainer
+import org.gradle.api.artifacts.dsl.DependencyHandler
+import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.SourceSetContainer
+import org.gradle.api.tasks.SourceSetOutput
+import org.gradle.api.tasks.TaskContainer
+import org.gradle.api.tasks.TaskProvider
+import org.gradle.api.tasks.testing.Test
+import org.gradle.api.tasks.testing.TestReport
+import org.gradle.language.base.plugins.LifecycleBasePlugin
+import org.gradle.plugins.ide.idea.model.IdeaModel
+import org.gradle.plugins.ide.idea.model.IdeaModule
+
+import org.grails.gradle.plugin.util.SourceSets
+
+import static 
org.gradle.api.plugins.JavaPlugin.TEST_IMPLEMENTATION_CONFIGURATION_NAME
+import static 
org.gradle.api.plugins.JavaPlugin.TEST_RUNTIME_ONLY_CONFIGURATION_NAME
+import static org.gradle.api.plugins.JavaPlugin.TEST_TASK_NAME
+import static org.gradle.api.tasks.SourceSet.TEST_SOURCE_SET_NAME
+
+/**
+ * Gradle plugin that provides a {@code testPhases} extension for defining
+ * additional test phases (e.g. integrationTest, functionalTest).
+ *
+ * <p>Each {@link TestPhase} added to the container automatically gets its own
+ * source set, dependency configurations, {@link Test} task, and merged test
+ * report contribution.</p>
+ *
+ * @since 7.1
+ */
+@CompileStatic
+class TestPhasesGradlePlugin implements Plugin<Project> {
+
+    static final String EXTENSION_NAME = 'testPhases'
+    static final String MERGE_TEST_REPORTS_TASK_NAME = 'mergeTestReports'
+
+    private NamedDomainObjectContainer<TestPhase> testPhases
+
+    @Override
+    void apply(Project project) {
+        testPhases = project.container(TestPhase)
+        project.extensions.add(EXTENSION_NAME, testPhases)
+
+        registerMergeTestReports(project)
+
+        testPhases.configureEach { TestPhase phase ->
+            configureTestPhase(project, phase)
+        }
+    }
+
+    private void configureTestPhase(Project project, TestPhase phase) {
+        String phaseName = phase.name
+        String implConfigName = "${phaseName}Implementation"
+        String runtimeOnlyConfigName = "${phaseName}RuntimeOnly"
+
+        File[] sourceDirs = findTestPhaseSources(project, phase)
+        List<File> acceptedSourceDirs = []
+
+        final SourceSetContainer sourceSets = 
SourceSets.findSourceSets(project)
+        final SourceSetOutput mainSourceSetOutput = 
SourceSets.findMainSourceSet(project).output
+        final SourceSetOutput testSourceSetOutput = 
SourceSets.findSourceSet(project, TEST_SOURCE_SET_NAME).output
+        final SourceSet phaseSourceSet = sourceSets.create(phaseName)
+        phaseSourceSet.compileClasspath += mainSourceSetOutput + 
testSourceSetOutput
+        phaseSourceSet.runtimeClasspath += mainSourceSetOutput + 
testSourceSetOutput
+
+        if (sourceDirs != null) {
+            for (File srcDir in sourceDirs) {
+                registerSourceDir(phaseSourceSet, srcDir)
+                acceptedSourceDirs.add(srcDir)
+            }
+        }
+
+        final File resources = new File(project.projectDir, 'grails-app/conf')
+        phaseSourceSet.resources.srcDir(resources)
+
+        final DependencyHandler dependencies = project.dependencies
+        dependencies.add(implConfigName, mainSourceSetOutput)
+        dependencies.add(implConfigName, testSourceSetOutput)
+
+        final ConfigurationContainer configurations = project.configurations
+        configurations.named(implConfigName).configure {
+            
it.extendsFrom(configurations.named(TEST_IMPLEMENTATION_CONFIGURATION_NAME).get())
+        }
+        configurations.named(runtimeOnlyConfigName).configure {
+            
it.extendsFrom(configurations.named(TEST_RUNTIME_ONLY_CONFIGURATION_NAME).get())
+        }
+
+        final TaskContainer tasks = project.tasks
+        final TaskProvider<Test> testTask = tasks.register(phaseName, Test)
+        testTask.configure {
+            it.group = LifecycleBasePlugin.VERIFICATION_GROUP
+            it.testClassesDirs = phaseSourceSet.output.classesDirs
+            it.classpath = phaseSourceSet.runtimeClasspath
+            it.shouldRunAfter(TEST_TASK_NAME)
+            it.finalizedBy(MERGE_TEST_REPORTS_TASK_NAME)
+            it.reports.html.required.set(false)
+            it.maxParallelForks = 1
+            it.testLogging {
+                events('passed')
+            }
+            it.systemProperty(phase.systemPropertyName, true)
+        }
+        tasks.named('check').configure {
+            it.dependsOn(testTask)
+        }
+
+        addPhaseToMergeTestReports(project, phaseName)
+
+        if (phase.ideaIntegration) {
+            final File[] files = acceptedSourceDirs.toArray(new 
File[acceptedSourceDirs.size()])
+            integrateIdea(project, files)
+        }
+    }
+
+    private void registerMergeTestReports(Project project) {
+        project.tasks.register(MERGE_TEST_REPORTS_TASK_NAME, TestReport) {
+            it.mustRunAfter(project.tasks.withType(Test).toArray())
+            
it.destinationDirectory.set(project.layout.buildDirectory.dir('reports/tests'))
+            it.testResults.from(
+                    
project.files(project.layout.buildDirectory.dir('test-results/test/binary'))
+            )
+        }
+    }
+
+    private void addPhaseToMergeTestReports(Project project, String phaseName) 
{
+        project.tasks.named(MERGE_TEST_REPORTS_TASK_NAME, 
TestReport).configure {
+            it.testResults.from(
+                    
project.files(project.layout.buildDirectory.dir("test-results/${phaseName}/binary"))
+            )
+        }
+    }
+
+    @CompileDynamic
+    private void registerSourceDir(SourceSet sourceSet, File srcDir) {
+        sourceSet."${srcDir.name}".srcDir(srcDir)
+    }
+
+    @CompileDynamic
+    private void integrateIdea(Project project, File[] acceptedSourceDirs) {
+        project.pluginManager.withPlugin('idea') { ->
+            def ideaExtension = project.getExtensions().getByType(IdeaModel)
+            ideaExtension.module { IdeaModule it ->
+                it.testSources.from(acceptedSourceDirs)
+            }
+        }
+    }
+
+    File[] findTestPhaseSources(Project project, TestPhase phase) {

Review Comment:
   static?



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/TestPhasesGradlePlugin.groovy:
##########
@@ -0,0 +1,177 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.gradle.plugin.core
+
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+
+import org.gradle.api.NamedDomainObjectContainer
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.artifacts.ConfigurationContainer
+import org.gradle.api.artifacts.dsl.DependencyHandler
+import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.SourceSetContainer
+import org.gradle.api.tasks.SourceSetOutput
+import org.gradle.api.tasks.TaskContainer
+import org.gradle.api.tasks.TaskProvider
+import org.gradle.api.tasks.testing.Test
+import org.gradle.api.tasks.testing.TestReport
+import org.gradle.language.base.plugins.LifecycleBasePlugin
+import org.gradle.plugins.ide.idea.model.IdeaModel
+import org.gradle.plugins.ide.idea.model.IdeaModule
+
+import org.grails.gradle.plugin.util.SourceSets
+
+import static 
org.gradle.api.plugins.JavaPlugin.TEST_IMPLEMENTATION_CONFIGURATION_NAME
+import static 
org.gradle.api.plugins.JavaPlugin.TEST_RUNTIME_ONLY_CONFIGURATION_NAME
+import static org.gradle.api.plugins.JavaPlugin.TEST_TASK_NAME
+import static org.gradle.api.tasks.SourceSet.TEST_SOURCE_SET_NAME
+
+/**
+ * Gradle plugin that provides a {@code testPhases} extension for defining
+ * additional test phases (e.g. integrationTest, functionalTest).
+ *
+ * <p>Each {@link TestPhase} added to the container automatically gets its own
+ * source set, dependency configurations, {@link Test} task, and merged test
+ * report contribution.</p>
+ *
+ * @since 7.1
+ */
+@CompileStatic
+class TestPhasesGradlePlugin implements Plugin<Project> {
+
+    static final String EXTENSION_NAME = 'testPhases'
+    static final String MERGE_TEST_REPORTS_TASK_NAME = 'mergeTestReports'
+
+    private NamedDomainObjectContainer<TestPhase> testPhases
+
+    @Override
+    void apply(Project project) {
+        testPhases = project.container(TestPhase)
+        project.extensions.add(EXTENSION_NAME, testPhases)
+
+        registerMergeTestReports(project)
+
+        testPhases.configureEach { TestPhase phase ->
+            configureTestPhase(project, phase)
+        }
+    }
+
+    private void configureTestPhase(Project project, TestPhase phase) {
+        String phaseName = phase.name
+        String implConfigName = "${phaseName}Implementation"
+        String runtimeOnlyConfigName = "${phaseName}RuntimeOnly"
+
+        File[] sourceDirs = findTestPhaseSources(project, phase)
+        List<File> acceptedSourceDirs = []
+
+        final SourceSetContainer sourceSets = 
SourceSets.findSourceSets(project)
+        final SourceSetOutput mainSourceSetOutput = 
SourceSets.findMainSourceSet(project).output
+        final SourceSetOutput testSourceSetOutput = 
SourceSets.findSourceSet(project, TEST_SOURCE_SET_NAME).output
+        final SourceSet phaseSourceSet = sourceSets.create(phaseName)
+        phaseSourceSet.compileClasspath += mainSourceSetOutput + 
testSourceSetOutput
+        phaseSourceSet.runtimeClasspath += mainSourceSetOutput + 
testSourceSetOutput
+
+        if (sourceDirs != null) {
+            for (File srcDir in sourceDirs) {
+                registerSourceDir(phaseSourceSet, srcDir)
+                acceptedSourceDirs.add(srcDir)
+            }
+        }
+
+        final File resources = new File(project.projectDir, 'grails-app/conf')
+        phaseSourceSet.resources.srcDir(resources)
+
+        final DependencyHandler dependencies = project.dependencies
+        dependencies.add(implConfigName, mainSourceSetOutput)
+        dependencies.add(implConfigName, testSourceSetOutput)
+
+        final ConfigurationContainer configurations = project.configurations
+        configurations.named(implConfigName).configure {
+            
it.extendsFrom(configurations.named(TEST_IMPLEMENTATION_CONFIGURATION_NAME).get())
+        }
+        configurations.named(runtimeOnlyConfigName).configure {
+            
it.extendsFrom(configurations.named(TEST_RUNTIME_ONLY_CONFIGURATION_NAME).get())
+        }
+
+        final TaskContainer tasks = project.tasks
+        final TaskProvider<Test> testTask = tasks.register(phaseName, Test)
+        testTask.configure {
+            it.group = LifecycleBasePlugin.VERIFICATION_GROUP
+            it.testClassesDirs = phaseSourceSet.output.classesDirs
+            it.classpath = phaseSourceSet.runtimeClasspath
+            it.shouldRunAfter(TEST_TASK_NAME)
+            it.finalizedBy(MERGE_TEST_REPORTS_TASK_NAME)
+            it.reports.html.required.set(false)
+            it.maxParallelForks = 1
+            it.testLogging {
+                events('passed')
+            }
+            it.systemProperty(phase.systemPropertyName, true)
+        }
+        tasks.named('check').configure {
+            it.dependsOn(testTask)
+        }
+
+        addPhaseToMergeTestReports(project, phaseName)
+
+        if (phase.ideaIntegration) {
+            final File[] files = acceptedSourceDirs.toArray(new 
File[acceptedSourceDirs.size()])
+            integrateIdea(project, files)
+        }
+    }
+
+    private void registerMergeTestReports(Project project) {
+        project.tasks.register(MERGE_TEST_REPORTS_TASK_NAME, TestReport) {
+            it.mustRunAfter(project.tasks.withType(Test).toArray())
+            
it.destinationDirectory.set(project.layout.buildDirectory.dir('reports/tests'))
+            it.testResults.from(
+                    
project.files(project.layout.buildDirectory.dir('test-results/test/binary'))
+            )
+        }
+    }
+
+    private void addPhaseToMergeTestReports(Project project, String phaseName) 
{
+        project.tasks.named(MERGE_TEST_REPORTS_TASK_NAME, 
TestReport).configure {
+            it.testResults.from(
+                    
project.files(project.layout.buildDirectory.dir("test-results/${phaseName}/binary"))
+            )
+        }
+    }
+
+    @CompileDynamic
+    private void registerSourceDir(SourceSet sourceSet, File srcDir) {
+        sourceSet."${srcDir.name}".srcDir(srcDir)
+    }
+
+    @CompileDynamic
+    private void integrateIdea(Project project, File[] acceptedSourceDirs) {

Review Comment:
   static?



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/TestPhasesGradlePlugin.groovy:
##########
@@ -0,0 +1,177 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.gradle.plugin.core
+
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+
+import org.gradle.api.NamedDomainObjectContainer
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.artifacts.ConfigurationContainer
+import org.gradle.api.artifacts.dsl.DependencyHandler
+import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.SourceSetContainer
+import org.gradle.api.tasks.SourceSetOutput
+import org.gradle.api.tasks.TaskContainer
+import org.gradle.api.tasks.TaskProvider
+import org.gradle.api.tasks.testing.Test
+import org.gradle.api.tasks.testing.TestReport
+import org.gradle.language.base.plugins.LifecycleBasePlugin
+import org.gradle.plugins.ide.idea.model.IdeaModel
+import org.gradle.plugins.ide.idea.model.IdeaModule
+
+import org.grails.gradle.plugin.util.SourceSets
+
+import static 
org.gradle.api.plugins.JavaPlugin.TEST_IMPLEMENTATION_CONFIGURATION_NAME
+import static 
org.gradle.api.plugins.JavaPlugin.TEST_RUNTIME_ONLY_CONFIGURATION_NAME
+import static org.gradle.api.plugins.JavaPlugin.TEST_TASK_NAME
+import static org.gradle.api.tasks.SourceSet.TEST_SOURCE_SET_NAME
+
+/**
+ * Gradle plugin that provides a {@code testPhases} extension for defining
+ * additional test phases (e.g. integrationTest, functionalTest).
+ *
+ * <p>Each {@link TestPhase} added to the container automatically gets its own
+ * source set, dependency configurations, {@link Test} task, and merged test
+ * report contribution.</p>
+ *
+ * @since 7.1
+ */
+@CompileStatic
+class TestPhasesGradlePlugin implements Plugin<Project> {
+
+    static final String EXTENSION_NAME = 'testPhases'
+    static final String MERGE_TEST_REPORTS_TASK_NAME = 'mergeTestReports'
+
+    private NamedDomainObjectContainer<TestPhase> testPhases
+
+    @Override
+    void apply(Project project) {
+        testPhases = project.container(TestPhase)
+        project.extensions.add(EXTENSION_NAME, testPhases)
+
+        registerMergeTestReports(project)
+
+        testPhases.configureEach { TestPhase phase ->
+            configureTestPhase(project, phase)
+        }
+    }
+
+    private void configureTestPhase(Project project, TestPhase phase) {
+        String phaseName = phase.name
+        String implConfigName = "${phaseName}Implementation"
+        String runtimeOnlyConfigName = "${phaseName}RuntimeOnly"
+
+        File[] sourceDirs = findTestPhaseSources(project, phase)
+        List<File> acceptedSourceDirs = []
+
+        final SourceSetContainer sourceSets = 
SourceSets.findSourceSets(project)
+        final SourceSetOutput mainSourceSetOutput = 
SourceSets.findMainSourceSet(project).output
+        final SourceSetOutput testSourceSetOutput = 
SourceSets.findSourceSet(project, TEST_SOURCE_SET_NAME).output
+        final SourceSet phaseSourceSet = sourceSets.create(phaseName)
+        phaseSourceSet.compileClasspath += mainSourceSetOutput + 
testSourceSetOutput
+        phaseSourceSet.runtimeClasspath += mainSourceSetOutput + 
testSourceSetOutput
+
+        if (sourceDirs != null) {
+            for (File srcDir in sourceDirs) {
+                registerSourceDir(phaseSourceSet, srcDir)
+                acceptedSourceDirs.add(srcDir)
+            }
+        }
+
+        final File resources = new File(project.projectDir, 'grails-app/conf')
+        phaseSourceSet.resources.srcDir(resources)
+
+        final DependencyHandler dependencies = project.dependencies
+        dependencies.add(implConfigName, mainSourceSetOutput)
+        dependencies.add(implConfigName, testSourceSetOutput)
+
+        final ConfigurationContainer configurations = project.configurations
+        configurations.named(implConfigName).configure {
+            
it.extendsFrom(configurations.named(TEST_IMPLEMENTATION_CONFIGURATION_NAME).get())
+        }
+        configurations.named(runtimeOnlyConfigName).configure {
+            
it.extendsFrom(configurations.named(TEST_RUNTIME_ONLY_CONFIGURATION_NAME).get())
+        }
+
+        final TaskContainer tasks = project.tasks
+        final TaskProvider<Test> testTask = tasks.register(phaseName, Test)
+        testTask.configure {
+            it.group = LifecycleBasePlugin.VERIFICATION_GROUP
+            it.testClassesDirs = phaseSourceSet.output.classesDirs
+            it.classpath = phaseSourceSet.runtimeClasspath
+            it.shouldRunAfter(TEST_TASK_NAME)
+            it.finalizedBy(MERGE_TEST_REPORTS_TASK_NAME)
+            it.reports.html.required.set(false)
+            it.maxParallelForks = 1
+            it.testLogging {
+                events('passed')
+            }
+            it.systemProperty(phase.systemPropertyName, true)
+        }
+        tasks.named('check').configure {

Review Comment:
   `.configure` redundant?



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/TestPhasesGradlePlugin.groovy:
##########
@@ -0,0 +1,177 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.gradle.plugin.core
+
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+
+import org.gradle.api.NamedDomainObjectContainer
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.artifacts.ConfigurationContainer
+import org.gradle.api.artifacts.dsl.DependencyHandler
+import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.SourceSetContainer
+import org.gradle.api.tasks.SourceSetOutput
+import org.gradle.api.tasks.TaskContainer
+import org.gradle.api.tasks.TaskProvider
+import org.gradle.api.tasks.testing.Test
+import org.gradle.api.tasks.testing.TestReport
+import org.gradle.language.base.plugins.LifecycleBasePlugin
+import org.gradle.plugins.ide.idea.model.IdeaModel
+import org.gradle.plugins.ide.idea.model.IdeaModule
+
+import org.grails.gradle.plugin.util.SourceSets
+
+import static 
org.gradle.api.plugins.JavaPlugin.TEST_IMPLEMENTATION_CONFIGURATION_NAME
+import static 
org.gradle.api.plugins.JavaPlugin.TEST_RUNTIME_ONLY_CONFIGURATION_NAME
+import static org.gradle.api.plugins.JavaPlugin.TEST_TASK_NAME
+import static org.gradle.api.tasks.SourceSet.TEST_SOURCE_SET_NAME
+
+/**
+ * Gradle plugin that provides a {@code testPhases} extension for defining
+ * additional test phases (e.g. integrationTest, functionalTest).
+ *
+ * <p>Each {@link TestPhase} added to the container automatically gets its own
+ * source set, dependency configurations, {@link Test} task, and merged test
+ * report contribution.</p>
+ *
+ * @since 7.1
+ */
+@CompileStatic
+class TestPhasesGradlePlugin implements Plugin<Project> {
+
+    static final String EXTENSION_NAME = 'testPhases'
+    static final String MERGE_TEST_REPORTS_TASK_NAME = 'mergeTestReports'
+
+    private NamedDomainObjectContainer<TestPhase> testPhases
+
+    @Override
+    void apply(Project project) {
+        testPhases = project.container(TestPhase)
+        project.extensions.add(EXTENSION_NAME, testPhases)
+
+        registerMergeTestReports(project)
+
+        testPhases.configureEach { TestPhase phase ->
+            configureTestPhase(project, phase)
+        }
+    }
+
+    private void configureTestPhase(Project project, TestPhase phase) {
+        String phaseName = phase.name
+        String implConfigName = "${phaseName}Implementation"
+        String runtimeOnlyConfigName = "${phaseName}RuntimeOnly"
+
+        File[] sourceDirs = findTestPhaseSources(project, phase)
+        List<File> acceptedSourceDirs = []
+
+        final SourceSetContainer sourceSets = 
SourceSets.findSourceSets(project)
+        final SourceSetOutput mainSourceSetOutput = 
SourceSets.findMainSourceSet(project).output
+        final SourceSetOutput testSourceSetOutput = 
SourceSets.findSourceSet(project, TEST_SOURCE_SET_NAME).output
+        final SourceSet phaseSourceSet = sourceSets.create(phaseName)
+        phaseSourceSet.compileClasspath += mainSourceSetOutput + 
testSourceSetOutput
+        phaseSourceSet.runtimeClasspath += mainSourceSetOutput + 
testSourceSetOutput
+
+        if (sourceDirs != null) {
+            for (File srcDir in sourceDirs) {
+                registerSourceDir(phaseSourceSet, srcDir)
+                acceptedSourceDirs.add(srcDir)
+            }
+        }
+
+        final File resources = new File(project.projectDir, 'grails-app/conf')
+        phaseSourceSet.resources.srcDir(resources)
+
+        final DependencyHandler dependencies = project.dependencies
+        dependencies.add(implConfigName, mainSourceSetOutput)
+        dependencies.add(implConfigName, testSourceSetOutput)
+
+        final ConfigurationContainer configurations = project.configurations
+        configurations.named(implConfigName).configure {
+            
it.extendsFrom(configurations.named(TEST_IMPLEMENTATION_CONFIGURATION_NAME).get())
+        }
+        configurations.named(runtimeOnlyConfigName).configure {
+            
it.extendsFrom(configurations.named(TEST_RUNTIME_ONLY_CONFIGURATION_NAME).get())
+        }
+
+        final TaskContainer tasks = project.tasks

Review Comment:
   Why `final`? Use `def` for local variables where the type can be inferred?



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/TestPhasesGradlePlugin.groovy:
##########
@@ -0,0 +1,177 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.gradle.plugin.core
+
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+
+import org.gradle.api.NamedDomainObjectContainer
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.artifacts.ConfigurationContainer
+import org.gradle.api.artifacts.dsl.DependencyHandler
+import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.SourceSetContainer
+import org.gradle.api.tasks.SourceSetOutput
+import org.gradle.api.tasks.TaskContainer
+import org.gradle.api.tasks.TaskProvider
+import org.gradle.api.tasks.testing.Test
+import org.gradle.api.tasks.testing.TestReport
+import org.gradle.language.base.plugins.LifecycleBasePlugin
+import org.gradle.plugins.ide.idea.model.IdeaModel
+import org.gradle.plugins.ide.idea.model.IdeaModule
+
+import org.grails.gradle.plugin.util.SourceSets
+
+import static 
org.gradle.api.plugins.JavaPlugin.TEST_IMPLEMENTATION_CONFIGURATION_NAME
+import static 
org.gradle.api.plugins.JavaPlugin.TEST_RUNTIME_ONLY_CONFIGURATION_NAME
+import static org.gradle.api.plugins.JavaPlugin.TEST_TASK_NAME
+import static org.gradle.api.tasks.SourceSet.TEST_SOURCE_SET_NAME
+
+/**
+ * Gradle plugin that provides a {@code testPhases} extension for defining
+ * additional test phases (e.g. integrationTest, functionalTest).
+ *
+ * <p>Each {@link TestPhase} added to the container automatically gets its own
+ * source set, dependency configurations, {@link Test} task, and merged test
+ * report contribution.</p>
+ *
+ * @since 7.1
+ */
+@CompileStatic
+class TestPhasesGradlePlugin implements Plugin<Project> {
+
+    static final String EXTENSION_NAME = 'testPhases'
+    static final String MERGE_TEST_REPORTS_TASK_NAME = 'mergeTestReports'
+
+    private NamedDomainObjectContainer<TestPhase> testPhases
+
+    @Override
+    void apply(Project project) {
+        testPhases = project.container(TestPhase)
+        project.extensions.add(EXTENSION_NAME, testPhases)
+
+        registerMergeTestReports(project)
+
+        testPhases.configureEach { TestPhase phase ->
+            configureTestPhase(project, phase)
+        }
+    }
+
+    private void configureTestPhase(Project project, TestPhase phase) {
+        String phaseName = phase.name
+        String implConfigName = "${phaseName}Implementation"
+        String runtimeOnlyConfigName = "${phaseName}RuntimeOnly"
+
+        File[] sourceDirs = findTestPhaseSources(project, phase)
+        List<File> acceptedSourceDirs = []
+
+        final SourceSetContainer sourceSets = 
SourceSets.findSourceSets(project)
+        final SourceSetOutput mainSourceSetOutput = 
SourceSets.findMainSourceSet(project).output
+        final SourceSetOutput testSourceSetOutput = 
SourceSets.findSourceSet(project, TEST_SOURCE_SET_NAME).output
+        final SourceSet phaseSourceSet = sourceSets.create(phaseName)
+        phaseSourceSet.compileClasspath += mainSourceSetOutput + 
testSourceSetOutput
+        phaseSourceSet.runtimeClasspath += mainSourceSetOutput + 
testSourceSetOutput
+
+        if (sourceDirs != null) {
+            for (File srcDir in sourceDirs) {
+                registerSourceDir(phaseSourceSet, srcDir)
+                acceptedSourceDirs.add(srcDir)
+            }
+        }
+
+        final File resources = new File(project.projectDir, 'grails-app/conf')
+        phaseSourceSet.resources.srcDir(resources)
+
+        final DependencyHandler dependencies = project.dependencies
+        dependencies.add(implConfigName, mainSourceSetOutput)
+        dependencies.add(implConfigName, testSourceSetOutput)
+
+        final ConfigurationContainer configurations = project.configurations
+        configurations.named(implConfigName).configure {
+            
it.extendsFrom(configurations.named(TEST_IMPLEMENTATION_CONFIGURATION_NAME).get())
+        }
+        configurations.named(runtimeOnlyConfigName).configure {
+            
it.extendsFrom(configurations.named(TEST_RUNTIME_ONLY_CONFIGURATION_NAME).get())
+        }
+
+        final TaskContainer tasks = project.tasks
+        final TaskProvider<Test> testTask = tasks.register(phaseName, Test)

Review Comment:
   Why `final`? Use `def` for local variables where the type can be inferred? 
Add configure closure param instead of repeating on next line? 



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/AbstractGroovyTemplatePlugin.groovy:
##########
@@ -93,15 +96,17 @@ class AbstractGroovyTemplatePlugin implements 
Plugin<Project> {
                 task.dependsOn(templateCompileTask)
             }
         }
-        if (project.plugins.hasPlugin(IntegrationTestGradlePlugin)) {
-            
project.plugins.withType(IntegrationTestGradlePlugin).configureEach { plugin ->
-                if (tasks.names.contains('compileIntegrationTestGroovy')) {
-                    tasks.named('compileIntegrationTestGroovy').configure { 
Task task ->
+        project.plugins.withType(TestPhasesGradlePlugin).configureEach {
+            NamedDomainObjectContainer<TestPhase> testPhases = 
(NamedDomainObjectContainer<TestPhase>) 
project.extensions.getByName(TestPhasesGradlePlugin.EXTENSION_NAME)

Review Comment:
   - Use `def` for local variables where the type can be inferred?
   - Use more Groovy idiomatic: `as NamedDomainObjectContainer<TestPhase>`?



##########
grails-test-examples/test-phases/grails-app/controllers/testphases/GreetingController.groovy:
##########
@@ -0,0 +1,29 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package testphases
+
+class GreetingController {

Review Comment:
   `@GrailsCompileStatic`?



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/AbstractGroovyTemplatePlugin.groovy:
##########
@@ -93,15 +96,17 @@ class AbstractGroovyTemplatePlugin implements 
Plugin<Project> {
                 task.dependsOn(templateCompileTask)
             }
         }
-        if (project.plugins.hasPlugin(IntegrationTestGradlePlugin)) {
-            
project.plugins.withType(IntegrationTestGradlePlugin).configureEach { plugin ->
-                if (tasks.names.contains('compileIntegrationTestGroovy')) {
-                    tasks.named('compileIntegrationTestGroovy').configure { 
Task task ->
+        project.plugins.withType(TestPhasesGradlePlugin).configureEach {
+            NamedDomainObjectContainer<TestPhase> testPhases = 
(NamedDomainObjectContainer<TestPhase>) 
project.extensions.getByName(TestPhasesGradlePlugin.EXTENSION_NAME)
+            testPhases.configureEach { TestPhase phase ->
+                String compileTaskName = 
"compile${phase.name.capitalize()}Groovy"
+                if (tasks.names.contains(compileTaskName)) {
+                    tasks.named(compileTaskName).configure { Task task ->
                         task.dependsOn(templateCompileTask)
                     }
                 }
-                if (tasks.names.contains('integrationTest')) {
-                    tasks.named('integrationTest').configure { Task task ->
+                if (tasks.names.contains(phase.name)) {
+                    tasks.named(phase.name).configure { Task task ->

Review Comment:
   `.configure` redundant?



##########
grails-test-examples/test-phases/grails-app/init/testphases/Application.groovy:
##########
@@ -0,0 +1,30 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package testphases
+
+import grails.boot.GrailsApp
+import grails.boot.config.GrailsAutoConfiguration
+
+class Application extends GrailsAutoConfiguration {

Review Comment:
   `@CompileStatic`?



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/IntegrationTestGradlePlugin.groovy:
##########
@@ -18,135 +18,55 @@
  */
 package org.grails.gradle.plugin.core
 
-import groovy.transform.CompileDynamic
 import groovy.transform.CompileStatic
 
+import org.gradle.api.NamedDomainObjectContainer
 import org.gradle.api.Plugin
 import org.gradle.api.Project
-import org.gradle.api.artifacts.ConfigurationContainer
-import org.gradle.api.artifacts.dsl.DependencyHandler
-import org.gradle.api.tasks.SourceSet
-import org.gradle.api.tasks.SourceSetContainer
-import org.gradle.api.tasks.SourceSetOutput
-import org.gradle.api.tasks.TaskContainer
-import org.gradle.api.tasks.TaskProvider
-import org.gradle.api.tasks.testing.Test
-import org.gradle.api.tasks.testing.TestReport
-import org.gradle.language.base.plugins.LifecycleBasePlugin
-import org.gradle.plugins.ide.idea.model.IdeaModel
-import org.gradle.plugins.ide.idea.model.IdeaModule
-
-import org.grails.gradle.plugin.util.SourceSets
-
-import static 
org.gradle.api.plugins.JavaPlugin.TEST_IMPLEMENTATION_CONFIGURATION_NAME
-import static 
org.gradle.api.plugins.JavaPlugin.TEST_RUNTIME_ONLY_CONFIGURATION_NAME
-import static org.gradle.api.plugins.JavaPlugin.TEST_TASK_NAME
-import static org.gradle.api.tasks.SourceSet.TEST_SOURCE_SET_NAME
 
 /**
- * Gradle plugin for adding separate src/integration-test folder to hold 
integration tests
+ * Gradle plugin for adding separate src/integration-test folder to hold 
integration tests.
  *
- * Adds integrationTestImplementation and integrationTestRuntimeOnly 
configurations that extend from testCompileClasspath and testRuntimeClasspath
+ * <p>This plugin applies {@link TestPhasesGradlePlugin} and registers the 
default
+ * {@code integrationTest} phase. Additional test phases can be added via the
+ * {@code testPhases} extension.</p>
+ *
+ * <p>Adds integrationTestImplementation and integrationTestRuntimeOnly 
configurations
+ * that extend from testCompileClasspath and testRuntimeClasspath.</p>
  *
  */
 @CompileStatic
 class IntegrationTestGradlePlugin implements Plugin<Project> {
 
+    @Deprecated(forRemoval = true, since = '7.1')
     static final String INTEGRATION_TEST_IMPLEMENTATION_CONFIGURATION_NAME = 
'integrationTestImplementation'
+
+    @Deprecated(forRemoval = true, since = '7.1')
     static final String INTEGRATION_TEST_RUNTIME_ONLY_CONFIGURATION_NAME = 
'integrationTestRuntimeOnly'
+
     static final String INTEGRATION_TEST_SOURCE_SET_NAME = 'integrationTest'
+
+    @Deprecated(forRemoval = true, since = '7.1')
     static final String INTEGRATION_TEST_TASK_NAME = 'integrationTest'
+
+    @Deprecated(forRemoval = true, since = '7.1')
     static final String MERGE_TEST_REPORTS_TASK_NAME = 'mergeTestReports'
+
+    @Deprecated(forRemoval = true, since = '7.1')
     static final String GRAILS_INTEGRATION_TEST_INDICATOR = 
'is.grails.integration.test'
 
     boolean ideaIntegration = true
+
     String sourceFolderName = 'src/integration-test'
 
     @Override
     void apply(Project project) {
-        File[] sourceDirs = findIntegrationTestSources(project)
-
-        List<File> acceptedSourceDirs = []
-        final SourceSetContainer sourceSets = 
SourceSets.findSourceSets(project)
-        final SourceSetOutput mainSourceSetOutput = 
SourceSets.findMainSourceSet(project).output
-        final SourceSetOutput testSourceSetOutput = 
SourceSets.findSourceSet(project, TEST_SOURCE_SET_NAME).output
-        final SourceSet integrationTest = 
sourceSets.create(INTEGRATION_TEST_SOURCE_SET_NAME)
-        integrationTest.compileClasspath += mainSourceSetOutput + 
testSourceSetOutput
-        integrationTest.runtimeClasspath += mainSourceSetOutput + 
testSourceSetOutput
-
-        for (File srcDir in sourceDirs) {
-            registerSourceDir(integrationTest, srcDir)
-            acceptedSourceDirs.add(srcDir)
-        }
+        project.pluginManager.apply(TestPhasesGradlePlugin)
 
-        final File resources = new File(project.projectDir, 'grails-app/conf')
-        integrationTest.resources.srcDir(resources)
-
-        final DependencyHandler dependencies = project.dependencies
-        dependencies.add(INTEGRATION_TEST_IMPLEMENTATION_CONFIGURATION_NAME, 
mainSourceSetOutput)
-        dependencies.add(INTEGRATION_TEST_IMPLEMENTATION_CONFIGURATION_NAME, 
testSourceSetOutput)
-
-        final ConfigurationContainer configurations = project.configurations
-        
configurations.named(INTEGRATION_TEST_IMPLEMENTATION_CONFIGURATION_NAME).configure
 {
-            
it.extendsFrom(configurations.named(TEST_IMPLEMENTATION_CONFIGURATION_NAME).get())
-        }
-        
configurations.named(INTEGRATION_TEST_RUNTIME_ONLY_CONFIGURATION_NAME).configure
 {
-            
it.extendsFrom(configurations.named(TEST_RUNTIME_ONLY_CONFIGURATION_NAME).get())
+        NamedDomainObjectContainer<TestPhase> testPhases = 
(NamedDomainObjectContainer<TestPhase>) 
project.extensions.getByName(TestPhasesGradlePlugin.EXTENSION_NAME)

Review Comment:
   - Use `def` for local variables where the type can be inferred?
   - Use more Groovy idiomatic: `as NamedDomainObjectContainer<TestPhase>`?



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/TestPhasesGradlePlugin.groovy:
##########
@@ -0,0 +1,177 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.gradle.plugin.core
+
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+
+import org.gradle.api.NamedDomainObjectContainer
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.artifacts.ConfigurationContainer
+import org.gradle.api.artifacts.dsl.DependencyHandler
+import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.SourceSetContainer
+import org.gradle.api.tasks.SourceSetOutput
+import org.gradle.api.tasks.TaskContainer
+import org.gradle.api.tasks.TaskProvider
+import org.gradle.api.tasks.testing.Test
+import org.gradle.api.tasks.testing.TestReport
+import org.gradle.language.base.plugins.LifecycleBasePlugin
+import org.gradle.plugins.ide.idea.model.IdeaModel
+import org.gradle.plugins.ide.idea.model.IdeaModule
+
+import org.grails.gradle.plugin.util.SourceSets
+
+import static 
org.gradle.api.plugins.JavaPlugin.TEST_IMPLEMENTATION_CONFIGURATION_NAME
+import static 
org.gradle.api.plugins.JavaPlugin.TEST_RUNTIME_ONLY_CONFIGURATION_NAME
+import static org.gradle.api.plugins.JavaPlugin.TEST_TASK_NAME
+import static org.gradle.api.tasks.SourceSet.TEST_SOURCE_SET_NAME
+
+/**
+ * Gradle plugin that provides a {@code testPhases} extension for defining
+ * additional test phases (e.g. integrationTest, functionalTest).
+ *
+ * <p>Each {@link TestPhase} added to the container automatically gets its own
+ * source set, dependency configurations, {@link Test} task, and merged test
+ * report contribution.</p>
+ *
+ * @since 7.1
+ */
+@CompileStatic
+class TestPhasesGradlePlugin implements Plugin<Project> {
+
+    static final String EXTENSION_NAME = 'testPhases'
+    static final String MERGE_TEST_REPORTS_TASK_NAME = 'mergeTestReports'
+
+    private NamedDomainObjectContainer<TestPhase> testPhases
+
+    @Override
+    void apply(Project project) {
+        testPhases = project.container(TestPhase)
+        project.extensions.add(EXTENSION_NAME, testPhases)
+
+        registerMergeTestReports(project)
+
+        testPhases.configureEach { TestPhase phase ->
+            configureTestPhase(project, phase)
+        }
+    }
+
+    private void configureTestPhase(Project project, TestPhase phase) {
+        String phaseName = phase.name
+        String implConfigName = "${phaseName}Implementation"
+        String runtimeOnlyConfigName = "${phaseName}RuntimeOnly"
+
+        File[] sourceDirs = findTestPhaseSources(project, phase)
+        List<File> acceptedSourceDirs = []
+
+        final SourceSetContainer sourceSets = 
SourceSets.findSourceSets(project)
+        final SourceSetOutput mainSourceSetOutput = 
SourceSets.findMainSourceSet(project).output
+        final SourceSetOutput testSourceSetOutput = 
SourceSets.findSourceSet(project, TEST_SOURCE_SET_NAME).output
+        final SourceSet phaseSourceSet = sourceSets.create(phaseName)
+        phaseSourceSet.compileClasspath += mainSourceSetOutput + 
testSourceSetOutput
+        phaseSourceSet.runtimeClasspath += mainSourceSetOutput + 
testSourceSetOutput
+
+        if (sourceDirs != null) {
+            for (File srcDir in sourceDirs) {
+                registerSourceDir(phaseSourceSet, srcDir)
+                acceptedSourceDirs.add(srcDir)
+            }
+        }
+
+        final File resources = new File(project.projectDir, 'grails-app/conf')
+        phaseSourceSet.resources.srcDir(resources)
+
+        final DependencyHandler dependencies = project.dependencies
+        dependencies.add(implConfigName, mainSourceSetOutput)
+        dependencies.add(implConfigName, testSourceSetOutput)
+
+        final ConfigurationContainer configurations = project.configurations
+        configurations.named(implConfigName).configure {
+            
it.extendsFrom(configurations.named(TEST_IMPLEMENTATION_CONFIGURATION_NAME).get())
+        }
+        configurations.named(runtimeOnlyConfigName).configure {
+            
it.extendsFrom(configurations.named(TEST_RUNTIME_ONLY_CONFIGURATION_NAME).get())
+        }
+
+        final TaskContainer tasks = project.tasks
+        final TaskProvider<Test> testTask = tasks.register(phaseName, Test)
+        testTask.configure {
+            it.group = LifecycleBasePlugin.VERIFICATION_GROUP
+            it.testClassesDirs = phaseSourceSet.output.classesDirs
+            it.classpath = phaseSourceSet.runtimeClasspath
+            it.shouldRunAfter(TEST_TASK_NAME)
+            it.finalizedBy(MERGE_TEST_REPORTS_TASK_NAME)
+            it.reports.html.required.set(false)
+            it.maxParallelForks = 1
+            it.testLogging {
+                events('passed')
+            }
+            it.systemProperty(phase.systemPropertyName, true)
+        }
+        tasks.named('check').configure {
+            it.dependsOn(testTask)
+        }
+
+        addPhaseToMergeTestReports(project, phaseName)
+
+        if (phase.ideaIntegration) {
+            final File[] files = acceptedSourceDirs.toArray(new 
File[acceptedSourceDirs.size()])
+            integrateIdea(project, files)
+        }
+    }
+
+    private void registerMergeTestReports(Project project) {

Review Comment:
   static?



##########
grails-test-examples/test-phases/grails-app/services/testphases/GreetingService.groovy:
##########
@@ -0,0 +1,27 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package testphases
+
+class GreetingService {

Review Comment:
   `@CompileStatic`?



##########
grails-doc/src/en/guide/testing/testPhases.adoc:
##########
@@ -0,0 +1,199 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+////
+
+A Grails application has two test phases by default: `unit` (the standard 
`test` source set) and `integration` (added automatically by the Grails Gradle 
plugin). You can define additional test phases - such as `functionalTest`, 
`smokeTest`, or `performanceTest` - using the `testPhases` extension in your 
`build.gradle`.
+
+==== How Test Phases Work
+
+Each test phase is a self-contained Gradle source set with its own:
+
+* **Source directory** - e.g. `src/functional-test/groovy`
+* **Dependency configurations** - e.g. `functionalTestImplementation`, 
`functionalTestRuntimeOnly` (extending the test configurations)
+* **Gradle `Test` task** - e.g. `./gradlew functionalTest`
+* **System property** - e.g. `is.grails.functional.test` (set to `true` at 
runtime)
+
+The `integrationTest` phase that Grails has always provided is now implemented 
on top of the same mechanism, so all customization options described here apply 
to it as well.
+
+
+==== Adding a Custom Test Phase
+
+To add a new test phase, declare it in the `testPhases` block in your 
`build.gradle`:
+
+[source,groovy]
+----
+testPhases {
+    functionalTest { }
+}
+----
+
+This single declaration creates:
+
+* A `functionalTest` source set reading from `src/functional-test/groovy` (and 
`src/functional-test/java`)
+* `functionalTestImplementation` and `functionalTestRuntimeOnly` 
configurations that extend from the test configurations
+* A `functionalTest` Gradle task wired into the `check` lifecycle
+* IntelliJ IDEA integration marking the source folder as test sources
+
+You can then add dependencies specific to that phase:
+
+[source,groovy]
+----
+dependencies {
+    functionalTestImplementation testFixtures('org.apache.grails:grails-geb')
+    functionalTestImplementation 
'org.apache.grails:grails-testing-support-http-client'
+}
+----
+
+And place your test classes under `src/functional-test/groovy`:
+
+[source,groovy]
+----
+// src/functional-test/groovy/com/example/HomeFunctionalSpec.groovy
+package com.example
+
+import grails.plugin.geb.ContainerGebSpec
+import grails.testing.mixin.integration.Integration
+
+@Integration
+class HomeFunctionalSpec extends ContainerGebSpec {
+
+    void "test home page renders"() {
+        when:
+        go('/')
+
+        then:
+        driver.pageSource.contains('Welcome')

Review Comment:
   driver is `delegate` and can be removed:
   ```groovy
   then:
   pageSource.contains('Welcome')
   ```



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/TestPhasesGradlePlugin.groovy:
##########
@@ -0,0 +1,177 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.gradle.plugin.core
+
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+
+import org.gradle.api.NamedDomainObjectContainer
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.artifacts.ConfigurationContainer
+import org.gradle.api.artifacts.dsl.DependencyHandler
+import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.SourceSetContainer
+import org.gradle.api.tasks.SourceSetOutput
+import org.gradle.api.tasks.TaskContainer
+import org.gradle.api.tasks.TaskProvider
+import org.gradle.api.tasks.testing.Test
+import org.gradle.api.tasks.testing.TestReport
+import org.gradle.language.base.plugins.LifecycleBasePlugin
+import org.gradle.plugins.ide.idea.model.IdeaModel
+import org.gradle.plugins.ide.idea.model.IdeaModule
+
+import org.grails.gradle.plugin.util.SourceSets
+
+import static 
org.gradle.api.plugins.JavaPlugin.TEST_IMPLEMENTATION_CONFIGURATION_NAME
+import static 
org.gradle.api.plugins.JavaPlugin.TEST_RUNTIME_ONLY_CONFIGURATION_NAME
+import static org.gradle.api.plugins.JavaPlugin.TEST_TASK_NAME
+import static org.gradle.api.tasks.SourceSet.TEST_SOURCE_SET_NAME
+
+/**
+ * Gradle plugin that provides a {@code testPhases} extension for defining
+ * additional test phases (e.g. integrationTest, functionalTest).
+ *
+ * <p>Each {@link TestPhase} added to the container automatically gets its own
+ * source set, dependency configurations, {@link Test} task, and merged test
+ * report contribution.</p>
+ *
+ * @since 7.1
+ */
+@CompileStatic
+class TestPhasesGradlePlugin implements Plugin<Project> {
+
+    static final String EXTENSION_NAME = 'testPhases'
+    static final String MERGE_TEST_REPORTS_TASK_NAME = 'mergeTestReports'
+
+    private NamedDomainObjectContainer<TestPhase> testPhases
+
+    @Override
+    void apply(Project project) {
+        testPhases = project.container(TestPhase)
+        project.extensions.add(EXTENSION_NAME, testPhases)
+
+        registerMergeTestReports(project)
+
+        testPhases.configureEach { TestPhase phase ->
+            configureTestPhase(project, phase)
+        }
+    }
+
+    private void configureTestPhase(Project project, TestPhase phase) {
+        String phaseName = phase.name
+        String implConfigName = "${phaseName}Implementation"
+        String runtimeOnlyConfigName = "${phaseName}RuntimeOnly"
+
+        File[] sourceDirs = findTestPhaseSources(project, phase)
+        List<File> acceptedSourceDirs = []
+
+        final SourceSetContainer sourceSets = 
SourceSets.findSourceSets(project)
+        final SourceSetOutput mainSourceSetOutput = 
SourceSets.findMainSourceSet(project).output
+        final SourceSetOutput testSourceSetOutput = 
SourceSets.findSourceSet(project, TEST_SOURCE_SET_NAME).output
+        final SourceSet phaseSourceSet = sourceSets.create(phaseName)
+        phaseSourceSet.compileClasspath += mainSourceSetOutput + 
testSourceSetOutput
+        phaseSourceSet.runtimeClasspath += mainSourceSetOutput + 
testSourceSetOutput
+
+        if (sourceDirs != null) {
+            for (File srcDir in sourceDirs) {
+                registerSourceDir(phaseSourceSet, srcDir)
+                acceptedSourceDirs.add(srcDir)
+            }
+        }
+
+        final File resources = new File(project.projectDir, 'grails-app/conf')
+        phaseSourceSet.resources.srcDir(resources)
+
+        final DependencyHandler dependencies = project.dependencies
+        dependencies.add(implConfigName, mainSourceSetOutput)
+        dependencies.add(implConfigName, testSourceSetOutput)
+
+        final ConfigurationContainer configurations = project.configurations
+        configurations.named(implConfigName).configure {
+            
it.extendsFrom(configurations.named(TEST_IMPLEMENTATION_CONFIGURATION_NAME).get())
+        }
+        configurations.named(runtimeOnlyConfigName).configure {
+            
it.extendsFrom(configurations.named(TEST_RUNTIME_ONLY_CONFIGURATION_NAME).get())
+        }
+
+        final TaskContainer tasks = project.tasks
+        final TaskProvider<Test> testTask = tasks.register(phaseName, Test)
+        testTask.configure {
+            it.group = LifecycleBasePlugin.VERIFICATION_GROUP
+            it.testClassesDirs = phaseSourceSet.output.classesDirs
+            it.classpath = phaseSourceSet.runtimeClasspath
+            it.shouldRunAfter(TEST_TASK_NAME)
+            it.finalizedBy(MERGE_TEST_REPORTS_TASK_NAME)
+            it.reports.html.required.set(false)
+            it.maxParallelForks = 1
+            it.testLogging {
+                events('passed')
+            }
+            it.systemProperty(phase.systemPropertyName, true)
+        }
+        tasks.named('check').configure {
+            it.dependsOn(testTask)
+        }
+
+        addPhaseToMergeTestReports(project, phaseName)
+
+        if (phase.ideaIntegration) {
+            final File[] files = acceptedSourceDirs.toArray(new 
File[acceptedSourceDirs.size()])
+            integrateIdea(project, files)
+        }
+    }
+
+    private void registerMergeTestReports(Project project) {
+        project.tasks.register(MERGE_TEST_REPORTS_TASK_NAME, TestReport) {
+            it.mustRunAfter(project.tasks.withType(Test).toArray())
+            
it.destinationDirectory.set(project.layout.buildDirectory.dir('reports/tests'))
+            it.testResults.from(
+                    
project.files(project.layout.buildDirectory.dir('test-results/test/binary'))
+            )
+        }
+    }
+
+    private void addPhaseToMergeTestReports(Project project, String phaseName) 
{
+        project.tasks.named(MERGE_TEST_REPORTS_TASK_NAME, 
TestReport).configure {
+            it.testResults.from(
+                    
project.files(project.layout.buildDirectory.dir("test-results/${phaseName}/binary"))
+            )
+        }
+    }
+
+    @CompileDynamic
+    private void registerSourceDir(SourceSet sourceSet, File srcDir) {
+        sourceSet."${srcDir.name}".srcDir(srcDir)
+    }
+
+    @CompileDynamic
+    private void integrateIdea(Project project, File[] acceptedSourceDirs) {
+        project.pluginManager.withPlugin('idea') { ->
+            def ideaExtension = project.getExtensions().getByType(IdeaModel)

Review Comment:
   Groovy property getter?



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/TestPhasesGradlePlugin.groovy:
##########
@@ -0,0 +1,177 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.gradle.plugin.core
+
+import groovy.transform.CompileDynamic
+import groovy.transform.CompileStatic
+
+import org.gradle.api.NamedDomainObjectContainer
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.artifacts.ConfigurationContainer
+import org.gradle.api.artifacts.dsl.DependencyHandler
+import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.SourceSetContainer
+import org.gradle.api.tasks.SourceSetOutput
+import org.gradle.api.tasks.TaskContainer
+import org.gradle.api.tasks.TaskProvider
+import org.gradle.api.tasks.testing.Test
+import org.gradle.api.tasks.testing.TestReport
+import org.gradle.language.base.plugins.LifecycleBasePlugin
+import org.gradle.plugins.ide.idea.model.IdeaModel
+import org.gradle.plugins.ide.idea.model.IdeaModule
+
+import org.grails.gradle.plugin.util.SourceSets
+
+import static 
org.gradle.api.plugins.JavaPlugin.TEST_IMPLEMENTATION_CONFIGURATION_NAME
+import static 
org.gradle.api.plugins.JavaPlugin.TEST_RUNTIME_ONLY_CONFIGURATION_NAME
+import static org.gradle.api.plugins.JavaPlugin.TEST_TASK_NAME
+import static org.gradle.api.tasks.SourceSet.TEST_SOURCE_SET_NAME
+
+/**
+ * Gradle plugin that provides a {@code testPhases} extension for defining
+ * additional test phases (e.g. integrationTest, functionalTest).
+ *
+ * <p>Each {@link TestPhase} added to the container automatically gets its own
+ * source set, dependency configurations, {@link Test} task, and merged test
+ * report contribution.</p>
+ *
+ * @since 7.1
+ */
+@CompileStatic
+class TestPhasesGradlePlugin implements Plugin<Project> {
+
+    static final String EXTENSION_NAME = 'testPhases'
+    static final String MERGE_TEST_REPORTS_TASK_NAME = 'mergeTestReports'
+
+    private NamedDomainObjectContainer<TestPhase> testPhases
+
+    @Override
+    void apply(Project project) {
+        testPhases = project.container(TestPhase)
+        project.extensions.add(EXTENSION_NAME, testPhases)
+
+        registerMergeTestReports(project)
+
+        testPhases.configureEach { TestPhase phase ->
+            configureTestPhase(project, phase)
+        }
+    }
+
+    private void configureTestPhase(Project project, TestPhase phase) {
+        String phaseName = phase.name
+        String implConfigName = "${phaseName}Implementation"
+        String runtimeOnlyConfigName = "${phaseName}RuntimeOnly"
+
+        File[] sourceDirs = findTestPhaseSources(project, phase)
+        List<File> acceptedSourceDirs = []
+
+        final SourceSetContainer sourceSets = 
SourceSets.findSourceSets(project)
+        final SourceSetOutput mainSourceSetOutput = 
SourceSets.findMainSourceSet(project).output
+        final SourceSetOutput testSourceSetOutput = 
SourceSets.findSourceSet(project, TEST_SOURCE_SET_NAME).output
+        final SourceSet phaseSourceSet = sourceSets.create(phaseName)
+        phaseSourceSet.compileClasspath += mainSourceSetOutput + 
testSourceSetOutput
+        phaseSourceSet.runtimeClasspath += mainSourceSetOutput + 
testSourceSetOutput
+
+        if (sourceDirs != null) {
+            for (File srcDir in sourceDirs) {
+                registerSourceDir(phaseSourceSet, srcDir)
+                acceptedSourceDirs.add(srcDir)
+            }
+        }
+
+        final File resources = new File(project.projectDir, 'grails-app/conf')
+        phaseSourceSet.resources.srcDir(resources)
+
+        final DependencyHandler dependencies = project.dependencies
+        dependencies.add(implConfigName, mainSourceSetOutput)
+        dependencies.add(implConfigName, testSourceSetOutput)
+
+        final ConfigurationContainer configurations = project.configurations
+        configurations.named(implConfigName).configure {
+            
it.extendsFrom(configurations.named(TEST_IMPLEMENTATION_CONFIGURATION_NAME).get())
+        }
+        configurations.named(runtimeOnlyConfigName).configure {
+            
it.extendsFrom(configurations.named(TEST_RUNTIME_ONLY_CONFIGURATION_NAME).get())
+        }
+
+        final TaskContainer tasks = project.tasks
+        final TaskProvider<Test> testTask = tasks.register(phaseName, Test)
+        testTask.configure {
+            it.group = LifecycleBasePlugin.VERIFICATION_GROUP
+            it.testClassesDirs = phaseSourceSet.output.classesDirs
+            it.classpath = phaseSourceSet.runtimeClasspath
+            it.shouldRunAfter(TEST_TASK_NAME)
+            it.finalizedBy(MERGE_TEST_REPORTS_TASK_NAME)
+            it.reports.html.required.set(false)
+            it.maxParallelForks = 1
+            it.testLogging {
+                events('passed')
+            }
+            it.systemProperty(phase.systemPropertyName, true)
+        }
+        tasks.named('check').configure {
+            it.dependsOn(testTask)
+        }
+
+        addPhaseToMergeTestReports(project, phaseName)
+
+        if (phase.ideaIntegration) {
+            final File[] files = acceptedSourceDirs.toArray(new 
File[acceptedSourceDirs.size()])

Review Comment:
   Why final?



##########
grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/AbstractGroovyTemplatePlugin.groovy:
##########
@@ -93,15 +96,17 @@ class AbstractGroovyTemplatePlugin implements 
Plugin<Project> {
                 task.dependsOn(templateCompileTask)
             }
         }
-        if (project.plugins.hasPlugin(IntegrationTestGradlePlugin)) {
-            
project.plugins.withType(IntegrationTestGradlePlugin).configureEach { plugin ->
-                if (tasks.names.contains('compileIntegrationTestGroovy')) {
-                    tasks.named('compileIntegrationTestGroovy').configure { 
Task task ->
+        project.plugins.withType(TestPhasesGradlePlugin).configureEach {
+            NamedDomainObjectContainer<TestPhase> testPhases = 
(NamedDomainObjectContainer<TestPhase>) 
project.extensions.getByName(TestPhasesGradlePlugin.EXTENSION_NAME)
+            testPhases.configureEach { TestPhase phase ->
+                String compileTaskName = 
"compile${phase.name.capitalize()}Groovy"
+                if (tasks.names.contains(compileTaskName)) {
+                    tasks.named(compileTaskName).configure { Task task ->

Review Comment:
   `.configure` redundant?



##########
grails-test-examples/test-phases/src/functional-test/groovy/testphases/GreetingControllerFunctionalSpec.groovy:
##########
@@ -0,0 +1,35 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package testphases
+
+import grails.plugin.geb.ContainerGebSpec
+import grails.testing.mixin.integration.Integration
+
+@Integration
+class GreetingControllerFunctionalSpec extends ContainerGebSpec {
+
+    void "test greeting controller renders response"() {
+        when: 'navigating to the greeting controller'
+        go('/greeting/index')
+
+        then: 'the page contains the greeting message'
+        driver.pageSource.contains('Hello')

Review Comment:
   driver is `delegate` and can be removed:
   ```groovy
   then:
   pageSource.contains('Welcome')
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to