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:45 UTC
(grails-static-website) 08/09: Make grails-multi-module v8 match its dev-reload prose and add test snippets
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')
+ }
+}