Skip to content

Commit 733c089

Browse files
committed
Add new test utils
1 parent 5a5fc51 commit 733c089

File tree

4 files changed

+703
-0
lines changed

4 files changed

+703
-0
lines changed

internal/pkg/testutils/assert.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG
3+
4+
package testutils
5+
6+
// Package test provides utilities for validating CLI command test results with
7+
// explicit helpers for error expectations and value comparisons. By splitting
8+
// error and value handling the package keeps assertions simple and removes the
9+
// need for dynamic type checks in every test case.
10+
//
11+
// Example usage:
12+
//
13+
// // Expect a specific error type
14+
// if !test.AssertError(t, run(), &cliErr.FlagValidationError{}) {
15+
// return
16+
// }
17+
//
18+
// // Expect any error
19+
// if !test.AssertError(t, run(), true) {
20+
// return
21+
// }
22+
//
23+
// // Expect error message substring
24+
// if !test.AssertError(t, run(), "not found") {
25+
// return
26+
// }
27+
//
28+
// // Compare complex structs with private fields
29+
// test.AssertValue(t, got, want, test.WithAllowUnexported(MyStruct{}))
30+
31+
import (
32+
"errors"
33+
"reflect"
34+
"strings"
35+
"testing"
36+
37+
"github.com/google/go-cmp/cmp"
38+
"github.com/google/go-cmp/cmp/cmpopts"
39+
)
40+
41+
// AssertError verifies that an observed error satisfies the expected condition.
42+
//
43+
// Returns:
44+
// - bool: True if the test should continue to value checks (i.e., no error occurred).
45+
//
46+
// Behavior:
47+
// 1. If err is nil:
48+
// - If want is nil or false: Success.
49+
// - If want is anything else: Fails test (Expected error but got nil).
50+
// 2. If err is non-nil:
51+
// - If want is nil or false: Fails test (Unexpected error).
52+
// - If want is true: Success (Any error accepted).
53+
// - If want is string: Asserts err.Error() contains the string.
54+
// - If want is error: Asserts errors.Is(err, want) or type match.
55+
func AssertError(t testing.TB, got error, want any) bool {
56+
t.Helper()
57+
58+
// Case 1: No error occurred
59+
if got == nil {
60+
if want == nil || want == false {
61+
return true
62+
}
63+
t.Errorf("got nil error, want %v", want)
64+
return false
65+
}
66+
67+
// Case 2: Error occurred
68+
if want == nil || want == false {
69+
t.Errorf("got unexpected error: %v", got)
70+
return false
71+
}
72+
73+
if want == true {
74+
return false // Error expected and received, stop test
75+
}
76+
77+
// Handle string error type expectation
78+
if wantStr, ok := want.(string); ok {
79+
if !strings.Contains(got.Error(), wantStr) {
80+
t.Errorf("got error %q, want substring %q", got, wantStr)
81+
}
82+
return false
83+
}
84+
85+
// Handle specific error type expectation
86+
if wantErr, ok := want.(error); ok {
87+
if checkErrorMatch(got, wantErr) {
88+
return false
89+
}
90+
t.Errorf("got error %v, want %v", got, wantErr)
91+
return false
92+
}
93+
94+
t.Errorf("invalid want type %T for AssertError", want)
95+
return false
96+
}
97+
98+
func checkErrorMatch(got, want error) bool {
99+
if errors.Is(got, want) {
100+
return true
101+
}
102+
103+
// Fallback to type check using errors.As to handle wrapped errors
104+
if want != nil {
105+
typ := reflect.TypeOf(want)
106+
// errors.As requires a pointer to the target type.
107+
// reflect.New(typ) returns *T where T is the type of want.
108+
target := reflect.New(typ).Interface()
109+
if errors.As(got, target) {
110+
return true
111+
}
112+
}
113+
114+
return false
115+
}
116+
117+
// DiffFunc compares two values and returns a diff string. An empty string means
118+
// equality.
119+
type DiffFunc func(got, want any) string
120+
121+
// ValueComparisonOption configures how HandleValueResult applies cmp options or
122+
// diffing strategies.
123+
type ValueComparisonOption func(*valueComparisonConfig)
124+
125+
type valueComparisonConfig struct {
126+
diffFunc DiffFunc
127+
cmpOptions []cmp.Option
128+
}
129+
130+
func (config *valueComparisonConfig) getDiffFunc() DiffFunc {
131+
if config.diffFunc != nil {
132+
return config.diffFunc
133+
}
134+
return func(got, want any) string {
135+
return cmp.Diff(got, want, config.cmpOptions...)
136+
}
137+
}
138+
139+
// WithCmpOptions accumulates cmp.Options used during value comparison.
140+
func WithAssertionCmpOptions(opts ...cmp.Option) ValueComparisonOption {
141+
return func(config *valueComparisonConfig) {
142+
config.cmpOptions = append(config.cmpOptions, opts...)
143+
}
144+
}
145+
146+
// WithAllowUnexported enables comparison of unexported fields for the provided
147+
// struct types.
148+
func WithAllowUnexported(types ...any) ValueComparisonOption {
149+
return WithAssertionCmpOptions(cmp.AllowUnexported(types...))
150+
}
151+
152+
// WithDiffFunc sets a custom diffing function. Providing this option overrides
153+
// the default cmp-based diff logic.
154+
func WithDiffFunc(diffFunc DiffFunc) ValueComparisonOption {
155+
return func(config *valueComparisonConfig) {
156+
config.diffFunc = diffFunc
157+
}
158+
}
159+
160+
// WithIgnoreFields ignores the specified fields on the provided type during comparison.
161+
// It uses cmpopts.IgnoreFields to ensure type-safe filtering.
162+
func WithIgnoreFields(typ any, names ...string) ValueComparisonOption {
163+
return WithAssertionCmpOptions(cmpopts.IgnoreFields(typ, names...))
164+
}
165+
166+
// AssertValue compares two values with cmp.Diff while allowing callers to
167+
// tweak the diff strategy via ValueComparisonOption. A non-empty diff is
168+
// reported as an error containing the diff output.
169+
func AssertValue[T any](t testing.TB, got, want T, opts ...ValueComparisonOption) {
170+
t.Helper()
171+
// Configure comparison options
172+
config := &valueComparisonConfig{}
173+
for _, opt := range opts {
174+
opt(config)
175+
}
176+
// Perform comparison and report diff
177+
diff := config.getDiffFunc()(got, want)
178+
if diff != "" {
179+
t.Errorf("values do not match: %s", diff)
180+
}
181+
}

0 commit comments

Comments
 (0)