You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by jd...@apache.org on 2026/03/30 21:05:35 UTC

(grails-core) branch multipleTestPhases created (now d550a9e680)

This is an automated email from the ASF dual-hosted git repository.

jdaugherty pushed a change to branch multipleTestPhases
in repository https://gitbox.apache.org/repos/asf/grails-core.git


      at d550a9e680 doc - add documentation for new phase functionality

This branch includes the following new commits:

     new 002ddee073 feature - support for custom test phases that mirror the integration test phase configuration
     new 4f4cd62d9b test - add functional testing demonstrating multiple test phases
     new d550a9e680 doc - add documentation for new phase functionality

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(grails-core) 01/03: feature - support for custom test phases that mirror the integration test phase configuration

Posted by jd...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

jdaugherty pushed a commit to branch multipleTestPhases
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 002ddee073a1b6f7594cdbedcc8943247e4d04ea
Author: James Daugherty <jd...@jdresources.net>
AuthorDate: Mon Mar 30 16:56:54 2026 -0400

    feature - support for custom test phases that mirror the integration test phase configuration
---
 grails-gradle/plugins/build.gradle                 |   6 +
 .../plugin/core/IntegrationTestGradlePlugin.groovy | 123 ++-------------------
 .../org/grails/gradle/plugin/core/TestPhase.groovy |  51 +++++++++
 ...Plugin.groovy => TestPhasesGradlePlugin.groovy} | 116 +++++++++++--------
 .../views/AbstractGroovyTemplatePlugin.groovy      |  16 +--
 settings.gradle                                    |   4 +-
 6 files changed, 150 insertions(+), 166 deletions(-)

diff --git a/grails-gradle/plugins/build.gradle b/grails-gradle/plugins/build.gradle
index cedf995b7a..48e186272a 100644
--- a/grails-gradle/plugins/build.gradle
+++ b/grails-gradle/plugins/build.gradle
@@ -124,6 +124,12 @@ gradlePlugin {
             id = 'org.apache.grails.gradle.grails-exploded'
             implementationClass = 'org.grails.gradle.plugin.exploded.GrailsExplodedPlugin'
         }
+        grailsTestPhases {
+            displayName = 'Grails Test Phases Plugin'
+            description = 'A plugin that provides a testPhases extension for defining additional test phases (e.g. integrationTest, functionalTest).'
+            id = 'org.apache.grails.gradle.grails-test-phases'
+            implementationClass = 'org.grails.gradle.plugin.core.TestPhasesGradlePlugin'
+        }
         grailsIntegrationTest {
             displayName = 'Grails Integration Test Plugin'
             description = 'A plugin that adds support for the Grails `integration` test phase.'
diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/IntegrationTestGradlePlugin.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/IntegrationTestGradlePlugin.groovy
index 4010c0f763..57a2259949 100644
--- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/IntegrationTestGradlePlugin.groovy
+++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/IntegrationTestGradlePlugin.groovy
@@ -18,135 +18,32 @@
  */
 package org.grails.gradle.plugin.core
 
-import groovy.transform.CompileDynamic
 import groovy.transform.CompileStatic
 
 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.
+ *
+ * <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>
  *
- * Adds integrationTestImplementation and integrationTestRuntimeOnly configurations that extend from testCompileClasspath and testRuntimeClasspath
+ * <p>Adds integrationTestImplementation and integrationTestRuntimeOnly configurations
+ * that extend from testCompileClasspath and testRuntimeClasspath.</p>
  *
  */
 @CompileStatic
 class IntegrationTestGradlePlugin implements Plugin<Project> {
 
-    static final String INTEGRATION_TEST_IMPLEMENTATION_CONFIGURATION_NAME = 'integrationTestImplementation'
-    static final String INTEGRATION_TEST_RUNTIME_ONLY_CONFIGURATION_NAME = 'integrationTestRuntimeOnly'
     static final String INTEGRATION_TEST_SOURCE_SET_NAME = 'integrationTest'
-    static final String INTEGRATION_TEST_TASK_NAME = 'integrationTest'
-    static final String MERGE_TEST_REPORTS_TASK_NAME = 'mergeTestReports'
-    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)
-        }
-
-        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())
-        }
-
-        final TaskContainer tasks = project.tasks
-        final TaskProvider<Test> integrationTestTask = tasks.register(INTEGRATION_TEST_TASK_NAME, Test)
-        integrationTestTask.configure {
-            it.group = LifecycleBasePlugin.VERIFICATION_GROUP
-            it.testClassesDirs = integrationTest.output.classesDirs
-            it.classpath = integrationTest.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(GRAILS_INTEGRATION_TEST_INDICATOR, true)
-        }
-        tasks.named('check').configure {
-            it.dependsOn(integrationTestTask)
-        }
-
-        tasks.register(MERGE_TEST_REPORTS_TASK_NAME, TestReport) {
-            it.mustRunAfter(tasks.withType(Test).toArray())
-            it.destinationDirectory.set(project.layout.buildDirectory.dir('reports/tests'))
-            // These must point to the binary test results directory generated by a Test task instance.
-            // If Test task instances are specified directly, this task would depend on them and run them.
-            it.testResults.from(
-                    project.files("$project.buildDir/test-results/binary/test", "$project.buildDir/test-results/binary/integrationTest"),
-                    // different versions of Gradle store these results in different places. ugh.
-                    project.files("$project.buildDir/test-results/test/binary", "$project.buildDir/test-results/integrationTest/binary")
-            )
-        }
-
-        if (ideaIntegration) {
-            final File[] files = acceptedSourceDirs.toArray(new File[acceptedSourceDirs.size()])
-            integrateIdea(project, files)
-        }
-    }
-
-    @CompileDynamic
-    private void registerSourceDir(SourceSet integrationTest, File srcDir) {
-        integrationTest."${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)
-            }
-        }
-    }
+        project.pluginManager.apply(TestPhasesGradlePlugin)
 
