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

(grails-static-website) branch add-comprehensive-tests-v8-guides created (now e6573bf5c89)

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

jamesfredley pushed a change to branch add-comprehensive-tests-v8-guides
in repository https://gitbox.apache.org/repos/asf/grails-static-website.git


      at e6573bf5c89 Add a Testing chapter to grails-docker-bootbuildimage v8

This branch includes the following new commits:

     new 24e61b80a54 Make grails-spock-test-tour v8 tests actually compile and pass
     new 171d09a8212 Make grails-rest-library v8 compile and add a tested testing chapter
     new e14cf263ab2 Make grails-github-actions-cicd v8 CI stages run real tests
     new 7e5337ef9ea Fix grails-htmx v8 HTMX URLs and add a Testing chapter
     new dc82cef583a Add a Testing chapter to grails-tailwindcss v8
     new 2b2587cc5ab Fix grails-vite-spa v8 SPA serving and add a Testing chapter
     new 54bad4f33b3 Fix grails-fields-custom-widgets-and-wrappers v8 rendering bugs + Testing chapter
     new de58e3a8e48 Make grails-multi-module v8 match its dev-reload prose and add test snippets
     new e6573bf5c89 Add a Testing chapter to grails-docker-bootbuildimage v8

The 9 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-static-website) 02/09: Make grails-rest-library v8 compile and add a tested testing chapter

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

jamesfredley pushed a commit to branch add-comprehensive-tests-v8-guides
in repository https://gitbox.apache.org/repos/asf/grails-static-website.git

commit 171d09a8212cdcbad7b7a145d450f4c4ba9913a0
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Thu May 28 15:07:36 2026 -0400

    Make grails-rest-library v8 compile and add a tested testing chapter
    
    The sample app did not compile against Grails 8.0.0-SNAPSHOT and had no
    tests. It now builds and ships 27 green tests (19 unit + 8 integration/
    functional). Re-vendored snippets and corrected prose:
    
    - BootStrap snippet: drop the manual Logger field that collides with the
      injected log property (compile failure); bootstrapData chapter now
      describes the Author.withTransaction wrapper, not @Transactional
    - BookController snippet: bulkCreate uses the injected MessageSource (the
      message() taglib is not available in a rest-api controller), so an
      invalid bulk POST returns the documented 422 instead of a 500
    
    New "Testing the API" chapter (registered in conf/guides.yml toc) with
    vendored snippets:
    
    - application-test.yml: testcontainers PostgreSQL + create-drop schema +
      databasemigration.updateOnStart false (the database-migration plugin
      otherwise blocks schema creation in the test env)
    - BookSpec / AuthorSpec: domain constraints and the hasMany mapping
    - BookControllerSpec: pagination clamping
    - BookIntegrationSpec: filtering / paging / relationships (seed-aware)
    - BookFunctionalSpec: the JSON surface over HTTP incl. the all-or-nothing
      bulkCreate contract
    - documents the testcontainers-bom and grails-testing-support-web deps
    
    Assisted-by: claude-code:claude-opus-4-7
---
 conf/guides.yml                                    |   2 +
 .../v8/guide/bootstrapData.adoc                    |   2 +-
 guides/grails-rest-library/v8/guide/testing.adoc   |  72 ++++++++++++
 .../controllers/example/BookController.groovy      |   5 +-
 .../grails-app/init/example/BootStrap.groovy       |   4 -
 .../groovy/example/BookFunctionalSpec.groovy       | 127 +++++++++++++++++++++
 .../groovy/example/BookIntegrationSpec.groovy      |  61 ++++++++++
 .../src/test/groovy/example/AuthorSpec.groovy      |  46 ++++++++
 .../test/groovy/example/BookControllerSpec.groovy  |  43 +++++++
 .../src/test/groovy/example/BookSpec.groovy        |  78 +++++++++++++
 .../src/test/resources/application-test.yml        |   8 ++
 11 files changed, 442 insertions(+), 6 deletions(-)

diff --git a/conf/guides.yml b/conf/guides.yml
index b1e98e996b9..0403110c4f3 100644
--- a/conf/guides.yml
+++ b/conf/guides.yml
@@ -4069,6 +4069,8 @@ guides:
             title: All-or-Nothing Bulk Create
           versioning:
             title: URL-Prefix vs Content-Negotiation Versioning
+          testing:
+            title: Testing the API
           runningTheApp:
             title: Running the Application
           helpWithGrails:
diff --git a/guides/grails-rest-library/v8/guide/bootstrapData.adoc b/guides/grails-rest-library/v8/guide/bootstrapData.adoc
index 68b143d8a75..892a3ed5f80 100644
--- a/guides/grails-rest-library/v8/guide/bootstrapData.adoc
+++ b/guides/grails-rest-library/v8/guide/bootstrapData.adoc
@@ -11,4 +11,4 @@ Two guards keep this safe:
 * `Environment.current == Environment.PRODUCTION` early-exit means the seed never runs against a real database.
 * The `Author.count() > 0` check makes the seed idempotent during development - subsequent restarts do not create duplicate rows.
 
-The `@Transactional` annotation on the `init` closure means the four `save(failOnError: true)` calls all commit together or all roll back. Without it, a failure on book 3 would leave books 1 and 2 in the database in a half-loaded state.
+The `Author.withTransaction { }` wrapper means the four `save(failOnError: true)` calls all commit together or all roll back. Without it, a failure on book 3 would leave books 1 and 2 in the database in a half-loaded state - and a bare `save()` in `BootStrap` outside any transaction may not flush at all.
diff --git a/guides/grails-rest-library/v8/guide/testing.adoc b/guides/grails-rest-library/v8/guide/testing.adoc
new file mode 100644
index 00000000000..067c62af0ac
--- /dev/null
+++ b/guides/grails-rest-library/v8/guide/testing.adoc
@@ -0,0 +1,72 @@
+The library API ships with a layered Spock suite: fast domain and controller unit tests, `@Integration` tests against a real database, and functional HTTP tests that drive the JSON endpoints end to end. The integration and functional layers run against a Testcontainers-managed PostgreSQL database, so a Docker daemon is the only host requirement.
+
+== Test dependencies
+
+Testcontainers artifacts are not in the Grails BOM, so they need an explicit `testcontainers-bom` to resolve a version. The web testing support is required for `ControllerUnitTest` even in a `rest-api` profile app:
+
+[source,groovy]
+.build.gradle (test dependencies)
+----
+testImplementation "org.apache.grails:grails-testing-support-datamapping"
+testImplementation "org.apache.grails:grails-testing-support-views-gson"
+testImplementation "org.apache.grails:grails-testing-support-web"
+testImplementation "org.spockframework:spock-core"
+testImplementation platform("org.testcontainers:testcontainers-bom:1.20.4")
+testImplementation "org.testcontainers:postgresql"
+testImplementation "org.testcontainers:spock"
+testImplementation "org.testcontainers:testcontainers"
+----
+
+Because this app uses the database-migration plugin, an empty `changelog.groovy` means GORM will not auto-create the schema in the test environment. The test profile therefore turns the migration off and lets GORM create the schema directly:
+
+[source,yaml]
+.src/test/resources/application-test.yml
+----
+include::../snippets/src/test/resources/application-test.yml[]
+----
+
+== Unit: domain constraints and controller paging
+
+`DataTest` registers the in-memory datastore for `Author` and `Book` so constraint logic runs in milliseconds with no Spring context. `Book` `belongsTo` `Author`, so both classes are mocked:
+
+[source,groovy]
+.src/test/groovy/example/BookSpec.groovy
+----
+include::../snippets/src/test/groovy/example/BookSpec.groovy[]
+----
+
+The `RestfulController` pagination clamping (default 25, hard cap 100, negative treated as default) is pinned with a `ControllerUnitTest`:
+
+[source,groovy]
+.src/test/groovy/example/BookControllerSpec.groovy
+----
+include::../snippets/src/test/groovy/example/BookControllerSpec.groovy[]
+----
+
+== Integration: relationships against a real database
+
+`@Integration` with `@Rollback` boots the full context and isolates each method. `BootStrap` seeds four books outside production, so these specs use ISBNs that do not collide with the seed set and filter queries by an author they create:
+
+[source,groovy]
+.src/integration-test/groovy/example/BookIntegrationSpec.groovy
+----
+include::../snippets/src/integration-test/groovy/example/BookIntegrationSpec.groovy[]
+----
+
+== Functional: the REST surface over real HTTP
+
+The functional spec sends real requests with `java.net.http.HttpClient` against the booted app (port injected via `@Value('${local.server.port}')`). It asserts the paginated envelope, the page-size clamp, and - most importantly - the all-or-nothing `bulkCreate` contract: a request with one invalid entry returns `422` and persists *nothing*. `@Rollback` is not used (the endpoints commit), so the fixture is created and read back inside `withNewTransaction` blocks:
+
+[source,groovy]
+.src/integration-test/groovy/example/BookFunctionalSpec.groovy
+----
+include::../snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy[]
+----
+
+Run the suite:
+
+[source,bash]
+----
+./gradlew test             # unit - milliseconds, no Docker
+./gradlew integrationTest  # integration + functional - Docker required
+----
diff --git a/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/BookController.groovy b/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/BookController.groovy
index 80290b7bb63..f878cae6dc1 100644
--- a/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/BookController.groovy
+++ b/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/BookController.groovy
@@ -2,6 +2,7 @@ package example
 
 import grails.gorm.transactions.Transactional
 import grails.rest.RestfulController
+import org.springframework.context.MessageSource
 import org.springframework.http.HttpStatus
 import org.springframework.validation.FieldError
 
