Skip to content

Commit 3e3d0b1

Browse files
warbertoddbaertbeeme1mr
authored
feat: TestProvider for easy, parallel-safe testing (#295)
added experimental test-aware provider Signed-off-by: Bernd Warmuth <[email protected]> Co-authored-by: Todd Baert <[email protected]> Co-authored-by: Michael Beemer <[email protected]>
1 parent dee5ec7 commit 3e3d0b1

File tree

4 files changed

+347
-36
lines changed

4 files changed

+347
-36
lines changed

README.md

+58-16
Original file line numberDiff line numberDiff line change
@@ -432,29 +432,71 @@ func (h MyHook) Error(context context.Context, hookContext openfeature.HookConte
432432
433433
## Testing
434434

435-
To test interactions with OpenFeature API and Client, you can rely on `openfeature.IEvaluation` & `openfeature.IClient` interfaces.
435+
The SDK provides a `NewTestProvider` which allows you to set flags for the scope of a test.
436+
The `TestProvider` is thread-safe and can be used in tests that run in parallel.
436437

437-
While you may use global methods to interact with the API, it is recommended to obtain the singleton API instance so that you can use appropriate mocks for your testing needs,
438+
Call `testProvider.UsingFlags(t, tt.flags)` to set flags for a test, and clean them up with `testProvider.Cleanup()`
438439

439440
```go
440-
// global helper
441-
openfeature.SetProvider(myProvider)
441+
import (
442+
"github.com/open-feature/go-sdk/openfeature"
443+
"github.com/open-feature/go-sdk/openfeature/testing"
444+
)
442445

443-
// singleton instance - preferred
444-
apiInstance := openfeature.GetApiInstance()
445-
apiInstance.SetProvider(myProvider)
446-
```
446+
testProvider := NewTestProvider()
447+
err := openfeature.GetApiInstance().SetProvider(testProvider)
448+
if err != nil {
449+
t.Errorf("unable to set provider")
450+
}
451+
452+
// configure flags for this test suite
453+
tests := map[string]struct {
454+
flags map[string]memprovider.InMemoryFlag
455+
want bool
456+
}{
457+
"test when flag is true": {
458+
flags: map[string]memprovider.InMemoryFlag{
459+
"my_flag": {
460+
State: memprovider.Enabled,
461+
DefaultVariant: "on",
462+
Variants: map[string]any{
463+
"on": true,
464+
},
465+
},
466+
},
467+
want: true,
468+
},
469+
"test when flag is false": {
470+
flags: map[string]memprovider.InMemoryFlag{
471+
"my_flag": {
472+
State: memprovider.Enabled,
473+
DefaultVariant: "off",
474+
Variants: map[string]any{
475+
"off": false,
476+
},
477+
},
478+
},
479+
want: false,
480+
},
481+
}
447482

448-
Similarly, while you have option (due to historical reasons) to create a client with `openfeature.NewClient()` helper, it is recommended to use API to generate the client which returns an `IClient` instance.
483+
for name, tt := range tests {
484+
tt := tt
485+
name := name
486+
t.Run(name, func(t *testing.T) {
449487

450-
```go
451-
// global helper
452-
openfeature.NewClient("myClient")
488+
// be sure to clean up your flags
489+
defer testProvider.Cleanup()
490+
testProvider.UsingFlags(t, tt.flags)
453491

454-
// using API instance - preferred
455-
apiInstance := openfeature.GetApiInstance()
456-
apiInstance.GetClient()
457-
apiInstance.GetNamedClient("myClient")
492+
// your code under test
493+
got := functionUnderTest()
494+
495+
if got != tt.want {
496+
t.Fatalf("uh oh, value is not as expected: got %v, want %v", got, tt.want)
497+
}
498+
})
499+
}
458500
```
459501

460502
<!-- x-hide-in-docs-start -->

go.sum

-20
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
22
github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI=
33
github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0=
4-
github.com/cucumber/godog v0.14.0 h1:h/K4t7XBxsFBF+UJEahNqJ1/2VHVepRXCSq3WWWnehs=
5-
github.com/cucumber/godog v0.14.0/go.mod h1:FX3rzIDybWABU4kuIXLZ/qtqEe1Ac5RdXmqvACJOces=
64
github.com/cucumber/godog v0.14.1 h1:HGZhcOyyfaKclHjJ+r/q93iaTJZLKYW6Tv3HkmUE6+M=
75
github.com/cucumber/godog v0.14.1/go.mod h1:FX3rzIDybWABU4kuIXLZ/qtqEe1Ac5RdXmqvACJOces=
86
github.com/cucumber/godog v0.15.0 h1:51AL8lBXF3f0cyA5CV4TnJFCTHpgiy+1x1Hb3TtZUmo=
@@ -13,12 +11,9 @@ github.com/cucumber/messages/go/v22 v22.0.0/go.mod h1:aZipXTKc0JnjCsXrJnuZpWhtay
1311
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
1412
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
1513
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
16-
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
17-
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
1814
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
1915
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
2016
github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
21-
github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI=
2217
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
2318
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
2419
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
@@ -33,7 +28,6 @@ github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b
3328
github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE=
3429
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
3530
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
36-
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
3731
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
3832
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
3933
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
@@ -59,12 +53,6 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
5953
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
6054
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
6155
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
62-
golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3 h1:/RIbNt/Zr7rVhIkQhooTxCxFcdWLGIKnZA4IXNFSrvo=
63-
golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
64-
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ=
65-
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
66-
golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY=
67-
golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
6856
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
6957
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
7058
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -81,14 +69,6 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc
8169
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
8270
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
8371
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
84-
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
85-
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
86-
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
87-
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
88-
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
89-
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
90-
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
91-
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
9272
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
9373
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
9474
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

openfeature/testing/testprovider.go

+122
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package testing
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"runtime"
7+
"sync"
8+
"testing"
9+
10+
"github.com/open-feature/go-sdk/openfeature"
11+
"github.com/open-feature/go-sdk/openfeature/memprovider"
12+
)
13+
14+
const testNameKey = "testName"
15+
16+
// NewTestProvider creates a new `TestAwareProvider`
17+
func NewTestProvider() TestProvider {
18+
return TestProvider{
19+
providers: &sync.Map{},
20+
}
21+
}
22+
23+
// TestProvider is the recommended way to defined flags within the scope of a test.
24+
// It uses the InMemoryProvider, with flags scoped per test.
25+
// Before executing a test, specify the flag values to be used for the specific test using the UsingFlags function
26+
type TestProvider struct {
27+
openfeature.NoopProvider
28+
providers *sync.Map
29+
}
30+
31+
// UsingFlags sets flags for the scope of a test
32+
func (tp TestProvider) UsingFlags(test *testing.T, flags map[string]memprovider.InMemoryFlag) {
33+
storeGoroutineLocal(test.Name())
34+
tp.providers.Store(test.Name(), memprovider.NewInMemoryProvider(flags))
35+
}
36+
37+
// Cleanup deletes the flags provider bound to the current test and should be executed after each test execution
38+
// e.g. using a defer statement.
39+
func (tp TestProvider) Cleanup() {
40+
tp.providers.Delete(getGoroutineLocal())
41+
deleteGoroutineLocal()
42+
}
43+
44+
func (tp TestProvider) BooleanEvaluation(ctx context.Context, flag string, defaultValue bool, flCtx openfeature.FlattenedContext) openfeature.BoolResolutionDetail {
45+
return tp.getProvider().BooleanEvaluation(ctx, flag, defaultValue, flCtx)
46+
}
47+
48+
func (tp TestProvider) StringEvaluation(ctx context.Context, flag string, defaultValue string, flCtx openfeature.FlattenedContext) openfeature.StringResolutionDetail {
49+
return tp.getProvider().StringEvaluation(ctx, flag, defaultValue, flCtx)
50+
}
51+
52+
func (tp TestProvider) FloatEvaluation(ctx context.Context, flag string, defaultValue float64, flCtx openfeature.FlattenedContext) openfeature.FloatResolutionDetail {
53+
return tp.getProvider().FloatEvaluation(ctx, flag, defaultValue, flCtx)
54+
}
55+
56+
func (tp TestProvider) IntEvaluation(ctx context.Context, flag string, defaultValue int64, flCtx openfeature.FlattenedContext) openfeature.IntResolutionDetail {
57+
return tp.getProvider().IntEvaluation(ctx, flag, defaultValue, flCtx)
58+
}
59+
60+
func (tp TestProvider) ObjectEvaluation(ctx context.Context, flag string, defaultValue interface{}, flCtx openfeature.FlattenedContext) openfeature.InterfaceResolutionDetail {
61+
return tp.getProvider().ObjectEvaluation(ctx, flag, defaultValue, flCtx)
62+
}
63+
64+
func (tp TestProvider) Hooks() []openfeature.Hook {
65+
return tp.NoopProvider.Hooks()
66+
}
67+
68+
func (tp TestProvider) Metadata() openfeature.Metadata {
69+
return tp.NoopProvider.Metadata()
70+
}
71+
72+
func (tp TestProvider) getProvider() openfeature.FeatureProvider {
73+
// Retrieve the test name from the goroutine-local storage.
74+
testName, ok := getGoroutineLocal().(string)
75+
if !ok {
76+
panic("unable to detect test name; be sure to call `UsingFlags` in the scope of a test (in T.run)!")
77+
}
78+
79+
// Load the feature provider corresponding to the test name.
80+
provider, ok := tp.providers.Load(testName)
81+
if !ok {
82+
panic("unable to find feature provider for given test name: " + testName)
83+
}
84+
85+
// Assert that the loaded provider is of type openfeature.FeatureProvider.
86+
featureProvider, ok := provider.(openfeature.FeatureProvider)
87+
if !ok {
88+
panic("invalid type for feature provider for given test name: " + testName)
89+
}
90+
91+
return featureProvider
92+
}
93+
94+
var goroutineLocalData sync.Map
95+
96+
func storeGoroutineLocal(value interface{}) {
97+
gID := getGoroutineID()
98+
goroutineLocalData.Store(fmt.Sprintf("%d_%v", gID, testNameKey), value)
99+
}
100+
101+
func getGoroutineLocal() interface{} {
102+
gID := getGoroutineID()
103+
value, _ := goroutineLocalData.Load(fmt.Sprintf("%d_%v", gID, testNameKey))
104+
return value
105+
}
106+
107+
func deleteGoroutineLocal() {
108+
gID := getGoroutineID()
109+
goroutineLocalData.Delete(fmt.Sprintf("%d_%v", gID, testNameKey))
110+
}
111+
112+
func getGoroutineID() uint64 {
113+
var buf [64]byte
114+
n := runtime.Stack(buf[:], false)
115+
stackLine := string(buf[:n])
116+
var gID uint64
117+
_, err := fmt.Sscanf(stackLine, "goroutine %d ", &gID)
118+
if err != nil {
119+
panic("unable to extract GID from stack trace")
120+
}
121+
return gID
122+
}

0 commit comments

Comments
 (0)