diff --git a/CHANGELOG.md b/CHANGELOG.md index 56f8fd2c..e30736ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ The format is based on [Keep a Changelog], and this project adheres to - Added `ReportGenerationContext` to hold information required when generating test reports. +- Added `Test.Annotate()` to add human-readable annotations to values within + test reports. ### Changed diff --git a/compare.go b/compare.go index ad7162b0..6118a08b 100644 --- a/compare.go +++ b/compare.go @@ -18,13 +18,7 @@ type MessageComparator func(a, b dogma.Message) bool // It supports comparison of protocol buffers messages using the proto.Equal() // function. All other types are compared using reflect.DeepEqual(). func DefaultMessageComparator(a, b dogma.Message) bool { - if pa, ok := a.(proto.Message); ok { - if pb, ok := b.(proto.Message); ok { - return proto.Equal(pa, pb) - } - } - - return reflect.DeepEqual(a, b) + return equal(a, b) } // WithMessageComparator returns a test option that sets the comparator @@ -38,3 +32,14 @@ func WithMessageComparator(c MessageComparator) TestOption { t.predicateOptions.MessageComparator = c }) } + +// equal returns true if a and b are considered equal. +func equal(a, b any) bool { + if pa, ok := a.(proto.Message); ok { + if pb, ok := b.(proto.Message); ok { + return proto.Equal(pa, pb) + } + } + + return reflect.DeepEqual(a, b) +} diff --git a/expectation.message.go b/expectation.message.go index 2601ee13..5efd5e60 100644 --- a/expectation.message.go +++ b/expectation.message.go @@ -198,16 +198,16 @@ func (p *messagePredicate) Report(ctx ReportGenerationContext) *Report { s.AppendListItem("check the message type, should it be a pointer?") } - p.buildDiff(rep) + p.buildDiff(ctx, rep) return rep } // buildDiff adds a "message diff" section to the result. -func (p *messagePredicate) buildDiff(rep *Report) { +func (p *messagePredicate) buildDiff(ctx ReportGenerationContext, rep *Report) { report.WriteDiff( &rep.Section("Message Diff").Content, - report.RenderMessage(p.expectedMessage), - report.RenderMessage(p.bestMatch.Message), + ctx.renderMessage(p.expectedMessage), + ctx.renderMessage(p.bestMatch.Message), ) } diff --git a/go.mod b/go.mod index a7b07100..89c2bfba 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ toolchain go1.22.3 require ( github.com/dogmatiq/configkit v0.13.6 github.com/dogmatiq/cosyne v0.2.0 - github.com/dogmatiq/dapper v0.5.3 + github.com/dogmatiq/dapper v0.6.0 github.com/dogmatiq/dogma v0.14.1 github.com/dogmatiq/iago v0.4.0 github.com/dogmatiq/linger v1.1.0 diff --git a/go.sum b/go.sum index 29fff5b5..14d11e30 100644 --- a/go.sum +++ b/go.sum @@ -5,8 +5,8 @@ github.com/dogmatiq/configkit v0.13.6 h1:tgMl55VC9ra1NjMfZJIyHTInTi/mTh8AScTpasI github.com/dogmatiq/configkit v0.13.6/go.mod h1:wjuets0+wsD/7pYFE9bX2YvZ6LlrEx330yBG1nXoDSw= github.com/dogmatiq/cosyne v0.2.0 h1:tO957BpS4I9kqSw31ds6Ef4CXvV8zPAqWzbXKElsGWg= github.com/dogmatiq/cosyne v0.2.0/go.mod h1:dD8EZjbRX7FFw9t6P7l1nwoZbA7YxtOCfl9ZZAHPucU= -github.com/dogmatiq/dapper v0.5.3 h1:DZkitO0TiokaiZt+9J7UNnagW2ezSYmJUlDTXLWGf8g= -github.com/dogmatiq/dapper v0.5.3/go.mod h1:lrBXvNri2wXkk1T0muaTUqd5lVDwIBRKeOzVRU46XI0= +github.com/dogmatiq/dapper v0.6.0 h1:hnWUsjnt3nUiC9hmkPvuxrnMd7fYNz1i+/GS3gOx0Xs= +github.com/dogmatiq/dapper v0.6.0/go.mod h1:ubRHWzt73s0MsPpGhWvnfW/Z/1YPnrkCsQv6CUOZVEw= github.com/dogmatiq/dogma v0.14.1 h1:uSYkfDR9Wr7zCphDIZBZpKOr1QLD4UpTJy5vD+lDFpg= github.com/dogmatiq/dogma v0.14.1/go.mod h1:9lyVA+6V2+E/exV0IrBOrkUiyFwIATEhv+b0vnB2umQ= github.com/dogmatiq/iago v0.4.0 h1:57nZqVT34IZxtCZEW/RFif7DNUEjMXgevfr/Mmd0N8I= diff --git a/internal/report/message.go b/internal/report/message.go deleted file mode 100644 index 15820409..00000000 --- a/internal/report/message.go +++ /dev/null @@ -1,25 +0,0 @@ -package report - -import ( - "github.com/dogmatiq/dapper" - "github.com/dogmatiq/dogma" -) - -// RenderMessage returns a human-readable representation of v. -func RenderMessage(v dogma.Message) string { - return printer.Format(v) -} - -// printer is the Dapper printer used to render values. -var printer dapper.Printer - -func init() { - printer = dapper.Printer{ - // Copy the default config. - Config: dapper.DefaultPrinter.Config, - } - - // Then modify the settings we want to change. - printer.Config.OmitPackagePaths = true - printer.Config.OmitUnexportedFields = true -} diff --git a/internal/report/message_test.go b/internal/report/message_test.go deleted file mode 100644 index d5cf4d0a..00000000 --- a/internal/report/message_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package report_test - -import ( - "strings" - - . "github.com/dogmatiq/dogma/fixtures" - . "github.com/dogmatiq/testkit/internal/report" - g "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = g.Describe("func RenderMessage()", func() { - g.It("returns a suitable representation", func() { - Expect( - RenderMessage(MessageA1), - ).To(Equal(join( - "fixtures.MessageA{", - ` Value: "A1"`, - "}", - ))) - }) -}) - -func join(values ...string) string { - return strings.Join(values, "\n") -} diff --git a/report.go b/report.go index c1b20b7e..c1b8e90e 100644 --- a/report.go +++ b/report.go @@ -4,6 +4,8 @@ import ( "io" "strings" + "github.com/dogmatiq/dapper" + "github.com/dogmatiq/dogma" "github.com/dogmatiq/iago/count" "github.com/dogmatiq/iago/indent" "github.com/dogmatiq/iago/must" @@ -24,6 +26,13 @@ const ( failedMatchesSection = "Failed Matches" ) +// Annotation is a textual description of a value that provides additional +// context in test reports. +type Annotation struct { + Value any + Text string +} + // ReportGenerationContext is the context in which a report is generated. type ReportGenerationContext struct { // TreeOk is true if the entire "tree" of expectations is considered to have @@ -33,6 +42,12 @@ type ReportGenerationContext struct { // IsInverted is true if the expectation is inverted, i.e. it is expected // NOT to be met. IsInverted bool + + printer *dapper.Printer +} + +func (c ReportGenerationContext) renderMessage(m dogma.Message) string { + return c.printer.Format(m) } // Report is a report on the outcome of an expectation. diff --git a/test.go b/test.go index dec72735..6f022c01 100644 --- a/test.go +++ b/test.go @@ -3,11 +3,13 @@ package testkit import ( "context" "fmt" + "reflect" "regexp" "strings" "time" "github.com/dogmatiq/configkit" + "github.com/dogmatiq/dapper" "github.com/dogmatiq/dogma" "github.com/dogmatiq/iago/must" "github.com/dogmatiq/testkit/engine" @@ -26,6 +28,7 @@ type Test struct { executor CommandExecutor predicateOptions PredicateOptions operationOptions []engine.OperationOption + annotations []Annotation } // Begin starts a new test. @@ -124,9 +127,41 @@ func (t *Test) Expect(act Action, e Expectation) *Test { return t // required when using a mock testingT that does not panic } + options := []dapper.Option{ + dapper.WithPackagePaths(false), + dapper.WithUnexportedStructFields(false), + } + + for _, a := range t.annotations { + rt := reflect.TypeOf(a.Value) + + options = append( + options, + dapper.WithAnnotator( + func(v dapper.Value) string { + // Check that the types are EXACT, otherwise the annotation + // can be duplicated, for example, once when boxed in an + // interface, and again when descending into that boxed + // value. + if rt != v.Value.Type() { + return "" + } + + if !equal(a.Value, v.Value.Interface()) { + return "" + } + + return a.Text + }, + ), + ) + } + ctx := ReportGenerationContext{ - TreeOk: p.Ok(), + TreeOk: p.Ok(), + printer: dapper.NewPrinter(options...), } + rep := p.Report(ctx) buf := &strings.Builder{} @@ -153,6 +188,14 @@ func (t *Test) CommandExecutor() dogma.CommandExecutor { return &t.executor } +// Annotate adds an annotation to v. +// +// The annotation text is displayed whenever v is rendered in a test report. +func (t *Test) Annotate(v any, text string) *Test { + t.annotations = append(t.annotations, Annotation{v, text}) + return t +} + // EnableHandlers enables a set of handlers by name. // // It panics if any of the handler names are not recognized. diff --git a/test_test.go b/test_test.go index 0c3922c4..354deca6 100644 --- a/test_test.go +++ b/test_test.go @@ -281,4 +281,59 @@ var _ = g.Describe("type Test", func() { }).To(PanicWith(`the "" application does not have any handlers with names that match the regular expression (^\", "8ec6465c-d4e3-411c-a05b-898a4b608284") + + c.RegisterAggregate(&AggregateMessageHandler{ + ConfigureFunc: func(c dogma.AggregateConfigurer) { + c.Identity("", "a9cdc28d-ec85-4130-af86-4a2ae86a43dd") + c.Routes( + dogma.HandlesCommand[MessageC](), + dogma.RecordsEvent[MessageE](), + ) + }, + RouteCommandToInstanceFunc: func(dogma.Command) string { + return "" + }, + HandleCommandFunc: func( + _ dogma.AggregateRoot, + s dogma.AggregateCommandScope, + m dogma.Command, + ) { + s.RecordEvent(MessageE1) + }, + }) + }, + } + + t := &testingmock.T{FailSilently: true} + + Begin(t, app). + Annotate("E1", "anna's customer ID"). + Annotate("E2", "bob's customer ID"). + Expect( + ExecuteCommand(MessageC1), + ToRecordEvent(MessageE2), + ) + + expectReport( + `✗ record a specific 'fixtures.MessageE' event`, + ``, + ` | EXPLANATION`, + ` | a similar event was recorded by the '' aggregate message handler`, + ` | `, + ` | SUGGESTIONS`, + ` | • check the content of the message`, + ` | `, + ` | MESSAGE DIFF`, + ` | fixtures.MessageE{`, + ` | Value: "E[-2-]{+1+}" <<[-bob-]{+anna+}'s customer ID>>`, + ` | }`, + )(t) + }) + }) })