@@ -9,6 +10,8 @@ class BookController extends RestfulController<Book> {
 
     static responseFormats = ['json']
 
+    MessageSource messageSource
+
     BookController() {
         super(Book)
     }
@@ -77,7 +80,7 @@ class BookController extends RestfulController<Book> {
                         Map<String, Object> payload = [
                             object : error.objectName,
                             code   : error.code,
-                            message: message(error: error)
+                            message: messageSource.getMessage(error, request.locale)
                         ]
                         if (error instanceof FieldError) {
                             payload.field = error.field
diff --git a/guides/grails-rest-library/v8/snippets/grails-app/init/example/BootStrap.groovy b/guides/grails-rest-library/v8/snippets/grails-app/init/example/BootStrap.groovy
index 3b5e6c087b7..49600e353fc 100644
--- a/guides/grails-rest-library/v8/snippets/grails-app/init/example/BootStrap.groovy
+++ b/guides/grails-rest-library/v8/snippets/grails-app/init/example/BootStrap.groovy
@@ -1,13 +1,9 @@
 package example
 
 import grails.util.Environment
-import org.slf4j.Logger
-import org.slf4j.LoggerFactory
 
 class BootStrap {
 
-    private static final Logger log = LoggerFactory.getLogger(BootStrap)
-
     Closure init = { servletContext ->
 
         if (Environment.current == Environment.PRODUCTION) {
diff --git a/guides/grails-rest-library/v8/snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy b/guides/grails-rest-library/v8/snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy
new file mode 100644
index 00000000000..5abd3999696
--- /dev/null
+++ b/guides/grails-rest-library/v8/snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy
@@ -0,0 +1,127 @@
+package example
+
+import grails.testing.mixin.integration.Integration
+import groovy.json.JsonOutput
+import groovy.json.JsonSlurper
+import org.springframework.beans.factory.annotation.Value
+import spock.lang.Specification
+
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+
+/**
+ * Functional tests drive the real REST/JSON surface over HTTP with
+ * java.net.http.HttpClient. @Rollback is deliberately NOT used: the
+ * endpoints commit, so the fixture is seeded (and read back) inside
+ * withNewTransaction blocks. BootStrap data is not populated in the
+ * @Integration context, so each spec owns its fixture.
+ */
+@Integration
+class BookFunctionalSpec extends Specification {
+
+    @Value('${local.server.port}')
+    Integer serverPort
+
+    HttpClient client = HttpClient.newHttpClient()
+
+    Long authorId
+
+    void setup() {
+        Book.withNewTransaction {
+            Author a = Author.findByName('Functional Author') ?: new Author(name: 'Functional Author').save(failOnError: true)
+            authorId = a.id
+            if (!Book.findByIsbn('9781111111113')) {
+                new Book(author: a, title: 'Seeded One', isbn: '9781111111113', pageCount: 100).save(failOnError: true)
+            }
+            if (!Book.findByIsbn('9782222222226')) {
+                new Book(author: a, title: 'Seeded Two', isbn: '9782222222226', pageCount: 200).save(failOnError: true)
+            }
+        }
+    }
+
+    private URI uri(String path) { URI.create("http://localhost:${serverPort}${path}") }
+
+    private HttpResponse<String> getJson(String path) {
+        client.send(HttpRequest.newBuilder(uri(path)).GET().build(), HttpResponse.BodyHandlers.ofString())
+    }
+
+    private HttpResponse<String> postJson(String path, String body) {
+        client.send(HttpRequest.newBuilder(uri(path))
+                .header('Content-Type', 'application/json')
+                .POST(HttpRequest.BodyPublishers.ofString(body)).build(),
+                HttpResponse.BodyHandlers.ofString())
+    }
+
+    void "GET /v1/books returns a paginated JSON envelope"() {
+        when:
+        HttpResponse<String> resp = getJson('/v1/books')
+        Map json = new JsonSlurper().parseText(resp.body()) as Map
+
+        then:
+        resp.statusCode() == 200
+        json.containsKey('page')
+        json.containsKey('pageSize')
+        json.total >= 2
+        json.items.size() >= 2
+        json.items.every { it.title && it.isbn && it.author?.name }
+    }
+
+    void "GET /v1/books?max= clamps the page size to 100"() {
+        when:
+        HttpResponse<String> resp = getJson('/v1/books?max=500')
+        Map json = new JsonSlurper().parseText(resp.body()) as Map
+
+        then:
+        resp.statusCode() == 200
+        json.pageSize == 100
+    }
+
+    void "POST /v1/books/bulk creates every book and returns 201"() {
+        given:
+        String body = JsonOutput.toJson([
+            [title: 'The Two Towers',         isbn: '9780547928203', pageCount: 416, author: [id: authorId]],
+            [title: 'The Return of the King', isbn: '9780547928197', pageCount: 432, author: [id: authorId]]
+        ])
+
+        when:
+        HttpResponse<String> resp = postJson('/v1/books/bulk', body)
+
+        then:
+        resp.statusCode() == 201
+        Book.withNewTransaction {
+            Book.findByIsbn('9780547928203') != null && Book.findByIsbn('9780547928197') != null
+        }
+    }
+
+    void "POST /v1/books/bulk rolls back entirely when any entry is invalid"() {
+        given: 'one valid and one invalid entry in the same request'
+        long before = Book.withNewTransaction { Book.count() }
+        String body = JsonOutput.toJson([
+            [title: 'Would Be Valid', isbn: '9780000000086', pageCount: 100, author: [id: authorId]],
+            [title: '',               isbn: 'not-an-isbn',   author: [id: authorId]]
+        ])
+
+        when:
+        HttpResponse<String> resp = postJson('/v1/books/bulk', body)
+        Map json = new JsonSlurper().parseText(resp.body()) as Map
+
+        then: 'a 422 reports the failing index and NOTHING is persisted'
+        resp.statusCode() == 422
+        json.books.size() == 1
+        json.books.first().index == 1
+        Book.withNewTransaction { Book.count() } == before
+        Book.withNewTransaction { Book.findByIsbn('9780000000086') == null }
+    }
+
+    void "GET /v1/authors returns the authors as JSON"() {
+        when:
+        HttpResponse<String> resp = getJson('/v1/authors')
+        Map json = new JsonSlurper().parseText(resp.body()) as Map
+
+        then:
+        resp.statusCode() == 200
+        json.total >= 1
+        json.items.any { it.name == 'Functional Author' }
+    }
+}
diff --git a/guides/grails-rest-library/v8/snippets/src/integration-test/groovy/example/BookIntegrationSpec.groovy b/guides/grails-rest-library/v8/snippets/src/integration-test/groovy/example/BookIntegrationSpec.groovy
new file mode 100644
index 00000000000..f3ceee4e298
--- /dev/null
+++ b/guides/grails-rest-library/v8/snippets/src/integration-test/groovy/example/BookIntegrationSpec.groovy
@@ -0,0 +1,61 @@
+package example
+
+import grails.gorm.transactions.Rollback
+import grails.testing.mixin.integration.Integration
+import spock.lang.Specification
+
+/**
+ * @Integration boots the full Spring context against the Testcontainers
+ * PostgreSQL database. @Rollback isolates each method, so a bare GORM call
+ * runs inside an ambient session.
+ *
+ * BootStrap seeds four books outside production, so these specs use ISBNs
+ * that do not collide with the seed set and filter queries by an author
+ * they create themselves.
+ */
+@Integration
+@Rollback
+class BookIntegrationSpec extends Specification {
+
+    void "books are filtered by author with a where query"() {
+        given:
+        Author tolkien = new Author(name: 'Integration Tolkien').save(flush: true, failOnError: true)
+        Author leguin  = new Author(name: 'Integration Le Guin').save(flush: true, failOnError: true)
+        new Book(author: tolkien, title: 'TT Book', isbn: '9783000000010', pageCount: 310).save(flush: true, failOnError: true)
+        new Book(author: leguin,  title: 'LG Book', isbn: '9783000000027', pageCount: 205).save(flush: true, failOnError: true)
+
+        when:
+        List<Book> tolkienBooks = Book.where { author.id == tolkien.id }.list()
+
+        then:
+        tolkienBooks.size() == 1
+        tolkienBooks.first().title == 'TT Book'
+    }
+
+    void "pagination returns at most the requested page size"() {
+        given:
+        Author a = new Author(name: 'Integration Prolific').save(flush: true, failOnError: true)
+        (1..5).each { int i ->
+            new Book(author: a, title: "Paged ${i}", isbn: String.format('978400000000%d', i), pageCount: 100 + i)
+                .save(flush: true, failOnError: true)
+        }
+
+        when:
+        List<Book> firstPage = Book.where { author.id == a.id }.list(max: 2, offset: 0, sort: 'title', order: 'asc')
+
+        then:
+        firstPage.size() == 2
+        firstPage*.title == ['Paged 1', 'Paged 2']
+    }
+
+    void "an author's books collection reflects persisted books"() {
+        given:
+        Author a = new Author(name: 'Integration Author').save(flush: true, failOnError: true)
+        a.addToBooks(new Book(title: 'One', isbn: '9785000000019', pageCount: 100))
+        a.addToBooks(new Book(title: 'Two', isbn: '9785000000026', pageCount: 200))
+        a.save(flush: true, failOnError: true)
+
+        expect:
+        Author.get(a.id).books.size() == 2
+    }
+}
diff --git a/guides/grails-rest-library/v8/snippets/src/test/groovy/example/AuthorSpec.groovy b/guides/grails-rest-library/v8/snippets/src/test/groovy/example/AuthorSpec.groovy
new file mode 100644
index 00000000000..bcd76ddf7db
--- /dev/null
+++ b/guides/grails-rest-library/v8/snippets/src/test/groovy/example/AuthorSpec.groovy
@@ -0,0 +1,46 @@
+package example
+
+import grails.testing.gorm.DataTest
+import spock.lang.Specification
+
+/**
+ * Domain constraint unit tests for Author plus the hasMany books mapping.
+ */
+class AuthorSpec extends Specification implements DataTest {
+
+    Class[] getDomainClassesToMock() { [Author, Book] }
+
+    void "a valid author validates"() {
+        expect:
+        new Author(name: 'J.R.R. Tolkien').validate()
+    }
+
+    void "name is required"() {
+        when:
+        Author a = new Author()
+
+        then:
+        !a.validate()
+        a.errors.getFieldError('name').code == 'nullable'
+    }
+
+    void "biography is optional but capped at maxSize"() {
+        when:
+        Author a = new Author(name: 'X', biography: 'a' * 4001)
+
+        then:
+        !a.validate()
+        a.errors.getFieldError('biography').code == 'maxSize.exceeded'
+    }
+
+    void "an author owns many books through addToBooks"() {
+        given:
+        Author a = new Author(name: 'Tolkien').save(flush: true, failOnError: true)
+        a.addToBooks(new Book(title: 'The Hobbit', isbn: '9780547928227', pageCount: 310))
+        a.save(flush: true, failOnError: true)
+
+        expect:
+        Author.get(a.id).books.size() == 1
+        Book.findByIsbn('9780547928227').author.name == 'Tolkien'
+    }
+}
diff --git a/guides/grails-rest-library/v8/snippets/src/test/groovy/example/BookControllerSpec.groovy b/guides/grails-rest-library/v8/snippets/src/test/groovy/example/BookControllerSpec.groovy
new file mode 100644
index 00000000000..4817c053d1a
--- /dev/null
+++ b/guides/grails-rest-library/v8/snippets/src/test/groovy/example/BookControllerSpec.groovy
@@ -0,0 +1,43 @@
+package example
+
+import grails.testing.gorm.DataTest
+import grails.testing.web.controllers.ControllerUnitTest
+import spock.lang.Specification
+import spock.lang.Unroll
+
+/**
+ * Controller unit test for the pagination clamping the custom index action
+ * applies before it queries. No Spring context, no real database.
+ */
+class BookControllerSpec extends Specification
+        implements ControllerUnitTest<BookController>, DataTest {
+
+    Class[] getDomainClassesToMock() { [Author, Book] }
+
+    @Unroll
+    void "index clamps max #requested to #expected"() {
+        when:
+        controller.index(requested)
+
+        then:
+        controller.params.max == expected
+
+        where:
+        requested || expected
+        500       || 100   // capped at 100
+        10        || 10    // within range, untouched
+        null      || 25    // default
+        -5        || 25    // negative treated as default
+    }
+
+    void "index never produces a negative offset"() {
+        given:
+        controller.params.offset = -10
+
+        when:
+        controller.index(25)
+
+        then:
+        controller.params.offset == 0
+    }
+}
diff --git a/guides/grails-rest-library/v8/snippets/src/test/groovy/example/BookSpec.groovy b/guides/grails-rest-library/v8/snippets/src/test/groovy/example/BookSpec.groovy
new file mode 100644
index 00000000000..bde99a3ae41
--- /dev/null
+++ b/guides/grails-rest-library/v8/snippets/src/test/groovy/example/BookSpec.groovy
@@ -0,0 +1,78 @@
+package example
+
+import grails.testing.gorm.DataTest
+import spock.lang.Specification
+import spock.lang.Unroll
+
+/**
+ * Domain constraint unit tests for Book. DataTest mocks both Book and its
+ * owning Author so the belongsTo association can be satisfied without a
+ * Spring context or a real database.
+ */
+class BookSpec extends Specification implements DataTest {
+
+    Class[] getDomainClassesToMock() { [Author, Book] }
+
+    private Book bookWith(Map overrides) {
+        Author author = new Author(name: 'Author').save(flush: true, failOnError: true)
+        new Book([author: author, title: 'A Title', isbn: '9780547928227', pageCount: 100] + overrides)
+    }
+
+    void "a fully populated book validates"() {
+        expect:
+        bookWith([:]).validate()
+    }
+
+    void "author is required"() {
+        when:
+        Book b = new Book(title: 'No Author', isbn: '9780547928227')
+
+        then:
+        !b.validate()
+        b.errors.getFieldError('author').code == 'nullable'
+    }
+
+    void "title is required"() {
+        when:
+        Book b = bookWith(title: null)
+
+        then:
+        !b.validate()
+        b.errors.getFieldError('title').code == 'nullable'
+    }
+
+    void "isbn must match the ISBN pattern"() {
+        when:
+        Book b = bookWith(isbn: 'not-an-isbn')
+
+        then:
+        !b.validate()
+        b.errors.getFieldError('isbn').code == 'matches.invalid'
+    }
+
+    void "isbn must be unique"() {
+        given:
+        bookWith(isbn: '9780547928227').save(flush: true, failOnError: true)
+
+        when:
+        Book dup = bookWith(isbn: '9780547928227')
+
+        then:
+        !dup.validate()
+        dup.errors.getFieldError('isbn').code == 'unique'
+    }
+
+    @Unroll
+    void "pageCount #pageCount is #valid"() {
+        expect:
+        bookWith(pageCount: pageCount).validate() == valid
+
+        where:
+        pageCount || valid
+        null      || true
+        1         || true
+        500       || true
+        0         || false
+        -5        || false
+    }
+}
diff --git a/guides/grails-rest-library/v8/snippets/src/test/resources/application-test.yml b/guides/grails-rest-library/v8/snippets/src/test/resources/application-test.yml
new file mode 100644
index 00000000000..8d5999fd500
--- /dev/null
+++ b/guides/grails-rest-library/v8/snippets/src/test/resources/application-test.yml
@@ -0,0 +1,8 @@
+dataSource:
+  url: jdbc:tc:postgresql:12:///postgres
+  driverClassName: org.testcontainers.jdbc.ContainerDatabaseDriver
+  dbCreate: create-drop
+grails:
+  plugin:
+    databasemigration:
+      updateOnStart: false


(grails-static-website) 08/09: Make grails-multi-module v8 match its dev-reload prose and add test snippets

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

jamesfredley pushed a commit to branch add-comprehensive-tests-v8-guides
in repository https://gitbox.apache.org/repos/asf/grails-static-website.git

commit de58e3a8e487b82b760eb78ea4a60fe734f05f16
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Thu May 28 17:02:09 2026 -0400

    Make grails-multi-module v8 match its dev-reload prose and add test snippets
    
    The sample app diverged from the guide (the Dev Reload chapter documents the
    grails-exploded plugin + grails { plugins { } } block, but the apps used a
    plain dependency), the admin REST surface did not route, and the modules had
    only the forge-default Geb spec. The sample now runs 10 green tests (6 unit +
    4 functional) and matches the documented setup.
    
    Expanded testingPerModule with vendored snippets:
    
    - shared-core BookSpec / BookServiceSpec (the shared domain + @Service)
    - webapp-admin UrlMappings (the new "/books"(resources:'book') so the
      RestfulController routes) + AdminBookFunctionalSpec (REST CRUD over HTTP)
    - webapp-customer CatalogFunctionalSpec (the read-only catalog)
    
    The Dev Reload and Webapps chapters already documented the exploded-plugin +
    grails.plugins wiring; the sample app now actually implements it, so the
    guide's primary reload recipe works as written.
    
    Assisted-by: claude-code:claude-opus-4-7
---
 .../v8/guide/testingPerModule.adoc                 | 42 +++++++++++
 .../src/test/groovy/example/BookServiceSpec.groovy | 36 ++++++++++
 .../src/test/groovy/example/BookSpec.groovy        | 40 +++++++++++
 .../controllers/example/UrlMappings.groovy         | 21 ++++++
 .../groovy/admin/AdminBookFunctionalSpec.groovy    | 82 ++++++++++++++++++++++
 .../groovy/customer/CatalogFunctionalSpec.groovy   | 51 ++++++++++++++
 6 files changed, 272 insertions(+)

diff --git a/guides/grails-multi-module/v8/guide/testingPerModule.adoc b/guides/grails-multi-module/v8/guide/testingPerModule.adoc
index 8e9fc508085..9d0b059f9ee 100644
--- a/guides/grails-multi-module/v8/guide/testingPerModule.adoc
+++ b/guides/grails-multi-module/v8/guide/testingPerModule.adoc
@@ -4,6 +4,48 @@ Tests live with the module they cover.
 * `webapp-customer` keeps its own web and functional tests for the customer-facing UI.
 * `webapp-admin` keeps its own CRUD and admin-side functional tests.
 
+== shared-core: the domain and data service
+
+The plugin owns the model, so its tests own the model's behaviour. Both webapps inherit a tested `Book` and `BookService` rather than re-testing them:
+
+[source,groovy]
+.shared-core/src/test/groovy/example/BookSpec.groovy
+----
+include::../snippets/shared-core/src/test/groovy/example/BookSpec.groovy[]
+----
+
+[source,groovy]
+.shared-core/src/test/groovy/example/BookServiceSpec.groovy
+----
+include::../snippets/shared-core/src/test/groovy/example/BookServiceSpec.groovy[]
+----
+
+== webapp-admin: the REST surface over real HTTP
+
+The admin `BookController` is a `RestfulController`, so the admin module adds a `resources` URL mapping to route the REST verbs, then drives it with a functional spec. The `Book` it persists comes from the plugin - proving the shared model works end to end in a booted app:
+
+[source,groovy]
+.webapp-admin/grails-app/controllers/example/UrlMappings.groovy
+----
+include::../snippets/webapp-admin/grails-app/controllers/example/UrlMappings.groovy[]
+----
+
+[source,groovy]
+.webapp-admin/src/integration-test/groovy/admin/AdminBookFunctionalSpec.groovy
+----
+include::../snippets/webapp-admin/src/integration-test/groovy/admin/AdminBookFunctionalSpec.groovy[]
+----
+
+== webapp-customer: the read-only catalog
+
+The customer functional spec drives the hand-rolled `CatalogController`, which injects the same plugin `BookService`:
+
+[source,groovy]
+.webapp-customer/src/integration-test/groovy/customer/CatalogFunctionalSpec.groovy
+----
+include::../snippets/webapp-customer/src/integration-test/groovy/customer/CatalogFunctionalSpec.groovy[]
+----
+
 Run them granularly during development:
 
 [source,bash]
diff --git a/guides/grails-multi-module/v8/snippets/shared-core/src/test/groovy/example/BookServiceSpec.groovy b/guides/grails-multi-module/v8/snippets/shared-core/src/test/groovy/example/BookServiceSpec.groovy
new file mode 100644
index 00000000000..1eb01fcead6
--- /dev/null
+++ b/guides/grails-multi-module/v8/snippets/shared-core/src/test/groovy/example/BookServiceSpec.groovy
@@ -0,0 +1,36 @@
+package example
+
+import grails.testing.gorm.DataTest
+import grails.testing.services.ServiceUnitTest
+import spock.lang.Specification
+
+/**
+ * Unit test for the @Service(Book) data service in shared-core. Both
+ * webapps inject this same BookService bean, so testing its query shape
+ * once here covers the admin and customer modules' data access.
+ */
+class BookServiceSpec extends Specification
+        implements ServiceUnitTest<BookService>, DataTest {
+
+    Class[] getDomainClassesToMock() { [Book] }
+
+    void setup() {
+        new Book(title: 'Old',   isbn: '9780000000001', pageCount: 100, publishedOn: Date.parse('yyyy-MM-dd', '1990-01-01')).save(flush: true, failOnError: true)
+        new Book(title: 'Recent', isbn: '9780000000002', pageCount: 200, publishedOn: Date.parse('yyyy-MM-dd', '2020-01-01')).save(flush: true, failOnError: true)
+    }
+
+    void "findByIsbn returns the matching book"() {
+        expect:
+        service.findByIsbn('9780000000002').title == 'Recent'
+    }
+
+    void "count reflects persisted books"() {
+        expect:
+        service.count() == 2
+    }
+
+    void "countByPublishedOnGreaterThanEquals filters by date"() {
+        expect:
+        service.countByPublishedOnGreaterThanEquals(Date.parse('yyyy-MM-dd', '2000-01-01')) == 1
+    }
+}
diff --git a/guides/grails-multi-module/v8/snippets/shared-core/src/test/groovy/example/BookSpec.groovy b/guides/grails-multi-module/v8/snippets/shared-core/src/test/groovy/example/BookSpec.groovy
new file mode 100644
index 00000000000..b3c76031cfe
--- /dev/null
+++ b/guides/grails-multi-module/v8/snippets/shared-core/src/test/groovy/example/BookSpec.groovy
@@ -0,0 +1,40 @@
+package example
+
+import grails.testing.gorm.DomainUnitTest
+import spock.lang.Specification
+
+/**
+ * Unit test for the Book domain that lives in the shared-core plugin.
+ * Proving the domain's constraints here means both webapps inherit a
+ * tested model - they never redefine it.
+ */
+class BookSpec extends Specification implements DomainUnitTest<Book> {
+
+    void "title and isbn are required"() {
+        when:
+        Book b = new Book()
+
+        then:
+        !b.validate()
+        b.errors.getFieldError('title').code == 'nullable'
+        b.errors.getFieldError('isbn').code == 'nullable'
+    }
+
+    void "isbn must match the ISBN pattern and be unique"() {
+        given:
+        new Book(title: 'First', isbn: '9780547928227', pageCount: 100).save(flush: true, failOnError: true)
+
+        expect:
+        !new Book(title: 'Bad', isbn: 'nope').validate()
+
+        and:
+        Book dup = new Book(title: 'Dup', isbn: '9780547928227')
+        !dup.validate()
+        dup.errors.getFieldError('isbn').code == 'unique'
+    }
+
+    void "a valid book validates"() {
+        expect:
+        new Book(title: 'The Hobbit', isbn: '9780547928227', pageCount: 310).validate()
+    }
+}
diff --git a/guides/grails-multi-module/v8/snippets/webapp-admin/grails-app/controllers/example/UrlMappings.groovy b/guides/grails-multi-module/v8/snippets/webapp-admin/grails-app/controllers/example/UrlMappings.groovy
new file mode 100644
index 00000000000..6400e946279
--- /dev/null
+++ b/guides/grails-multi-module/v8/snippets/webapp-admin/grails-app/controllers/example/UrlMappings.groovy
@@ -0,0 +1,21 @@
+package example
+
+class UrlMappings {
+    static mappings = {
+        // REST resource mapping so the admin BookController (a RestfulController)
+        // routes GET/POST/PUT/DELETE on /books to its CRUD actions.
+        "/books"(resources: 'book')
+
+        "/$namespace/$controller/$action?/$id?(.$format)?" {}
+        "/$controller/$action?/$id?(.$format)?"{
+            constraints {
+                // apply constraints here
+            }
+        }
+
+        "/"(view:"/index")
+        "500"(view:'/error')
+        "404"(view:'/notFound')
+
+    }
+}
diff --git a/guides/grails-multi-module/v8/snippets/webapp-admin/src/integration-test/groovy/admin/AdminBookFunctionalSpec.groovy b/guides/grails-multi-module/v8/snippets/webapp-admin/src/integration-test/groovy/admin/AdminBookFunctionalSpec.groovy
new file mode 100644
index 00000000000..7ab7bee52ab
--- /dev/null
+++ b/guides/grails-multi-module/v8/snippets/webapp-admin/src/integration-test/groovy/admin/AdminBookFunctionalSpec.groovy
@@ -0,0 +1,82 @@
+package admin
+
+import example.Book
+import grails.testing.mixin.integration.Integration
+import groovy.json.JsonOutput
+import groovy.json.JsonSlurper
+import org.springframework.beans.factory.annotation.Value
+import spock.lang.Specification
+
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+
+/**
+ * Functional test for the admin webapp's REST CRUD over real HTTP. The
+ * Book domain and BookService it persists through both come from the
+ * shared-core plugin - this proves the cross-module reuse works end to end
+ * in a booted app. @Rollback is not used (the endpoint commits), so the
+ * fixture is read back inside withNewTransaction.
+ */
+@Integration
+class AdminBookFunctionalSpec extends Specification {
+
+    @Value('${local.server.port}')
+    Integer serverPort
+
+    HttpClient client = HttpClient.newHttpClient()
+
+    private URI uri(String path) { URI.create("http://localhost:${serverPort}${path}") }
+
+    void "POST /book creates a shared-core Book and returns 201"() {
+        given:
+        String body = JsonOutput.toJson([title: 'Dune', isbn: '9780441013593', pageCount: 412])
+
+        when:
+        HttpResponse<String> resp = client.send(
+                HttpRequest.newBuilder(uri('/books'))
+                        .header('Content-Type', 'application/json')
+                        .header('Accept', 'application/json')
+                        .POST(HttpRequest.BodyPublishers.ofString(body)).build(),
+                HttpResponse.BodyHandlers.ofString())
+
+        then:
+        resp.statusCode() == 201
+        Book.withNewTransaction { Book.findByIsbn('9780441013593')?.title == 'Dune' }
+    }
+
+    void "GET /book lists the shared-core Books as JSON"() {
+        given:
+        Book.withNewTransaction {
+            if (!Book.findByIsbn('9780547928227')) {
+                new Book(title: 'The Hobbit', isbn: '9780547928227', pageCount: 310).save(failOnError: true)
+            }
+        }
+
+        when:
+        HttpResponse<String> resp = client.send(
+                HttpRequest.newBuilder(uri('/books.json')).GET().build(),
+                HttpResponse.BodyHandlers.ofString())
+        def json = new JsonSlurper().parseText(resp.body())
+
+        then:
+        resp.statusCode() == 200
+        json*.isbn.contains('9780547928227')
+    }
+
+    void "POST /book with a malformed isbn returns 422"() {
+        given:
+        String body = JsonOutput.toJson([title: 'Bad', isbn: 'not-an-isbn'])
+
+        when:
+        HttpResponse<String> resp = client.send(
+                HttpRequest.newBuilder(uri('/books'))
+                        .header('Content-Type', 'application/json')
+                        .header('Accept', 'application/json')
+                        .POST(HttpRequest.BodyPublishers.ofString(body)).build(),
+                HttpResponse.BodyHandlers.ofString())
+
+        then:
+        resp.statusCode() == 422
+    }
+}
diff --git a/guides/grails-multi-module/v8/snippets/webapp-customer/src/integration-test/groovy/customer/CatalogFunctionalSpec.groovy b/guides/grails-multi-module/v8/snippets/webapp-customer/src/integration-test/groovy/customer/CatalogFunctionalSpec.groovy
new file mode 100644
index 00000000000..6411e8b3e00
--- /dev/null
+++ b/guides/grails-multi-module/v8/snippets/webapp-customer/src/integration-test/groovy/customer/CatalogFunctionalSpec.groovy
@@ -0,0 +1,51 @@
+package customer
+
+import example.Book
+import grails.testing.mixin.integration.Integration
+import groovy.json.JsonSlurper
+import org.springframework.beans.factory.annotation.Value
+import spock.lang.Specification
+
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+
+/**
+ * Functional test for the customer webapp's read-only catalog over real
+ * HTTP. The CatalogController injects the shared-core BookService and the
+ * Book domain, proving the same shared module backs the customer app too.
+ */
+@Integration
+class CatalogFunctionalSpec extends Specification {
+
+    @Value('${local.server.port}')
+    Integer serverPort
+
+    HttpClient client = HttpClient.newHttpClient()
+
+    void setup() {
+        Book.withNewTransaction {
+            if (!Book.findByIsbn('9780547928227')) {
+                new Book(title: 'The Hobbit', isbn: '9780547928227', pageCount: 310).save(failOnError: true)
+            }
+            if (!Book.findByIsbn('9780441013593')) {
+                new Book(title: 'Dune', isbn: '9780441013593', pageCount: 412).save(failOnError: true)
+            }
+        }
+    }
+
+    void "GET /catalog lists the shared-core Books as JSON"() {
+        when:
+        HttpResponse<String> resp = client.send(
+                HttpRequest.newBuilder(URI.create("http://localhost:${serverPort}/catalog.json"))
+                        .header('Accept', 'application/json')
+                        .GET().build(),
+                HttpResponse.BodyHandlers.ofString())
+        def json = new JsonSlurper().parseText(resp.body())
+
+        then:
+        resp.statusCode() == 200
+        json*.isbn.contains('9780547928227')
+        json*.isbn.contains('9780441013593')
+    }
+}


(grails-static-website) 01/09: Make grails-spock-test-tour v8 tests actually compile and pass

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

jamesfredley pushed a commit to branch add-comprehensive-tests-v8-guides
in repository https://gitbox.apache.org/repos/asf/grails-static-website.git

commit 24e61b80a549376b4e279d4c63c839d7e8f1cd50
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Thu May 28 14:47:59 2026 -0400

    Make grails-spock-test-tour v8 tests actually compile and pass
    
    Every Spock layer the guide demonstrates now compiles and passes against
    Grails 8.0.0-SNAPSHOT (16 unit + 4 integration/functional/Geb green). The
    specs were authored but never executed and several were broken.
    
    Snippets re-vendored from the fixed sample app:
    
    - BookSpec: getFieldError(...), the nullable-vs-blank map-constructor gotcha,
      added blank + maxSize constraint cases
    - BookServiceSpec: flush seeded books so the GORM finders see them
    - BookControllerSpec: RestfulController.show() via params.id (no-arg action)
    - BookIntegrationSpec: flush before the duplicate save; a @Service save throws
      ValidationException
    - BookController / UrlMappings / book/index.gsp: render an HTML table at /books
    - BookFunctionalSpec: seed committed data via withNewTransaction
    
    Prose corrected where it described code that never ran or made false claims:
    
    - domainUnitTest: getFieldError + the nullable-vs-blank gotcha
    - integrationTest: Testcontainers PostgreSQL (not H2); @Service save throws
      ValidationException (not hasErrors)
    - functionalTest: new "page the test drives" section + committed-seed rationale
    - platform: jacoco executionData uses layout.buildDirectory, not deprecated buildDir
    - whatYouWillBuild: index action also renders an HTML table
    
    Assisted-by: claude-code:claude-opus-4-7
---
 .../v8/guide/domainUnitTest.adoc                   |  2 +-
 .../v8/guide/functionalTest.adoc                   | 28 ++++++++++++++++++++++
 .../v8/guide/integrationTest.adoc                  |  4 ++--
 .../grails-spock-test-tour/v8/guide/platform.adoc  |  2 +-
 .../v8/guide/whatYouWillBuild.adoc                 |  2 +-
 .../controllers/example/BookController.groovy      | 16 ++++++++++++-
 .../controllers/example/UrlMappings.groovy         | 18 ++++++++++++++
 .../grails-app/init/example/BootStrap.groovy       | 18 ++++++++++++++
 .../v8/snippets/grails-app/views/book/index.gsp    | 24 +++++++++++++++++++
 .../groovy/example/BookFunctionalSpec.groovy       | 15 ++++++++++--
 .../groovy/example/BookIntegrationSpec.groovy      | 15 +++++++-----
 .../test/groovy/example/BookControllerSpec.groovy  |  8 ++++---
 .../src/test/groovy/example/BookServiceSpec.groovy | 10 ++++----
 .../src/test/groovy/example/BookSpec.groovy        | 25 ++++++++++++++++---
 14 files changed, 163 insertions(+), 24 deletions(-)

diff --git a/guides/grails-spock-test-tour/v8/guide/domainUnitTest.adoc b/guides/grails-spock-test-tour/v8/guide/domainUnitTest.adoc
index 5157c3857a5..ac24c1f3434 100644
--- a/guides/grails-spock-test-tour/v8/guide/domainUnitTest.adoc
+++ b/guides/grails-spock-test-tour/v8/guide/domainUnitTest.adoc
@@ -8,6 +8,6 @@ include::../snippets/src/test/groovy/example/BookSpec.groovy[]
 
 Three patterns to point at:
 
-* `b.errors.fieldError('title').code == 'blank'` checks the *constraint code*, not the localised message. Codes are stable across releases and locales; messages are not.
+* `b.errors.getFieldError('title').code` checks the *constraint code*, not the localised message. Codes are stable across releases and locales; messages are not. Note the subtlety the spec demonstrates: omitting `title` from the map constructor leaves it `null`, so the `nullable` code fires - not `blank`. To exercise the `blank` constraint you must assign `b.title = ''` *directly*, because Grails data binding converts an empty string passed through the map constructor into `null`.
 * `@Unroll` plus a `where:` data table collapses what would otherwise be five copy-pasted tests into one parameterised spec. The `#pageCount` token in the test name expands to the actual value, so the IDE's run-tree shows each row separately.
 * No `@Transactional`, no Spring, no JDBC. The whole spec runs in a few hundred milliseconds across all six tests.
diff --git a/guides/grails-spock-test-tour/v8/guide/functionalTest.adoc b/guides/grails-spock-test-tour/v8/guide/functionalTest.adoc
index 39cf75182da..0333cd4128d 100644
--- a/guides/grails-spock-test-tour/v8/guide/functionalTest.adoc
+++ b/guides/grails-spock-test-tour/v8/guide/functionalTest.adoc
@@ -6,6 +6,34 @@ Functional tests use Geb 8 to drive a real browser against a real booted app. In
 include::../snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy[]
 ----
 
+== The page the test drives
+
+`go '/books'` needs a real HTML page with a table. `BookController` stays a `RestfulController` for the JSON CRUD surface, but its `index` action is overridden so the HTML format renders a GSP while JSON clients keep their collection response:
+
+[source,groovy]
+.grails-app/controllers/example/BookController.groovy
+----
+include::../snippets/grails-app/controllers/example/BookController.groovy[]
+----
+
+A `resources` URL mapping exposes the controller at `/books`:
+
+[source,groovy]
+.grails-app/controllers/example/UrlMappings.groovy
+----
+include::../snippets/grails-app/controllers/example/UrlMappings.groovy[]
+----
+
+and a minimal GSP renders the table the spec asserts against:
+
+[source,html]
+.grails-app/views/book/index.gsp
+----
+include::../snippets/grails-app/views/book/index.gsp[]
+----
+
+Because the spec is `@Integration` *without* `@Rollback`, it seeds its fixture inside a `withNewTransaction` block so the row is committed and visible to the browser. A bare GORM call in an un-rolled-back `@Integration` spec has no ambient Hibernate session and would throw. `BootStrap` seed data is *not* a substitute here: it does not populate the test database in the integration context, so the spec owns its own fixture.
+
 Three patterns to highlight:
 
 * `@Integration` is *mandatory* for `ContainerGebSpec`. The Spock extension validates the annotation at runtime and throws `IllegalArgumentException: ContainerGebSpec classes must be annotated with @Integration.` if it is missing.
diff --git a/guides/grails-spock-test-tour/v8/guide/integrationTest.adoc b/guides/grails-spock-test-tour/v8/guide/integrationTest.adoc
index f873e3a0d87..e9715caea96 100644
--- a/guides/grails-spock-test-tour/v8/guide/integrationTest.adoc
+++ b/guides/grails-spock-test-tour/v8/guide/integrationTest.adoc
@@ -1,4 +1,4 @@
-`@Integration` boots the full Spring context against a real datasource (whatever the `test` environment in `application.yml` configures - H2 by default). `@Rollback` rolls back every test method's database changes so specs stay isolated.
+`@Integration` boots the full Spring context against a real datasource - the `test` environment here points at a Testcontainers-managed PostgreSQL database (`jdbc:tc:postgresql:...` in `application-test.yml`), so the specs run against the same engine as production. `@Rollback` rolls back every test method's database changes so specs stay isolated.
 
 [source,groovy]
 .src/integration-test/groovy/example/BookIntegrationSpec.groovy
@@ -10,6 +10,6 @@ Three things to highlight:
 
 * `BookService bookService` is a real Spring-injected bean here, not a unit-test mock. The full GORM stack runs; `service.save()` does a real INSERT, `service.get()` does a real SELECT.
 * `@Rollback` is non-negotiable. Without it, the second test sees the first test's data and the third test sees both. Specs become order-dependent and impossible to debug.
-* The duplicate-ISBN test deliberately catches the constraint failure inside the second `save` call rather than letting it throw. `unique: true` constraint failures surface as `errors.hasErrors()`, not as exceptions, in GORM 7+.
+* The duplicate-ISBN test first *flushes* the persisted book (`Book.withSession { it.flush() }`) so the `unique` validator can see it - without the flush the second insert is still pending and validation passes. It then expects the second `save` to throw: a GORM `@Service` save applies `failOnError` semantics, so a `unique: true` violation surfaces as a thrown `grails.validation.ValidationException`. (Calling `domain.save()` directly, without `failOnError: true`, would instead return `nu [...]
 
 Integration specs run an order of magnitude slower than unit tests (5-20 seconds per spec). Use them only for what unit tests genuinely cannot answer: cross-layer wiring, security filter chains, bootstrap-data correctness.
diff --git a/guides/grails-spock-test-tour/v8/guide/platform.adoc b/guides/grails-spock-test-tour/v8/guide/platform.adoc
index e8333d8870f..4207d61f5e1 100644
--- a/guides/grails-spock-test-tour/v8/guide/platform.adoc
+++ b/guides/grails-spock-test-tour/v8/guide/platform.adoc
@@ -19,7 +19,7 @@ plugins {
 
 jacocoTestReport {
     dependsOn test, integrationTest
-    executionData fileTree(buildDir).include('jacoco/*.exec')
+    executionData fileTree(layout.buildDirectory.get().asFile).include('jacoco/*.exec')
     reports {
         xml.required = true       // for Codecov upload
         html.required = true      // for human inspection
diff --git a/guides/grails-spock-test-tour/v8/guide/whatYouWillBuild.adoc b/guides/grails-spock-test-tour/v8/guide/whatYouWillBuild.adoc
index c55261a1f65..f2f7873e8f1 100644
--- a/guides/grails-spock-test-tour/v8/guide/whatYouWillBuild.adoc
+++ b/guides/grails-spock-test-tour/v8/guide/whatYouWillBuild.adoc
@@ -2,7 +2,7 @@ By the end of the guide your sample app will have:
 
 * `Book.groovy` - a small domain class with `title`, `isbn` (regex-validated, unique), and `pageCount` (positive integer).
 * `BookService.groovy` - an `@Service(Book)` GORM data service with `findByIsbn`, `countByPageCountGreaterThanEquals`, and the standard CRUD methods.
-* `BookController.groovy` - a `RestfulController<Book>` so the JSON CRUD surface is testable from `ControllerUnitTest`.
+* `BookController.groovy` - a `RestfulController<Book>` exposing a JSON CRUD surface (testable from `ControllerUnitTest`) whose `index` action also renders an HTML table at `/books` for the functional test to drive.
 * Five spec files exercising the five test layers:
 ** `BookSpec` - `DomainUnitTest<Book>`, constraint validation including a `@Unroll` parameterised `where:` table.
 ** `BookServiceSpec` - `ServiceUnitTest<BookService>` + `DataTest`, query-shape testing against the in-memory datastore.
diff --git a/guides/grails-spock-test-tour/v8/snippets/grails-app/controllers/example/BookController.groovy b/guides/grails-spock-test-tour/v8/snippets/grails-app/controllers/example/BookController.groovy
index 4c8df2ca0a8..25801a70624 100644
--- a/guides/grails-spock-test-tour/v8/snippets/grails-app/controllers/example/BookController.groovy
+++ b/guides/grails-spock-test-tour/v8/snippets/grails-app/controllers/example/BookController.groovy
@@ -3,7 +3,21 @@ package example
 import grails.rest.RestfulController
 
 class BookController extends RestfulController<Book> {
-    static responseFormats = ['json']
+    // 'json' keeps the REST CRUD surface; 'html' lets the index action render
+    // grails-app/views/book/index.gsp so a browser (and the Geb functional
+    // test) gets a real HTML table at /books.
+    static responseFormats = ['json', 'html']
     BookService bookService
     BookController() { super(Book) }
+
+    // Override index so the HTML format renders the GSP table view (through the
+    // sitemesh layout) while JSON clients still get the REST collection.
+    def index(Integer max) {
+        params.max = Math.min(max ?: 100, 100)
+        List<Book> books = listAllResources(params)
+        withFormat {
+            html { [bookList: books, bookCount: countResources()] }
+            json { respond books, [model: [bookCount: countResources()]] }
+        }
+    }
 }
diff --git a/guides/grails-spock-test-tour/v8/snippets/grails-app/controllers/example/UrlMappings.groovy b/guides/grails-spock-test-tour/v8/snippets/grails-app/controllers/example/UrlMappings.groovy
new file mode 100644
index 00000000000..b4d194157e6
--- /dev/null
+++ b/guides/grails-spock-test-tour/v8/snippets/grails-app/controllers/example/UrlMappings.groovy
@@ -0,0 +1,18 @@
+package example
+
+class UrlMappings {
+    static mappings = {
+        "/books"(resources: 'book')
+        "/$namespace/$controller/$action?/$id?(.$format)?" {}
+        "/$controller/$action?/$id?(.$format)?"{
+            constraints {
+                // apply constraints here
+            }
+        }
+
+        "/"(view:"/index")
+        "500"(view:'/error')
+        "404"(view:'/notFound')
+
+    }
+}
diff --git a/guides/grails-spock-test-tour/v8/snippets/grails-app/init/example/BootStrap.groovy b/guides/grails-spock-test-tour/v8/snippets/grails-app/init/example/BootStrap.groovy
new file mode 100644
index 00000000000..1001d724f9d
--- /dev/null
+++ b/guides/grails-spock-test-tour/v8/snippets/grails-app/init/example/BootStrap.groovy
@@ -0,0 +1,18 @@
+package example
+
+class BootStrap {
+
+    def init = { servletContext ->
+        // Seed a few books so the /books page (and the Geb functional test)
+        // has rows to render. ISBNs are distinct from those the specs use.
+        if (Book.count() == 0) {
+            new Book(title: 'The Hobbit',  isbn: '9780261103344', pageCount: 310).save(failOnError: true)
+            new Book(title: 'Dune',        isbn: '9780441013593', pageCount: 412).save(failOnError: true)
+            new Book(title: 'Neuromancer', isbn: '9780441569595', pageCount: 271).save(failOnError: true)
+        }
+    }
+
+    def destroy = {
+    }
+
+}
\ No newline at end of file
diff --git a/guides/grails-spock-test-tour/v8/snippets/grails-app/views/book/index.gsp b/guides/grails-spock-test-tour/v8/snippets/grails-app/views/book/index.gsp
new file mode 100644
index 00000000000..4636596ba33
--- /dev/null
+++ b/guides/grails-spock-test-tour/v8/snippets/grails-app/views/book/index.gsp
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <meta name="layout" content="main"/>
+    <title>Book List</title>
+</head>
+<body>
+<h1>Book List</h1>
+<table>
+    <thead>
+        <tr><th>Title</th><th>ISBN</th><th>Pages</th></tr>
+    </thead>
+    <tbody>
+        <g:each in="${bookList}" var="book">
+            <tr>
+                <td>${book.title}</td>
+                <td>${book.isbn}</td>
+                <td>${book.pageCount}</td>
+            </tr>
+        </g:each>
+    </tbody>
+</table>
+</body>
+</html>
diff --git a/guides/grails-spock-test-tour/v8/snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy b/guides/grails-spock-test-tour/v8/snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy
index 38abc215fb7..7039114d9d4 100644
--- a/guides/grails-spock-test-tour/v8/snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy
+++ b/guides/grails-spock-test-tour/v8/snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy
@@ -14,17 +14,28 @@ import grails.testing.mixin.integration.Integration
  * @Integration is mandatory: GrailsContainerGebExtension throws at
  * runtime if the annotation is missing. @Rollback is NOT used here -
  * functional tests exercise the full HTTP stack and need committed
- * data.
+ * data. The fixture is therefore seeded inside its own
+ * withNewTransaction block; a bare GORM call would fail because an
+ * @Integration spec without @Rollback has no ambient Hibernate session.
  */
 @Integration
 class BookFunctionalSpec extends ContainerGebSpec {
 
-    void "the book index page renders the seeded books"() {
+    void setup() {
+        Book.withNewTransaction {
+            if (!Book.findByIsbn('9780261103344')) {
+                new Book(title: 'The Hobbit', isbn: '9780261103344', pageCount: 310).save(failOnError: true)
+            }
+        }
+    }
+
+    void "the book index page renders the committed books"() {
         when:
         go '/books'
 
         then:
         title.contains('Book')
         $('table tbody tr').size() > 0
+        $('table tbody').text().contains('The Hobbit')
     }
 }
diff --git a/guides/grails-spock-test-tour/v8/snippets/src/integration-test/groovy/example/BookIntegrationSpec.groovy b/guides/grails-spock-test-tour/v8/snippets/src/integration-test/groovy/example/BookIntegrationSpec.groovy
index 289c130b9f2..86e52185ca5 100644
--- a/guides/grails-spock-test-tour/v8/snippets/src/integration-test/groovy/example/BookIntegrationSpec.groovy
+++ b/guides/grails-spock-test-tour/v8/snippets/src/integration-test/groovy/example/BookIntegrationSpec.groovy
@@ -2,6 +2,7 @@ package example
 
 import grails.gorm.transactions.Rollback
 import grails.testing.mixin.integration.Integration
+import grails.validation.ValidationException
 import spock.lang.Specification
 
 /**
@@ -37,14 +38,16 @@ class BookIntegrationSpec extends Specification {
         reloaded.isbn  == '9780547928227'
     }
 
-    void "saving a book with a duplicate isbn fails validation"() {
-        given:
+    void "saving a book with a duplicate isbn is rejected"() {
+        given: 'a persisted book, flushed so the unique validator can see it'
         bookService.save(new Book(title: 'First',  isbn: '9780547928227', pageCount: 100))
+        Book.withSession { it.flush() }
 
-        when:
-        Book second = bookService.save(new Book(title: 'Second', isbn: '9780547928227', pageCount: 200))
+        when: 'a second book reuses the isbn'
+        bookService.save(new Book(title: 'Second', isbn: '9780547928227', pageCount: 200))
 
-        then:
-        second == null || second.hasErrors()
+        then: 'the @Service save throws a ValidationException (failOnError is implicit) carrying the unique error'
+        ValidationException ex = thrown()
+        ex.message.contains('unique')
     }
 }
diff --git a/guides/grails-spock-test-tour/v8/snippets/src/test/groovy/example/BookControllerSpec.groovy b/guides/grails-spock-test-tour/v8/snippets/src/test/groovy/example/BookControllerSpec.groovy
index 0b7a71cc136..442aa5159ff 100644
--- a/guides/grails-spock-test-tour/v8/snippets/src/test/groovy/example/BookControllerSpec.groovy
+++ b/guides/grails-spock-test-tour/v8/snippets/src/test/groovy/example/BookControllerSpec.groovy
@@ -22,9 +22,10 @@ class BookControllerSpec extends Specification
         Book b = new Book(title: 'The Hobbit', isbn: '9780547928227', pageCount: 310)
                   .save(failOnError: true, flush: true)
 
-        when:
+        when: 'RestfulController.show() reads params.id - it takes no argument'
         request.method = 'GET'
-        controller.show(b.id)
+        params.id = b.id
+        controller.show()
 
         then:
         response.status == HttpStatus.OK.value()
@@ -34,7 +35,8 @@ class BookControllerSpec extends Specification
     void "GET /books/{id} for a missing id returns 404"() {
         when:
         request.method = 'GET'
-        controller.show(99999L)
+        params.id = 99999L
+        controller.show()
 
         then:
         response.status == HttpStatus.NOT_FOUND.value()
diff --git a/guides/grails-spock-test-tour/v8/snippets/src/test/groovy/example/BookServiceSpec.groovy b/guides/grails-spock-test-tour/v8/snippets/src/test/groovy/example/BookServiceSpec.groovy
index 0dc77fe887e..fc9a625e386 100644
--- a/guides/grails-spock-test-tour/v8/snippets/src/test/groovy/example/BookServiceSpec.groovy
+++ b/guides/grails-spock-test-tour/v8/snippets/src/test/groovy/example/BookServiceSpec.groovy
@@ -16,10 +16,12 @@ class BookServiceSpec extends Specification
     Class[] getDomainClassesToMock() { [Book] }
 
     void setup() {
-        new Book(title: 'Short Story',  isbn: '9780000000001', pageCount: 50 ).save(failOnError: true)
-        new Book(title: 'Novella',      isbn: '9780000000002', pageCount: 150).save(failOnError: true)
-        new Book(title: 'Novel',        isbn: '9780000000003', pageCount: 400).save(failOnError: true)
-        new Book(title: 'Doorstopper',  isbn: '9780000000004', pageCount: 900).save(failOnError: true)
+        // flush: true forces the in-memory datastore to persist before the
+        // GORM finders below query it - without it the finders see zero rows.
+        new Book(title: 'Short Story',  isbn: '9780000000001', pageCount: 50 ).save(flush: true, failOnError: true)
+        new Book(title: 'Novella',      isbn: '9780000000002', pageCount: 150).save(flush: true, failOnError: true)
+        new Book(title: 'Novel',        isbn: '9780000000003', pageCount: 400).save(flush: true, failOnError: true)
+        new Book(title: 'Doorstopper',  isbn: '9780000000004', pageCount: 900).save(flush: true, failOnError: true)
     }
 
     void "countByPageCountGreaterThanEquals returns the right count"() {
diff --git a/guides/grails-spock-test-tour/v8/snippets/src/test/groovy/example/BookSpec.groovy b/guides/grails-spock-test-tour/v8/snippets/src/test/groovy/example/BookSpec.groovy
index 05781e851a6..b9b503801ea 100644
--- a/guides/grails-spock-test-tour/v8/snippets/src/test/groovy/example/BookSpec.groovy
+++ b/guides/grails-spock-test-tour/v8/snippets/src/test/groovy/example/BookSpec.groovy
@@ -15,12 +15,31 @@ import spock.lang.Unroll
 class BookSpec extends Specification implements DomainUnitTest<Book> {
 
     void "rejects a book with no title"() {
-        when:
+        when: 'title is omitted from the map constructor, so it is null'
+        Book b = new Book(isbn: '9780547928227', pageCount: 310)
+
+        then: 'the nullable constraint - not blank - is what fires for a null value'
+        !b.validate()
+        b.errors.getFieldError('title').code == 'nullable'
+    }
+
+    void "rejects a blank title"() {
+        when: 'a blank string is assigned directly (the map constructor would convert it to null)'
         Book b = new Book(isbn: '9780547928227', pageCount: 310)
+        b.title = ''
+
+        then: 'now the blank constraint fires'
+        !b.validate()
+        b.errors.getFieldError('title').code == 'blank'
+    }
+
+    void "rejects a title longer than maxSize"() {
+        when:
+        Book b = new Book(title: 'x' * 256, isbn: '9780547928227', pageCount: 310)
 
         then:
         !b.validate()
-        b.errors.fieldError('title').code == 'blank'
+        b.errors.getFieldError('title').code == 'maxSize.exceeded'
     }
 
     void "rejects a book with a malformed ISBN"() {
@@ -29,7 +48,7 @@ class BookSpec extends Specification implements DomainUnitTest<Book> {
 
         then:
         !b.validate()
-        b.errors.fieldError('isbn').code == 'matches.invalid'
+        b.errors.getFieldError('isbn').code == 'matches.invalid'
     }
 
     void "accepts a book with a valid ISBN-13"() {


(grails-static-website) 07/09: Fix grails-fields-custom-widgets-and-wrappers v8 rendering bugs + Testing chapter

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

jamesfredley pushed a commit to branch add-comprehensive-tests-v8-guides
in repository https://gitbox.apache.org/repos/asf/grails-static-website.git

commit 54bad4f33b3c51f245cd13a72b053277705684fa
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Thu May 28 16:47:56 2026 -0400

    Fix grails-fields-custom-widgets-and-wrappers v8 rendering bugs + Testing chapter
    
    Three custom-widget/table rendering bugs threw at runtime and the seed
    many-to-many never persisted. The sample app now renders all custom widgets
    and runs 20 green tests (12 unit + 8 integration/Geb).
    
    Re-vendored snippets and added prose:
    
    - isbn _widget.gsp: doubled the backslashes in the pattern attribute (\d is an
      illegal Groovy escape in a GSP attribute, so create/edit threw); bookIsbn
      chapter gains a note explaining the GSP escaping
    - templates/_fields/_table.gsp: domainClass.javaClass.simpleName (was
      domainClass.simpleName, which threw on HibernatePersistentEntity) - this now
      matches the appWideTable chapter's own prose
    - BootStrap: wrap the seed in Book.withTransaction so the Book<->Tag join
      persists; bootstrapData chapter explains why
    
    New "Testing the Widgets and Wrappers" chapter (registered in conf/guides.yml
    toc): BookSpec/AuthorSpec (constraints + associations), LibraryIntegrationSpec
    (m2m + one-to-one), LibraryFunctionalSpec (Geb tests that the custom f:table,
    display widgets, ISBN widget, and association widgets actually render).
    
    Assisted-by: claude-code:claude-opus-4-7
---
 conf/guides.yml                                    |  2 +
 .../v8/guide/bookIsbn.adoc                         |  5 ++
 .../v8/guide/bootstrapData.adoc                    |  2 +
 .../v8/guide/testing.adoc                          | 54 ++++++++++++++++
 .../grails-app/init/example/BootStrap.groovy       |  7 +++
 .../grails-app/views/_fields/book/isbn/_widget.gsp |  2 +-
 .../grails-app/views/templates/_fields/_table.gsp  |  2 +-
 .../groovy/example/LibraryFunctionalSpec.groovy    | 72 ++++++++++++++++++++++
 .../groovy/example/LibraryIntegrationSpec.groovy   | 55 +++++++++++++++++
 .../src/test/groovy/example/AuthorSpec.groovy      | 51 +++++++++++++++
 .../src/test/groovy/example/BookSpec.groovy        | 66 ++++++++++++++++++++
 11 files changed, 316 insertions(+), 2 deletions(-)

diff --git a/conf/guides.yml b/conf/guides.yml
index 7fc569a9bf6..895b179da0d 100644
--- a/conf/guides.yml
+++ b/conf/guides.yml
@@ -2364,6 +2364,8 @@ guides:
             perColumnCells: Per-Column Cell Overrides
           cleanCrud:
             title: The Result - Clean CRUD GSPs
+          testing:
+            title: Testing the Widgets and Wrappers
           helpWithGrails:
             title: Do you need help with Grails?
 
diff --git a/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/bookIsbn.adoc b/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/bookIsbn.adoc
index f3672cfd746..2452dff938c 100644
--- a/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/bookIsbn.adoc
+++ b/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/bookIsbn.adoc
@@ -11,3 +11,8 @@ include::../snippets/grails-app/views/_fields/book/isbn/_widget.gsp[]
 This is the most-specific lookup the plugin will try, so it _wins_ over `_fields/string/_widget.gsp`. The `String` dispatcher you wrote earlier still applies to every other `String` property on every domain class - only `Book.isbn` is special-cased.
 
 Note how the per-property widget can still reuse the wrapper from `_fields/default/_wrapper.gsp` by emitting just the input and the help line - the wrapper supplies the label, required marker, and validation feedback.
+
+[IMPORTANT]
+====
+The backslashes in the `pattern` attribute are *doubled* (`\\d`, not `\d`). A GSP attribute value is compiled into a Groovy string, and `\d` is not a valid Groovy escape sequence - it fails template compilation with `Unexpected character`. Writing `\\d` in the GSP emits a literal `\d` into the rendered HTML, which is what the browser's `pattern` attribute needs. The server-side `matches:` constraint, written in a normal Groovy file, keeps the single-backslash form.
+====
diff --git a/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/bootstrapData.adoc b/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/bootstrapData.adoc
index 898b9fa1cb9..8d48eb3219f 100644
--- a/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/bootstrapData.adoc
+++ b/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/bootstrapData.adoc
@@ -6,4 +6,6 @@ Replace `grails-app/init/example/BootStrap.groovy` with the following so that th
 include::../snippets/grails-app/init/example/BootStrap.groovy[]
 ----
 
+The seed runs inside a `Book.withTransaction { }` block. This matters for the `Book`-`Tag` many-to-many: a bare `save()` in `BootStrap` runs outside any transaction and the join-table rows are not reliably flushed, while `save(flush: true)` without a transaction throws `TransactionRequiredException`. Wrapping the whole seed in a transaction lets the books, authors, contact info, and the many-to-many join all commit together.
+
 Restart the application with `./gradlew bootRun`. The H2 database is in-memory by default, so the seed data is recreated on every startup.
diff --git a/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/testing.adoc b/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/testing.adoc
new file mode 100644
index 00000000000..9cb64c44450
--- /dev/null
+++ b/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/testing.adoc
@@ -0,0 +1,54 @@
+Custom widgets and wrappers render fine "on my machine" right up until a GSP attribute or a template-lookup assumption breaks them silently. The sample app is covered at three layers: domain unit tests, `@Integration` tests for the associations, and Geb browser tests that prove the custom widgets actually render in a real page.
+
+== Unit: the domain constraints
+
+`DataTest` mocks the associated domains so `belongsTo`/`hasMany` are satisfied without a Spring context:
+
+[source,groovy]
+.src/test/groovy/example/BookSpec.groovy
+----
+include::../snippets/src/test/groovy/example/BookSpec.groovy[]
+----
+
+[source,groovy]
+.src/test/groovy/example/AuthorSpec.groovy
+----
+include::../snippets/src/test/groovy/example/AuthorSpec.groovy[]
+----
+
+== Integration: the associations against a real database
+
+`@Integration` + `@Rollback` boots the full context against the in-memory H2 database and asserts on the `BootStrap` seed - including the `Book`-`Tag` many-to-many and the `Author`-`ContactInfo` one-to-one:
+
+[source,groovy]
+.src/integration-test/groovy/example/LibraryIntegrationSpec.groovy
+----
+include::../snippets/src/integration-test/groovy/example/LibraryIntegrationSpec.groovy[]
+----
+
+== Browser: the custom widgets through Geb
+
+`ContainerGebSpec` starts a Selenium-Chrome container via Testcontainers and drives the booted app, so only a running Docker daemon is required. These specs are the ones that catch the GSP-level bugs custom widgets are prone to:
+
+[source,groovy]
+.src/integration-test/groovy/example/LibraryFunctionalSpec.groovy
+----
+include::../snippets/src/integration-test/groovy/example/LibraryFunctionalSpec.groovy[]
+----
+
+The assertions map directly to the customisations earlier in the guide:
+
+* the index page renders the app-wide custom `f:table` with its dark header and the seeded rows;
+* the show page renders the value through the custom display widgets;
+* the create form renders the per-property ISBN widget (with its `pattern` and help text), the many-to-one author select, and one many-to-many checkbox per `Tag`;
+* the author show page renders the one-to-one `ContactInfo` through its `toString()`.
+
+A browser test here is not gold-plating: the custom ISBN widget's `pattern` attribute and the app-wide table's `domainClass.javaClass.simpleName` lookup are both the kind of thing that compiles cleanly and only fails when the page is actually rendered in a request. The Geb layer exercises exactly that path.
+
+Run it all:
+
+[source,bash]
+----
+./gradlew test             # unit - milliseconds, no Docker
+./gradlew integrationTest  # integration + Geb browser tests - Docker required
+----
diff --git a/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/grails-app/init/example/BootStrap.groovy b/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/grails-app/init/example/BootStrap.groovy
index 7fc5d8bbc3c..a4eb6d45fa6 100644
--- a/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/grails-app/init/example/BootStrap.groovy
+++ b/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/grails-app/init/example/BootStrap.groovy
@@ -3,6 +3,12 @@ package example
 class BootStrap {
 
     def init = { servletContext ->
+      // Wrap the seed in a transaction so the writes - including the
+      // many-to-many Book<->Tag join rows - flush and commit together.
+      // A bare save() in BootStrap runs outside any transaction, and the
+      // join table is not reliably persisted; save(flush: true) without a
+      // transaction throws TransactionRequiredException.
+      Book.withTransaction {
         Tag fantasy = new Tag(name: 'Fantasy').save(failOnError: true)
         Tag classic = new Tag(name: 'Classic').save(failOnError: true)
         Tag romance = new Tag(name: 'Romance').save(failOnError: true)
@@ -55,6 +61,7 @@ class BootStrap {
                 author: austen
         )
         pride.addToTags(romance).addToTags(classic).save(failOnError: true)
+      }
     }
 
     def destroy = {}
diff --git a/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/grails-app/views/_fields/book/isbn/_widget.gsp b/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/grails-app/views/_fields/book/isbn/_widget.gsp
index 66c5c4f749c..e2892291ba1 100644
--- a/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/grails-app/views/_fields/book/isbn/_widget.gsp
+++ b/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/grails-app/views/_fields/book/isbn/_widget.gsp
@@ -1,7 +1,7 @@
 <g:textField name="${prefix}${property}"
              value="${value}"
              class="${invalid ? 'form-control is-invalid' : 'form-control'}"
-             pattern="(?:\d{10}|\d{13}|\d{3}-\d-\d{2}-\d{6}-\d)"
+             pattern="(?:\\d{10}|\\d{13}|\\d{3}-\\d-\\d{2}-\\d{6}-\\d)"
              maxlength="20"
              placeholder="ISBN-10 or ISBN-13"
              required="${required}"/>
diff --git a/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/grails-app/views/templates/_fields/_table.gsp b/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/grails-app/views/templates/_fields/_table.gsp
index 9f3664fb9e1..ee3c83b4c82 100644
--- a/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/grails-app/views/templates/_fields/_table.gsp
+++ b/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/grails-app/views/templates/_fields/_table.gsp
@@ -3,7 +3,7 @@
         <tr>
             <g:each in="${domainProperties}" var="prop">
                 <th scope="col">
-                    <g:message code="${domainClass.simpleName.toLowerCase()}.${prop.name}.label" default="${prop.naturalName}"/>
+                    <g:message code="${domainClass.javaClass.simpleName.toLowerCase()}.${prop.name}.label" default="${prop.naturalName}"/>
                 </th>
             </g:each>
             <th scope="col" class="text-end">Actions</th>
diff --git a/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/src/integration-test/groovy/example/LibraryFunctionalSpec.groovy b/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/src/integration-test/groovy/example/LibraryFunctionalSpec.groovy
new file mode 100644
index 00000000000..98a8726ef94
--- /dev/null
+++ b/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/src/integration-test/groovy/example/LibraryFunctionalSpec.groovy
@@ -0,0 +1,72 @@
+package example
+
+import grails.plugin.geb.ContainerGebSpec
+import grails.testing.mixin.integration.Integration
+
+/**
+ * Browser tests for the Fields-plugin UI, driven by Geb 8 against the real
+ * booted app. ContainerGebSpec starts a Selenium-Chrome container via
+ * Testcontainers, so only a running Docker daemon is required. BootStrap
+ * seeds the library at startup, so these read-only specs assert on it.
+ *
+ * The point is to prove the custom widgets and wrappers actually render -
+ * f:table on the index, f:display on the show page (custom isbn widget,
+ * description wrapper), and the custom association widgets on the create form.
+ */
+@Integration
+class LibraryFunctionalSpec extends ContainerGebSpec {
+
+    void "the book index renders the custom f:table with seeded rows"() {
+        when:
+        go '/book/index'
+
+        then:
+        title == 'Books'
+        $('table.table-striped').size() == 1
+        $('table.table-striped thead.table-dark').size() == 1
+        $('table.table-striped tbody tr').size() >= 2
+        $('table.table-striped tbody').text().contains('The Hobbit')
+        $('table.table-striped tbody').text().contains('Pride and Prejudice')
+    }
+
+    void "the book show page renders the custom isbn widget and description wrapper"() {
+        given: 'the seeded Hobbit id'
+        Long id = Book.withNewTransaction { Book.findByTitle('The Hobbit').id }
+
+        when:
+        go "/book/show/${id}"
+
+        then: 'f:display renders the value and the custom isbn display includes its formatted output'
+        $('body').text().contains('The Hobbit')
+        $('body').text().contains('9780547928227')
+        $('body').text().contains('A reluctant hobbit')
+    }
+
+    void "the book create form renders the custom widgets"() {
+        when:
+        go '/book/create'
+
+        then: 'the custom isbn widget renders with its pattern and help text'
+        $('input', name: 'isbn').size() == 1
+        $('input', name: 'isbn').@pattern.contains('\\d{13}')
+        $('body').text().contains('10 or 13 digits')
+
+        and: 'the manyToOne author widget renders as a select'
+        $('select', name: 'author.id').size() == 1
+
+        and: 'the manyToMany tags widget renders a checkbox per seeded tag'
+        $('input', type: 'checkbox', name: 'tags').size() == 4
+    }
+
+    void "the author show page renders the one-to-one contactInfo via its custom display"() {
+        given:
+        Long id = Author.withNewTransaction { Author.findByName('J.R.R. Tolkien').id }
+
+        when:
+        go "/author/show/${id}"
+
+        then: 'f:display renders the hasOne ContactInfo through its toString()'
+        $('body').text().contains('J.R.R. Tolkien')
+        $('body').text().contains('Contact for J.R.R. Tolkien')
+    }
+}
diff --git a/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/src/integration-test/groovy/example/LibraryIntegrationSpec.groovy b/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/src/integration-test/groovy/example/LibraryIntegrationSpec.groovy
new file mode 100644
index 00000000000..893d4187f46
--- /dev/null
+++ b/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/src/integration-test/groovy/example/LibraryIntegrationSpec.groovy
@@ -0,0 +1,55 @@
+package example
+
+import grails.gorm.transactions.Rollback
+import grails.testing.mixin.integration.Integration
+import spock.lang.Specification
+
+/**
+ * @Integration boots the full context against the in-memory H2 database.
+ * @Rollback isolates each method. BootStrap seeds two authors and two books
+ * at startup, so these specs assert on that committed seed data and create
+ * their own rows with distinct values.
+ */
+@Integration
+@Rollback
+class LibraryIntegrationSpec extends Specification {
+
+    void "BootStrap seeded the library"() {
+        expect:
+        Book.findByTitle('The Hobbit') != null
+        Book.findByTitle('Pride and Prejudice') != null
+        Author.findByName('J.R.R. Tolkien') != null
+    }
+
+    void "a book belongs to an author and has many tags"() {
+        given:
+        Book hobbit = Book.findByTitle('The Hobbit')
+
+        expect:
+        hobbit.author.name == 'J.R.R. Tolkien'
+        hobbit.tags*.name.sort() == ['Adventure', 'Classic', 'Fantasy']
+    }
+
+    void "an author has a one-to-one contactInfo"() {
+        given:
+        Author tolkien = Author.findByName('J.R.R. Tolkien')
+
+        expect:
+        tolkien.contactInfo != null
+        tolkien.contactInfo.phone == '+44 20 7946 0958'
+    }
+
+    void "a new book round-trips with its association"() {
+        given:
+        Author a = Author.findByName('Jane Austen')
+
+        when:
+        Book b = new Book(title: 'Emma', isbn: '9780141439587', genre: 'Fiction',
+                description: 'A young woman who fancies herself a matchmaker.',
+                publishedDate: new Date(), priceUSD: 8.50G, inStock: true, author: a)
+                .save(flush: true, failOnError: true)
+
+        then:
+        Book.get(b.id).author.name == 'Jane Austen'
+    }
+}
diff --git a/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/src/test/groovy/example/AuthorSpec.groovy b/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/src/test/groovy/example/AuthorSpec.groovy
new file mode 100644
index 00000000000..19de4ecd508
--- /dev/null
+++ b/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/src/test/groovy/example/AuthorSpec.groovy
@@ -0,0 +1,51 @@
+package example
+
+import grails.testing.gorm.DataTest
+import spock.lang.Specification
+
+/**
+ * Domain constraint unit tests for Author, including the email/url
+ * constraints and the hasOne ContactInfo / hasMany Book associations.
+ */
+class AuthorSpec extends Specification implements DataTest {
+
+    Class[] getDomainClassesToMock() { [Author, Book, Tag, ContactInfo] }
+
+    void "a valid author validates"() {
+        expect:
+        new Author(name: 'Tolkien', email: 'jrr@example.com', bio: 'bio').validate()
+    }
+
+    void "email must be well formed"() {
+        when:
+        Author a = new Author(name: 'X', email: 'not-an-email', bio: 'bio')
+
+        then:
+        !a.validate()
+        a.errors.getFieldError('email').code == 'email.invalid'
+    }
+
+    void "website must be a valid url when present"() {
+        when:
+        Author a = new Author(name: 'X', email: 'x@example.com', bio: 'bio', website: 'not a url')
+
+        then:
+        !a.validate()
+        a.errors.getFieldError('website').code == 'url.invalid'
+    }
+
+    void "an author owns many books"() {
+        given:
+        Author a = new Author(name: 'Tolkien', email: 'jrr@example.com', bio: 'bio')
+                .save(flush: true, failOnError: true)
+        a.addToBooks(new Book(title: 'The Hobbit', isbn: '9780547928227', genre: 'Fiction',
+                description: 'd', publishedDate: new Date(), priceUSD: 9.99G, inStock: true))
+        a.save(flush: true, failOnError: true)
+
+        expect:
+        Author.get(a.id).books.size() == 1
+        // the hasOne ContactInfo association is exercised in LibraryIntegrationSpec,
+        // where a real datastore sets the inverse side that DataTest's in-memory
+        // store does not.
+    }
+}
diff --git a/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/src/test/groovy/example/BookSpec.groovy b/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/src/test/groovy/example/BookSpec.groovy
new file mode 100644
index 00000000000..7c2431b92f6
--- /dev/null
+++ b/guides/grails-fields-custom-widgets-and-wrappers/v8/snippets/src/test/groovy/example/BookSpec.groovy
@@ -0,0 +1,66 @@
+package example
+
+import grails.testing.gorm.DataTest
+import spock.lang.Specification
+import spock.lang.Unroll
+
+/**
+ * Domain constraint unit tests for Book. DataTest mocks the associated
+ * domains so belongsTo/hasMany are satisfied without a Spring context.
+ */
+class BookSpec extends Specification implements DataTest {
+
+    Class[] getDomainClassesToMock() { [Author, Book, Tag] }
+
+    private Book bookWith(Map overrides = [:]) {
+        Author a = new Author(name: 'A', email: 'a@example.com', bio: 'bio').save(flush: true, failOnError: true)
+        new Book([title: 'T', isbn: '9780547928227', genre: 'Fiction', description: 'd',
+                  publishedDate: new Date(), priceUSD: 9.99G, inStock: true, author: a] + overrides)
+    }
+
+    void "a fully populated book validates"() {
+        expect:
+        bookWith().validate()
+    }
+
+    void "title and description are required"() {
+        when:
+        Book b = bookWith(title: null, description: null)
+
+        then:
+        !b.validate()
+        b.errors.getFieldError('title').code == 'nullable'
+        b.errors.getFieldError('description').code == 'nullable'
+    }
+
+    @Unroll
+    void "isbn '#isbn' isValid=#isValid"() {
+        expect:
+        bookWith(isbn: isbn).validate() == isValid
+
+        where:
+        isbn            || isValid
+        '9780547928227' || true
+        '0547928227'    || true
+        'abc'           || false
+        '123'           || false
+    }
+
+    void "genre must be in the allowed list"() {
+        when:
+        Book b = bookWith(genre: 'Cooking')
+
+        then:
+        !b.validate()
+        b.errors.getFieldError('genre').code == 'not.inList'
+    }
+
+    void "priceUSD cannot be negative"() {
+        when:
+        Book b = bookWith(priceUSD: -1.00G)
+
+        then:
+        !b.validate()
+        b.errors.getFieldError('priceUSD').code == 'min.notmet'
+    }
+}


(grails-static-website) 04/09: Fix grails-htmx v8 HTMX URLs and add a Testing chapter

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

jamesfredley pushed a commit to branch add-comprehensive-tests-v8-guides
in repository https://gitbox.apache.org/repos/asf/grails-static-website.git

commit 7e5337ef9eaa958c3cfcb42c474ae8f6813bc4e6
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Thu May 28 15:42:41 2026 -0400

    Fix grails-htmx v8 HTMX URLs and add a Testing chapter
    
    The HTMX interactions never worked: createLink(controller, action) reverse
    mapping is ambiguous for the shared /tasks and /tasks/$id REST routes and fell
    back to /task/create, which no mapping serves, so every hx-post/patch/delete
    404'd. The sample app now drives all five swap types green (5 unit + 3
    integration + 6 Geb functional).
    
    - re-vendored the view partials to build HTMX URLs with createLink(uri:
      '/tasks...') so they match the REST mappings
    - urlMappings chapter: explains why createLink(uri:) is used instead of
      controller/action reverse mapping for the shared REST routes
    - new "Testing the HTMX UI" chapter (registered in conf/guides.yml toc):
      TaskSpec (constraints), TaskIntegrationSpec (findAllByTitleIlike, ordering,
      toggle), TaskTrackerFunctionalSpec (Geb tests of every HTMX swap, with
      withConfirm for delete)
    
    Assisted-by: claude-code:claude-opus-4-7
---
 conf/guides.yml                                    |   2 +
 guides/grails-htmx/v8/guide/testing.adoc           |  45 ++++++++
 guides/grails-htmx/v8/guide/urlMappings.adoc       |   5 +
 .../v8/snippets/grails-app/views/task/_task.gsp    |   6 +-
 .../snippets/grails-app/views/task/_taskEdit.gsp   |   4 +-
 .../snippets/grails-app/views/task/_taskForm.gsp   |   2 +-
 .../v8/snippets/grails-app/views/task/index.gsp    |   2 +-
 .../groovy/example/TaskIntegrationSpec.groovy      |  48 +++++++++
 .../example/TaskTrackerFunctionalSpec.groovy       | 115 +++++++++++++++++++++
 .../src/test/groovy/example/TaskSpec.groovy        |  48 +++++++++
 10 files changed, 270 insertions(+), 7 deletions(-)

diff --git a/conf/guides.yml b/conf/guides.yml
index 0403110c4f3..63fa1d01c71 100644
--- a/conf/guides.yml
+++ b/conf/guides.yml
@@ -2827,6 +2827,8 @@ guides:
             title: CSRF With HTMX
           spaComparison:
             title: HTMX vs an SPA
+          testing:
+            title: Testing the HTMX UI
           helpWithGrails:
             title: Do you need help with Grails?
 
diff --git a/guides/grails-htmx/v8/guide/testing.adoc b/guides/grails-htmx/v8/guide/testing.adoc
new file mode 100644
index 00000000000..910526d5736
--- /dev/null
+++ b/guides/grails-htmx/v8/guide/testing.adoc
@@ -0,0 +1,45 @@
+The sample app is covered at three layers: fast domain unit tests, `@Integration` tests for the GORM queries the controller relies on, and Geb browser tests that drive the real HTMX interactions. The integration and functional layers run against the in-memory H2 database; the browser layer additionally needs a running Docker daemon for the Selenium container.
+
+== Unit: the Task domain
+
+`DomainUnitTest<Task>` exercises the constraints in milliseconds with no Spring context:
+
+[source,groovy]
+.src/test/groovy/example/TaskSpec.groovy
+----
+include::../snippets/src/test/groovy/example/TaskSpec.groovy[]
+----
+
+== Integration: the queries behind the endpoints
+
+The live-search action calls `Task.findAllByTitleIlike(...)` and the list relies on the `dateCreated desc` mapping. An `@Integration` + `@Rollback` spec pins both against a real database:
+
+[source,groovy]
+.src/integration-test/groovy/example/TaskIntegrationSpec.groovy
+----
+include::../snippets/src/integration-test/groovy/example/TaskIntegrationSpec.groovy[]
+----
+
+== Browser: the HTMX interactions through Geb
+
+`ContainerGebSpec` starts a Selenium-Chrome container via Testcontainers and drives the booted app. Each HTMX swap type is exercised: the create OOB prepend, the toggle and inline-edit `outerHTML` swaps, the live-search `innerHTML` swap, and the delete removal. `withConfirm(true)` answers the `hx-confirm` dialog on delete:
+
+[source,groovy]
+.src/integration-test/groovy/example/TaskTrackerFunctionalSpec.groovy
+----
+include::../snippets/src/integration-test/groovy/example/TaskTrackerFunctionalSpec.groovy[]
+----
+
+A few disciplines keep these browser tests reliable:
+
+* `@Rollback` is *not* used - the HTMX endpoints commit, so each spec creates its fixture through the UI (or `withNewTransaction`) and uses unique titles so rows left by earlier feature methods do not interfere.
+* `waitFor { ... }` wraps every assertion that follows an HTMX request, because the swap happens asynchronously after the AJAX round trip.
+* Rows are targeted by their stable `#task-${id}` id, which survives the `outerHTML` swaps, rather than by text that changes mid-edit.
+
+Run it all:
+
+[source,bash]
+----
+./gradlew test             # unit - milliseconds, no Docker
+./gradlew integrationTest  # integration + Geb browser tests - Docker required
+----
diff --git a/guides/grails-htmx/v8/guide/urlMappings.adoc b/guides/grails-htmx/v8/guide/urlMappings.adoc
index 417c5a81390..baa5570dcee 100644
--- a/guides/grails-htmx/v8/guide/urlMappings.adoc
+++ b/guides/grails-htmx/v8/guide/urlMappings.adoc
@@ -10,6 +10,11 @@ The repetition (`/tasks` for both index and create, distinguished by method) is
 
 This is also close to the Grails 8 `web` profile skeleton, which still shows explicit `"/$controller/$action?/$id?(.$format)?"` mappings in `UrlMappings.groovy`.
 
+[IMPORTANT]
+====
+Because `/tasks` is shared by `index` and `create`, and `/tasks/$id` by `show`, `update`, and `delete`, Grails' reverse URL mapping cannot unambiguously pick a route from a controller+action pair alone - `createLink(controller: 'task', action: 'create')` falls back to the default `/task/create` convention, which no mapping serves, so the HTMX request 404s and nothing swaps. The partials therefore build the HTMX URLs from the path directly, e.g. `hx-post="${createLink(uri: '/tasks')}"` an [...]
+====
+
 Files touched:
 
 * `grails-app/controllers/example/UrlMappings.groovy`
diff --git a/guides/grails-htmx/v8/snippets/grails-app/views/task/_task.gsp b/guides/grails-htmx/v8/snippets/grails-app/views/task/_task.gsp
index 5024649748e..08afe00f120 100644
--- a/guides/grails-htmx/v8/snippets/grails-app/views/task/_task.gsp
+++ b/guides/grails-htmx/v8/snippets/grails-app/views/task/_task.gsp
@@ -2,7 +2,7 @@
 
     <button type="button"
             class="btn btn-sm ${task.done ? 'btn-success' : 'btn-outline-secondary'}"
-            hx-post="${createLink(controller: 'task', action: 'toggle', id: task.id)}"
+            hx-post="${createLink(uri: '/tasks/' + task.id + '/toggle')}"
             hx-target="closest li"
             hx-swap="outerHTML"
             aria-pressed="${task.done}"
@@ -12,7 +12,7 @@
 
     <button type="button"
             class="btn btn-link p-0 text-start text-decoration-none flex-grow-1 ${task.done ? 'text-body-secondary' : 'text-body'}"
-            hx-get="${createLink(controller: 'task', action: 'editForm', id: task.id)}"
+            hx-get="${createLink(uri: '/tasks/' + task.id + '/edit')}"
             hx-target="closest li"
             hx-swap="outerHTML"
             title="Click to edit">
@@ -21,7 +21,7 @@
 
     <button type="button"
             class="btn btn-sm btn-outline-danger"
-            hx-delete="${createLink(controller: 'task', action: 'delete', id: task.id)}"
+            hx-delete="${createLink(uri: '/tasks/' + task.id)}"
             hx-target="closest li"
             hx-swap="outerHTML swap:200ms"
             hx-confirm="Delete this task?"
diff --git a/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskEdit.gsp b/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskEdit.gsp
index cdc2a27171b..104a3e5ed56 100644
--- a/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskEdit.gsp
+++ b/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskEdit.gsp
@@ -3,7 +3,7 @@
             action="update"
             id="${task.id}"
             class="d-flex gap-2"
-            hx-patch="${createLink(controller: 'task', action: 'update', id: task.id)}"
+            hx-patch="${createLink(uri: '/tasks/' + task.id)}"
             hx-target="closest li"
             hx-swap="outerHTML">
         <g:textField name="title"
@@ -14,7 +14,7 @@
         <button type="submit" class="btn btn-primary btn-sm">Save</button>
         <button type="button"
                 class="btn btn-outline-secondary btn-sm"
-                hx-get="${createLink(controller: 'task', action: 'show', id: task.id)}"
+                hx-get="${createLink(uri: '/tasks/' + task.id)}"
                 hx-target="closest li"
                 hx-swap="outerHTML">Cancel</button>
     </g:form>
diff --git a/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskForm.gsp b/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskForm.gsp
index aec6c746d15..19fc8358eaa 100644
--- a/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskForm.gsp
+++ b/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskForm.gsp
@@ -2,7 +2,7 @@
     <g:form controller="task"
             action="create"
             class="d-flex gap-2 mb-3 align-items-start"
-            hx-post="${createLink(controller: 'task', action: 'create')}"
+            hx-post="${createLink(uri: '/tasks')}"
             hx-target="#taskFormContainer"
             hx-swap="outerHTML">
         <div class="flex-grow-1">
diff --git a/guides/grails-htmx/v8/snippets/grails-app/views/task/index.gsp b/guides/grails-htmx/v8/snippets/grails-app/views/task/index.gsp
index 27eb71c310d..ba4adde54cb 100644
--- a/guides/grails-htmx/v8/snippets/grails-app/views/task/index.gsp
+++ b/guides/grails-htmx/v8/snippets/grails-app/views/task/index.gsp
@@ -17,7 +17,7 @@
            value="${q}"
            placeholder="Search tasks..."
            class="form-control mb-3"
-           hx-get="${createLink(controller: 'task', action: 'search')}"
+           hx-get="${createLink(uri: '/tasks/search')}"
             hx-trigger="keyup changed delay:300ms"
             hx-target="#taskList"
             hx-swap="innerHTML"/>
diff --git a/guides/grails-htmx/v8/snippets/src/integration-test/groovy/example/TaskIntegrationSpec.groovy b/guides/grails-htmx/v8/snippets/src/integration-test/groovy/example/TaskIntegrationSpec.groovy
new file mode 100644
index 00000000000..cfa01ecee8a
--- /dev/null
+++ b/guides/grails-htmx/v8/snippets/src/integration-test/groovy/example/TaskIntegrationSpec.groovy
@@ -0,0 +1,48 @@
+package example
+
+import grails.gorm.transactions.Rollback
+import grails.testing.mixin.integration.Integration
+import spock.lang.Specification
+
+/**
+ * @Integration boots the full context against the in-memory H2 database.
+ * @Rollback isolates each method and provides an ambient session, so the
+ * GORM queries the TaskController relies on can be exercised directly.
+ */
+@Integration
+@Rollback
+class TaskIntegrationSpec extends Specification {
+
+    void "findAllByTitleIlike matches case-insensitively (the live-search query)"() {
+        given:
+        new Task(title: 'Buy Milk').save(flush: true, failOnError: true)
+        new Task(title: 'Walk the dog').save(flush: true, failOnError: true)
+
+        expect:
+        Task.findAllByTitleIlike('%milk%')*.title == ['Buy Milk']
+        Task.findAllByTitleIlike('%MILK%')*.title == ['Buy Milk']
+        Task.findAllByTitleIlike('%walk%')*.title == ['Walk the dog']
+    }
+
+    void "tasks list newest first per the domain mapping"() {
+        given:
+        new Task(title: 'Older').save(flush: true, failOnError: true)
+        Thread.sleep(20)
+        new Task(title: 'Newer').save(flush: true, failOnError: true)
+
+        expect:
+        Task.list().first().title == 'Newer'
+    }
+
+    void "toggling done persists"() {
+        given:
+        Task t = new Task(title: 'Toggle').save(flush: true, failOnError: true)
+
+        when:
+        t.done = true
+        t.save(flush: true)
+
+        then:
+        Task.get(t.id).done
+    }
+}
diff --git a/guides/grails-htmx/v8/snippets/src/integration-test/groovy/example/TaskTrackerFunctionalSpec.groovy b/guides/grails-htmx/v8/snippets/src/integration-test/groovy/example/TaskTrackerFunctionalSpec.groovy
new file mode 100644
index 00000000000..2b5ea6a1f40
--- /dev/null
+++ b/guides/grails-htmx/v8/snippets/src/integration-test/groovy/example/TaskTrackerFunctionalSpec.groovy
@@ -0,0 +1,115 @@
+package example
+
+import grails.plugin.geb.ContainerGebSpec
+import grails.testing.mixin.integration.Integration
+
+/**
+ * Browser tests for the htmx interactions, driven by Geb 8 against a real
+ * booted application. ContainerGebSpec starts a Selenium-Chrome container via
+ * Testcontainers, so the only host requirement is a running Docker daemon.
+ *
+ * Each htmx swap type is exercised: the create OOB prepend, the toggle/edit
+ * outerHTML swaps, the live-search innerHTML swap, and the delete removal.
+ * @Rollback is not used (the endpoints commit); tests use unique titles so
+ * they stay independent of rows left by earlier feature methods.
+ */
+@Integration
+class TaskTrackerFunctionalSpec extends ContainerGebSpec {
+
+    private void addTaskThroughForm(String taskTitle) {
+        $('#taskFormContainer').find('input', name: 'title').value(taskTitle)
+        $('#taskFormContainer').find('button', type: 'submit').click()
+        waitFor { $('#taskList').text().contains(taskTitle) }
+    }
+
+    private rowFor(String taskTitle) {
+        $('#taskList li').findAll { it.text().contains(taskTitle) }[0]
+    }
+
+    void 'the task page loads with the add form and search box'() {
+        when:
+        go '/tasks'
+
+        then:
+        title == 'HTMX Task Tracker'
+        $('h1').text() == 'Tasks'
+        $('#taskFormContainer').size() == 1
+        $('input', name: 'q').size() == 1
+    }
+
+    void 'adding a task prepends it to the list via an OOB swap and resets the form'() {
+        given:
+        go '/tasks'
+
+        when:
+        addTaskThroughForm('OOB swap task')
+
+        then: 'the new row is at the top of the list'
+        waitFor { $('#taskList li', 0).text().contains('OOB swap task') }
+
+        and: 'the add form input was reset by the swap'
+        $('#taskFormContainer').find('input', name: 'title').value() == ''
+    }
+
+    void 'toggling a task applies the done styling'() {
+        given:
+        go '/tasks'
+        addTaskThroughForm('Toggle styling task')
+
+        when: 'clicking the toggle button (first button in the row)'
+        rowFor('Toggle styling task').find('button')[0].click()
+
+        then: 'the swapped-in row carries the done class'
+        waitFor { rowFor('Toggle styling task').classes().contains('text-decoration-line-through') }
+    }
+
+    void 'clicking a task opens the inline edit form and saving updates the title'() {
+        given:
+        go '/tasks'
+        addTaskThroughForm('Editable task')
+        Long id = Task.withNewTransaction { Task.findByTitle('Editable task').id }
+
+        when: 'clicking the title button opens the edit form'
+        $("#task-${id}").find('button', title: 'Click to edit').click()
+
+        then:
+        waitFor { $("#task-${id}").find('input', name: 'title').size() == 1 }
+
+        when: 'editing and saving (the row keeps its id across the swap)'
+        $("#task-${id}").find('input', name: 'title').value('Edited task title')
+        $("#task-${id}").find('button', type: 'submit').click()
+
+        then:
+        waitFor { $("#task-${id}").text().contains('Edited task title') }
+    }
+
+    void 'live search filters the list via an innerHTML swap'() {
+        given:
+        go '/tasks'
+        addTaskThroughForm('Searchable alpha unique')
+        addTaskThroughForm('Searchable beta unique')
+
+        when: 'typing a term that matches only one task'
+        $('input', name: 'q').value('alpha unique')
+
+        then:
+        waitFor {
+            $('#taskList').text().contains('Searchable alpha unique') &&
+                    !$('#taskList').text().contains('Searchable beta unique')
+        }
+    }
+
+    void 'deleting a task removes its row'() {
+        given:
+        go '/tasks'
+        addTaskThroughForm('Deletable task')
+
+        when: 'confirming the delete'
+        withConfirm(true) {
+            rowFor('Deletable task').find('button', 'aria-label': 'Delete').click()
+        }
+
+        then:
+        waitFor { !$('#taskList').text().contains('Deletable task') }
+    }
+}
diff --git a/guides/grails-htmx/v8/snippets/src/test/groovy/example/TaskSpec.groovy b/guides/grails-htmx/v8/snippets/src/test/groovy/example/TaskSpec.groovy
new file mode 100644
index 00000000000..4ac6b53cca6
--- /dev/null
+++ b/guides/grails-htmx/v8/snippets/src/test/groovy/example/TaskSpec.groovy
@@ -0,0 +1,48 @@
+package example
+
+import grails.testing.gorm.DomainUnitTest
+import spock.lang.Specification
+
+/**
+ * Domain unit tests for Task - constraints and defaults, no Spring context.
+ */
+class TaskSpec extends Specification implements DomainUnitTest<Task> {
+
+    void "title is required"() {
+        when:
+        Task t = new Task()
+
+        then:
+        !t.validate()
+        t.errors.getFieldError('title').code == 'nullable'
+    }
+
+    void "a blank title is rejected"() {
+        when: 'a blank string is assigned directly (the map constructor would null it)'
+        Task t = new Task()
+        t.title = ''
+
+        then:
+        !t.validate()
+        t.errors.getFieldError('title').code == 'blank'
+    }
+
+    void "a title longer than maxSize is rejected"() {
+        when:
+        Task t = new Task(title: 'x' * 256)
+
+        then:
+        !t.validate()
+        t.errors.getFieldError('title').code == 'maxSize.exceeded'
+    }
+
+    void "a new task defaults to not done"() {
+        expect:
+        !new Task(title: 'Something').done
+    }
+
+    void "a valid task validates"() {
+        expect:
+        new Task(title: 'Buy milk').validate()
+    }
+}


(grails-static-website) 03/09: Make grails-github-actions-cicd v8 CI stages run real tests

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

jamesfredley pushed a commit to branch add-comprehensive-tests-v8-guides
in repository https://gitbox.apache.org/repos/asf/grails-static-website.git

commit e14cf263ab2301b2021d8a88f5187d180985f5e5
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Thu May 28 15:19:32 2026 -0400

    Make grails-github-actions-cicd v8 CI stages run real tests
    
    The CI workflow ran ./gradlew functionalTest, but the guide never showed the
    task that defines it and the app had no unit or integration tests, so two of
    the four stages were hollow and the functional stage would fail. The sample
    app now runs 6 green tests across the three stages (3 unit + 2 integration +
    1 Geb functional).
    
    Re-vendored snippets and expanded the testing chapters:
    
    - unitTests: adds the Message domain + MessageSpec (DomainUnitTest)
    - integrationTests: adds MessageIntegrationSpec and explains the
      integrationTest -> *IntegrationSpec / functionalTest -> *FunctionalSpec split
    - functionalTests: documents the build.gradle wiring that actually defines the
      functionalTest task (a second Test task over the integration-test source set,
      filtered to the Geb specs) and shows HomePageFunctionalSpec
    
    Assisted-by: claude-code:claude-opus-4-7
---
 .../v8/guide/functionalTests.adoc                  | 34 ++++++++++++++++++++
 .../v8/guide/integrationTests.adoc                 | 10 ++++++
 .../v8/guide/unitTests.adoc                        | 16 ++++++++++
 .../grails-app/domain/example/Message.groovy       | 15 +++++++++
 .../groovy/example/HomePageFunctionalSpec.groovy   | 22 +++++++++++++
 .../groovy/example/MessageIntegrationSpec.groovy   | 36 ++++++++++++++++++++++
 .../src/test/groovy/example/MessageSpec.groovy     | 34 ++++++++++++++++++++
 7 files changed, 167 insertions(+)

diff --git a/guides/grails-github-actions-cicd/v8/guide/functionalTests.adoc b/guides/grails-github-actions-cicd/v8/guide/functionalTests.adoc
index 23cb4618a9a..40e224e54d1 100644
--- a/guides/grails-github-actions-cicd/v8/guide/functionalTests.adoc
+++ b/guides/grails-github-actions-cicd/v8/guide/functionalTests.adoc
@@ -28,3 +28,37 @@ The `functional-test` job boots the application against a Testcontainers-managed
 `if: always()` on the geb-reports upload is intentional: even on a green run you may want the screenshots from `build/geb-reports/` to confirm the page rendered as expected. On a red run, those screenshots are how you debug "works on my machine" failures - the runner's screenshot of the failed page is usually enough to spot the missing element.
 
 No `apt-get install` of Chrome/Firefox is needed - the browser process lives inside the Selenium container Testcontainers pulls (`selenium/standalone-chrome` by default). The first cold run pulls the image (~200 MB) and is slower; subsequent runs reuse the cached layer. The legacy `geb-with-webdriver-binaries` forge feature is obsolete in Grails 8 and is not used here.
+
+== Wiring the functionalTest task
+
+`./gradlew functionalTest` is not a built-in Grails task. Grails gives you `test` (unit) and `integrationTest`; the functional stage is a second `Test` task over the same `integration-test` source set, filtered to the Geb specs. The `integrationTest` task is filtered the opposite way so the two stages never overlap:
+
+[source,groovy]
+.build.gradle
+----
+tasks.named('integrationTest', Test) {
+    filter {
+        includeTestsMatching '*IntegrationSpec'
+    }
+}
+
+tasks.register('functionalTest', Test) {
+    group = 'verification'
+    description = 'Runs the Geb functional specs against the booted application.'
+    testClassesDirs = sourceSets.integrationTest.output.classesDirs
+    classpath = sourceSets.integrationTest.runtimeClasspath
+    useJUnitPlatform()
+    filter {
+        includeTestsMatching '*FunctionalSpec'
+    }
+    shouldRunAfter tasks.named('integrationTest')
+}
+----
+
+Reusing the `integration-test` source set means `@Integration` boots the app exactly as it does for the integration stage - no separate source-set plumbing - while the name filter keeps the two CI jobs running disjoint sets of specs. The Geb spec itself extends `ContainerGebSpec` and is named to match the `functionalTest` filter:
+
+[source,groovy]
+.src/integration-test/groovy/example/HomePageFunctionalSpec.groovy
+----
+include::../snippets/src/integration-test/groovy/example/HomePageFunctionalSpec.groovy[]
+----
diff --git a/guides/grails-github-actions-cicd/v8/guide/integrationTests.adoc b/guides/grails-github-actions-cicd/v8/guide/integrationTests.adoc
index 770e815fae6..3e6e9577630 100644
--- a/guides/grails-github-actions-cicd/v8/guide/integrationTests.adoc
+++ b/guides/grails-github-actions-cicd/v8/guide/integrationTests.adoc
@@ -23,3 +23,13 @@ The three datasource environment variables on the `./gradlew integrationTest` st
 ====
 For the *functional* tests in the next chapter we switch from a service container to Testcontainers' Postgres. The trade-off: service containers are faster to spin up (the postgres image is already cached on the runner) but force every test in the suite to share the same database. Testcontainers gives each spec its own fresh container at the cost of an extra container start per spec class. For functional tests where database state isolation matters more than wall-clock speed, Testcontain [...]
 ====
+
+An `@Integration` spec with `@Rollback` runs against that database in the `integration-test` stage:
+
+[source,groovy]
+.src/integration-test/groovy/example/MessageIntegrationSpec.groovy
+----
+include::../snippets/src/integration-test/groovy/example/MessageIntegrationSpec.groovy[]
+----
+
+Both the DB-integration specs and the Geb functional specs live in `src/integration-test` so `@Integration` wires them identically, but they run as two distinct stages. The `integrationTest` task is narrowed to the `*IntegrationSpec` classes; the next chapter's `functionalTest` task picks up the `*FunctionalSpec` classes.
diff --git a/guides/grails-github-actions-cicd/v8/guide/unitTests.adoc b/guides/grails-github-actions-cicd/v8/guide/unitTests.adoc
index 2b9bdc9c08d..11f4cbafcf2 100644
--- a/guides/grails-github-actions-cicd/v8/guide/unitTests.adoc
+++ b/guides/grails-github-actions-cicd/v8/guide/unitTests.adoc
@@ -27,3 +27,19 @@ Two patterns worth pointing at:
 
 * `if: failure()` on the artefact upload uploads test reports only when the job fails. On success there is no point in carrying around `index.html` files; on failure they are the first thing you click on.
 * `--no-daemon` ensures the JVM starts cold and reports honest first-run times. The Gradle dependency cache is still warm; the daemon caching disagreement was about JVM warm-up, not classpath resolution.
+
+The stage needs at least one real unit test to be meaningful. A tiny domain class gives the suite something to exercise across all three layers:
+
+[source,groovy]
+.grails-app/domain/example/Message.groovy
+----
+include::../snippets/grails-app/domain/example/Message.groovy[]
+----
+
+`DomainUnitTest` runs the constraints in milliseconds with no Spring context and no database, so it belongs in the fast `unit-test` stage:
+
+[source,groovy]
+.src/test/groovy/example/MessageSpec.groovy
+----
+include::../snippets/src/test/groovy/example/MessageSpec.groovy[]
+----
diff --git a/guides/grails-github-actions-cicd/v8/snippets/grails-app/domain/example/Message.groovy b/guides/grails-github-actions-cicd/v8/snippets/grails-app/domain/example/Message.groovy
new file mode 100644
index 00000000000..28f0a0d9632
--- /dev/null
+++ b/guides/grails-github-actions-cicd/v8/snippets/grails-app/domain/example/Message.groovy
@@ -0,0 +1,15 @@
+package example
+
+import grails.persistence.Entity
+
+@Entity
+class Message {
+
+    String content
+
+    static constraints = {
+        content blank: false, maxSize: 500
+    }
+
+    String toString() { content }
+}
diff --git a/guides/grails-github-actions-cicd/v8/snippets/src/integration-test/groovy/example/HomePageFunctionalSpec.groovy b/guides/grails-github-actions-cicd/v8/snippets/src/integration-test/groovy/example/HomePageFunctionalSpec.groovy
new file mode 100644
index 00000000000..a3f098c08ed
--- /dev/null
+++ b/guides/grails-github-actions-cicd/v8/snippets/src/integration-test/groovy/example/HomePageFunctionalSpec.groovy
@@ -0,0 +1,22 @@
+package example
+
+import grails.plugin.geb.ContainerGebSpec
+import grails.testing.mixin.integration.Integration
+
+/**
+ * Functional layer - runs in the `functional-test` CI stage
+ * (./gradlew functionalTest). ContainerGebSpec starts a Selenium-Chrome
+ * container via Testcontainers and drives the booted application; the only
+ * host requirement is a running Docker daemon.
+ */
+@Integration
+class HomePageFunctionalSpec extends ContainerGebSpec {
+
+    void 'the home page renders the welcome title'() {
+        when: 'visiting the home page'
+        go '/'
+
+        then: 'the page title is correct'
+        title == 'Welcome to Grails'
+    }
+}
diff --git a/guides/grails-github-actions-cicd/v8/snippets/src/integration-test/groovy/example/MessageIntegrationSpec.groovy b/guides/grails-github-actions-cicd/v8/snippets/src/integration-test/groovy/example/MessageIntegrationSpec.groovy
new file mode 100644
index 00000000000..670a3eb2f59
--- /dev/null
+++ b/guides/grails-github-actions-cicd/v8/snippets/src/integration-test/groovy/example/MessageIntegrationSpec.groovy
@@ -0,0 +1,36 @@
+package example
+
+import grails.gorm.transactions.Rollback
+import grails.testing.mixin.integration.Integration
+import spock.lang.Specification
+
+/**
+ * Integration layer - runs in the `integration-test` CI stage
+ * (./gradlew integrationTest) against a real PostgreSQL. @Rollback keeps
+ * each method isolated and gives a bare GORM call an ambient session.
+ */
+@Integration
+@Rollback
+class MessageIntegrationSpec extends Specification {
+
+    void "a message round-trips through GORM"() {
+        given:
+        Message saved = new Message(content: 'persist me').save(flush: true, failOnError: true)
+
+        when:
+        Message reloaded = Message.get(saved.id)
+
+        then:
+        reloaded.content == 'persist me'
+    }
+
+    void "a blank message is rejected by the database layer"() {
+        when:
+        Message m = new Message(content: '')
+        m.save(flush: true)
+
+        then:
+        m.hasErrors()
+        Message.get(m.id) == null
+    }
+}
diff --git a/guides/grails-github-actions-cicd/v8/snippets/src/test/groovy/example/MessageSpec.groovy b/guides/grails-github-actions-cicd/v8/snippets/src/test/groovy/example/MessageSpec.groovy
new file mode 100644
index 00000000000..c55990b44c0
--- /dev/null
+++ b/guides/grails-github-actions-cicd/v8/snippets/src/test/groovy/example/MessageSpec.groovy
@@ -0,0 +1,34 @@
+package example
+
+import grails.testing.gorm.DomainUnitTest
+import spock.lang.Specification
+
+/**
+ * Unit layer - runs in the `unit-test` CI stage (./gradlew test). No Spring
+ * context, no database; just the domain constraints.
+ */
+class MessageSpec extends Specification implements DomainUnitTest<Message> {
+
+    void "content is required"() {
+        when:
+        Message m = new Message()
+
+        then:
+        !m.validate()
+        m.errors.getFieldError('content').code == 'nullable'
+    }
+
+    void "content over maxSize is rejected"() {
+        when:
+        Message m = new Message(content: 'x' * 501)
+
+        then:
+        !m.validate()
+        m.errors.getFieldError('content').code == 'maxSize.exceeded'
+    }
+
+    void "a short message validates"() {
+        expect:
+        new Message(content: 'hello').validate()
+    }
+}


(grails-static-website) 09/09: Add a Testing chapter to grails-docker-bootbuildimage v8

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

jamesfredley pushed a commit to branch add-comprehensive-tests-v8-guides
in repository https://gitbox.apache.org/repos/asf/grails-static-website.git

commit e6573bf5c89ebdedca59bbda78fba185fa8f7eac
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Thu May 28 17:08:36 2026 -0400

    Add a Testing chapter to grails-docker-bootbuildimage v8
    
    The app shipped only the forge-default Geb spec and its test build could not
    resolve Testcontainers. The sample now runs 4 green functional tests over the
    Actuator health surface the guide configures.
    
    New "Testing the Health Probes" chapter (registered in conf/guides.yml toc)
    vendoring:
    
    - application-test.yml: testcontainers-bom dependency note + create-drop schema
      and databasemigration.updateOnStart false (the database-migration plugin
      otherwise blocks schema creation in the test env)
    - ActuatorHealthFunctionalSpec: /actuator/health, liveness and readiness all
      UP (readiness against Testcontainers PostgreSQL), and env/metrics stay closed
      - the same readiness endpoint the Compose HEALTHCHECK polls
    
    Assisted-by: claude-code:claude-opus-4-7
---
 conf/guides.yml                                    |  2 +
 .../v8/guide/testing.adoc                          | 34 ++++++++++
 .../example/ActuatorHealthFunctionalSpec.groovy    | 73 ++++++++++++++++++++++
 .../src/test/resources/application-test.yml        |  8 +++
 4 files changed, 117 insertions(+)

diff --git a/conf/guides.yml b/conf/guides.yml
index 895b179da0d..e4db8795db8 100644
--- a/conf/guides.yml
+++ b/conf/guides.yml
@@ -1939,6 +1939,8 @@ guides:
             title: Pushing to GitHub Container Registry
           nonRoot:
             title: Running as a Non-Root User
+          testing:
+            title: Testing the Health Probes
           helpWithGrails:
             title: Do you need help with Grails?
 
diff --git a/guides/grails-docker-bootbuildimage/v8/guide/testing.adoc b/guides/grails-docker-bootbuildimage/v8/guide/testing.adoc
new file mode 100644
index 00000000000..355b5444df3
--- /dev/null
+++ b/guides/grails-docker-bootbuildimage/v8/guide/testing.adoc
@@ -0,0 +1,34 @@
+The deployment contract for this app is not a web page - it is the Actuator health surface that the Docker `HEALTHCHECK` and a Kubernetes readiness/liveness probe poll. A functional test pins exactly that contract, booting the app against a Testcontainers PostgreSQL database (the readiness probe checks the datasource, so a real database is required for it to report `UP`).
+
+== Test configuration
+
+Testcontainers artifacts are not in the Grails BOM, so they need an explicit `testcontainers-bom`. Because the app uses the database-migration plugin with an empty changelog, the test profile turns migration off and lets GORM create the schema directly:
+
+[source,yaml]
+.src/test/resources/application-test.yml
+----
+include::../snippets/src/test/resources/application-test.yml[]
+----
+
+== The health-probe functional spec
+
+[source,groovy]
+.src/integration-test/groovy/example/ActuatorHealthFunctionalSpec.groovy
+----
+include::../snippets/src/integration-test/groovy/example/ActuatorHealthFunctionalSpec.groovy[]
+----
+
+The assertions encode the operational contract from the link:#healthCheck[health-probe chapter]:
+
+* `/actuator/health`, `/actuator/health/liveness`, and `/actuator/health/readiness` all report `UP` (the status is nested as `status.code` in Spring Boot's health JSON);
+* the readiness probe reports `UP` only because a real database is reachable - this is why the spec runs against Testcontainers PostgreSQL rather than a mock;
+* the operator-grade endpoints (`/actuator/env`, `/actuator/metrics`) stay closed (`404`), confirming the `exposure.include: health,info` restriction actually holds.
+
+Run it:
+
+[source,bash]
+----
+./gradlew integrationTest   # boots the app against Testcontainers PostgreSQL - Docker required
+----
+
+This is the same `readiness` endpoint the Compose `HEALTHCHECK` polls, so a green functional run is direct evidence the container's health check will pass once the image is built and run.
diff --git a/guides/grails-docker-bootbuildimage/v8/snippets/src/integration-test/groovy/example/ActuatorHealthFunctionalSpec.groovy b/guides/grails-docker-bootbuildimage/v8/snippets/src/integration-test/groovy/example/ActuatorHealthFunctionalSpec.groovy
new file mode 100644
index 00000000000..1ce6381da5a
--- /dev/null
+++ b/guides/grails-docker-bootbuildimage/v8/snippets/src/integration-test/groovy/example/ActuatorHealthFunctionalSpec.groovy
@@ -0,0 +1,73 @@
+package example
+
+import grails.testing.mixin.integration.Integration
+import groovy.json.JsonSlurper
+import org.springframework.beans.factory.annotation.Value
+import spock.lang.Specification
+
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+
+/**
+ * Functional tests for the Spring Boot Actuator health surface this guide
+ * configures - the same endpoints the Docker HEALTHCHECK and a Kubernetes
+ * readiness/liveness probe hit. @Integration boots the app against a
+ * Testcontainers PostgreSQL database, so the readiness probe (which checks
+ * the datasource) reports UP.
+ *
+ * No Geb here: the deployment contract is HTTP/JSON, not a browser page.
+ */
+@Integration
+class ActuatorHealthFunctionalSpec extends Specification {
+
+    @Value('${local.server.port}')
+    Integer serverPort
+
+    HttpClient client = HttpClient.newHttpClient()
+
+    private HttpResponse<String> get(String path) {
+        client.send(
+                HttpRequest.newBuilder(URI.create("http://localhost:${serverPort}${path}")).GET().build(),
+                HttpResponse.BodyHandlers.ofString())
+    }
+
+    // Spring Boot's health JSON nests the status as {code, description}, so
+    // the live status string is json.status.code.
+    private static String statusCode(String body) {
+        (new JsonSlurper().parseText(body) as Map).status.code
+    }
+
+    void "GET /actuator/health reports UP"() {
+        when:
+        HttpResponse<String> resp = get('/actuator/health')
+
+        then:
+        resp.statusCode() == 200
+        statusCode(resp.body()) == 'UP'
+    }
+
+    void "the liveness probe is exposed and UP"() {
+        when:
+        HttpResponse<String> resp = get('/actuator/health/liveness')
+
+        then:
+        resp.statusCode() == 200
+        statusCode(resp.body()) == 'UP'
+    }
+
+    void "the readiness probe is exposed and UP against a real database"() {
+        when:
+        HttpResponse<String> resp = get('/actuator/health/readiness')
+
+        then:
+        resp.statusCode() == 200
+        statusCode(resp.body()) == 'UP'
+    }
+
+    void "operator-grade endpoints stay closed"() {
+        expect: 'only health and info are on the public surface'
+        get('/actuator/env').statusCode() == 404
+        get('/actuator/metrics').statusCode() == 404
+    }
+}
diff --git a/guides/grails-docker-bootbuildimage/v8/snippets/src/test/resources/application-test.yml b/guides/grails-docker-bootbuildimage/v8/snippets/src/test/resources/application-test.yml
new file mode 100644
index 00000000000..8d5999fd500
--- /dev/null
+++ b/guides/grails-docker-bootbuildimage/v8/snippets/src/test/resources/application-test.yml
@@ -0,0 +1,8 @@
+dataSource:
+  url: jdbc:tc:postgresql:12:///postgres
+  driverClassName: org.testcontainers.jdbc.ContainerDatabaseDriver
+  dbCreate: create-drop
+grails:
+  plugin:
+    databasemigration:
+      updateOnStart: false


(grails-static-website) 05/09: Add a Testing chapter to grails-tailwindcss v8

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

jamesfredley pushed a commit to branch add-comprehensive-tests-v8-guides
in repository https://gitbox.apache.org/repos/asf/grails-static-website.git

commit dc82cef583ab5b62f9267c29389bcfb4a3b6ad4b
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Thu May 28 15:49:24 2026 -0400

    Add a Testing chapter to grails-tailwindcss v8
    
    The sample app's only test was the forge default (a 'Welcome to Grails' title
    assertion that never matched the actual 'Welcome to Grails + Tailwind' page),
    so it had never run. The app now has a real browser test suite (5 Geb specs).
    
    - new "Testing the Tailwind Build" chapter (registered in conf/guides.yml toc)
      vendoring TailwindFunctionalSpec: asserts the compiled app.css is served,
      that .card/.btn-primary apply real computed styles from the @apply layer,
      and that the class-based dark-mode toggle works and persists to localStorage
    
    Assisted-by: claude-code:claude-opus-4-7
---
 conf/guides.yml                                    |  2 +
 guides/grails-tailwindcss/v8/guide/testing.adoc    | 24 +++++++
 .../groovy/example/TailwindFunctionalSpec.groovy   | 77 ++++++++++++++++++++++
 3 files changed, 103 insertions(+)

diff --git a/conf/guides.yml b/conf/guides.yml
index 63fa1d01c71..8ccc559ce1c 100644
--- a/conf/guides.yml
+++ b/conf/guides.yml
@@ -4441,6 +4441,8 @@ guides:
             title: Class-Based Dark Mode
           componentLayer:
             title: Reusable Component Classes with @apply
+          testing:
+            title: Testing the Tailwind Build
           helpWithGrails:
             title: Do you need help with Grails?
 
diff --git a/guides/grails-tailwindcss/v8/guide/testing.adoc b/guides/grails-tailwindcss/v8/guide/testing.adoc
new file mode 100644
index 00000000000..7b2c6ec7756
--- /dev/null
+++ b/guides/grails-tailwindcss/v8/guide/testing.adoc
@@ -0,0 +1,24 @@
+A CSS build is easy to break silently: a misconfigured `@source`, a broken Gradle wiring, or an unserved bundle all render as "the page looks unstyled" with no error. A browser test catches every one of those because it asserts on *computed* styles in a real browser, against the real compiled `app.css`.
+
+`ContainerGebSpec` (from `testFixtures("org.apache.grails:grails-geb")`) starts a Selenium-Chrome container via Testcontainers and drives the booted app; the only host requirement is a running Docker daemon. The Tailwind build runs as part of the normal Gradle build (`tailwindBuild` is a dependency of `processResources` and `compileGroovy`), so by the time the app boots, `app.css` is compiled and bundled.
+
+[source,groovy]
+.src/integration-test/groovy/example/TailwindFunctionalSpec.groovy
+----
+include::../snippets/src/integration-test/groovy/example/TailwindFunctionalSpec.groovy[]
+----
+
+The assertions go beyond "the page loaded":
+
+* *The stylesheet is served* - the layout's `application.css` bundle (which `require`s the compiled `app.css`) is linked in the page head.
+* *Component classes apply real styles* - `getComputedStyle` confirms `.card` has non-zero padding and a non-transparent background, and `.btn-primary` resolves to a real background colour. If the `@apply` component layer or the `@source` scanning silently dropped those rules, the computed style would be the browser default and the test would fail.
+* *Dark mode works and persists* - clicking the toggle adds the `dark` class to `<html>` and writes `theme=dark` to `localStorage`; toggling back reverses both. This exercises the `@custom-variant dark` configuration end to end.
+
+Run it:
+
+[source,bash]
+----
+./gradlew integrationTest   # runs tailwindBuild, boots the app, drives Geb - Docker required
+----
+
+Because the build wires `tailwindBuild` ahead of `processResources`, there is no separate "build the CSS first" step - `./gradlew integrationTest` compiles the stylesheet, boots the app, and runs the browser assertions in one command.
diff --git a/guides/grails-tailwindcss/v8/snippets/src/integration-test/groovy/example/TailwindFunctionalSpec.groovy b/guides/grails-tailwindcss/v8/snippets/src/integration-test/groovy/example/TailwindFunctionalSpec.groovy
new file mode 100644
index 00000000000..3df792977dd
--- /dev/null
+++ b/guides/grails-tailwindcss/v8/snippets/src/integration-test/groovy/example/TailwindFunctionalSpec.groovy
@@ -0,0 +1,77 @@
+package example
+
+import grails.plugin.geb.ContainerGebSpec
+import grails.testing.mixin.integration.Integration
+
+/**
+ * Browser tests proving the Tailwind CSS 4 build is wired end to end:
+ * the compiled app.css is served, its utility and component classes apply
+ * real computed styles, and the class-based dark-mode toggle works and
+ * persists. ContainerGebSpec drives a Selenium-Chrome container via
+ * Testcontainers, so only a running Docker daemon is required.
+ */
+@Integration
+class TailwindFunctionalSpec extends ContainerGebSpec {
+
+    void 'the home page renders with the Tailwind title'() {
+        when:
+        go '/'
+
+        then:
+        title == 'Welcome to Grails + Tailwind'
+        $('h1').text().contains('Welcome to Grails + Tailwind CSS')
+    }
+
+    void 'the compiled Tailwind stylesheet is served'() {
+        when: 'reading the stylesheet hrefs the layout emitted'
+        go '/'
+        List<String> hrefs = $('link', rel: 'stylesheet').collect { it.@href }
+
+        then: 'the asset-pipeline application bundle (which requires app.css) is linked'
+        hrefs.any { it.contains('application.css') }
+    }
+
+    void 'the .card component class applies real Tailwind styles'() {
+        when:
+        go '/'
+
+        then: 'the compiled .card rule gives the section padding and a non-transparent background'
+        waitFor {
+            String padding = js.exec('return getComputedStyle(document.querySelector(".card")).paddingTop') as String
+            padding && padding != '0px'
+        }
+        String bg = js.exec('return getComputedStyle(document.querySelector(".card")).backgroundColor') as String
+        bg && bg != 'rgba(0, 0, 0, 0)' && bg != 'transparent'
+    }
+
+    void 'the .btn-primary component class applies a blue background'() {
+        when:
+        go '/'
+
+        then: 'btn-primary resolves to a non-transparent (blue) background from the component layer'
+        String bg = js.exec('return getComputedStyle(document.querySelector(".btn-primary")).backgroundColor') as String
+        bg && bg != 'rgba(0, 0, 0, 0)' && bg != 'transparent'
+    }
+
+    void 'the dark-mode toggle adds the dark class and persists the choice'() {
+        given:
+        go '/'
+
+        expect: 'starts light - no dark class on <html>'
+        !(js.exec('return document.documentElement.classList.contains("dark")') as Boolean)
+
+        when: 'clicking the toggle'
+        $('#theme-toggle').click()
+
+        then: 'the dark class is applied and saved to localStorage'
+        waitFor { js.exec('return document.documentElement.classList.contains("dark")') as Boolean }
+        (js.exec('return window.localStorage.getItem("theme")') as String) == 'dark'
+
+        when: 'toggling back'
+        $('#theme-toggle').click()
+
+        then:
+        waitFor { !(js.exec('return document.documentElement.classList.contains("dark")') as Boolean) }
+        (js.exec('return window.localStorage.getItem("theme")') as String) == 'light'
+    }
+}


(grails-static-website) 06/09: Fix grails-vite-spa v8 SPA serving and add a Testing chapter

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

jamesfredley pushed a commit to branch add-comprehensive-tests-v8-guides
in repository https://gitbox.apache.org/repos/asf/grails-static-website.git

commit 2b2587cc5ab990524d35539ee94bcb1d19ff4b3d
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Thu May 28 16:21:14 2026 -0400

    Fix grails-vite-spa v8 SPA serving and add a Testing chapter
    
    The SPA-serving chain was broken end to end and the backend had no tests.
    The sample app now runs 15 green tests (7 unit + 8 integration/functional)
    and genuinely serves the SPA same-origin.
    
    Re-vendored snippets and corrected prose:
    
    - index.gson: render the book template with collection: (it was building
      items as JSON strings) and declare Integer bookCount (was Long -> 500)
    - SpaController: serve index.html from the classpath (forward url: 404s in a
      rest-api app); spaController chapter rewritten to match, toc title updated
    - new AssetController + /assets/** mapping streams the Vite bundle same-origin
      (the rest-api profile serves no static resources); urlMappings chapter
      updated to document both routes
    
    New "Testing the API and SPA Serving" chapter (registered in conf/guides.yml
    toc) vendoring BookSpec, BookControllerSpec, BookIntegrationSpec, and
    ApiFunctionalSpec (JSON envelope, POST 201/422, same-origin no-CORS, and the
    SPA shell + /assets bundle served same-origin - which also exercises the Vite
    build + copy integration).
    
    Assisted-by: claude-code:claude-opus-4-7
---
 conf/guides.yml                                    |   4 +-
 guides/grails-vite-spa/v8/guide/spaController.adoc |   6 +-
 guides/grails-vite-spa/v8/guide/testing.adoc       |  62 ++++++++++
 guides/grails-vite-spa/v8/guide/urlMappings.adoc   |   5 +-
 .../controllers/example/AssetController.groovy     |  47 ++++++++
 .../controllers/example/SpaController.groovy       |  20 +++-
 .../controllers/example/UrlMappings.groovy         |   3 +
 .../backend/grails-app/views/book/index.gson       |   6 +-
 .../groovy/example/ApiFunctionalSpec.groovy        | 130 +++++++++++++++++++++
 .../groovy/example/BookIntegrationSpec.groovy      |  40 +++++++
 .../test/groovy/example/BookControllerSpec.groovy  |  47 ++++++++
 .../src/test/groovy/example/BookSpec.groovy        |  47 ++++++++
 12 files changed, 402 insertions(+), 15 deletions(-)

diff --git a/conf/guides.yml b/conf/guides.yml
index 8ccc559ce1c..7fc569a9bf6 100644
--- a/conf/guides.yml
+++ b/conf/guides.yml
@@ -4884,7 +4884,7 @@ guides:
           urlMappings:
             title: URL Mappings (/api/** vs SPA)
           spaController:
-            title: The SpaController Forwarder
+            title: Serving the SPA Shell
           frontend:
             title: The Vite/React Frontend
           viteConfig:
@@ -4895,6 +4895,8 @@ guides:
             title: Prod Build (One bootJar, One Origin)
           cors:
             title: Why CORS Never Appears
+          testing:
+            title: Testing the API and SPA Serving
           helpWithGrails:
             title: Do you need help with Grails?
 
diff --git a/guides/grails-vite-spa/v8/guide/spaController.adoc b/guides/grails-vite-spa/v8/guide/spaController.adoc
index 782a8147647..fbf5fc8451a 100644
--- a/guides/grails-vite-spa/v8/guide/spaController.adoc
+++ b/guides/grails-vite-spa/v8/guide/spaController.adoc
@@ -6,15 +6,13 @@ The `SpaController` is one action that hands every non-`/api/**` request to the
 include::../snippets/backend/grails-app/controllers/example/SpaController.groovy[]
 ----
 
-`forward url: '/index.html'` re-dispatches inside the same servlet container; the response status, content type, and caching headers all come from the static-resource handler that serves `index.html`. No HTML duplication, no template engine in the loop.
+The action reads the bundled `index.html` straight off the classpath (`classpath:public/index.html` - where the `copyFrontendToBackend` task placed the Vite output) and renders it as `text/html`. Reading the resource directly is robust across profiles and environments: a `rest-api` Grails app does not register a servlet static-resource handler for arbitrary paths, so a `forward url: '/index.html'` is claimed by Grails' URL mappings and returns a 404 instead of the file. Injecting `Resour [...]
 
 [NOTE]
 ====
-*Why not a wildcard URL mapping like `'/$path**'(controller: 'spa', action: 'index')`?* Because URL mappings resolve before static resources. A wildcard mapping would intercept `/favicon.ico` and `/assets/main-abc123.js`, returning the SPA's `index.html` instead of the asset. Letting Spring Boot's static-resource handler claim everything except `/api/**` and `/` is the simplest correct shape.
+*Why not a wildcard URL mapping like `'/$path**'(controller: 'spa', action: 'index')`?* Because that would also intercept `/api/**` and `/assets/**`. Mapping only `/` to the SPA controller, and routing `/assets/**` to the `AssetController` (next chapter), is the simplest correct shape. The `index.html` Vite emits references its JS and CSS by hashed `/assets/...` paths, which the SPA loads as a second request.
 ====
 
-The `forward` method available on every Grails controller is the same one the framework uses internally for URL-mapping rewrites; see `grails-controllers/src/main/groovy/grails/web/util/WebUtils.java` for the underlying implementation and `grails-doc/src/en/guide/theWebLayer/controllers/forwarding.adoc` for the user-facing reference.
-
 Files touched in this chapter:
 
 * `backend/grails-app/controllers/example/SpaController.groovy`
diff --git a/guides/grails-vite-spa/v8/guide/testing.adoc b/guides/grails-vite-spa/v8/guide/testing.adoc
new file mode 100644
index 00000000000..9e29abf7358
--- /dev/null
+++ b/guides/grails-vite-spa/v8/guide/testing.adoc
@@ -0,0 +1,62 @@
+The backend is covered at three layers: fast domain and controller unit tests, an `@Integration` test for persistence against a real database, and functional HTTP tests that exercise the JSON API, the same-origin CORS behaviour, and the SPA-serving path. The integration and functional layers run against a Testcontainers-managed PostgreSQL database; running `:backend:integrationTest` also triggers the frontend Vite build (via `processResources` -> `copyFrontendToBackend`), so the function [...]
+
+== Test dependencies
+
+Testcontainers artifacts are not in the Grails BOM, and a `rest-api` profile app needs the web testing support for `ControllerUnitTest`:
+
+[source,groovy]
+.backend/build.gradle (test dependencies)
+----
+testImplementation "org.apache.grails:grails-testing-support-datamapping"
+testImplementation "org.apache.grails:grails-testing-support-views-gson"
+testImplementation "org.apache.grails:grails-testing-support-web"
+testImplementation "org.spockframework:spock-core"
+testImplementation platform("org.testcontainers:testcontainers-bom:1.20.4")
+testImplementation "org.testcontainers:postgresql"
+testImplementation "org.testcontainers:spock"
+testImplementation "org.testcontainers:testcontainers"
+----
+
+== Unit: domain and controller
+
+[source,groovy]
+.backend/src/test/groovy/example/BookSpec.groovy
+----
+include::../snippets/backend/src/test/groovy/example/BookSpec.groovy[]
+----
+
+The `BookController` overrides `listAllResources` to clamp the page size. Because it reads `params.int('max', 25)`, the spec drives it through the controller's `GrailsParameterMap` (a plain `Map` has no `int()` accessor):
+
+[source,groovy]
+.backend/src/test/groovy/example/BookControllerSpec.groovy
+----
+include::../snippets/backend/src/test/groovy/example/BookControllerSpec.groovy[]
+----
+
+== Integration: persistence against a real database
+
+[source,groovy]
+.backend/src/integration-test/groovy/example/BookIntegrationSpec.groovy
+----
+include::../snippets/backend/src/integration-test/groovy/example/BookIntegrationSpec.groovy[]
+----
+
+== Functional: the API, CORS, and SPA serving over real HTTP
+
+The functional spec sends real requests with `java.net.http.HttpClient`. It asserts the JSON envelope shape (`{total, items:[{id,title,author,isbn}]}` - the `items` must be nested objects, which is why the JSON view renders the `book` template with `collection:` rather than collecting rendered strings), the same-origin layout's *absence* of CORS headers, and that both the SPA shell (`/`) and its hashed JS bundle (`/assets/**`) are served same-origin:
+
+[source,groovy]
+.backend/src/integration-test/groovy/example/ApiFunctionalSpec.groovy
+----
+include::../snippets/backend/src/integration-test/groovy/example/ApiFunctionalSpec.groovy[]
+----
+
+The SPA-serving assertions are the ones that catch the subtle wiring bugs: a `rest-api` Grails app registers no servlet static-resource handler, so `SpaController` reads `index.html` off the classpath and `AssetController` streams the `/assets/**` bundle - without both, the deployed SPA would render an empty shell that never loads its script. The functional test fails loudly if either route regresses.
+
+Run it:
+
+[source,bash]
+----
+./gradlew :backend:test             # unit - milliseconds, no Docker
+./gradlew :backend:integrationTest  # builds the SPA, boots the app, drives HTTP - Docker + node required
+----
diff --git a/guides/grails-vite-spa/v8/guide/urlMappings.adoc b/guides/grails-vite-spa/v8/guide/urlMappings.adoc
index 27a51491b33..bcd1f230212 100644
--- a/guides/grails-vite-spa/v8/guide/urlMappings.adoc
+++ b/guides/grails-vite-spa/v8/guide/urlMappings.adoc
@@ -7,9 +7,10 @@ include::../snippets/backend/grails-app/controllers/example/UrlMappings.groovy[]
 ----
 
 * `/api/books` (and any other future `/api/**` route) hits the JSON controllers.
-* Everything else - `/`, `/books/42`, `/about` - resolves to `SpaController.index`, which forwards to the bundled `index.html`. The SPA's client-side router takes over from there.
+* `/assets/**` resolves to `AssetController`, which streams the Vite-built JS and CSS bundle from the classpath. The `rest-api` profile registers no servlet static-resource handler, so without this route the SPA's hashed assets would 404 - the page would load its shell but never its script.
+* `/` resolves to `SpaController.index`, which serves the bundled `index.html`. The SPA's client-side router takes over from there.
 
-The forward (rather than a redirect) is what makes deep links survive a browser refresh: a hard reload on `/books/42` returns the same `index.html` payload, the SPA boots, and React Router parses the URL.
+Serving `index.html` from the controller (rather than relying on a static-resource handler) is what makes a hard reload on `/` return the SPA shell reliably across profiles; the browser then loads the hashed bundle from `/assets/**` and React boots.
 
 The URL-mapping DSL itself is covered in `grails-doc/src/en/guide/theWebLayer/urlMappings.adoc` and the per-method-routing story in `grails-doc/src/en/guide/theWebLayer/urlmappings/restfulMappings.adoc`.
 
diff --git a/guides/grails-vite-spa/v8/snippets/backend/grails-app/controllers/example/AssetController.groovy b/guides/grails-vite-spa/v8/snippets/backend/grails-app/controllers/example/AssetController.groovy
new file mode 100644
index 00000000000..695a3f9b7ce
--- /dev/null
+++ b/guides/grails-vite-spa/v8/snippets/backend/grails-app/controllers/example/AssetController.groovy
@@ -0,0 +1,47 @@
+package example
+
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.core.io.Resource
+import org.springframework.core.io.ResourceLoader
+import org.springframework.http.HttpStatus
+
+/**
+ * Serves the Vite-built SPA bundle (the hashed /assets/** files Vite emits
+ * and copyFrontendToBackend places on the classpath under public/assets/).
+ *
+ * The rest-api Grails profile registers no servlet static-resource handler,
+ * so these assets would 404 without an explicit route. Streaming them through
+ * a controller keeps everything same-origin: the SPA shell at '/' and its JS
+ * and CSS at '/assets/**' are all served by this one application.
+ */
+class AssetController {
+
+    @Autowired
+    ResourceLoader resourceLoader
+
+    def serve() {
+        String path = params.path
+        if (!path || path.contains('..')) {
+            response.status = HttpStatus.BAD_REQUEST.value()
+            return
+        }
+        Resource asset = resourceLoader.getResource("classpath:public/assets/${path}")
+        if (!asset.exists()) {
+            response.status = HttpStatus.NOT_FOUND.value()
+            return
+        }
+        response.contentType = contentTypeFor(path)
+        asset.inputStream.withStream { input ->
+            response.outputStream << input
+        }
+        response.outputStream.flush()
+    }
+
+    private static String contentTypeFor(String path) {
+        if (path.endsWith('.js'))   return 'text/javascript'
+        if (path.endsWith('.css'))  return 'text/css'
+        if (path.endsWith('.svg'))  return 'image/svg+xml'
+        if (path.endsWith('.json')) return 'application/json'
+        'application/octet-stream'
+    }
+}
diff --git a/guides/grails-vite-spa/v8/snippets/backend/grails-app/controllers/example/SpaController.groovy b/guides/grails-vite-spa/v8/snippets/backend/grails-app/controllers/example/SpaController.groovy
index 290e190891f..3de509874a9 100644
--- a/guides/grails-vite-spa/v8/snippets/backend/grails-app/controllers/example/SpaController.groovy
+++ b/guides/grails-vite-spa/v8/snippets/backend/grails-app/controllers/example/SpaController.groovy
@@ -1,15 +1,25 @@
 package example
 
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.core.io.Resource
+import org.springframework.core.io.ResourceLoader
+
 class SpaController {
     static responseFormats = ['html']
 
+    @Autowired
+    ResourceLoader resourceLoader
+
     /**
-     * Forwards any non-/api request to the SPA's bundled index.html
-     * (which Vite produced into src/main/resources/public/index.html).
-     * Spring Boot auto-serves that file at /; we forward here so client-
-     * side routes like /books/42 survive a browser refresh.
+     * Serves the SPA's bundled index.html (which Vite produced into
+     * src/main/resources/public/index.html and the build copied onto the
+     * classpath). Reading the classpath resource directly is robust across
+     * profiles: a client-side route like /books/42 that hard-reloads to '/'
+     * always gets the SPA shell, which then re-resolves the route in the
+     * browser.
      */
     def index() {
-        forward url: '/index.html'
+        Resource indexHtml = resourceLoader.getResource('classpath:public/index.html')
+        render(text: indexHtml.inputStream.getText('UTF-8'), contentType: 'text/html')
     }
 }
diff --git a/guides/grails-vite-spa/v8/snippets/backend/grails-app/controllers/example/UrlMappings.groovy b/guides/grails-vite-spa/v8/snippets/backend/grails-app/controllers/example/UrlMappings.groovy
index 939288ea6b4..fbddfe01d8a 100644
--- a/guides/grails-vite-spa/v8/snippets/backend/grails-app/controllers/example/UrlMappings.groovy
+++ b/guides/grails-vite-spa/v8/snippets/backend/grails-app/controllers/example/UrlMappings.groovy
@@ -8,6 +8,9 @@ class UrlMappings {
         // a hard reload to any deep link.
         '/' (controller: 'spa', action: 'index')
 
+        // The Vite-built JS/CSS bundle, served same-origin from the classpath.
+        "/assets/$path**"(controller: 'asset', action: 'serve')
+
         // JSON API surface consumed by the Vite/React frontend.
         '/api/books'(resources: 'book')
 
diff --git a/guides/grails-vite-spa/v8/snippets/backend/grails-app/views/book/index.gson b/guides/grails-vite-spa/v8/snippets/backend/grails-app/views/book/index.gson
index fab1f823029..a16a21918ab 100644
--- a/guides/grails-vite-spa/v8/snippets/backend/grails-app/views/book/index.gson
+++ b/guides/grails-vite-spa/v8/snippets/backend/grails-app/views/book/index.gson
@@ -2,10 +2,10 @@ import example.Book
 
 model {
     Iterable<Book> bookList
-    Long           bookCount
+    Integer        bookCount
 }
 
 json {
-    total   bookCount
-    items   bookList.collect { Book b -> g.render(template: 'book', model: [book: b]) }
+    total bookCount
+    items g.render(template: 'book', collection: bookList ?: [], var: 'book')
 }
diff --git a/guides/grails-vite-spa/v8/snippets/backend/src/integration-test/groovy/example/ApiFunctionalSpec.groovy b/guides/grails-vite-spa/v8/snippets/backend/src/integration-test/groovy/example/ApiFunctionalSpec.groovy
new file mode 100644
index 00000000000..842425639cb
--- /dev/null
+++ b/guides/grails-vite-spa/v8/snippets/backend/src/integration-test/groovy/example/ApiFunctionalSpec.groovy
@@ -0,0 +1,130 @@
+package example
+
+import grails.testing.mixin.integration.Integration
+import groovy.json.JsonOutput
+import groovy.json.JsonSlurper
+import org.springframework.beans.factory.annotation.Value
+import spock.lang.Specification
+
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+
+/**
+ * Functional tests over real HTTP with java.net.http.HttpClient.
+ *
+ * Covers the JSON API the SPA consumes, the same-origin layout's CORS
+ * behaviour (there is none - and that is the point of the guide), and the
+ * SPA-serving path. The SPA test also exercises the Vite build + copy
+ * integration: integrationTest's processResources runs frontendBuild and
+ * copies dist/ into public/, so GET / serves the built index.html.
+ *
+ * @Rollback is not used (the endpoints commit); the fixture is seeded and
+ * read back inside withNewTransaction blocks with ISBNs unique per test.
+ */
+@Integration
+class ApiFunctionalSpec extends Specification {
+
+    @Value('${local.server.port}')
+    Integer serverPort
+
+    HttpClient client = HttpClient.newHttpClient()
+
+    private URI uri(String path) { URI.create("http://localhost:${serverPort}${path}") }
+
+    void "GET /api/books returns the paginated JSON envelope"() {
+        given:
+        Book.withNewTransaction {
+            if (!Book.findByIsbn('9780441013593')) {
+                new Book(title: 'Dune', author: 'Frank Herbert', isbn: '9780441013593').save(failOnError: true)
+            }
+        }
+
+        when:
+        HttpResponse<String> resp = client.send(
+                HttpRequest.newBuilder(uri('/api/books')).GET().build(),
+                HttpResponse.BodyHandlers.ofString())
+        Map json = new JsonSlurper().parseText(resp.body()) as Map
+
+        then:
+        resp.statusCode() == 200
+        resp.headers().firstValue('Content-Type').get().contains('application/json')
+        json.containsKey('total')
+        json.items.any { it.isbn == '9780441013593' }
+    }
+
+    void "POST /api/books creates a book and returns 201"() {
+        given:
+        String body = JsonOutput.toJson([title: 'Neuromancer', author: 'William Gibson', isbn: '9780441569595'])
+
+        when:
+        HttpResponse<String> resp = client.send(
+                HttpRequest.newBuilder(uri('/api/books'))
+                        .header('Content-Type', 'application/json')
+                        .POST(HttpRequest.BodyPublishers.ofString(body)).build(),
+                HttpResponse.BodyHandlers.ofString())
+
+        then:
+        resp.statusCode() == 201
+        Book.withNewTransaction { Book.findByIsbn('9780441569595') != null }
+    }
+
+    void "POST /api/books with a malformed isbn returns 422"() {
+        given:
+        String body = JsonOutput.toJson([title: 'Bad', author: 'Nobody', isbn: 'not-an-isbn'])
+
+        when:
+        HttpResponse<String> resp = client.send(
+                HttpRequest.newBuilder(uri('/api/books'))
+                        .header('Content-Type', 'application/json')
+                        .POST(HttpRequest.BodyPublishers.ofString(body)).build(),
+                HttpResponse.BodyHandlers.ofString())
+
+        then:
+        resp.statusCode() == 422
+    }
+
+    void "the API does not emit CORS headers in the same-origin layout"() {
+        when: 'a request carries an Origin header as a cross-origin browser would'
+        HttpResponse<String> resp = client.send(
+                HttpRequest.newBuilder(uri('/api/books'))
+                        .header('Origin', 'http://evil.example.com')
+                        .GET().build(),
+                HttpResponse.BodyHandlers.ofString())
+
+        then: 'no Access-Control-Allow-Origin is returned - grails.cors is not enabled (see the CORS chapter)'
+        resp.statusCode() == 200
+        resp.headers().firstValue('Access-Control-Allow-Origin').isEmpty()
+    }
+
+    void "GET / serves the built SPA shell with hashed asset references"() {
+        when: 'requesting the SPA root (served from the Vite-built index.html)'
+        HttpResponse<String> resp = client.send(
+                HttpRequest.newBuilder(uri('/')).GET().build(),
+                HttpResponse.BodyHandlers.ofString())
+
+        then: 'the built SPA HTML is served from the same origin'
+        resp.statusCode() == 200
+        resp.headers().firstValue('Content-Type').get().contains('text/html')
+        resp.body().contains('<div id="root"></div>')
+        resp.body().contains('/assets/')
+    }
+
+    void "the hashed JS asset the SPA references is served"() {
+        given: 'the asset path Vite emitted into index.html'
+        HttpResponse<String> shell = client.send(
+                HttpRequest.newBuilder(uri('/')).GET().build(),
+                HttpResponse.BodyHandlers.ofString())
+        def m = (shell.body() =~ /src="(\/assets\/[^"]+\.js)"/)
+        assert m.find()
+        String assetPath = m.group(1)
+
+        when:
+        HttpResponse<String> asset = client.send(
+                HttpRequest.newBuilder(uri(assetPath)).GET().build(),
+                HttpResponse.BodyHandlers.ofString())
+
+        then: 'the static bundle is served from the same origin'
+        asset.statusCode() == 200
+    }
+}
diff --git a/guides/grails-vite-spa/v8/snippets/backend/src/integration-test/groovy/example/BookIntegrationSpec.groovy b/guides/grails-vite-spa/v8/snippets/backend/src/integration-test/groovy/example/BookIntegrationSpec.groovy
new file mode 100644
index 00000000000..4c81c4c6eff
--- /dev/null
+++ b/guides/grails-vite-spa/v8/snippets/backend/src/integration-test/groovy/example/BookIntegrationSpec.groovy
@@ -0,0 +1,40 @@
+package example
+
+import grails.gorm.transactions.Rollback
+import grails.testing.mixin.integration.Integration
+import spock.lang.Specification
+
+/**
+ * @Integration boots the full context against the Testcontainers
+ * PostgreSQL database. @Rollback isolates each method.
+ */
+@Integration
+@Rollback
+class BookIntegrationSpec extends Specification {
+
+    void "a book round-trips through GORM"() {
+        given: 'an ISBN distinct from the committed functional-test fixtures'
+        Book saved = new Book(title: 'Dune', author: 'Frank Herbert', isbn: '9789999999016')
+                .save(flush: true, failOnError: true)
+
+        when:
+        Book reloaded = Book.get(saved.id)
+
+        then:
+        reloaded.title == 'Dune'
+        reloaded.author == 'Frank Herbert'
+    }
+
+    void "the unique isbn constraint is enforced at the database layer"() {
+        given:
+        new Book(title: 'First', author: 'A', isbn: '9789999999023').save(flush: true, failOnError: true)
+
+        when:
+        Book dup = new Book(title: 'Second', author: 'B', isbn: '9789999999023')
+        dup.save(flush: true)
+
+        then:
+        dup.hasErrors()
+        Book.countByIsbn('9789999999023') == 1
+    }
+}
diff --git a/guides/grails-vite-spa/v8/snippets/backend/src/test/groovy/example/BookControllerSpec.groovy b/guides/grails-vite-spa/v8/snippets/backend/src/test/groovy/example/BookControllerSpec.groovy
new file mode 100644
index 00000000000..a6622864096
--- /dev/null
+++ b/guides/grails-vite-spa/v8/snippets/backend/src/test/groovy/example/BookControllerSpec.groovy
@@ -0,0 +1,47 @@
+package example
+
+import grails.testing.gorm.DataTest
+import grails.testing.web.controllers.ControllerUnitTest
+import spock.lang.Specification
+
+/**
+ * Controller unit test for the page-size clamp the overridden
+ * listAllResources applies. It reads params.int('max', 25), so the spec
+ * drives it through the controller's GrailsParameterMap (a plain Map has
+ * no int() accessor). No Spring context boots.
+ */
+class BookControllerSpec extends Specification
+        implements ControllerUnitTest<BookController>, DataTest {
+
+    Class[] getDomainClassesToMock() { [Book] }
+
+    void "listAllResources clamps max to 100"() {
+        given:
+        controller.params.max = '500'
+
+        when:
+        controller.listAllResources(controller.params)
+
+        then:
+        controller.params.max == 100
+    }
+
+    void "listAllResources keeps a max within range"() {
+        given:
+        controller.params.max = '10'
+
+        when:
+        controller.listAllResources(controller.params)
+
+        then:
+        controller.params.max == 10
+    }
+
+    void "listAllResources defaults max to 25 when absent"() {
+        when:
+        controller.listAllResources(controller.params)
+
+        then:
+        controller.params.max == 25
+    }
+}
diff --git a/guides/grails-vite-spa/v8/snippets/backend/src/test/groovy/example/BookSpec.groovy b/guides/grails-vite-spa/v8/snippets/backend/src/test/groovy/example/BookSpec.groovy
new file mode 100644
index 00000000000..fc6b1da0987
--- /dev/null
+++ b/guides/grails-vite-spa/v8/snippets/backend/src/test/groovy/example/BookSpec.groovy
@@ -0,0 +1,47 @@
+package example
+
+import grails.testing.gorm.DomainUnitTest
+import spock.lang.Specification
+
+/**
+ * Domain constraint unit tests for Book - no Spring context, no database.
+ */
+class BookSpec extends Specification implements DomainUnitTest<Book> {
+
+    void "a fully populated book validates"() {
+        expect:
+        new Book(title: 'Dune', author: 'Frank Herbert', isbn: '9780441013593').validate()
+    }
+
+    void "title, author and isbn are required"() {
+        when:
+        Book b = new Book()
+
+        then:
+        !b.validate()
+        b.errors.getFieldError('title').code == 'nullable'
+        b.errors.getFieldError('author').code == 'nullable'
+        b.errors.getFieldError('isbn').code == 'nullable'
+    }
+
+    void "isbn must match the ISBN pattern"() {
+        when:
+        Book b = new Book(title: 'X', author: 'Y', isbn: 'not-an-isbn')
+
+        then:
+        !b.validate()
+        b.errors.getFieldError('isbn').code == 'matches.invalid'
+    }
+
+    void "isbn must be unique"() {
+        given:
+        new Book(title: 'First', author: 'A', isbn: '9780441013593').save(flush: true, failOnError: true)
+
+        when:
+        Book dup = new Book(title: 'Second', author: 'B', isbn: '9780441013593')
+
+        then:
+        !dup.validate()
+        dup.errors.getFieldError('isbn').code == 'unique'
+    }
+}