diff --git a/otellog/go.mod b/otellog/go.mod index f66f301..a1e6b5d 100644 --- a/otellog/go.mod +++ b/otellog/go.mod @@ -1,3 +1,3 @@ module github.com/d-velop/dvelop-sdk-go/otellog -go 1.17 +go 1.18 diff --git a/otellog/otellogtest/recorder.go b/otellog/otellogtest/recorder.go new file mode 100644 index 0000000..83fdcf8 --- /dev/null +++ b/otellog/otellogtest/recorder.go @@ -0,0 +1,91 @@ +package otellogtest + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/d-velop/dvelop-sdk-go/otellog" +) + +type testingT interface { + Errorf(format string, args ...any) + Helper() +} + +type LogRecorder struct { + Events []otellog.Event + t testingT +} + +// NewLogRecorder creates a new LogRecorder that can be used to assert log messages. +// The given testingT is used to fail the test if an assertion fails. +// The default log output formatter is replaced with a formatter that records all log messages. +// The default time function is replaced with a function that always returns the same time (2022-01-01 01:02:03.000000004 UTC). +func NewLogRecorder(t testingT) *LogRecorder { + otellog.Default().Reset() + rec := &LogRecorder{[]otellog.Event{}, t} + + otellog.SetOutputFormatter(func(event *otellog.Event) ([]byte, error) { + rec.Events = append(rec.Events, *event) + return []byte{}, nil + }) + + otellog.SetTime(func() time.Time { + return time.Date(2022, time.January, 01, 1, 2, 3, 4, time.UTC) + }) + return rec +} + +// ShouldHaveLogged asserts that the given log message was logged at some point. +// The log message can be an otellog.Severity, string or any other type that can be converted to a string. +// If multiple arguments are given, they are treated as a logical AND. +func (l *LogRecorder) ShouldHaveLogged(conditions ...any) { + l.t.Helper() + + for _, event := range l.Events { + if matches(event, conditions...) { + return + } + } + + l.t.Errorf("no log found matching %v", conditions) +} + +func matches(event otellog.Event, conditions ...any) bool { + for _, condition := range conditions { + switch condition := condition.(type) { + case otellog.Severity: + if event.Severity != condition { + return false + } + case int: + if int(event.Severity) != condition { + return false + } + + case func(event otellog.Event) bool: + if !condition(event) { + return false + } + + default: + bodyAsString := fmt.Sprint(event.Body) + conditionAsString := fmt.Sprint(condition) + if !strings.Contains(bodyAsString, conditionAsString) { + return false + } + } + } + return true +} + +func ContainsAttribute(key string, value any) func(event otellog.Event) bool { + return func(event otellog.Event) bool { + bytes, _ := event.Attributes.MarshalJSON() + attributes := map[string]string{} + _ = json.Unmarshal(bytes, &attributes) + return attributes[key] == value + } +} diff --git a/otellog/otellogtest/recorder_test.go b/otellog/otellogtest/recorder_test.go new file mode 100644 index 0000000..7ea7ee1 --- /dev/null +++ b/otellog/otellogtest/recorder_test.go @@ -0,0 +1,195 @@ +package otellogtest_test + +import ( + "context" + "fmt" + "testing" + + "github.com/d-velop/dvelop-sdk-go/otellog" + "github.com/d-velop/dvelop-sdk-go/otellog/otellogtest" +) + +func TestLogRecorder_givenSomeLog_whenAsserting_thenShouldMatch(t *testing.T) { + recorder := otellogtest.NewLogRecorder(t) + otellog.Info(context.Background(), "foo") + + recorder.ShouldHaveLogged("foo") +} + +func TestLogRecorder_givenSomeLog_whenAsserting_thenShouldMatchPartially(t *testing.T) { + recorder := otellogtest.NewLogRecorder(t) + otellog.Info(context.Background(), "foo bar") + + recorder.ShouldHaveLogged("foo") +} + +func TestLogRecorder_givenSomeLog_whenAsserting_thenShouldMatchWithSeverity(t *testing.T) { + recorder := otellogtest.NewLogRecorder(t) + otellog.Info(context.Background(), "foo") + + recorder.ShouldHaveLogged(otellog.SeverityInfo, "foo") +} + +func TestLogRecorder_givenFunctionMatcher_whenAsserting_thenShouldMatch(t *testing.T) { + recorder := otellogtest.NewLogRecorder(t) + otellog.Info(context.Background(), "test") + + recorder.ShouldHaveLogged(func(event otellog.Event) bool { + return true + }) +} + +func TestLogRecorder_givenFunctionMatcherNotMatching_whenAsserting_thenShouldNotMatch(t *testing.T) { + ft := &fakeTestingT{} + recorder := otellogtest.NewLogRecorder(ft) + otellog.Info(context.Background(), "test") + + recorder.ShouldHaveLogged(func(event otellog.Event) bool { + return false + }) + + if !ft.failed { + t.Error("expected test to fail") + } +} + +func TestLogRecorder_givenAttributeMatcher_whenAsserting_thenShouldMatch(t *testing.T) { + recorder := otellogtest.NewLogRecorder(t) + otellog.WithAdditionalAttributes(map[string]any{"foo": "bar"}).Info(context.Background(), "test") + + recorder.ShouldHaveLogged("test", otellogtest.ContainsAttribute("foo", "bar")) +} + +func TestLogRecorder_givenAttributeMatcherNotMatching_whenAsserting_thenShouldNotMatch(t *testing.T) { + ft := &fakeTestingT{} + recorder := otellogtest.NewLogRecorder(ft) + otellog.WithAdditionalAttributes(map[string]any{"foo": "bar"}).Info(context.Background(), "test") + + recorder.ShouldHaveLogged("test", otellogtest.ContainsAttribute("hello", "world")) + + if !ft.failed { + t.Error("expected test to fail") + } +} + +func TestLogRecorder_givenOnlySeverity_whenAsserting_thenShouldMatch(t *testing.T) { + recorder := otellogtest.NewLogRecorder(t) + otellog.Info(context.Background(), "foo") + var severity otellog.Severity + { + severity = otellog.SeverityInfo + } + + recorder.ShouldHaveLogged(severity) +} + +func TestLogRecorder_givenSomeLog_whenAsserting_thenShouldNotMatch(t *testing.T) { + ft := &fakeTestingT{} + recorder := otellogtest.NewLogRecorder(ft) + otellog.Info(context.Background(), "foo") + + recorder.ShouldHaveLogged("bar") + if !ft.failed { + t.Error("expected test to fail") + } +} + +func TestLogRecorder_givenSomeLog_whenAsserting_thenShouldNotMatchWithSeverity(t *testing.T) { + ft := &fakeTestingT{} + recorder := otellogtest.NewLogRecorder(ft) + otellog.Info(context.Background(), "foo") + var severity otellog.Severity + { + severity = otellog.SeverityError + } + + recorder.ShouldHaveLogged(severity, "foo") + if !ft.failed { + t.Error("expected test to fail") + } +} + +func TestLogRecorder_givenSomeLog_whenAsserting_thenShouldNotMatchWithSeverityOnly(t *testing.T) { + ft := &fakeTestingT{} + recorder := otellogtest.NewLogRecorder(ft) + otellog.Info(context.Background(), "foo") + + recorder.ShouldHaveLogged(otellog.SeverityError) + if !ft.failed { + t.Error("expected test to fail") + } +} + +func TestLogRecorder_givenSomeLog_whenAsserting_thenShouldMatchWithMultipleConditions(t *testing.T) { + recorder := otellogtest.NewLogRecorder(t) + otellog.Info(context.Background(), "foo bar") + + recorder.ShouldHaveLogged("foo", "bar") +} + +func TestLogRecorder_givenSomeLog_whenAsserting_thenShouldNotMatchWithMultipleConditions(t *testing.T) { + ft := &fakeTestingT{} + recorder := otellogtest.NewLogRecorder(ft) + otellog.Info(context.Background(), "foo bar") + + recorder.ShouldHaveLogged("foo", "baz") + if !ft.failed { + t.Error("expected test to fail") + } +} + +func TestLogRecorder_givenSomeLog_whenAsserting_thenShouldMatchWithStringerThing(t *testing.T) { + recorder := otellogtest.NewLogRecorder(t) + otellog.Info(context.Background(), thing{"foo"}) + + recorder.ShouldHaveLogged(thing{"foo"}) +} + +func TestLogRecorder_givenSomeLog_whenAsserting_thenFailsWithCorrectMessage(t *testing.T) { + ft := &fakeTestingT{} + recorder := otellogtest.NewLogRecorder(ft) + otellog.Info(context.Background(), "foo") + + recorder.ShouldHaveLogged("bar") + if !ft.failed { + t.Error("expected test to fail") + } + if ft.m != "no log found matching [bar]" { + t.Errorf("expected error message to be 'no log found matching [bar]', but was '%v'", ft.m) + } +} + +func TestLogRecorder_givenSomeLog_whenAsserting_thenFailsWithCorrectMessageAndSeverity(t *testing.T) { + ft := &fakeTestingT{} + recorder := otellogtest.NewLogRecorder(ft) + otellog.Info(context.Background(), "foo") + + recorder.ShouldHaveLogged(otellog.SeverityError, "bar") + if !ft.failed { + t.Error("expected test to fail") + } + if ft.m != "no log found matching [17 bar]" { + t.Errorf("expected error message to be 'no log found matching [17 bar]', but was '%v'", ft.m) + } +} + +type thing struct { + content string +} + +func (t thing) String() string { + return t.content +} + +type fakeTestingT struct { + failed bool + m string +} + +func (f *fakeTestingT) Errorf(format string, args ...any) { + f.failed = true + f.m = fmt.Sprintf(format, args...) +} + +func (f *fakeTestingT) Helper() { +}