Testing rules
Checking with a full build every time is slow. Build a small model yourself inside the rules module and run only the rules, and it is done in a second or two. No project, no generation run and no database needed.
Building the engine
Build the engine with the same combination as the project - the bundle + our ruleset.
private RuleEngine engine(SourceCode.TemplateSet set) {
return RuleCatalog.standard().engineFor("hexagonal-springboot") // the bundle that matches architecture in build.gradle
.register(new PetstoreRules())
.templateSet(set); // V3 (Java) or KOTLIN
}
private RuleEngine engine() {
return engine(SourceCode.TemplateSet.V3);
}
| Project | Bundle name | TemplateSet |
|---|---|---|
layered + java | jpa-springboot-v3 | V3 |
ddd + java | ddd-springboot | V3 |
hexagonal + java | hexagonal-springboot | V3 |
hexagonal + kotlin | hexagonal-springboot-kt | KOTLIN |
Building the model - a YAML line as it is
// Customer:
// meta: entity @root @expose(20)
// fields:
// customerId: long @id @sequence
// firstName: string(50)! @search(like)
// email: string(100) @email @pii
private EntitySpec customer() {
return new EntitySpec("Customer")
.domain("petstore", "Petstore")
.meta("root", "")
.meta("expose", "20")
.pkg("entity", PKG + ".domain.model")
.pkg("dto", PKG + ".application.dto")
.pkg("srchdto", PKG + ".application.dto")
.field("customerId", "long @id @sequence")
.field("firstName", "string(50)! @search(like)")
.field("email", "string(100) @email @pii");
}
| Code | YAML |
|---|---|
new EntitySpec("Customer").domain("petstore", "Petstore") | Customer: (domain petstore) |
.meta("root", ""), .meta("topic", "petstore.order") | meta: entity @root @topic(petstore.order) |
.field("email", "string(100) @email @pii") | the field line as it is |
.pkg("dto", "…") | a package from namespace |
.config("ps.maxPageSize", "100") | config: in env.yml |
.query("summarizeByStatus", "List<Object[]>", "SELECT …"), .nativeQuery(...) | a method in repository.yml |
.addCommand("dispatch", Collections.emptyList()) | commands: |
.containedIn("PurchaseOrder") | a member that belongs to a root |
.entitySplitForBehavior("PurchaseOrder") | a root split into two files - for testing the behavior target |
new DomainSpec("petstore", "Petstore").entity(e) | a domain (for domain rules) |
new EnumSpec("OrderStatus").constant("PLACED", "PLC", "Placed") | enum.yml |
Rendering and checking
@Test
void aRootIsAnAggregateRootAndItsIdAnIdentity() {
String out = engine().run(customer(), "entity").render();
assertTrue(out.contains("@AggregateRoot"), out);
assertTrue(out.contains("import org.jmolecules.ddd.annotation.AggregateRoot;"), out);
assertTrue(out.contains("@Identity"), out);
}
@Test
void aPiiFieldIsWriteOnly() {
String out = engine().run(customer(), "dto").render();
assertTrue(out.contains("@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)"), out);
}
@Test
void aLikeSearchNeedsTwoCharacters() {
String out = engine().run(customer(), "srchdto").render();
assertTrue(out.contains("@Size(min = 2)"), out);
}
run(model, "target").render() returns the whole file that would be generated, as a string. If you
are curious about the result, just look at it with System.out.println(out). Pass out as the failure
message and you see the file when a test fails.
New files - the package and the slots
@Test
void eachEventGetsATopic() {
DomainSpec petstore = new DomainSpec("petstore", "Petstore").entity(purchaseOrder());
assertEquals(1, new PetstoreRules().artifactsFor(petstore).size());
// The generator hands the rule the package artifactsFor chose; a test says it.
SourceCode code = engine().packageName(PKG + ".domain.event").run(petstore, "eventTopics");
String out = code.render();
assertTrue(out.contains("PURCHASE_ORDER_SHIPPED = \"petstore.order.shipped\";"), out);
// An unfilled ${slot} renders as an empty line, not an error: ask for them.
assertTrue(code.unfilledSlots().isEmpty(), String.valueOf(code.unfilledSlots()));
}
@Test
void eventTopicsInKotlin() {
DomainSpec petstore = new DomainSpec("petstore", "Petstore").entity(purchaseOrder());
SourceCode code = engine(SourceCode.TemplateSet.KOTLIN).packageName(PKG + ".domain.event")
.run(petstore, "eventTopics");
assertTrue(code.render().contains("const val PURCHASE_ORDER_SHIPPED = \"petstore.order.shipped\""));
assertTrue(code.unfilledSlots().isEmpty(), String.valueOf(code.unfilledSlots()));
}
@Test
void noTopicNoFile() {
assertTrue(new PetstoreRules().artifactsFor(new DomainSpec("petstore", "Petstore").entity(customer())).isEmpty());
}
The skeletons are in the rules module's src/main/resources, so tests find them as they are. Put a
skeleton used only in tests in src/test/resources/elasticore-template/v3/.
Model checks
@AfterEach
void forget() {
ModelCheck.reset();
}
@Test
void selectStarIsRefused() {
EntitySpec order = purchaseOrder()
.nativeQuery("everything", "List<Object[]>", "SELECT * FROM ps_purchase_order");
engine().run(order, "repository");
assertEquals(1, ModelCheck.failures().size(), String.valueOf(ModelCheck.failures()));
}
Only fail collects in ModelCheck.failures(). warn goes to the console, so to test a warning,
swap System.out for a moment and capture it.
Comparing with the default rules
"What did our rule take out of the default output" is checked against a render without the ruleset:
@Test
void dynamicUpdateIsTakenOff() {
assertFalse(engine().run(customer(), "entity").render().contains("@DynamicUpdate"));
String builtIn = RuleCatalog.standard().engineFor("hexagonal-springboot")
.templateSet(SourceCode.TemplateSet.V3).run(customer(), "entity").render();
assertTrue(builtIn.contains("@DynamicUpdate"), builtIn);
}
./gradlew :ps-rules:test
The pet store's PetstoreRulesTest has 21 tests and finishes in under 2 seconds.
Checking in the application
Rule tests check "what is generated". Whether "what is generated works" is checked with application tests. Write down what the rules promise:
/** What the team rules promise, on the application they generated. */
@SpringBootTest
@AutoConfigureMockMvc // Boot 4: org.springframework.boot.webmvc.test.autoconfigure
class PetstoreRulesAppliedTest {
@Autowired MockMvc mvc;
@Test
void everyRootSaysItsRole() {
for (Class<?> root : new Class<?>[]{Category.class, Product.class, Item.class, Customer.class}) {
assertTrue(root.isAnnotationPresent(AggregateRoot.class), root.getSimpleName());
}
}
@Test
void aOneLetterSearchIsRefused() throws Exception {
mvc.perform(post("/api/petstore/category/search").contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"a\"}"))
.andExpect(status().isBadRequest());
mvc.perform(post("/api/petstore/category/search").contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"fi\"}"))
.andExpect(status().isOk());
}
@Test
void aCategoryCannotBeDeleted() throws Exception {
mvc.perform(delete("/api/petstore/category/1000")).andExpect(status().isMethodNotAllowed());
}
}
./gradlew :test --tests '*PetstoreRulesAppliedTest' # 3 tests, 0 failures
What was actually checked in the pet store:
| Rule | Request | Result |
|---|---|---|
ps-pii | POST /api/petstore/customer (with email) | 200, no email in the response |
ps-like-min | POST /api/petstore/category/search {"name":"a"} | 400 {"code":"invalid","fieldErrors":[{"field":"name","code":"Size",…}]} |
ps-like-min | the same with {"name":"fi"} | 200 |
ps-no-delete | DELETE /api/petstore/category/1000, POST /api/petstore/category/delete | 405 |
The 400 from
ps-like-minneeds the search endpoint to take its body with@Valid- controllers generated from the release after rc1 do. A controller generated by rc1 has no@Validonsearch(@RequestBody …), so the rule's@Sizeis not checked (200). A controller is a developer's file, so add@Validyourself to controllers you have already changed.