From ef4058e7d9771db3e8a3a67bbc6f3b69a3d89459 Mon Sep 17 00:00:00 2001 From: Keepers Date: Tue, 5 Nov 2024 15:27:06 -0700 Subject: [PATCH] add tester framework Adds a /tester subpackage containing a test framework for ctx and errors containing clues. Callers can assert that Key:Value pairs are present in either a ctx or error, or that labels exist in an error. --- cluerr/err.go | 9 + internal/node/node.go | 4 + tester/tester.go | 293 +++++++++++++++++++++++++++++ tester/tester_test.go | 417 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 723 insertions(+) create mode 100644 tester/tester.go create mode 100644 tester/tester_test.go diff --git a/cluerr/err.go b/cluerr/err.go index 098e4cb..423fd9f 100644 --- a/cluerr/err.go +++ b/cluerr/err.go @@ -44,6 +44,15 @@ type Err struct { data *node.Node } +// Node retrieves the node values from the error. +func (err *Err) Node() *node.Node { + if isNilErrIface(err) { + return &node.Node{} + } + + return err.Values() +} + // ------------------------------------------------------------ // tree operations // ------------------------------------------------------------ diff --git a/internal/node/node.go b/internal/node/node.go index 59e6ccb..4153d94 100644 --- a/internal/node/node.go +++ b/internal/node/node.go @@ -15,6 +15,10 @@ import ( // data nodes // --------------------------------------------------------------------------- +type Noder interface { + Node() *Node +} + // Node contains the data tracked by both clues in contexts and in errors. // // These nodes compose a tree, such that nodes can walk their ancestry path from diff --git a/tester/tester.go b/tester/tester.go new file mode 100644 index 0000000..19376c2 --- /dev/null +++ b/tester/tester.go @@ -0,0 +1,293 @@ +package tester + +import ( + "context" + "slices" + + "github.com/alcionai/clues" + "github.com/alcionai/clues/cluerr" + "github.com/alcionai/clues/internal/node" + "github.com/alcionai/clues/internal/stringify" +) + +// --------------------------------------------------------------------------- +// types and interfaces +// --------------------------------------------------------------------------- + +type anyVal struct{} + +// AnyVal will pass the test so long as the key for this value exists. +var AnyVal = anyVal{} + +// AllPass will always pass the test. +var AllPass = "all labels will pass if provided this magic string" + +type expectGot struct { + expect string + got string +} + +// errLogfer allows us to pass in a mock testing.T +type errLogfer interface { + Error(args ...any) + Errorf(format string, args ...any) + Log(args ...any) + Logf(format string, args ...any) +} + +// --------------------------------------------------------------------------- +// assertions +// --------------------------------------------------------------------------- + +// Contains checks whether the errOrCtx (which should contain +// either an error or context.Context) contains the provided +// key:value pairs. +// +// Returns true if the test fails. +func Contains( + t errLogfer, + errOrCtx any, + kvs ...any, +) bool { + if slices.Contains(kvs, any(AllPass)) { + t.Log("AllPass found; passing test") + return false + } + + // some sanity prechecks + if len(kvs) == 0 { + t.Error("no key:value properties provided to test") + return true + } + + if len(kvs)%2 == 1 { + t.Error("odd count of key:value parameters") + return true + } + + n, ok := getNode(t, errOrCtx) + if !ok { + return true + } + + var ( + values = n.Map() + badVals = map[string]expectGot{} + foundKeys = map[string]struct{}{} + missingKeys = map[string]struct{}{} + ) + + // iterate through each k,v pair looking for matches. + for i := 0; i < len(kvs); i += 2 { + k, v := stringify.Marshal(kvs[i], false), kvs[i+1] + + gotV, found := values[k] + if !found { + missingKeys[k] = struct{}{} + continue + } + + foundKeys[k] = struct{}{} + + if v == AnyVal { + continue + } + + var ( + expected = stringify.Marshal(v, false) + got = stringify.Marshal(gotV, false) + ) + + if expected != got { + badVals[k] = expectGot{expected, got} + } + } + + // early pass check + if len(badVals) == 0 && len(missingKeys) == 0 { + return false + } + + showContainsResults(t, values, badVals, foundKeys, missingKeys) + + return true +} + +// Contains checks whether the errOrCtx (which should contain +// either an error or context.Context) contains the provided +// map. +// +// Returns true if the test fails. +func ContainsMap( + t errLogfer, + errOrCtx any, + m map[string]any, +) bool { + if len(m) == 0 { + t.Error("no map properties provided to test") + return true + } + + n, ok := getNode(t, errOrCtx) + if !ok { + return true + } + + var ( + values = n.Map() + badVals = map[string]expectGot{} + foundKeys = map[string]struct{}{} + missingKeys = map[string]struct{}{} + ) + + // iterate through each k,v pair looking for matches. + for k, v := range m { + gotV, found := values[k] + if !found { + missingKeys[k] = struct{}{} + continue + } + + foundKeys[k] = struct{}{} + + if v == AnyVal { + continue + } + + var ( + expected = stringify.Marshal(v, false) + got = stringify.Marshal(gotV, false) + ) + + if expected != got { + badVals[k] = expectGot{expected, got} + } + } + + // early pass check + if len(badVals) == 0 && len(missingKeys) == 0 { + return false + } + + showContainsResults(t, values, badVals, foundKeys, missingKeys) + + return true +} + +// ContainsLabels checks whether the error(which should contain +// a cluerr.Err) contains the labels. If provided zero labels to +// check against, asserts that the error contains zero labels. +// Can be provided tester.AllPass to skip the check for a single +// test case. +// +// Returns true if the test fails. +func ContainsLabels( + t errLogfer, + err error, + expected ...string, +) bool { + // support an always-pass case + if slices.Contains(expected, AllPass) { + t.Log("AllPass found; passing test") + return false + } + + labels := cluerr.Labels(err) + + if err == nil { + if len(expected) > 0 { + t.Error("expected labels, but error is nil") + } + + return len(expected) != 0 + } + + if len(expected) == 0 && len(labels) > 0 { + t.Errorf("expected no labels in error, got:\t%v", labels) + return true + } + + extraLabels := map[string]struct{}{} + + for l := range labels { + if !slices.Contains(expected, l) { + extraLabels[l] = struct{}{} + } + } + + var errored bool + + for _, expect := range expected { + if _, ok := labels[expect]; !ok { + t.Error("missing label:", expect) + errored = true + } + } + + if errored { + t.Log("Unchecked labels") + + for extra := range extraLabels { + t.Log("-", extra) + } + + t.Log("") + } + + return errored +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +func showContainsResults( + t errLogfer, + values map[string]any, + badVals map[string]expectGot, + foundKeys map[string]struct{}, + missingKeys map[string]struct{}, +) { + // sanity showcase: print out all unchecked values + if len(foundKeys) < len(values) { + t.Log("Unchecked attributes") + + for k, v := range values { + if _, ok := foundKeys[k]; !ok { + t.Log("-", k+":", stringify.Marshal(v, false)) + } + } + + t.Log("") + } + + // failure showcase + for k := range missingKeys { + t.Error("missing entry with key ", k) + } + + for k, eg := range badVals { + t.Errorf( + "unexpected value:\n\tkey: %s\n\texpected: %s\n\tgot: %s\n", + k, + eg.expect, + eg.got) + } +} + +func getNode( + t errLogfer, + eoc any, +) (*node.Node, bool) { + if noder, ok := eoc.(node.Noder); ok { + return noder.Node(), true + } + + if ctx, ok := eoc.(context.Context); ok { + return clues.In(ctx), true + } + + t.Error("tester can only check error and context.Context values") + + return nil, false +} diff --git a/tester/tester_test.go b/tester/tester_test.go new file mode 100644 index 0000000..6fb4d2a --- /dev/null +++ b/tester/tester_test.go @@ -0,0 +1,417 @@ +package tester_test + +import ( + "context" + "errors" + "testing" + + "github.com/alcionai/clues" + "github.com/alcionai/clues/cluerr" + "github.com/alcionai/clues/tester" + "github.com/stretchr/testify/assert" +) + +type mockT struct { + t *testing.T + shouldErr bool + sawErr bool +} + +func (t *mockT) Error(args ...any) { + t.sawErr = true + + if !t.shouldErr { + t.t.Error(append([]any{"unexpected error:"}, args...)...) + } +} + +func (t *mockT) Errorf(format string, args ...any) { + t.sawErr = true + + if !t.shouldErr { + t.t.Errorf( + "unexpected error: "+format, + append([]any{"unexpected error:"}, args...)...) + } +} + +func (t *mockT) Log(args ...any) { + t.t.Log(args...) +} + +func (t *mockT) Logf(format string, args ...any) { + t.t.Logf(format, args...) +} + +func (t *mockT) verify() { + if t.shouldErr && !t.sawErr { + t.t.Error("expected an error, saw none") + } +} + +func TestContains(t *testing.T) { + table := []struct { + name string + input any + want []any + expecter func(t *testing.T) *mockT + expectFailed bool + }{ + { + name: "nil", + input: nil, + want: nil, + expecter: func(t *testing.T) *mockT { + return &mockT{t, true, false} + }, + expectFailed: true, + }, + { + name: "nil wants with ctx background", + input: context.Background(), + want: nil, + expecter: func(t *testing.T) *mockT { + return &mockT{t, true, false} + }, + expectFailed: true, + }, + { + name: "nil wants with new error", + input: cluerr.New("new"), + want: nil, + expecter: func(t *testing.T) *mockT { + return &mockT{t, true, false} + }, + expectFailed: true, + }, + { + name: "non-cluerr error", + input: errors.New("new"), + want: []any{"foo", "bar"}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, true, false} + }, + expectFailed: true, + }, + { + name: "ctx with match", + input: clues.Add(context.Background(), "foo", "bar"), + want: []any{"foo", "bar"}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, false, false} + }, + }, + { + name: "ctx with match and extras", + input: clues.Add(context.Background(), 1, 2, "foo", "bar"), + want: []any{1, 2}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, false, false} + }, + }, + { + name: "ctx with bad match", + input: clues.Add(context.Background(), "foo", "bar"), + want: []any{"foo", "fnords"}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, true, false} + }, + expectFailed: true, + }, + { + name: "ctx with missing key", + input: clues.Add(context.Background(), 1, 2), + want: []any{3, tester.AnyVal}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, true, false} + }, + expectFailed: true, + }, + { + name: "error with match", + input: cluerr.New("new").With("foo", "bar"), + want: []any{"foo", "bar"}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, false, false} + }, + }, + { + name: "error with match and extras", + input: cluerr.New("new").With(1, 2, "foo", "bar"), + want: []any{1, 2}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, false, false} + }, + }, + { + name: "error with bad match", + input: cluerr.New("new").With("foo", "bar"), + want: []any{"foo", "fnords"}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, true, false} + }, + expectFailed: true, + }, + { + name: "error with missing key", + input: cluerr.New("new").With(1, 2), + want: []any{3, tester.AnyVal}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, true, false} + }, + expectFailed: true, + }, + { + name: "AllPass", + input: cluerr.New("new"), + want: []any{tester.AllPass}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, false, false} + }, + }, + } + for _, test := range table { + t.Run(test.name, func(t *testing.T) { + et := test.expecter(t) + + failed := tester.Contains(et, test.input, test.want...) + + et.verify() + + assert.Equal(t, test.expectFailed, failed) + }) + } +} + +func TestContainsMap(t *testing.T) { + table := []struct { + name string + input any + want map[string]any + expecter func(t *testing.T) *mockT + expectFailed bool + }{ + { + name: "nil", + input: nil, + want: nil, + expecter: func(t *testing.T) *mockT { + return &mockT{t, true, false} + }, + expectFailed: true, + }, + { + name: "nil wants with ctx background", + input: context.Background(), + want: nil, + expecter: func(t *testing.T) *mockT { + return &mockT{t, true, false} + }, + expectFailed: true, + }, + { + name: "nil wants with new error", + input: cluerr.New("new"), + want: nil, + expecter: func(t *testing.T) *mockT { + return &mockT{t, true, false} + }, + expectFailed: true, + }, + { + name: "non-cluerr error", + input: errors.New("new"), + want: map[string]any{"foo": "bar"}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, true, false} + }, + expectFailed: true, + }, + { + name: "ctx with match", + input: clues.Add(context.Background(), "foo", "bar"), + want: map[string]any{"foo": "bar"}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, false, false} + }, + }, + { + name: "ctx with match and extras", + input: clues.Add(context.Background(), 1, 2, "foo", "bar"), + want: map[string]any{"1": 2}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, false, false} + }, + }, + { + name: "ctx with bad match", + input: clues.Add(context.Background(), "foo", "bar"), + want: map[string]any{"foo": "fnords"}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, true, false} + }, + expectFailed: true, + }, + { + name: "ctx with missing key", + input: clues.Add(context.Background(), 1, 2), + want: map[string]any{"3": tester.AnyVal}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, true, false} + }, + expectFailed: true, + }, + { + name: "error with match", + input: cluerr.New("new").With("foo", "bar"), + want: map[string]any{"foo": "bar"}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, false, false} + }, + }, + { + name: "error with match and extras", + input: cluerr.New("new").With(1, 2, "foo", "bar"), + want: map[string]any{"1": 2}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, false, false} + }, + }, + { + name: "error with bad match", + input: cluerr.New("new").With("foo", "bar"), + want: map[string]any{"foo": "fnords"}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, true, false} + }, + expectFailed: true, + }, + { + name: "error with missing key", + input: cluerr.New("new").With(1, 2), + want: map[string]any{"3": tester.AnyVal}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, true, false} + }, + expectFailed: true, + }, + } + for _, test := range table { + t.Run(test.name, func(t *testing.T) { + et := test.expecter(t) + + failed := tester.ContainsMap(et, test.input, test.want) + + et.verify() + + assert.Equal(t, test.expectFailed, failed) + }) + } +} + +func TestContainsLabels(t *testing.T) { + table := []struct { + name string + err error + want []string + expecter func(t *testing.T) *mockT + expectFailed bool + }{ + { + name: "nil error, nil labels", + err: nil, + want: nil, + expecter: func(t *testing.T) *mockT { + return &mockT{t, false, false} + }, + }, + { + name: "nil error expecting labels", + err: nil, + want: []string{"fisher"}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, true, false} + }, + expectFailed: true, + }, + { + name: "error expecting no labels", + err: cluerr.New("new"), + want: nil, + expecter: func(t *testing.T) *mockT { + return &mockT{t, false, false} + }, + }, + { + name: "error with labels expecting no labels", + err: cluerr.New("new").Label("label"), + want: nil, + expecter: func(t *testing.T) *mockT { + return &mockT{t, true, false} + }, + expectFailed: true, + }, + { + name: "matched labels", + err: cluerr.New("new").Label("label"), + want: []string{"label"}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, false, false} + }, + }, + { + name: "multiple matched labels", + err: cluerr.New("new").Label("label", "ihaveseenthefnords"), + want: []string{"ihaveseenthefnords", "label"}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, false, false} + }, + }, + { + name: "matched labels with extras", + err: cluerr.New("new").Label("label", "ihaveseenthefnords"), + want: []string{"label"}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, false, false} + }, + }, + { + name: "missing labels", + err: cluerr.New("new").Label("label"), + want: []string{"slab"}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, true, false} + }, + expectFailed: true, + }, + { + name: "partially mismatched labels", + err: cluerr.New("new").Label("label", "ihaveseenthefnords"), + want: []string{"label", "fisher flannigan fitzbog"}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, true, false} + }, + expectFailed: true, + }, + { + name: "always pass", + err: cluerr.New("new").Label("label"), + want: []string{tester.AllPass}, + expecter: func(t *testing.T) *mockT { + return &mockT{t, false, false} + }, + expectFailed: false, + }, + } + for _, test := range table { + t.Run(test.name, func(t *testing.T) { + et := test.expecter(t) + + failed := tester.ContainsLabels(et, test.err, test.want...) + + et.verify() + + assert.Equal(t, test.expectFailed, failed) + }) + } +}