From c0515f2452e5cffef86e6eb63664de1b7baa9895 Mon Sep 17 00:00:00 2001 From: Forrest Marshall Date: Tue, 31 Dec 2024 11:54:31 -0800 Subject: [PATCH] add emtiness testutils --- lib/utils/testutils/testutils.go | 239 +++++++++++ lib/utils/testutils/testutils_test.go | 564 ++++++++++++++++++++++++++ 2 files changed, 803 insertions(+) create mode 100644 lib/utils/testutils/testutils.go create mode 100644 lib/utils/testutils/testutils_test.go diff --git a/lib/utils/testutils/testutils.go b/lib/utils/testutils/testutils.go new file mode 100644 index 0000000000000..3659b4892568e --- /dev/null +++ b/lib/utils/testutils/testutils.go @@ -0,0 +1,239 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package testutils + +import ( + "fmt" + "reflect" + "strings" +) + +// ExhaustiveNonEmpty is a helper that uses reflection to check if a given value and its sub-elements are non-empty. Exhaustive +// non-emptiness is evaluated in the following ways: +// +// - Pointers/Interfaces are considered exhaustively non-empty if their underlying value is exhaustively non-empty. +// - Slices/Arrays are considered exhaustively non-empty if they have at least one exhaustively non-empty element. +// - Maps are considered exhaustively non-empty if they have at least one exhaustively non-empty value. +// - Structs are considered exhaustively non-empty if all their exported fields are non-empty. +// - All other types are considered exhaustively non-empty if reflect.Value.IsZero is false. +// +// The ignoreOpts parameter is a variadic list of strings that represent the fully qualified field names of struct fields that +// should be ignored when checking for non-emptiness. For example, to ignore the field Bar on type Foo pass in "Foo.Bar" as an +// ignore option. Note that embedded type fields have to be ignored by the parent type's name (i.e. `Outer.Field` rather than +// `Inner.Field`). +// +// The intended usecase of this helper is to ensure that new fields added to a struct are included in test cases that want to +// cover all fields. For example, a test of serialization/deserialization logic might assert that the sample struct is exhaustively +// non-empty in order to force new fields to be covered by the test. +func ExhaustiveNonEmpty(item any, ignoreOpts ...string) bool { + value := reflect.ValueOf(item) + + ignore := make(map[string]struct{}, len(ignoreOpts)) + for _, opt := range ignoreOpts { + ignore[opt] = struct{}{} + } + + return exhaustiveNonEmpty(value, ignore) +} + +func exhaustiveNonEmpty(value reflect.Value, ignore map[string]struct{}) bool { + if !value.IsValid() { + // indicates that reflect.ValueOf/Value.Elem was called on a nil pointer/interface + return false + } + + switch value.Kind() { + case reflect.Pointer, reflect.Interface: + // recursively check the underlying value + return exhaustiveNonEmpty(value.Elem(), ignore) + case reflect.Slice, reflect.Array: + if value.Len() == 0 { + return false + } + + for i := 0; i < value.Len(); i++ { + if exhaustiveNonEmpty(value.Index(i), ignore) { + return true + } + } + return false + case reflect.Map: + if value.Len() == 0 { + return false + } + + mr := value.MapRange() + + for mr.Next() { + if exhaustiveNonEmpty(mr.Value(), ignore) { + return true + } + } + + return false + case reflect.Struct: + var fieldsConsidered int + for _, vf := range reflect.VisibleFields(value.Type()) { + if vf.Anonymous { + // skip the embedded type itself since this loop will + // end up processing each of the embedded type's fields as + // a member of this type's fields. + continue + } + + if !vf.IsExported() { + // skip non-exported fields + continue + } + + fieldsConsidered++ + + // skip fields if `.` is in the ignore list + if _, ok := ignore[fmt.Sprintf("%s.%s", value.Type().Name(), vf.Name)]; ok { + continue + } + + if !exhaustiveNonEmpty(value.FieldByIndex(vf.Index), ignore) { + return false + } + } + + if fieldsConsidered == 0 { + // fallback to basic nonzeroness check for structs with no exported fields (necessary + // in order to achieve expected behavior for types like time.Time). + return !value.IsZero() + } + + return true + default: + // fallback to basic nonzeroness check for all other types + return !value.IsZero() + } +} + +// FindAllEmpty is a helper that uses reflection to find all empty sub-components of a given value. It functions similary to the ExhaustiveNonEmpty +// check, but may return a non-empty list of paths in cases where ExhaustiveNonEmpty would return false since it records all empty members of +// collections even if the collection contains a non-empty member. +// +// The intended usecase for FindAllEmpty is to build helpful failure messages in tests that assert that a struct is non-empty. +// +// Note that this function panics if the top-level item passed in is nil. +func FindAllEmpty(item any, ignoreOpts ...string) []string { + value := reflect.ValueOf(item) + + if !value.IsValid() { + panic("FindAllEmpty called with nil top-level item") + } + + // dereference pointers and interfaces so that the root find logic starts from + // a concrete type (makes the returned paths more consistent/understandable). + switch value.Kind() { + case reflect.Ptr, reflect.Interface: + if value.IsNil() { + panic("FindAllEmpty called with nil top-level pointer/interface") + } + return FindAllEmpty(value.Elem().Interface(), ignoreOpts...) + } + + ignore := make(map[string]struct{}, len(ignoreOpts)) + for _, opt := range ignoreOpts { + ignore[opt] = struct{}{} + } + + path := []string{value.Type().Name()} + + return findAllEmpty(value, ignore, path) +} + +func findAllEmpty(value reflect.Value, ignore map[string]struct{}, path []string) []string { + if !value.IsValid() { + // indicates that reflect.ValueOf/Value.Elem was called on a nil pointer/interface + return []string{strings.Join(path, ".")} + } + + switch value.Kind() { + case reflect.Pointer, reflect.Interface: + // recursively check the underlying value + return findAllEmpty(value.Elem(), ignore, path) + case reflect.Slice, reflect.Array: + if value.Len() == 0 { + return []string{strings.Join(path, ".")} + } + + var emptyPaths []string + for i := 0; i < value.Len(); i++ { + emptyPaths = append(emptyPaths, findAllEmpty(value.Index(i), ignore, append(path, fmt.Sprintf("%d", i)))...) + } + return emptyPaths + case reflect.Map: + if value.Len() == 0 { + return []string{strings.Join(path, ".")} + } + + mr := value.MapRange() + + var emptyPaths []string + for mr.Next() { + emptyPaths = append(emptyPaths, findAllEmpty(mr.Value(), ignore, append(path, fmt.Sprintf("%v", mr.Key().Interface())))...) + } + + return emptyPaths + case reflect.Struct: + emptyPaths := make([]string, 0, value.NumField()) + var fieldsConsidered int + for _, vf := range reflect.VisibleFields(value.Type()) { + if vf.Anonymous { + // skip the embedded type itself since this loop will + // end up processing each of the embedded type's fields as + // a member of this type's fields. + continue + } + + if !vf.IsExported() { + // skip non-exported fields + continue + } + + fieldsConsidered++ + + // skip fields if `.` is in the ignore list + if _, ok := ignore[fmt.Sprintf("%s.%s", value.Type().Name(), vf.Name)]; ok { + continue + } + + emptyPaths = append(emptyPaths, findAllEmpty(value.FieldByIndex(vf.Index), ignore, append(path, vf.Name))...) + } + + if fieldsConsidered == 0 { + // fallback to basic nonzeroness check for structs with no exported fields (necessary + // in order to achieve expected behavior for types like time.Time). + if value.IsZero() { + return []string{strings.Join(path, ".")} + } + } + + return emptyPaths + default: + // fallback to basic nonzeroness check for all other types + if value.IsZero() { + return []string{strings.Join(path, ".")} + } + return nil + } +} diff --git a/lib/utils/testutils/testutils_test.go b/lib/utils/testutils/testutils_test.go new file mode 100644 index 0000000000000..35c73d15b7122 --- /dev/null +++ b/lib/utils/testutils/testutils_test.go @@ -0,0 +1,564 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package testutils + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestExhaustiveNonEmptyBasics tests the basic functionality of ExhaustiveNonEmpty using various +// combinations of simple types. +func TestExhaustiveNonEmptyBasics(t *testing.T) { + t.Parallel() + tts := []struct { + desc string + value any + expect bool + }{ + { + desc: "basic nil", + value: nil, + expect: false, + }, + { + desc: "nil slice", + value: []string(nil), + expect: false, + }, + { + desc: "empty slice", + value: []string{}, + expect: false, + }, + { + desc: "slice with empty element", + value: []string{""}, + expect: false, + }, + { + desc: "non-empty slice", + value: []string{"a"}, + expect: true, + }, + { + desc: "slice with mix of empty and non-empty elements", + value: []string{"", "a"}, + expect: true, + }, + { + desc: "nil pointer", + value: (*string)(nil), + expect: false, + }, + { + desc: "pointer to empty string", + value: new(string), + expect: false, + }, + { + desc: "pointer to non-empty string", + value: func() *string { + s := "a" + return &s + }(), + expect: true, + }, + { + desc: "zero int", + value: int(0), + expect: false, + }, + { + desc: "non-zero int", + value: int(1), + expect: true, + }, + { + desc: "nil map", + value: map[string]string(nil), + expect: false, + }, + { + desc: "empty map", + value: map[string]string{}, + expect: false, + }, + { + desc: "map with empty value", + value: map[string]string{ + "a": "", + }, + expect: false, + }, + { + desc: "map with non-empty value", + value: map[string]string{ + "a": "b", + }, + expect: true, + }, + { + desc: "map with mix of empty and non-empty values", + value: map[string]string{ + "a": "", + "b": "c", + }, + expect: true, + }, + { + desc: "zero time", + value: time.Time{}, + expect: false, + }, + { + desc: "non-zero time", + value: time.Now(), + expect: true, + }, + } + + for _, tt := range tts { + t.Run(tt.desc, func(t *testing.T) { + require.Equal(t, tt.expect, ExhaustiveNonEmpty(tt.value), "value=%+v", tt.value) + }) + } +} + +// TestExhaustiveNonEmptyStruct tests the basic functionality of ExhaustiveNonEmpty using different struct field/nesting +// scenarios. This test also covers the behavior of struct field ignore options. +func TestExhaustiveNonEmptyStruct(t *testing.T) { + t.Parallel() + type Inner struct { + Field string + } + + type Outer struct { + Inner + Slice []Inner + Pointer *Inner + Value Inner + Map map[string]Inner + } + + newNonEmpty := func() Outer { + return Outer{ + Inner: Inner{ + Field: "a", + }, + Slice: []Inner{ + {Field: "b"}, + }, + Pointer: &Inner{Field: "c"}, + Value: Inner{Field: "d"}, + Map: map[string]Inner{ + "e": {Field: "f"}, + }, + } + } + + tts := []struct { + desc string + value any + ignore []string + expect bool + }{ + { + desc: "empty struct", + value: Outer{}, + expect: false, + }, + { + desc: "non-empty struct", + value: newNonEmpty(), + expect: true, + }, + { + desc: "pointer to empty struct", + value: new(Outer), + expect: false, + }, + { + desc: "pointer to non-empty struct", + value: func() *Outer { + v := newNonEmpty() + return &v + }(), + expect: true, + }, + { + desc: "struct with empty embed", + value: func() Outer { + v := newNonEmpty() + v.Inner = Inner{} + return v + }(), + expect: false, + }, + { + desc: "struct with nil slice", + value: func() Outer { + v := newNonEmpty() + v.Slice = nil + return v + }(), + expect: false, + }, + { + desc: "struct with empty slice", + value: func() Outer { + v := newNonEmpty() + v.Slice = []Inner{} + return v + }(), + expect: false, + }, + { + desc: "struct with empty slice element", + value: func() Outer { + v := newNonEmpty() + v.Slice = []Inner{{}} + return v + }(), + expect: false, + }, + { + desc: "struct with nil pointer", + value: func() Outer { + v := newNonEmpty() + v.Pointer = nil + return v + }(), + expect: false, + }, + { + desc: "struct with empty pointer", + value: func() Outer { + v := newNonEmpty() + v.Pointer = &Inner{} + return v + }(), + expect: false, + }, + { + desc: "struct with empty value", + value: func() Outer { + v := newNonEmpty() + v.Value = Inner{} + return v + }(), + expect: false, + }, + { + desc: "struct with nil map", + value: func() Outer { + v := newNonEmpty() + v.Map = nil + return v + }(), + expect: false, + }, + { + desc: "struct with empty map", + value: func() Outer { + v := newNonEmpty() + v.Map = map[string]Inner{} + return v + }(), + expect: false, + }, + { + desc: "struct with empty map value", + value: func() Outer { + v := newNonEmpty() + v.Map = map[string]Inner{"a": {}} + return v + }(), + expect: false, + }, + { + desc: "ignore top-level field", + value: func() Outer { + v := newNonEmpty() + v.Value = Inner{} + return v + }(), + ignore: []string{"Outer.Value"}, + expect: true, + }, + { + desc: "ignore embedded field", + value: func() Outer { + v := newNonEmpty() + v.Inner = Inner{} + return v + }(), + ignore: []string{"Outer.Field"}, // embedded ignores use the outer type name + expect: true, + }, + { + desc: "ignore slice element field", + value: func() Outer { + v := newNonEmpty() + v.Slice = []Inner{{}} + return v + }(), + ignore: []string{"Inner.Field"}, + expect: true, + }, + { + desc: "ignore pointer field", + value: func() Outer { + v := newNonEmpty() + v.Pointer = &Inner{} + return v + }(), + ignore: []string{"Inner.Field"}, + expect: true, + }, + { + desc: "ignore map value field", + value: func() Outer { + v := newNonEmpty() + v.Map = map[string]Inner{"a": {}} + return v + }(), + ignore: []string{"Inner.Field"}, + expect: true, + }, + } + + for _, tt := range tts { + t.Run(tt.desc, func(t *testing.T) { + require.Equal(t, tt.expect, ExhaustiveNonEmpty(tt.value, tt.ignore...), "value=%+v", tt.value) + }) + } +} + +// TestFindAllEmptyStruct tests the basic functionality of FindAllEmpty using different struct field/nesting +// scenarios. This test also covers the behavior of struct field ignore options. +func TestFindAllEmptyStruct(t *testing.T) { + t.Parallel() + type Inner struct { + Field string + } + + type Outer struct { + Inner + Slice []Inner + Pointer *Inner + Value Inner + Map map[string]Inner + } + + newNonEmpty := func() Outer { + return Outer{ + Inner: Inner{ + Field: "a", + }, + Slice: []Inner{ + {Field: "b"}, + }, + Pointer: &Inner{Field: "c"}, + Value: Inner{Field: "d"}, + Map: map[string]Inner{ + "e": {Field: "f"}, + }, + } + } + + tts := []struct { + desc string + value any + ignore []string + expect []string + }{ + { + desc: "empty struct", + value: Outer{}, + expect: []string{"Outer.Field", "Outer.Slice", "Outer.Pointer", "Outer.Value.Field", "Outer.Map"}, + }, + { + desc: "non-empty struct", + value: newNonEmpty(), + expect: nil, + }, + { + desc: "pointer to empty struct", + value: new(Outer), + expect: []string{"Outer.Field", "Outer.Slice", "Outer.Pointer", "Outer.Value.Field", "Outer.Map"}, + }, + { + desc: "pointer to non-empty struct", + value: func() *Outer { + v := newNonEmpty() + return &v + }(), + expect: nil, + }, + { + desc: "struct with empty embed", + value: func() Outer { + v := newNonEmpty() + v.Inner = Inner{} + return v + }(), + expect: []string{"Outer.Field"}, + }, + { + desc: "struct with nil slice", + value: func() Outer { + v := newNonEmpty() + v.Slice = nil + return v + }(), + expect: []string{"Outer.Slice"}, + }, + { + desc: "struct with empty slice", + value: func() Outer { + v := newNonEmpty() + v.Slice = []Inner{} + return v + }(), + expect: []string{"Outer.Slice"}, + }, + { + desc: "struct with empty slice element", + value: func() Outer { + v := newNonEmpty() + v.Slice = []Inner{{}} + return v + }(), + expect: []string{"Outer.Slice.0.Field"}, + }, + { + desc: "struct with nil pointer", + value: func() Outer { + v := newNonEmpty() + v.Pointer = nil + return v + }(), + expect: []string{"Outer.Pointer"}, + }, + { + desc: "struct with empty pointer", + value: func() Outer { + v := newNonEmpty() + v.Pointer = &Inner{} + return v + }(), + expect: []string{"Outer.Pointer.Field"}, + }, + { + desc: "struct with empty value", + value: func() Outer { + v := newNonEmpty() + v.Value = Inner{} + return v + }(), + expect: []string{"Outer.Value.Field"}, + }, + { + desc: "struct with nil map", + value: func() Outer { + v := newNonEmpty() + v.Map = nil + return v + }(), + expect: []string{"Outer.Map"}, + }, + { + desc: "struct with empty map", + value: func() Outer { + v := newNonEmpty() + v.Map = map[string]Inner{} + return v + }(), + expect: []string{"Outer.Map"}, + }, + { + desc: "struct with empty map value", + value: func() Outer { + v := newNonEmpty() + v.Map = map[string]Inner{"a": {}} + return v + }(), + expect: []string{"Outer.Map.a.Field"}, + }, + { + desc: "ignore top-level field", + value: func() Outer { + v := newNonEmpty() + v.Value = Inner{} + return v + }(), + ignore: []string{"Outer.Value"}, + expect: nil, + }, + { + desc: "ignore embedded field", + value: func() Outer { + v := newNonEmpty() + v.Inner = Inner{} + return v + }(), + ignore: []string{"Outer.Field"}, // embedded ignores use the outer type name + expect: nil, + }, + { + desc: "ignore slice element field", + value: func() Outer { + v := newNonEmpty() + v.Slice = []Inner{{}} + return v + }(), + ignore: []string{"Inner.Field"}, + expect: nil, + }, + { + desc: "ignore pointer field", + value: func() Outer { + v := newNonEmpty() + v.Pointer = &Inner{} + return v + }(), + ignore: []string{"Inner.Field"}, + expect: nil, + }, + { + desc: "ignore map value field", + value: func() Outer { + v := newNonEmpty() + v.Map = map[string]Inner{"a": {}} + return v + }(), + ignore: []string{"Inner.Field"}, + expect: nil, + }, + } + + for _, tt := range tts { + t.Run(tt.desc, func(t *testing.T) { + require.ElementsMatch(t, tt.expect, FindAllEmpty(tt.value, tt.ignore...), "value=%+v", tt.value) + }) + } +}