Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 29 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
[OpenFeature](https://openfeature.dev) is an open specification that provides a vendor-agnostic, community-driven API for feature flagging that works with your favorite feature flag management tool.

<!-- x-hide-in-docs-end -->

## 🚀 Quick start

### Requirements
Expand Down Expand Up @@ -90,17 +91,17 @@ See [here](https://pkg.go.dev/github.com/open-feature/go-sdk/openfeature) for th
## 🌟 Features

| Status | Features | Description |
| ------ |---------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ✅ | [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. |
| ✅ | [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). |
| ✅ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
| ✅ | [Tracking](#tracking) | Associate user actions with feature flag evaluations. |
| ✅ | [Logging](#logging) | Integrate with popular logging packages. |
| ✅ | [Domains](#domains) | Logically bind clients with providers. |
| ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. |
| ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
| ✅ | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread) |
| ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
| ------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| ✅ | [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. |
| ✅ | [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). |
| ✅ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
| ✅ | [Tracking](#tracking) | Associate user actions with feature flag evaluations. |
| ✅ | [Logging](#logging) | Integrate with popular logging packages. |
| ✅ | [Domains](#domains) | Logically bind clients with providers. |
| ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. |
| ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
| ✅ | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread) |
| ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |

<sub>Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌</sub>

Expand Down Expand Up @@ -152,7 +153,6 @@ evalCtx := openfeature.NewEvaluationContext(
boolValue, err := client.BooleanValue(context.TODO(), "boolFlag", false, evalCtx)
```


### Hooks

[Hooks](https://openfeature.dev/docs/reference/concepts/hooks) allow for custom logic to be added at well-defined points of the flag evaluation life-cycle
Expand Down Expand Up @@ -309,7 +309,6 @@ import "github.com/open-feature/go-sdk/openfeature"
openfeature.Shutdown()
```


### Transaction Context Propagation

Transaction context is a container for transaction-specific evaluation context (e.g. user id, user agent, IP).
Expand Down Expand Up @@ -514,14 +513,29 @@ for name, tt := range tests {
}
```

If your test code runs in a different goroutine, `TestProvider.UsingFlags` returns a context that should be used for evaluations.
You can pass `*testing.T` directly.

```go
// In your test, 't' is a *testing.T
ctx := testProvider.UsingFlags(t, tt.flags)

go func() {
// Make sure to use the context returned by UsingFlags in the new goroutine.
// The context carries the necessary information for the TestProvider.
_ = openfeature.NewDefaultClient().Boolean(ctx, "my_flag", false, openfeature.EvaluationContext{})
}()
```

### Mocks

Mocks are also available for testing purposes for all interfaces within the OpenFeature SDK. These are primarily
intended for internal use for testing the SDK, but have been exported to ease the testing burden for any extensions
or custom components (e.g. hooks & providers). These mocks are not include in builds by default. The build tag
or custom components (e.g. hooks & providers). These mocks are not include in builds by default. The build tag
`testtools` must be used to have the mocks included in builds.

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

## ⭐️ Support the project

- Give this repo a ⭐️!
Expand All @@ -542,4 +556,5 @@ Interested in contributing? Great, we'd love your help! To get started, take a l
</a>

Made with [contrib.rocks](https://contrib.rocks).

<!-- x-hide-in-docs-end -->
58 changes: 47 additions & 11 deletions openfeature/testing/testprovider.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import (
"fmt"
"runtime"
"sync"
"testing"

"github.com/open-feature/go-sdk/openfeature"
"github.com/open-feature/go-sdk/openfeature/memprovider"
)

const testNameKey = "testName"
type contextKey string

const testNameKey contextKey = "testName"

// NewTestProvider creates a new `TestAwareProvider`
func NewTestProvider() TestProvider {
Expand All @@ -28,12 +31,38 @@ type TestProvider struct {
providers *sync.Map
}

type TestFramework = interface{ Name() string }
type TestFramework = interface {
Name() string
}

// testFrameworkWithContext is an optional interface for tests that can provide
// a context, enabling context-aware evaluation when flags are used across goroutines.
type testFrameworkWithContext interface {
Comment on lines +38 to +40
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the purpose of this secondary interface? why not just update TestFramework to contain Context() context.Context? it'd be much simpler that way...

for that matter, why not use testing.TB instead? the TestFramework interface seems like an unnecessary abstraction.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would love to use testing.TB, but there will likely be someone who doesn’t use it in a specific case, and their tests or code might fail. Since go-sdk is already at v1, that could make people unhappy. So I work around...

TestFramework
Context() context.Context
}

// UsingFlags sets flags for the scope of a test.
func (tp TestProvider) UsingFlags(test TestFramework, flags map[string]memprovider.InMemoryFlag) {
func (tp TestProvider) UsingFlags(test TestFramework, flags map[string]memprovider.InMemoryFlag) context.Context {
storeGoroutineLocal(test.Name())
tp.providers.Store(test.Name(), memprovider.NewInMemoryProvider(flags))
ctx := context.Background()

// allow to pass the context without breaking changes
if t, ok := test.(testFrameworkWithContext); ok {
if tctx := t.Context(); tctx != nil {
ctx = tctx
}
}

// if test is testing.TB add the auto Cleanup
if t, ok := test.(testing.TB); ok {
t.Cleanup(func() {
tp.Cleanup()
})
}

return context.WithValue(ctx, testNameKey, test.Name())
}

// Cleanup deletes the flags provider bound to the current test and should be executed after each test execution
Expand All @@ -44,23 +73,23 @@ func (tp TestProvider) Cleanup() {
}

func (tp TestProvider) BooleanEvaluation(ctx context.Context, flag string, defaultValue bool, flatCtx openfeature.FlattenedContext) openfeature.BoolResolutionDetail {
return tp.getProvider().BooleanEvaluation(ctx, flag, defaultValue, flatCtx)
return tp.getProvider(ctx).BooleanEvaluation(ctx, flag, defaultValue, flatCtx)
}

func (tp TestProvider) StringEvaluation(ctx context.Context, flag string, defaultValue string, flatCtx openfeature.FlattenedContext) openfeature.StringResolutionDetail {
return tp.getProvider().StringEvaluation(ctx, flag, defaultValue, flatCtx)
return tp.getProvider(ctx).StringEvaluation(ctx, flag, defaultValue, flatCtx)
}

func (tp TestProvider) FloatEvaluation(ctx context.Context, flag string, defaultValue float64, flatCtx openfeature.FlattenedContext) openfeature.FloatResolutionDetail {
return tp.getProvider().FloatEvaluation(ctx, flag, defaultValue, flatCtx)
return tp.getProvider(ctx).FloatEvaluation(ctx, flag, defaultValue, flatCtx)
}

func (tp TestProvider) IntEvaluation(ctx context.Context, flag string, defaultValue int64, flatCtx openfeature.FlattenedContext) openfeature.IntResolutionDetail {
return tp.getProvider().IntEvaluation(ctx, flag, defaultValue, flatCtx)
return tp.getProvider(ctx).IntEvaluation(ctx, flag, defaultValue, flatCtx)
}

func (tp TestProvider) ObjectEvaluation(ctx context.Context, flag string, defaultValue any, flatCtx openfeature.FlattenedContext) openfeature.InterfaceResolutionDetail {
return tp.getProvider().ObjectEvaluation(ctx, flag, defaultValue, flatCtx)
return tp.getProvider(ctx).ObjectEvaluation(ctx, flag, defaultValue, flatCtx)
}

func (tp TestProvider) Hooks() []openfeature.Hook {
Expand All @@ -71,11 +100,18 @@ func (tp TestProvider) Metadata() openfeature.Metadata {
return tp.NoopProvider.Metadata()
}

func (tp TestProvider) getProvider() openfeature.FeatureProvider {
func (tp TestProvider) getProvider(ctx context.Context) openfeature.FeatureProvider {
// Retrieve the test name from the goroutine-local storage.
testName, ok := getGoroutineLocal().(string)
//
if ctx == nil {
ctx = context.Background()
}
Comment on lines +105 to +108
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove this. If ctx is nil here, then it's a programmer error that would be masked by silent substitution with context.Background().

testName, ok := ctx.Value(testNameKey).(string)
if !ok {
panic("unable to detect test name; be sure to call `UsingFlags` in the scope of a test (in T.run)!")
testName, ok = getGoroutineLocal().(string)
if !ok {
panic("unable to detect test name; be sure to call `UsingFlags` in the scope of a test (in T.run)!")
}
}

// Load the feature provider corresponding to the test name.
Expand Down
83 changes: 83 additions & 0 deletions openfeature/testing/testprovider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@ package testing

import (
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"

"github.com/open-feature/go-sdk/openfeature"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestParallelSingletonUsage(t *testing.T) {
Expand Down Expand Up @@ -156,6 +162,83 @@ func Test_TestAwareProviderPanics(t *testing.T) {
})
}

func TestServeWithAnotherGoroutine(t *testing.T) {
t.Run("sample 1", func(t *testing.T) {
testProvider := NewTestProvider()
ctx := testProvider.UsingFlags(t, map[string]memprovider.InMemoryFlag{
"myflag": {
DefaultVariant: "defaultVariant",
Variants: map[string]any{"defaultVariant": true},
},
})

err := openfeature.SetProviderAndWait(testProvider)
require.NoError(t, err)

handlerDone := make(chan struct{})
handler := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
_ = openfeature.NewDefaultClient().Boolean(ctx, "myflag", false, openfeature.TransactionContext(ctx))

select {
case <-r.Context().Done():
w.WriteHeader(http.StatusServiceUnavailable)
default:
w.WriteHeader(http.StatusOK)
}
close(handlerDone)
}

req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/drain", nil)
w := httptest.NewRecorder()

// Start the handler in a goroutine for _reasons_
// This is what triggers the TestProvider bug
go func() {
handler(w, req)
}()

// Wait for handler to complete
require.Eventually(t, func() bool {
select {
case <-handlerDone:
return true
default:
return false
}
}, time.Second, 50*time.Millisecond, "expected request was not process within timeout")

assert.Equal(t, http.StatusOK, w.Code)
})

t.Run("sample 2", func(t *testing.T) {
provider := NewTestProvider()
ctx := provider.UsingFlags(t, map[string]memprovider.InMemoryFlag{
"foo": {
State: memprovider.Enabled,
DefaultVariant: "true",
Variants: map[string]any{
"true": true,
},
},
})
err := openfeature.SetProvider(provider)
require.NoError(t, err)

var wg sync.WaitGroup

for i := range 2 {
wg.Add(1)
go func(i int) {
defer wg.Done()
client := openfeature.NewDefaultClient()
client.Boolean(ctx, "foo", false, openfeature.EvaluationContext{})
}(i)
}
wg.Wait()
})
}

func functionUnderTest() bool {
got := openfeature.NewDefaultClient().
Boolean(context.TODO(), "my_flag", false, openfeature.EvaluationContext{})
Expand Down
Loading