Documentation
¶
Overview ¶
Package archtest is the REPORT stage of the pipeline: it turns the violations a rule reported into the message a human reads, and a list of them into a pass flag and one report.
It is the only place in the library where a message is built. A violation carries data — the offending file, the pattern it broke, the cycle it sits in, the mood the rule was written in — and never a sentence about it, so that phrasing, numbering and color are decided once, for every rule family, here. An adapter to a test framework asks ResultFactory for a Result and prints it; it does not format, and neither does a domain module.
Two doors, and the one a test should reach for is the assert helper. It checks the rule and reports what it found in one call, with nothing to register and nothing to configure — AssertPasses for one rule, in any framework, and AssertAllPass for a suite of them, one named subtest per rule on the standard library's own handle:
archtest.AssertPasses(t, rule, nil) archtest.AssertAllPass(t, rules, nil)
The other door is the two factories the helpers are written over, for a caller assembling a report of its own shape — a summary line, a file of its own, a framework this package has never heard of:
violation := archtest.NewViolationFactory(nil).Message(oneViolation)
result := archtest.NewResultFactory(nil).Result(everyViolation)
if !result.Passed {
t.Error(result.Message)
}
Every message has one shape — the subject that disagreed with the rule, the requirement it broke in the words the rule was written in, and what was found instead — because a reader who has learned to read one rule family's failure has then learned all of them. Palette names the parts of that shape rather than the rule families, for the same reason.
A nil *MessageOptions means the defaults everywhere: plain text, every violation listed. Color is opted into, because test output is read from a CI log as often as from a terminal.
The package is named archtest and not testing, which is the name the layout table and the sibling ports use. A package called testing shadows the stdlib testing in exactly the file that needs both — a test — and forces every user to alias one of the two imports. It is the answer AGENTS.md's Go-specifics section reaches for, and the same answer common/archerror already took for `error`.
Index ¶
- func AssertAllPass(t TestingRunner, rules map[string]fluentapi.Checkable, options *AssertOptions)
- func AssertPasses(t TestingT, rule fluentapi.Checkable, options *AssertOptions)
- type AssertOptions
- type Color
- type MessageOptions
- type Palette
- type Result
- type ResultFactory
- type TestingRunner
- type TestingT
- type ViolationFactory
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func AssertAllPass ¶
func AssertAllPass(t TestingRunner, rules map[string]fluentapi.Checkable, options *AssertOptions)
AssertAllPass asserts a whole suite of rules at once, each in its own named subtest. It is the path a Go test suite should reach for as soon as it has more than one rule to keep:
func TestTheArchitectureHolds(t *testing.T) {
archunit.AssertAllPass(t, map[string]archunit.Checkable{
"the api does not touch the database": archunit.ProjectFiles(nil).
InFolder("internal/api/**").
ShouldNot().
DependOnFiles().
InFolder("internal/db/**"),
"no file depends on another in a circle": archunit.ProjectFiles(nil).Should().HaveNoCycles(),
}, nil)
}
Every rule is asserted through AssertPasses, so there is no rule logic here and no second idea of how a failure reads: the check is the rule's, the report is the report layer's, and what this adds is the shape Go's own testing package gives a result. One pass or fail line per rule, each rule selectable on its own with `go test -run 'TestTheArchitectureHolds/the_api_does_not_touch_the_database'`, and a failure filed under the name its author gave it:
--- FAIL: TestTheArchitectureHolds (0.62s)
--- FAIL: TestTheArchitectureHolds/the_api_does_not_touch_the_database (0.59s)
project files, path without filename matches "internal/api/**", should not, depend on files, path without filename matches "internal/db/**"
1 violation:
1. internal/api/handler.go: should not, depend on files, path without filename matches "internal/db/**"; it depends on internal/db/conn.go
The suite is a map from the name to the rule, and its rules are asserted in the sorted order of their names. A map is what keeps a name and the rule it belongs to together at the call site, and what makes two rules under one name impossible; sorting is what makes a suite's output the same on every run, since Go randomizes map iteration on purpose.
A failure inside a subtest is still located at the user's own AssertAllPass line, exactly as a single assertion's is, rather than at a line of this file: the standard library, asked to blame a frame inside a subtest and finding every frame of it marked as a helper, walks out into the stack the subtest was created from, where this helper's own frames are marked too. The rule's own sentence heads the report underneath, so the name its author gave it and the rule it stands for are both on screen.
A suite with no rules in it is one failure and no subtests, in those words, rather than a run that quietly asserted nothing. A nil rule under a name is reported by AssertPasses inside that name's subtest, so a suite cannot lose a rule in silence either.
The options bag is the whole suite's: how every rule is run, and how each failure is written. A nil *AssertOptions means the defaults, so AssertAllPass(t, rules, nil) is the ordinary call, and the one rule that needs knobs of its own — a selection that is legitimately empty, a report that has to be cut short — is asserted beside the suite with its own AssertPasses call.
func AssertPasses ¶
func AssertPasses(t TestingT, rule fluentapi.Checkable, options *AssertOptions)
AssertPasses checks the rule and fails the test with the formatted violations when it does not hold. It is the library's test-framework glue, and the call an architecture test about one rule ends in — AssertAllPass is the same assertion over a suite of them, and is written over this one:
func TestTheApiDoesNotTouchTheDatabase(t *testing.T) {
rule := archunit.ProjectFiles(nil).
InFolder("internal/api/**").
ShouldNot().
DependOnFiles().
InFolder("internal/db/**")
archunit.AssertPasses(t, rule, nil)
}
A rule that holds reports nothing at all: a passing test that printed its own success would bury the one that did not. A rule that does not hold is one t.Error, carrying the rule as the user wrote it and then the whole report — the count, and the violations numbered from one:
project files, path without filename matches "internal/api/**", should not, depend on files, path without filename matches "internal/db/**" 1 violation: 1. internal/api/handler.go: should not, depend on files, path without filename matches "internal/db/**"; it depends on internal/db/conn.go
The rule's own sentence is the first line because a test that asserts several rules, or asserts one in a loop, otherwise reports a list of files with nothing saying which sentence they broke. It is left out when the rule cannot describe itself, which no rule the library builds does.
A nil *AssertOptions means the defaults, which is what makes this helper the documented fallback: it needs no configuration, no registration and no framework of its own. AssertOptions is how a suite asks for colored output, a violation limit, or a check that allows an empty selection.
Three things it does not do. It does not stop the test — Error and not Fatal, so that a suite reports every rule it checked rather than the first that broke. It does not return the violations: a caller who wants the data rather than the failure calls Check, which is the layer below and is public for exactly that. And it never raises for a rule failure, because a failing rule is a Violation in a list; the only failure it reports outside the report is a technical one, and it says so in those words.
t is required: with nowhere to report to there is nothing this helper can do, and a nil handle is a mistake in the test rather than something to pass over in silence. A nil rule, on the other hand, is reported as an ordinary failure — the test has somewhere to say so.
Types ¶
type AssertOptions ¶
type AssertOptions struct {
// Check is how the rule is run, and reaches Checkable.Check unchanged: whether an empty selection is
// allowed, where the progress log goes, which build tags the project is analyzed under. The zero value
// is what rule.Check(nil) would have done.
Check fluentapi.CheckOptions
// Message is how a rule that does not hold is reported: the palette the report is painted in, and how
// many violations it lists. The zero value is plain text and every violation, which is the right default
// for output that is read from a CI log as often as from a terminal.
Message MessageOptions
}
AssertOptions is the one options bag the assert helpers take: how the rule is run, and how the failure is written. AssertAllPass takes the same bag for a whole suite of rules, which is why nothing in it is about one rule in particular.
AssertPasses spans both halves of the pipeline's tail — it checks and then it reports — so it needs the knobs of both, and it holds the two existing bags rather than re-declaring their fields. A flat bag with `AllowEmptyTests` and `MaxViolations` side by side would read more briefly and would be a second place where every knob the library ever grows has to be added, in step, forever.
It is a struct with a nil-means-defaults contract, like the two bags inside it, and every default is a zero value — a quiet, strict check reported as plain text with every violation listed — so a nil bag, the zero bag and an explicitly empty one all describe the same assertion. That is why the ordinary call is AssertPasses(t, rule, nil). Read a nil bag through WithDefaults rather than reaching for a field.
func (*AssertOptions) WithDefaults ¶
func (o *AssertOptions) WithDefaults() AssertOptions
WithDefaults returns the options an assertion should actually run with: a copy of the receiver, or the defaults when the receiver is nil.
It resolves through each inner bag's own WithDefaults rather than copying the fields, so that a default added to CheckOptions or MessageOptions later is honored here without this file being touched — and so that the check options arrive with their slices already cloned, as every other caller of them gets them.
type Color ¶
type Color uint8
Color is a terminal color the report paints one part of a message in: the offender, the rule it broke, the count at the top.
It is a closed set of names rather than an escape sequence a caller hands in. Report policy is this layer's, so a report that is assembled in one place can be changed in one place — and a caller able to pass an arbitrary escape sequence could put a cursor move, or a clear-screen, into somebody's test output.
ColorNone is the zero value and paints nothing, so the zero Palette is a plain-text report and color is opted into rather than out of. That is worth more than it looks: test output is read at least as often from a CI log, where an escape sequence is noise, as from a terminal.
const ( // ColorNone paints nothing at all: Paint hands the text back exactly as it came in. It is the zero // value, and what every field of the zero Palette is. ColorNone Color = iota // ColorRed is the failure color: the count at the top of a failing report, and what an offender was // found to actually do. ColorRed // ColorGreen is the pass color: the one line a report of a rule that holds consists of. ColorGreen // ColorYellow is the requirement color: the rule an offender broke, in the words it was written in. ColorYellow // ColorBlue is for a palette of a caller's own; the default palette does not use it. ColorBlue // ColorMagenta is for a palette of a caller's own; the default palette does not use it. ColorMagenta // ColorCyan is the subject color: the file, or the cycle, a reader has to go and look at. ColorCyan // ColorGray is the hint color: the sentence that explains a failure rather than reporting it. ColorGray )
The colors a report can be painted in: the eight of the ANSI basic set that are legible on a light and on a dark background, which rules out black and white.
func (Color) Paint ¶
Paint returns text wrapped in this color's escape sequence, ready to be written to a terminal, and returns it unchanged when there is nothing to paint: ColorNone, an undeclared color, or empty text.
It is the only place in the library that emits an escape sequence. Everything else names a Color and asks for it here, which is what keeps a plain report and a colored one the same message.
func (Color) String ¶
String names the color as a report and a test failure spell it: "red", "gray", "none".
It is deliberately the name and not the escape sequence. A Color reaching a `%v` by accident — in a test's own failure message, in a log line — would otherwise print as an invisible control sequence, and the reader would be told nothing at all.
type MessageOptions ¶
type MessageOptions struct {
// Palette is which color each part of a message is painted in. The zero Palette paints nothing, so
// the default is plain text: an escape sequence in a CI log is noise, and the caller who wants color
// is the one who knows whether a terminal is there. DefaultPalette is the palette to ask for.
Palette Palette
// MaxViolations is how many violations a report lists before it says how many it left out. Zero, and
// anything below it, means every violation.
//
// A rule that a repository has never been held to can report hundreds of files, and a test failure
// that scrolls a terminal is one nobody reads the top of. The cut is never silent — the report says
// how many were left out and which knob did it — because a truncated list that looks complete is
// worse than a long one.
MaxViolations int
}
MessageOptions is the one options bag this layer takes: everything about how a report is written, as opposed to what it reports.
It is a struct with a nil-means-defaults contract, like fluentapi.CheckOptions, and every default is a zero value — a plain-text report that lists every violation — so a nil bag, the zero bag and an explicitly empty one all describe the same report. Read a nil bag through WithDefaults rather than reaching for a field.
func (*MessageOptions) WithDefaults ¶
func (o *MessageOptions) WithDefaults() MessageOptions
WithDefaults returns the options a report should actually be written with: a copy of the receiver, or the defaults when the receiver is nil.
Both factories start here, so the nil-means-defaults contract is honored in one place instead of being re-derived as a nil check per field, and a default that is not a zero value can be added later without touching either of them. There is nothing to clone: a Palette is six colors and a limit is an int, so the copy shares nothing with the caller's own bag.
type Palette ¶
type Palette struct {
// Failure paints the count at the top of a failing report — `3 violations:`.
Failure Color
// Pass paints the one line a report of a rule that holds consists of.
Pass Color
// Subject paints the thing that disagreed with the rule: the offending file, or the files a cycle
// runs through. It is what a reader has to go and open, so it is the piece worth finding first.
Subject Color
// Requirement paints the rule the subject broke, in the words it was written in: the mood, the
// predicate and the patterns the user typed.
Requirement Color
// Finding paints what was found instead of the rule holding — the files an import reached, the
// absence of one — which is the offense itself under the negated mood.
Finding Color
// Hint paints a sentence that explains a failure rather than reporting it: the note that an empty
// rule is a violation, the note that a list was cut short. A reader who already knows can skip it,
// which is why it is the quietest color in the default palette.
Hint Color
}
Palette is which color each part of a report is painted in: one field per role a piece of a message plays, rather than one per violation kind.
Roles rather than kinds is the whole design. Every message this layer builds is the same sentence — this subject broke this requirement, and here is what was found instead — so a reader who has learned that the cyan word is the file to go and look at has learned it for every rule family the library ever grows, and a rule family that lands later needs no color of its own.
The zero Palette paints nothing, because every field of it is ColorNone: a report is plain text unless a caller asks for DefaultPalette, or fills in the roles it cares about. A nil *MessageOptions therefore means plain text, which is the right default for a CI log.
func DefaultPalette ¶
func DefaultPalette() Palette
DefaultPalette is the palette a caller who wants color and does not want to choose gets: the failing count and what was found in red, a rule that holds in green, the offender in cyan, the requirement in yellow and the explanatory notes in gray.
It is a function rather than a package-level variable so that a caller can neither change the library's idea of a default report nor be surprised by another caller having done so. Fill in a Palette of your own to depart from it; the zero value of any field is ColorNone, so a partial palette is a partially colored report rather than a broken one.
type Result ¶
type Result struct {
// Passed is whether the rule holds: true exactly when there were no violations. It is derived from
// the list rather than tracked beside it, which is why nothing in the library returns a boolean
// alongside a []Violation.
Passed bool
// Message is the report: one line naming how many violations there are and then one numbered line
// per violation, or the pass message. It is complete and ready to print — already colored if the
// options asked for that — and it never ends in a newline, because the caller's own t.Error, log line
// or diff already decides how the last line ends.
Message string
}
Result is a whole rule's outcome as a test framework needs it: whether the rule holds, and the one message to print when it does not.
It is the shape of every ArchUnit port's report — a pass flag plus a message — and the seam an adapter works against. An adapter reads these two fields and prints; it never assembles a message of its own, because then the library would phrase failures in as many ways as it has adapters.
The violations themselves are deliberately not here. A caller who wants the data rather than the prose already has it: Check returned it, and this layer is what turns it into words.
type ResultFactory ¶
type ResultFactory struct {
// contains filtered or unexported fields
}
ResultFactory shapes the violations a rule reported into a Result: the collection of constructors an adapter to a test framework goes through.
It is where numbering, ordering and the count at the top are decided, and it phrases each violation through a ViolationFactory rather than knowing any violation type itself. That split is the reason a new rule family costs this layer one case in one type switch and nothing else.
It is immutable and cheap: the options and the violation factory built from them. Build one per report, or keep one for a suite.
func NewResultFactory ¶
func NewResultFactory(options *MessageOptions) ResultFactory
NewResultFactory returns the factory that shapes results under these options. A nil *MessageOptions means the defaults, so NewResultFactory(nil) is the ordinary call: plain text, every violation listed.
func (ResultFactory) Result ¶
func (f ResultFactory) Result(violations []kernel.Violation) Result
Result is what a rule's violations read as: the pass flag, and the report.
An empty or nil list is the pass, because an empty result is what a rule that holds returns — there is no separate boolean anywhere in the library to disagree with the list. Anything else is a failure, and reads as the count and then the violations, numbered from one in the order the rule found them:
2 violations: 1. common/matching/filter.go: should, filename matches "regex_factory.go"; it does not 2. common/matching/match_target.go: should, filename matches "regex_factory.go"; it does not
The count comes first because it is the number a reader decides what to do next by, and it is the whole count even when MaxViolations lists fewer: a report that cut its list short says so on its own line, naming how many it left out and the knob that did it, so a truncated report can never be mistaken for a complete one.
type TestingRunner ¶
type TestingRunner interface {
TestingT
// Helper marks the calling frame as a helper, so that a failure is filed against the user's own
// AssertAllPass line rather than against a line of this file — both the one failure this helper reports
// itself, a suite with no rules in it, and the ones its subtests report through AssertPasses.
Helper()
// Run runs f as a named subtest and reports whether it passed. The signature is the stdlib's, so that
// *testing.T satisfies this interface without an adapter. The boolean is deliberately not read: a rule
// that does not hold has already reported itself, and the rules after it are asserted either way, the
// same way AssertPasses reports through Error rather than a fatal call.
Run(name string, f func(t *testing.T)) bool
}
TestingRunner is the standard library's own test handle, as the suite helper needs it: the method that records a failure, the mark that says a frame is a helper, and Run — the subtest, which is how Go's testing package gives a result its shape.
It is deliberately the one interface in this layer that is not framework-agnostic. Run's argument is a func(*testing.T), so nothing but *testing.T satisfies it — not *testing.B, whose Run takes a *testing.B, and not a third-party handle unless it is built on the stdlib's. That is the trade the two assert helpers split between them: AssertPasses asks for the one method every framework has and works anywhere, and this one asks for the standard library so that it can hand back what only the standard library can do.
Helper() is required here rather than asked for at the call, which is how AssertPasses treats it. A handle with Run(string, func(*testing.T)) is the stdlib's handle, and the stdlib's handle has Helper — an optional interface for a method that cannot be missing would be a branch no test could reach.
type TestingT ¶
type TestingT interface {
// Error records a failure and lets the test continue, which is Go's non-fatal assertion and what an
// architecture rule wants: a suite that checks several rules should report all of them in one run, not
// stop at the first. Its signature is the stdlib's, variadic and untyped, so that *testing.T satisfies
// this interface without an adapter — AssertPasses always passes it exactly one string.
Error(args ...any)
}
TestingT is the part of a test framework's handle AssertPasses needs: one method, the one that records a failure and lets the test carry on. TestingRunner is this interface plus what a suite of rules asks for on top, and nothing else in the library asks a framework for anything.
It is the smallest interface that can report anything, and that is the whole point of it. *testing.T and *testing.B satisfy it, so does stdlib testing.TB, and so does every third-party framework's handle that has ever called itself a drop-in for them — Ginkgo's GinkgoT(), a gocheck *check.C, a mock a user writes to test their own suite helper. There is nothing to register and nothing to configure: a framework works here by already having the method every framework has.
Helper() is deliberately not part of it. AssertPasses calls it when the handle has it, so a failure is reported against the user's own line, and a framework without it gets the report all the same rather than being locked out over a convenience.
type ViolationFactory ¶
type ViolationFactory struct {
// contains filtered or unexported fields
}
ViolationFactory phrases one violation: the collection of constructors that turns the data a rule reported into the sentence a reader gets.
It knows the library's own violation types by sight, because a message is built from a violation's fields — the file, the compiled pattern, the mood, the dependencies actually found — and each family has its own. What it does not recognize is phrased from ViolationKind and whatever the violation can say about itself, so a rule family in a module written later, or a Checkable of a user's own, still reports something a human can read while step 8 of AGENTS.md's "Adding a new rule" is outstanding.
It is immutable and cheap: a palette and nothing else. Build one per report, or keep one for a suite.
func NewViolationFactory ¶
func NewViolationFactory(options *MessageOptions) ViolationFactory
NewViolationFactory returns the factory that phrases violations under these options. A nil *MessageOptions means the defaults, so NewViolationFactory(nil) is the ordinary call and gives plain text.
func (ViolationFactory) Message ¶
func (f ViolationFactory) Message(violation kernel.Violation) string
Message is the sentence one violation reads as: what disagreed with the rule, the requirement it broke in the words the rule was written in, and what was found instead.
common/matching/filter.go: should, filename matches "regex_factory.go"; it does not files/api/handler.go: should not, depend on files, path without filename matches "files/db"; it depends on files/db/conn.go files/domain/order.go: should not, depend on external modules, path matches "*.*/**"; it depends on gorm.io/gorm common/a.go: should, have no cycles; it depends on itself through common/a.go -> common/b.go -> common/a.go layer "db": may not depend on layers "api"; it depends on api through db/conn.go -> api/handler.go component "internal/db": should not, be in zone of pain; it is, at abstractness 0 and instability 0 internal/api/handler.go: should, be below 400; it is not, at lines of code 900 internal/api.Handler: should, satisfy "be at most 10 methods wide"; it does not, at method count 40 slice "api": should not, contain dependency "db"; it depends on db through api/handler.go -> db/conn.go slice "api": should, adhere to diagram; it depends on db, which the diagram does not draw through api/a.go -> db/b.go component "cache": should, adhere to diagram; the project has no slice for it no files matched: path without filename matches "common/renamed"; an empty rule would hold forever, ...
The requirement is always rendered as the rule stated it, never as its negation — `should not, filename matches "*_test.go"` and not "filename does not match" — which is what keeps assertion.Mood.Holds the one place in the library that inverts anything. The mood is one word of the sentence, and what was found follows from the violation existing at all: under `should` the requirement does not hold, under `should not` it does.
A violation of a kind this layer has not been taught is phrased from its kind and its own String, and a nil violation reads as "(no violation)". Neither is a panic: this layer's whole job is to describe somebody else's failing test, and taking their test process down while doing it is the one outcome worse than a vague message.
func (ViolationFactory) Messages ¶
func (f ViolationFactory) Messages(violations []kernel.Violation) []string
Messages phrases every violation of a list, in the order they were reported, which is the order the rule found them in. It is what ResultFactory numbers, and what a caller assembling a report of its own shape asks for instead.
A nil or empty list is no messages rather than one saying so: an empty result is the pass, and how a pass reads is ResultFactory.Result's to say.