Add archunit module: ArchUnit for Spring Boot - enforcing layered architecture as a unit test
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
Captured from a real `mvn test` run against the module as committed: three
|
||||
ArchUnit rules (layered architecture, no field injection, no package cycles),
|
||||
all passing, no Spring context started anywhere in the run.
|
||||
|
||||
[INFO] Running com.ankurm.tutorials.junit.archunit.LayeredArchitectureTest
|
||||
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.311 s -- in com.ankurm.tutorials.junit.archunit.LayeredArchitectureTest
|
||||
[INFO] Results:
|
||||
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
|
||||
[INFO] BUILD SUCCESS
|
||||
[INFO] Total time: 3.618 s
|
||||
|
||||
The whole suite -- three architecture rules checked across every compiled class
|
||||
in the module -- finishes in about 1.3 seconds. There is no "Started
|
||||
Application in N seconds" line anywhere in this output, because there is no
|
||||
Application to start: @AnalyzeClasses reads .class files from disk, the same
|
||||
way a decompiler would, and none of these three rules need a single bean to
|
||||
exist at runtime to be checked.
|
||||
@@ -0,0 +1,46 @@
|
||||
Captured from a real `mvn test` run with a temporary class added to the controller
|
||||
package, com.ankurm.tutorials.junit.archunit.controller.BadOrderLookupController,
|
||||
whose constructor took OrderRepository directly instead of going through
|
||||
OrderStatusService. The class was deleted immediately after this run; it exists
|
||||
only in this transcript and in the "before/after" diagram in the published post.
|
||||
|
||||
[INFO] Running com.ankurm.tutorials.junit.archunit.LayeredArchitectureTest
|
||||
[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 1.445 s <<< FAILURE! -- in com.ankurm.tutorials.junit.archunit.LayeredArchitectureTest
|
||||
[ERROR] LayeredArchitectureTest.controllersOnlyCallServicesWhichOnlyCallRepositories -- Time elapsed: 1.402 s <<< FAILURE!
|
||||
java.lang.AssertionError:
|
||||
Architecture Violation [Priority: MEDIUM] - Rule 'Layered architecture considering all dependencies, consisting of
|
||||
layer 'Controller' ('..controller..')
|
||||
layer 'Service' ('..service..')
|
||||
layer 'Repository' ('..repository..')
|
||||
where layer 'Controller' may not be accessed by any layer
|
||||
where layer 'Service' may only be accessed by layers ['Controller']
|
||||
where layer 'Repository' may only be accessed by layers ['Service']' was violated (3 times):
|
||||
Constructor <com.ankurm.tutorials.junit.archunit.controller.BadOrderLookupController.<init>(com.ankurm.tutorials.junit.archunit.repository.OrderRepository)> has parameter of type <com.ankurm.tutorials.junit.archunit.repository.OrderRepository> in (BadOrderLookupController.java:0)
|
||||
Field <com.ankurm.tutorials.junit.archunit.controller.BadOrderLookupController.orderRepository> has type <com.ankurm.tutorials.junit.archunit.repository.OrderRepository> in (BadOrderLookupController.java:0)
|
||||
Method <com.ankurm.tutorials.junit.archunit.controller.BadOrderLookupController.rawStatus(java.lang.String)> calls method <com.ankurm.tutorials.junit.archunit.repository.OrderRepository.findStatus(java.lang.String)> in (BadOrderLookupController.java:25)
|
||||
at com.tngtech.archunit.lang.ArchRule$Assertions.assertNoViolation(ArchRule.java:94)
|
||||
at com.tngtech.archunit.lang.ArchRule$Assertions.check(ArchRule.java:86)
|
||||
at com.tngtech.archunit.library.Architectures$LayeredArchitecture.check(Architectures.java:347)
|
||||
at com.tngtech.archunit.junit.internal.ArchUnitTestDescriptor$ArchUnitRuleDescriptor.execute(ArchUnitTestDescriptor.java:168)
|
||||
at com.tngtech.archunit.junit.internal.ArchUnitTestDescriptor$ArchUnitRuleDescriptor.execute(ArchUnitTestDescriptor.java:151)
|
||||
at java.base/java.util.ArrayList.forEach(ArrayList.java:1604)
|
||||
at java.base/java.util.ArrayList.forEach(ArrayList.java:1604)
|
||||
|
||||
[INFO] Results:
|
||||
[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0
|
||||
[INFO] BUILD FAILURE
|
||||
|
||||
Three things worth noticing in this message:
|
||||
|
||||
1. ArchUnit reports all THREE offending facts about the one bad class in a single
|
||||
violation group: the constructor parameter, the field it gets assigned to, and
|
||||
the actual method call that crosses the layer boundary. One bad wire gets
|
||||
reported three ways because ArchUnit checks dependency *existence*, not just
|
||||
call sites -- the field and the constructor parameter are both "access" on
|
||||
their own, even on a line that never executes.
|
||||
2. The file:line at the end of each fact (BadOrderLookupController.java:25) points
|
||||
at real source positions ArchUnit extracted from the compiled .class file's
|
||||
debug info, not from parsing the .java file.
|
||||
3. "Priority: MEDIUM" is the rule's declared priority, not a severity the test
|
||||
assigns at failure time -- layeredArchitecture() rules default to MEDIUM, and
|
||||
a build can be configured to fail the whole run only above a given priority.
|
||||
@@ -0,0 +1,33 @@
|
||||
Captured from a real `mvn test -Dtest=FreezingArchRuleTest` run with no archunit_store/
|
||||
directory present yet and freeze.store.default.allowStoreCreation=true in
|
||||
src/test/resources/archunit.properties.
|
||||
|
||||
[INFO] Running com.ankurm.tutorials.junit.archunit.FreezingArchRuleTest
|
||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.268 s -- in com.ankurm.tutorials.junit.archunit.FreezingArchRuleTest
|
||||
[INFO] Results:
|
||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
|
||||
[INFO] BUILD SUCCESS
|
||||
|
||||
The build is green even though LegacyOrderExporter.export() genuinely declares
|
||||
`throws Exception` -- the one thing noMethods().should().declareThrowableOfType(Exception.class)
|
||||
exists to ban. That's the point of freeze(): on this first run there was no stored
|
||||
baseline yet, so FreezingArchRule recorded every violation it found right now as
|
||||
"already known" and created the store on disk instead of failing. The store this
|
||||
run produced, committed alongside this module:
|
||||
|
||||
archunit_store/stored.rules:
|
||||
#
|
||||
#Sun Oct 04 02:49:04 IST 2026
|
||||
no\ methods\ should\ declare\ throwable\ of\ type\ java.lang.Exception,\ because\ a\ specific\
|
||||
exception\ type\ documents\ what\ a\ caller\ actually\ has\ to\ handle;\ `throws\ Exception`\
|
||||
documents\ nothing=9e504a56-cf50-43d0-a977-82f8a097091b
|
||||
|
||||
archunit_store/9e504a56-cf50-43d0-a977-82f8a097091b:
|
||||
Method <com.ankurm.tutorials.junit.archunit.service.LegacyOrderExporter.export()> does
|
||||
declare throwable of type java.lang.Exception in (LegacyOrderExporter.java:14)
|
||||
|
||||
stored.rules maps one line per frozen rule (keyed by the rule's full description) to a
|
||||
UUID; that UUID is also a filename holding the exact violation text frozen under it, in
|
||||
the same format the live failure message uses. Delete that second file and the rule
|
||||
forgets the violation was ever acceptable -- which is exactly how you make a frozen
|
||||
violation start failing again once someone finally fixes it.
|
||||
@@ -0,0 +1,32 @@
|
||||
Captured from a real `mvn test -Dtest=FreezingArchRuleTest` run, taken immediately after
|
||||
the first run (docs/output/02-freeze-run1-baseline-captured.txt) had already frozen
|
||||
LegacyOrderExporter.export() into archunit_store/. Between the two runs: a second class,
|
||||
NewOrderBatchJob, was added with a method making the exact same mistake
|
||||
(`run() throws Exception`), and freeze.store.default.allowStoreCreation was flipped to
|
||||
false in archunit.properties -- the normal CI setting once a baseline exists, so nobody
|
||||
can accidentally create a fresh, empty baseline that silently un-freezes everything.
|
||||
|
||||
[INFO] Running com.ankurm.tutorials.junit.archunit.FreezingArchRuleTest
|
||||
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 1.156 s <<< FAILURE! -- in com.ankurm.tutorials.junit.archunit.FreezingArchRuleTest
|
||||
[ERROR] FreezingArchRuleTest.noNewGenericExceptionsDeclared -- Time elapsed: 1.145 s <<< FAILURE!
|
||||
java.lang.AssertionError:
|
||||
Architecture Violation [Priority: MEDIUM] - Rule 'no methods should declare throwable of type java.lang.Exception, because a specific exception type documents what a caller actually has to handle; `throws Exception` documents nothing' was violated (1 times):
|
||||
Method <com.ankurm.tutorials.junit.archunit.service.NewOrderBatchJob.run()> does declare throwable of type java.lang.Exception in (NewOrderBatchJob.java:15)
|
||||
at com.tngtech.archunit.lang.ArchRule$Assertions.assertNoViolation(ArchRule.java:94)
|
||||
at com.tngtech.archunit.lang.ArchRule$Assertions.check(ArchRule.java:86)
|
||||
at com.tngtech.archunit.library.freeze.FreezingArchRule.check(FreezingArchRule.java:97)
|
||||
[INFO] Results:
|
||||
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
|
||||
[INFO] BUILD FAILURE
|
||||
|
||||
This is the entire point of freeze(): the violation this test reports is ONLY
|
||||
NewOrderBatchJob.run(). LegacyOrderExporter.export() still declares `throws Exception`
|
||||
too -- nothing about it changed -- and it is not mentioned anywhere in this failure,
|
||||
because it was already recorded as accepted in archunit_store/ before this run started.
|
||||
A rule that would otherwise have to fail against every pre-existing offender at once
|
||||
instead fails against exactly one thing: the offender that showed up after the team
|
||||
agreed to stop adding new ones.
|
||||
|
||||
NewOrderBatchJob was deleted immediately after this run; it was never meant to survive
|
||||
past this transcript. docs/output/00- and docs/output/02- show the suite back to green
|
||||
with it removed and the frozen baseline still in place.
|
||||
@@ -0,0 +1,17 @@
|
||||
Captured from a real `mvn test` run against the module exactly as committed: all four
|
||||
rules across both test classes, including the frozen rule with its one accepted legacy
|
||||
violation still on record in archunit_store/.
|
||||
|
||||
[INFO] Running com.ankurm.tutorials.junit.archunit.LayeredArchitectureTest
|
||||
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.323 s -- in com.ankurm.tutorials.junit.archunit.LayeredArchitectureTest
|
||||
[INFO] Running com.ankurm.tutorials.junit.archunit.FreezingArchRuleTest
|
||||
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.051 s -- in com.ankurm.tutorials.junit.archunit.FreezingArchRuleTest
|
||||
[INFO] Results:
|
||||
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
|
||||
[INFO] BUILD SUCCESS
|
||||
[INFO] Total time: 3.847 s
|
||||
|
||||
Four rules, four green results, under four seconds total, with zero application
|
||||
classes started. This is the number to compare against docs/output/01- and
|
||||
docs/output/03- -- both of those are the same suite with one extra class added on
|
||||
purpose, each producing exactly one new failure and nothing else.
|
||||
Reference in New Issue
Block a user