-    File[] findIntegrationTestSources(Project project) {
-        project.file(sourceFolderName).listFiles({ File file -> file.isDirectory() && !file.name.contains('.') } as FileFilter)
+        TestPhasesGradlePlugin phasesPlugin = project.plugins.getPlugin(TestPhasesGradlePlugin)
+        phasesPlugin.testPhases.create(INTEGRATION_TEST_SOURCE_SET_NAME)
     }
 }
diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/TestPhase.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/TestPhase.groovy
new file mode 100644
index 0000000000..9fe1aa2165
--- /dev/null
+++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/TestPhase.groovy
@@ -0,0 +1,51 @@
+/*
+ *  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.CompileStatic
+
+import grails.util.GrailsNameUtils
+
+/**
+ * Represents a configurable test phase (e.g. integrationTest, functionalTest).
+ *
+ * <p>Default values for {@code sourceFolderName} and {@code systemPropertyName}
+ * are derived from the phase {@code name} so that users only need to declare the
+ * phase by name in most cases.</p>
+ *
+ * @since 2025.0
+ */
+@CompileStatic
+class TestPhase {
+
+    final String name
+
+    String sourceFolderName
+
+    String systemPropertyName
+
+    boolean ideaIntegration = true
+
+    TestPhase(String name) {
+        this.name = name
+        this.sourceFolderName = "src/${GrailsNameUtils.getScriptName(name)}"
+        String naturalPart = name.replaceAll(/Test$/, '')
+        this.systemPropertyName = "is.grails.${GrailsNameUtils.getScriptName(naturalPart)}.test"
+    }
+}
diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/IntegrationTestGradlePlugin.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/TestPhasesGradlePlugin.groovy
similarity index 51%
copy from grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/IntegrationTestGradlePlugin.groovy
copy to grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/TestPhasesGradlePlugin.groovy
index 4010c0f763..fc1d36ee19 100644
--- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/IntegrationTestGradlePlugin.groovy
+++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/TestPhasesGradlePlugin.groovy
@@ -21,6 +21,7 @@ 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
@@ -44,62 +45,76 @@ 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 that provides a {@code testPhases} extension for defining
+ * additional test phases (e.g. integrationTest, functionalTest).
  *
- * Adds integrationTestImplementation and integrationTestRuntimeOnly configurations that extend from testCompileClasspath and testRuntimeClasspath
+ * <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 2025.0
  */
 @CompileStatic
