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')
+    }
+}