diff --git a/CHANGELOG.md b/CHANGELOG.md index 301f93a..fde8065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ The format is based on [Keep a Changelog], and this project adheres to ### Added -- Add `InterceptCommandExecutor()` option +- Add `InterceptCommandExecutor()` and `InterceptEventRecorder()` options - Add functional options to `Call()`, see `CallOption` ## [0.13.4] - 2021-04-22 diff --git a/action.call.go b/action.call.go index d5efff5..9d849c6 100644 --- a/action.call.go +++ b/action.call.go @@ -47,6 +47,7 @@ type callAction struct { fn func() loc location.Location onExecute CommandExecutorInterceptor + onRecord EventRecorderInterceptor } func (a callAction) Caption() string { @@ -71,15 +72,14 @@ func (a callAction) Do(ctx context.Context, s ActionScope) error { defer s.Executor.Intercept(prev) } - s.Recorder.Engine = s.Engine - s.Recorder.Options = s.OperationOptions + // Setup the event recorder for use during this action. + s.Recorder.Bind(s.Engine, s.OperationOptions) + defer s.Recorder.Unbind() - defer func() { - // Reset the engine and options to nil so that the executor and recorder - // can not be used after this Call() action ends. - s.Recorder.Engine = nil - s.Recorder.Options = nil - }() + if a.onRecord != nil { + prev := s.Recorder.Intercept(a.onRecord) + defer s.Recorder.Intercept(prev) + } // Execute the user-supplied function. a.fn() diff --git a/action.go b/action.go index 6ad8916..38ccccd 100644 --- a/action.go +++ b/action.go @@ -50,7 +50,7 @@ type ActionScope struct { // Recorder is the event recorder returned by the Test's EventRecorder() // method. - Recorder *engine.EventRecorder + Recorder *EventRecorder // OperationOptions is the set of options that should be used with calling // Engine.Dispatch() or Engine.Tick(). diff --git a/recorder.go b/recorder.go new file mode 100644 index 0000000..608195b --- /dev/null +++ b/recorder.go @@ -0,0 +1,128 @@ +package testkit + +import ( + "context" + "sync" + + "github.com/dogmatiq/dogma" + "github.com/dogmatiq/testkit/engine" +) + +// EventRecorder is an implementation of dogma.EventRecorder that records events +// within the context of a Test. +// +// Each instance is bound to a particular Test. Use Test.EventRecorder() to +// obtain an instance. +type EventRecorder struct { + m sync.RWMutex + next engine.EventRecorder + interceptor EventRecorderInterceptor +} + +// RecordEvent records the event message m. +// +// It panics unless it is called during an Action, such as when calling +// Test.Prepare() or Test.Expect(). +func (r *EventRecorder) RecordEvent(ctx context.Context, m dogma.Message) error { + r.m.RLock() + defer r.m.RUnlock() + + if r.next.Engine == nil { + panic("RecordEvent(): can not be called outside of a test") + } + + if r.interceptor != nil { + return r.interceptor(ctx, m, r.next) + } + + return r.next.RecordEvent(ctx, m) +} + +// Bind sets the engine and options used to record events. +// +// It is intended for use within Action implementations that support recording +// events outside of a Dogma handler, such as Call(). +// +// It must be called before RecordEvent(), otherwise RecordEvent() panics. +// +// It must be accompanied by a call to Unbind() upon completion of the Action. +func (r *EventRecorder) Bind(eng *engine.Engine, options []engine.OperationOption) { + r.m.Lock() + defer r.m.Unlock() + + r.next.Engine = eng + r.next.Options = options +} + +// Unbind removes the engine and options configured by a prior call to Bind(). +// +// Calls to RecordEvent() on an unbound recorder will cause a panic. +func (r *EventRecorder) Unbind() { + r.m.Lock() + defer r.m.Unlock() + + r.next.Engine = nil + r.next.Options = nil +} + +// Intercept installs an interceptor function that is invoked whenever +// RecordEvent() is called. +// +// If fn is nil the interceptor is removed. +// +// It returns the previous interceptor, if any. +func (r *EventRecorder) Intercept(fn EventRecorderInterceptor) EventRecorderInterceptor { + r.m.Lock() + defer r.m.Unlock() + + prev := r.interceptor + r.interceptor = fn + + return prev +} + +// EventRecorderInterceptor is used by the InterceptEventRecorder() option to +// specify custom behavior for the dogma.EventRecorder returned by +// Test.EventRecorder(). +// +// m is the event being recorded. +// +// e can be used to record the event as it would be recorded without this +// interceptor installed. +type EventRecorderInterceptor func( + ctx context.Context, + m dogma.Message, + r dogma.EventRecorder, +) error + +// InterceptEventRecorder returns an option that causes fn to be called +// whenever an event is recorded via the dogma.EventRecorder returned by +// Test.EventRecorder(). +// +// Intercepting calls to the event recorder allows the user to simulate +// failures (or any other behavior) in the event recorder. +func InterceptEventRecorder(fn EventRecorderInterceptor) interface { + TestOption + CallOption +} { + if fn == nil { + panic("InterceptEventRecorder(): function must not be nil") + } + + return interceptEventRecorderOption{fn} +} + +// interceptEventRecorderOption is an implementation of both TestOption and +// CallOption that allows the InterceptEventRecorder() option to be used with +// both Test.Begin() and Call(). +type interceptEventRecorderOption struct { + fn EventRecorderInterceptor +} + +func (o interceptEventRecorderOption) applyTestOption(t *Test) { + t.recorder.Intercept(o.fn) +} + +func (o interceptEventRecorderOption) applyCallOption(a *callAction) { + a.onRecord = o.fn +} diff --git a/recorder_test.go b/recorder_test.go new file mode 100644 index 0000000..0802caa --- /dev/null +++ b/recorder_test.go @@ -0,0 +1,189 @@ +package testkit_test + +import ( + "context" + "errors" + + "github.com/dogmatiq/dogma" + . "github.com/dogmatiq/dogma/fixtures" + . "github.com/dogmatiq/testkit" + "github.com/dogmatiq/testkit/internal/testingmock" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("func InterceptEventRecorder()", func() { + var ( + testingT *testingmock.T + app dogma.Application + doNothing EventRecorderInterceptor + recordEventAndReturnError EventRecorderInterceptor + ) + + BeforeEach(func() { + testingT = &testingmock.T{} + + app = &Application{ + ConfigureFunc: func(c dogma.ApplicationConfigurer) { + c.Identity("", "") + + c.RegisterProcess(&ProcessMessageHandler{ + ConfigureFunc: func(c dogma.ProcessConfigurer) { + c.Identity("", "") + c.ConsumesEventType(MessageE{}) + c.ProducesCommandType(MessageC{}) + }, + RouteEventToInstanceFunc: func( + context.Context, + dogma.Message, + ) (string, bool, error) { + return "", true, nil + }, + HandleEventFunc: func( + _ context.Context, + _ dogma.ProcessRoot, + s dogma.ProcessEventScope, + _ dogma.Message, + ) error { + s.ExecuteCommand(MessageC1) + return nil + }, + }) + }, + } + + doNothing = func( + context.Context, + dogma.Message, + dogma.EventRecorder, + ) error { + return nil + } + + recordEventAndReturnError = func( + ctx context.Context, + m dogma.Message, + e dogma.EventRecorder, + ) error { + Expect(m).To(Equal(MessageE1)) + + err := e.RecordEvent(ctx, m) + Expect(err).ShouldNot(HaveOccurred()) + + return errors.New("") + } + }) + + It("panics if the interceptor function is nil", func() { + Expect(func() { + InterceptEventRecorder(nil) + }).To(PanicWith("InterceptEventRecorder(): function must not be nil")) + }) + + When("used as a TestOption", func() { + It("intercepts calls to RecordEvent()", func() { + test := Begin( + testingT, + app, + InterceptEventRecorder(recordEventAndReturnError), + ) + + test.EnableHandlers("") + + test.Expect( + Call(func() { + err := test.EventRecorder().RecordEvent( + context.Background(), + MessageE1, + ) + Expect(err).To(MatchError("")) + }), + ToExecuteCommand(MessageC1), + ) + }) + }) + + When("used as a CallOption", func() { + It("intercepts calls to RecordEvent()", func() { + test := Begin( + &testingmock.T{}, + app, + ) + + test.EnableHandlers("") + + test.Expect( + Call( + func() { + err := test.EventRecorder().RecordEvent( + context.Background(), + MessageE1, + ) + Expect(err).To(MatchError("")) + }, + InterceptEventRecorder(recordEventAndReturnError), + ), + ToExecuteCommand(MessageC1), + ) + }) + + It("uninstalls the interceptor upon completion of the Call() action", func() { + test := Begin( + &testingmock.T{}, + app, + ) + + test.Prepare( + Call( + func() { + err := test.EventRecorder().RecordEvent( + context.Background(), + MessageE1, + ) + Expect(err).To(MatchError("")) + }, + InterceptEventRecorder(recordEventAndReturnError), + ), + Call( + func() { + err := test.EventRecorder().RecordEvent( + context.Background(), + MessageE1, + ) + Expect(err).ShouldNot(HaveOccurred()) + }, + ), + ) + }) + + It("re-installs the test-level interceptor upon completion of the Call() action", func() { + test := Begin( + &testingmock.T{}, + app, + InterceptEventRecorder(recordEventAndReturnError), + ) + + test.Prepare( + Call( + func() { + err := test.EventRecorder().RecordEvent( + context.Background(), + MessageE1, + ) + Expect(err).ShouldNot(HaveOccurred()) + }, + InterceptEventRecorder(doNothing), + ), + Call( + func() { + err := test.EventRecorder().RecordEvent( + context.Background(), + MessageE1, + ) + Expect(err).To(MatchError("")) + }, + ), + ) + }) + }) +}) diff --git a/test.go b/test.go index 57562cd..c76a061 100644 --- a/test.go +++ b/test.go @@ -21,7 +21,7 @@ type Test struct { virtualClock time.Time engine *engine.Engine executor CommandExecutor - recorder engine.EventRecorder + recorder EventRecorder predicateOptions PredicateOptions operationOptions []engine.OperationOption }