-class IntegrationTestGradlePlugin implements Plugin<Project> {
+class TestPhasesGradlePlugin implements Plugin<Project> {
 
-    static final String INTEGRATION_TEST_IMPLEMENTATION_CONFIGURATION_NAME = 'integrationTestImplementation'
-    static final String INTEGRATION_TEST_RUNTIME_ONLY_CONFIGURATION_NAME = 'integrationTestRuntimeOnly'
-    static final String INTEGRATION_TEST_SOURCE_SET_NAME = 'integrationTest'
-    static final String INTEGRATION_TEST_TASK_NAME = 'integrationTest'
+    static final String EXTENSION_NAME = 'testPhases'
     static final String MERGE_TEST_REPORTS_TASK_NAME = 'mergeTestReports'
-    static final String GRAILS_INTEGRATION_TEST_INDICATOR = 'is.grails.integration.test'
 
-    boolean ideaIntegration = true
-    String sourceFolderName = 'src/integration-test'
+    NamedDomainObjectContainer<TestPhase> testPhases
 
     @Override
     void apply(Project project) {
-        File[] sourceDirs = findIntegrationTestSources(project)
+        testPhases = project.container(TestPhase)
+        project.extensions.add(EXTENSION_NAME, testPhases)
 
+        testPhases.all { 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 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)
+        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')
-        integrationTest.resources.srcDir(resources)
+        phaseSourceSet.resources.srcDir(resources)
 
         final DependencyHandler dependencies = project.dependencies
-        dependencies.add(INTEGRATION_TEST_IMPLEMENTATION_CONFIGURATION_NAME, mainSourceSetOutput)
-        dependencies.add(INTEGRATION_TEST_IMPLEMENTATION_CONFIGURATION_NAME, testSourceSetOutput)
+        dependencies.add(implConfigName, mainSourceSetOutput)
+        dependencies.add(implConfigName, testSourceSetOutput)
 
         final ConfigurationContainer configurations = project.configurations
-        configurations.named(INTEGRATION_TEST_IMPLEMENTATION_CONFIGURATION_NAME).configure {
+        configurations.named(implConfigName).configure {
             it.extendsFrom(configurations.named(TEST_IMPLEMENTATION_CONFIGURATION_NAME).get())
         }
-        configurations.named(INTEGRATION_TEST_RUNTIME_ONLY_CONFIGURATION_NAME).configure {
+        configurations.named(runtimeOnlyConfigName).configure {
             it.extendsFrom(configurations.named(TEST_RUNTIME_ONLY_CONFIGURATION_NAME).get())
         }
 
         final TaskContainer tasks = project.tasks
-        final TaskProvider<Test> integrationTestTask = tasks.register(INTEGRATION_TEST_TASK_NAME, Test)
-        integrationTestTask.configure {
+        final TaskProvider<Test> testTask = tasks.register(phaseName, Test)
+        testTask.configure {
             it.group = LifecycleBasePlugin.VERIFICATION_GROUP
-            it.testClassesDirs = integrationTest.output.classesDirs
-            it.classpath = integrationTest.runtimeClasspath
+            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)
@@ -107,33 +122,44 @@ class IntegrationTestGradlePlugin implements Plugin<Project> {
             it.testLogging {
                 events('passed')
             }
-            it.systemProperty(GRAILS_INTEGRATION_TEST_INDICATOR, true)
+            it.systemProperty(phase.systemPropertyName, true)
         }
         tasks.named('check').configure {
-            it.dependsOn(integrationTestTask)
+            it.dependsOn(testTask)
         }
 
-        tasks.register(MERGE_TEST_REPORTS_TASK_NAME, TestReport) {
-            it.mustRunAfter(tasks.withType(Test).toArray())
-            it.destinationDirectory.set(project.layout.buildDirectory.dir('reports/tests'))
-            // These must point to the binary test results directory generated by a Test task instance.
-            // If Test task instances are specified directly, this task would depend on them and run them.
-            it.testResults.from(
-                    project.files("$project.buildDir/test-results/binary/test", "$project.buildDir/test-results/binary/integrationTest"),
-                    // different versions of Gradle store these results in different places. ugh.
-                    project.files("$project.buildDir/test-results/test/binary", "$project.buildDir/test-results/integrationTest/binary")
-            )
-        }
+        configureMergeTestReports(project, phaseName)
 
-        if (ideaIntegration) {
+        if (phase.ideaIntegration) {
             final File[] files = acceptedSourceDirs.toArray(new File[acceptedSourceDirs.size()])
             integrateIdea(project, files)
         }
     }
 
+    private void configureMergeTestReports(Project project, String phaseName) {
+        final TaskContainer tasks = project.tasks
+        if (tasks.names.contains(MERGE_TEST_REPORTS_TASK_NAME)) {
+            tasks.named(MERGE_TEST_REPORTS_TASK_NAME, TestReport).configure {
+                it.testResults.from(
+                        project.files("${project.buildDir}/test-results/binary/${phaseName}"),
+                        project.files("${project.buildDir}/test-results/${phaseName}/binary")
+                )
+            }
+        } else {
+            tasks.register(MERGE_TEST_REPORTS_TASK_NAME, TestReport) {
+                it.mustRunAfter(tasks.withType(Test).toArray())
+                it.destinationDirectory.set(project.layout.buildDirectory.dir('reports/tests'))
+                it.testResults.from(
+                        project.files("${project.buildDir}/test-results/binary/test", "${project.buildDir}/test-results/binary/${phaseName}"),
+                        project.files("${project.buildDir}/test-results/test/binary", "${project.buildDir}/test-results/${phaseName}/binary")
+                )
+            }
+        }
+    }
+
     @CompileDynamic
-    private void registerSourceDir(SourceSet integrationTest, File srcDir) {
-        integrationTest."${srcDir.name}".srcDir(srcDir)
+    private void registerSourceDir(SourceSet sourceSet, File srcDir) {
+        sourceSet."${srcDir.name}".srcDir(srcDir)
     }
 
     @CompileDynamic
@@ -146,7 +172,7 @@ class IntegrationTestGradlePlugin implements Plugin<Project> {
         }
     }
 
-    File[] findIntegrationTestSources(Project project) {
-        project.file(sourceFolderName).listFiles({ File file -> file.isDirectory() && !file.name.contains('.') } as FileFilter)
+    File[] findTestPhaseSources(Project project, TestPhase phase) {
+        project.file(phase.sourceFolderName).listFiles({ File file -> file.isDirectory() && !file.name.contains('.') } as FileFilter)
     }
 }
diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/AbstractGroovyTemplatePlugin.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/AbstractGroovyTemplatePlugin.groovy
index 29ded3a609..100a57a22b 100644
--- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/AbstractGroovyTemplatePlugin.groovy
+++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/AbstractGroovyTemplatePlugin.groovy
@@ -33,7 +33,8 @@ import org.gradle.api.tasks.TaskContainer
 import org.gradle.api.tasks.bundling.Jar
 
 import grails.util.GrailsNameUtils
-import org.grails.gradle.plugin.core.IntegrationTestGradlePlugin
+import org.grails.gradle.plugin.core.TestPhase
+import org.grails.gradle.plugin.core.TestPhasesGradlePlugin
 import org.grails.gradle.plugin.util.SourceSets
 
 /**
@@ -93,15 +94,16 @@ 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 { TestPhasesGradlePlugin phasesPlugin ->
+            phasesPlugin.testPhases.all { 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 ->
                         task.dependsOn(templateCompileTask)
                     }
                 }
diff --git a/settings.gradle b/settings.gradle
index b514a71803..6d42c7f344 100644
--- a/settings.gradle
+++ b/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'
 )
 project(':grails-test-examples-async-events-pubsub-demo').projectDir = file('grails-test-examples/async-events-pubsub-demo')
 project(':grails-test-examples-app1').projectDir = file('grails-test-examples/app1')
@@ -438,6 +439,7 @@ project(':grails-test-examples-scaffolding').projectDir = file('grails-test-exam
 project(':grails-test-examples-scaffolding-fields').projectDir = file('grails-test-examples/scaffolding-fields')
 project(':grails-test-examples-views-functional-tests').projectDir = file('grails-test-examples/views-functional-tests')
 project(':grails-test-examples-views-functional-tests-plugin').projectDir = file('grails-test-examples/views-functional-tests-plugin')
+project(':grails-test-examples-test-phases').projectDir = file('grails-test-examples/test-phases')
 
 includeBuild('./grails-gradle') {
     name = 'grails-gradle'


(grails-core) 03/03: doc - add documentation for new phase functionality

Posted by jd...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

jdaugherty pushed a commit to branch multipleTestPhases
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit d550a9e680f354f061e2ce4ecb830c67481588b6
Author: James Daugherty <jd...@jdresources.net>
AuthorDate: Mon Mar 30 17:05:08 2026 -0400

    doc - add documentation for new phase functionality
---
 grails-doc/src/en/guide/index.adoc                 |   5 +
 grails-doc/src/en/guide/introduction/whatsNew.adoc |  15 ++
 grails-doc/src/en/guide/links.yml                  |   1 +
 .../en/guide/testing/runningTests/withGradle.adoc  |   7 +
 grails-doc/src/en/guide/testing/testPhases.adoc    | 199 +++++++++++++++++++++
 grails-doc/src/en/guide/toc.yml                    |   1 +
 6 files changed, 228 insertions(+)

diff --git a/grails-doc/src/en/guide/index.adoc b/grails-doc/src/en/guide/index.adoc
index e375a7be19..323a3efd2e 100644
--- a/grails-doc/src/en/guide/index.adoc
+++ b/grails-doc/src/en/guide/index.adoc
@@ -1005,6 +1005,11 @@ include::testing/integrationTesting.adoc[]
 
 include::testing/functionalTesting.adoc[]
 
+[[testPhases]]
+=== Custom Test Phases
+
+include::testing/testPhases.adoc[]
+
 [[i18n]]
 == Internationalization
 
diff --git a/grails-doc/src/en/guide/introduction/whatsNew.adoc b/grails-doc/src/en/guide/introduction/whatsNew.adoc
index d8733bff6a..79492edc28 100644
--- a/grails-doc/src/en/guide/introduction/whatsNew.adoc
+++ b/grails-doc/src/en/guide/introduction/whatsNew.adoc
@@ -105,3 +105,18 @@ and fluent response assertions via returning a TestHttpResponse wrapper of the a
 
 See the xref:testing.adoc#httpClientTestingSupport[HTTP Client Testing Support] section in the Testing chapter
 for setup and examples.
+
+==== Custom Test Phases (Grails 7.1+)
+
+Grails now supports defining custom test phases beyond the built-in `unit` and `integration` phases. Using the `testPhases` extension in your `build.gradle`, you can add phases such as `functionalTest`, `smokeTest`, or `performanceTest` - each with its own source set, dependency configurations, and Gradle task.
+
+[source,groovy]
+----
+testPhases {
+    functionalTest { }
+}
+----
+
+Each phase automatically sets a system property (e.g. `is.grails.functional.test`) that your code can use to detect which test phase is running. The existing `integrationTest` phase is now implemented on top of this same mechanism.
+
+See the xref:testing.adoc#testPhases[Custom Test Phases] section in the Testing chapter for full details.
diff --git a/grails-doc/src/en/guide/links.yml b/grails-doc/src/en/guide/links.yml
index f85e05e4c9..1a431fac48 100644
--- a/grails-doc/src/en/guide/links.yml
+++ b/grails-doc/src/en/guide/links.yml
@@ -224,6 +224,7 @@ mockingCodecs: 12.1.7 Mocking Codecs
 unitTestMetaprogramming: 12.1.8 Unit Test Metaprogramming
 integrationTesting: 12.2 Integration Testing
 functionalTesting: 12.3 Functional Testing
+testPhases: 12.4 Custom Test Phases
 i18n: 13. Internationalization
 understandingMessageBundles: 13.1 Understanding Message Bundles
 changingLocales: 13.2 Changing Locales
diff --git a/grails-doc/src/en/guide/testing/runningTests/withGradle.adoc b/grails-doc/src/en/guide/testing/runningTests/withGradle.adoc
index bcc1e913bc..855478c7ea 100644
--- a/grails-doc/src/en/guide/testing/runningTests/withGradle.adoc
+++ b/grails-doc/src/en/guide/testing/runningTests/withGradle.adoc
@@ -111,6 +111,13 @@ For "integration" tests, you would run:
 ./gradlew integrationTest
 ----
 
+If you have defined additional test phases using the `testPhases` extension (see <<testPhases>>), run them by their phase name:
+
+[source,shell]
+----
+./gradlew functionalTest
+----
+
 === Targeting Tests When Using Phases
 
 You can combine test and phase targeting:
diff --git a/grails-doc/src/en/guide/testing/testPhases.adoc b/grails-doc/src/en/guide/testing/testPhases.adoc
new file mode 100644
index 0000000000..5a5642c31b
--- /dev/null
+++ b/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')
+    }
+}
+----
+
+
+==== Customizing a Test Phase
+
+Each phase supports the following configurable properties:
+
+[cols="2,3,2"]
+|===
+| Property | Description | Default
+
+| `sourceFolderName`
+| Path to the source directory
+| `src/<phase-script-name>` (e.g. `src/functional-test`)
+
+| `systemPropertyName`
+| System property set to `true` when this phase runs
+| `is.grails.<phase>.test` (e.g. `is.grails.functional.test`)
+
+| `ideaIntegration`
+| Whether to register source dirs with IntelliJ IDEA
+| `true`
+|===
+
+Example with overrides:
+
+[source,groovy]
+----
+testPhases {
+    smokeTest {
+        sourceFolderName = 'src/smoke-tests'
+        systemPropertyName = 'is.smoke.test'
+    }
+}
+----
+
+
+==== Detecting the Current Test Phase
+
+Each test phase sets a system property that your test code (or application code running under test) can read to determine which phase is active:
+
+[cols="2,2"]
+|===
+| Phase | System Property
+
+| `integrationTest`
+| `is.grails.integration.test`
+
+| `functionalTest`
+| `is.grails.functional.test`
+
+| Custom phase
+| Configurable via `systemPropertyName` (see above)
+|===
+
+You can check these properties in your tests or in application code:
+
+[source,groovy]
+----
+if (System.getProperty('is.grails.functional.test')) {
+    // running inside the functionalTest phase
+}
+----
+
+This is useful when shared code needs to behave differently depending on the test phase -- for example, configuring different data sources, disabling certain interceptors, or adjusting timeouts.
+
+
+==== Running Custom Test Phases
+
+Custom test phases are run the same way as the built-in phases:
+
+[source,shell]
+----
+# Run only functional tests
+./gradlew functionalTest
+
+# Run only integration tests
+./gradlew integrationTest
+
+# Run all test phases (unit + integration + functional + any others)
+./gradlew check
+
+# Target specific tests within a phase
+./gradlew functionalTest --tests "com.example.HomeFunctionalSpec"
+----
+
+
+==== Multiple Test Phases Example
+
+A `build.gradle` that defines both integration and functional test phases:
+
+[source,groovy]
+----
+plugins {
+    id 'org.apache.grails.gradle.grails-web'
+}
+
+// integrationTest is already registered by grails-web.
+// Add a functionalTest phase:
+testPhases {
+    functionalTest { }
+}
+
+dependencies {
+    // Unit test dependencies
+    testImplementation 'org.apache.grails:grails-testing-support-web'
+
+    // Integration test dependencies
+    integrationTestImplementation testFixtures('org.apache.grails:grails-geb')
+
+    // Functional test dependencies
+    functionalTestImplementation testFixtures('org.apache.grails:grails-geb')
+    functionalTestImplementation 'org.apache.grails:grails-testing-support-http-client'
+}
+----
+
+With this setup, `./gradlew check` will execute the `test`, `integrationTest`, and `functionalTest` tasks in order, and the merged test report will include results from all phases.
diff --git a/grails-doc/src/en/guide/toc.yml b/grails-doc/src/en/guide/toc.yml
index 65df69ed3e..e2aa468712 100644
--- a/grails-doc/src/en/guide/toc.yml
+++ b/grails-doc/src/en/guide/toc.yml
@@ -305,6 +305,7 @@ testing:
     usefulProperties: Useful Properties
   integrationTesting: Integration Testing
   functionalTesting: Functional Testing
+  testPhases: Custom Test Phases
 bestPractices:
   title: Building a Successful Application
   codeAnalysisGroovy: Code Analysis and Styling for Groovy


(grails-core) 02/03: test - add functional testing demonstrating multiple test phases

Posted by jd...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

jdaugherty pushed a commit to branch multipleTestPhases
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 4f4cd62d9b1a2175f7ea5260b31fa56720ded9d3
Author: James Daugherty <jd...@jdresources.net>
AuthorDate: Mon Mar 30 17:00:52 2026 -0400

    test - add functional testing demonstrating multiple test phases
---
 grails-test-examples/test-phases/build.gradle      | 55 ++++++++++++++++++++++
 .../test-phases/grails-app/conf/application.yml    | 23 +++++++++
 .../test-phases/grails-app/conf/logback.xml        | 10 ++++
 .../testphases/GreetingController.groovy           | 29 ++++++++++++
 .../controllers/testphases/UrlMappings.groovy      | 34 +++++++++++++
 .../grails-app/init/testphases/Application.groovy  | 30 ++++++++++++
 .../services/testphases/GreetingService.groovy     | 27 +++++++++++
 .../GreetingControllerFunctionalSpec.groovy        | 35 ++++++++++++++
 .../GreetingServiceIntegrationSpec.groovy          | 37 +++++++++++++++
 .../groovy/testphases/GreetingServiceSpec.groovy   | 36 ++++++++++++++
 10 files changed, 316 insertions(+)

diff --git a/grails-test-examples/test-phases/build.gradle b/grails-test-examples/test-phases/build.gradle
new file mode 100644
index 0000000000..8c76b7c0ec
--- /dev/null
+++ b/grails-test-examples/test-phases/build.gradle
@@ -0,0 +1,55 @@
+/*
+ *  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.
+ */
+
+plugins {
+    id 'org.apache.grails.buildsrc.properties'
+    id 'org.apache.grails.buildsrc.compile'
+}
+
+version = '0.1'
+group = 'testphases'
+
+apply plugin: 'org.apache.grails.gradle.grails-web'
+
+// Add a functionalTest phase via the testPhases extension.
+// The integrationTest phase is already registered by grails-web.
+testPhases {
+    functionalTest { }
+}
+
+dependencies {
+    implementation platform(project(':grails-bom'))
+
+    implementation 'org.apache.grails:grails-dependencies-starter-web'
+    implementation 'com.h2database:h2'
+    implementation 'org.apache.grails:grails-layout'
+    implementation 'org.apache.grails:grails-data-hibernate5'
+
+    testImplementation 'org.apache.grails:grails-testing-support-web'
+    testImplementation 'org.apache.grails:grails-testing-support-datamapping'
+
+    integrationTestImplementation testFixtures('org.apache.grails:grails-geb')
+
+    functionalTestImplementation testFixtures('org.apache.grails:grails-geb')
+}
+
+apply {
+    from rootProject.layout.projectDirectory.file('gradle/functional-test-config.gradle')
+    from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle')
+}
diff --git a/grails-test-examples/test-phases/grails-app/conf/application.yml b/grails-test-examples/test-phases/grails-app/conf/application.yml
new file mode 100644
index 0000000000..d7fcf165cf
--- /dev/null
+++ b/grails-test-examples/test-phases/grails-app/conf/application.yml
@@ -0,0 +1,23 @@
+grails:
+    profile: web
+    codegen:
+        defaultPackage: testphases
+    mime:
+        types:
+            json:
+              - application/json
+            html:
+              - text/html
+            xml:
+              - text/xml
+              - application/xml
+            all: '*/*'
+
+environments:
+    test:
+        dataSource:
+            dbCreate: create-drop
+            url: jdbc:h2:mem:testDb;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000
+            driverClassName: org.h2.Driver
+            username: sa
+            password: ''
diff --git a/grails-test-examples/test-phases/grails-app/conf/logback.xml b/grails-test-examples/test-phases/grails-app/conf/logback.xml
new file mode 100644
index 0000000000..1f302ed038
--- /dev/null
+++ b/grails-test-examples/test-phases/grails-app/conf/logback.xml
@@ -0,0 +1,10 @@
+<configuration>
+    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder>
+            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
+        </encoder>
+    </appender>
+    <root level="error">
+        <appender-ref ref="STDOUT"/>
+    </root>
+</configuration>
diff --git a/grails-test-examples/test-phases/grails-app/controllers/testphases/GreetingController.groovy b/grails-test-examples/test-phases/grails-app/controllers/testphases/GreetingController.groovy
new file mode 100644
index 0000000000..cfd83c47ef
--- /dev/null
+++ b/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 {
+
+    GreetingService greetingService
+
+    def index() {
+        respond([message: greetingService.greet('World')])
+    }
+
+}
diff --git a/grails-test-examples/test-phases/grails-app/controllers/testphases/UrlMappings.groovy b/grails-test-examples/test-phases/grails-app/controllers/testphases/UrlMappings.groovy
new file mode 100644
index 0000000000..d90b77682c
--- /dev/null
+++ b/grails-test-examples/test-phases/grails-app/controllers/testphases/UrlMappings.groovy
@@ -0,0 +1,34 @@
+/*
+ *  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 UrlMappings {
+
+    static mappings = {
+        "/$controller/$action?/$id?(.$format)?" {
+            constraints {
+            }
+        }
+
+        '/'(view: '/index')
+        '500'(view: '/error')
+        '404'(view: '/notFound')
+    }
+
+}
diff --git a/grails-test-examples/test-phases/grails-app/init/testphases/Application.groovy b/grails-test-examples/test-phases/grails-app/init/testphases/Application.groovy
new file mode 100644
index 0000000000..7b5dd9cb83
--- /dev/null
+++ b/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 {
+
+    static void main(String[] args) {
+        GrailsApp.run(Application, args)
+    }
+
+}
diff --git a/grails-test-examples/test-phases/grails-app/services/testphases/GreetingService.groovy b/grails-test-examples/test-phases/grails-app/services/testphases/GreetingService.groovy
new file mode 100644
index 0000000000..861d7c7d4a
--- /dev/null
+++ b/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 {
+
+    String greet(String name) {
+        "Hello, ${name}!"
+    }
+
+}
diff --git a/grails-test-examples/test-phases/src/functional-test/groovy/testphases/GreetingControllerFunctionalSpec.groovy b/grails-test-examples/test-phases/src/functional-test/groovy/testphases/GreetingControllerFunctionalSpec.groovy
new file mode 100644
index 0000000000..55705e5d5d
--- /dev/null
+++ b/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')
+    }
+
+}
diff --git a/grails-test-examples/test-phases/src/integration-test/groovy/testphases/GreetingServiceIntegrationSpec.groovy b/grails-test-examples/test-phases/src/integration-test/groovy/testphases/GreetingServiceIntegrationSpec.groovy
new file mode 100644
index 0000000000..d2ff13b45d
--- /dev/null
+++ b/grails-test-examples/test-phases/src/integration-test/groovy/testphases/GreetingServiceIntegrationSpec.groovy
@@ -0,0 +1,37 @@
+/*
+ *  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.testing.mixin.integration.Integration
+import spock.lang.Specification
+
+@Integration
+class GreetingServiceIntegrationSpec extends Specification {
+
+    GreetingService greetingService
+
+    void "test greeting service is wired in integration context"() {
+        expect: 'the service is injected by the application context'
+        greetingService != null
+
+        and: 'it returns the expected greeting'
+        greetingService.greet('Integration') == 'Hello, Integration!'
+    }
+
+}
diff --git a/grails-test-examples/test-phases/src/test/groovy/testphases/GreetingServiceSpec.groovy b/grails-test-examples/test-phases/src/test/groovy/testphases/GreetingServiceSpec.groovy
new file mode 100644
index 0000000000..4c6262c729
--- /dev/null
+++ b/grails-test-examples/test-phases/src/test/groovy/testphases/GreetingServiceSpec.groovy
@@ -0,0 +1,36 @@
+/*
+ *  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.testing.services.ServiceUnitTest
+import spock.lang.Specification
+
+class GreetingServiceSpec extends Specification implements ServiceUnitTest<GreetingService> {
+
+    void "test greeting returns expected message"() {
+        expect:
+        service.greet('Grails') == 'Hello, Grails!'
+    }
+
+    void "test greeting with empty name"() {
+        expect:
+        service.greet('') == 'Hello, !'
+    }
+
+}