From c8403f27656646ad151fe6c2ab86643a0371ada3 Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:26:04 -0700 Subject: [PATCH] feat: added built-in mocking feature --- README.md | 17 ++--- client.go | 4 + mock.go | 202 +++++++++++++++++++++++++++++++++++++++++++++++++++ mock_test.go | 184 ++++++++++++++++++++++++++++++++++++++++++++++ switcher.go | 6 ++ 5 files changed, 403 insertions(+), 10 deletions(-) create mode 100644 mock.go create mode 100644 mock_test.go diff --git a/README.md b/README.md index a439030..4afff25 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,6 @@ A Go SDK for Switcher API - [Snapshot Monitoring](#snapshot-monitoring) - [Testing & Development](#testing--development) - [Built-in Mocking](#built-in-mocking) - - [Test Helpers](#test-helpers) - [Configuration Validation](#configuration-validation) - [Contributing](#contributing) @@ -484,9 +483,9 @@ if err != nil { ## Testing & Development -### Built-in Mocking (Under development) +### Built-in Mocking -The Go SDK provides test-oriented mocking capabilities adapted to Go idioms and safer state ownership. +The Go SDK provides client-scoped test mocking adapted to Go idioms and safer state ownership. ```go sdk := client.NewClient(ctx) @@ -497,6 +496,10 @@ assert.NoError(t, err) assert.True(t, enabled) ``` +```go +sdk.Assume("FEATURE01").True().Cleanup(t) +``` + ```go sdk.Assume("FEATURE01").True(). When(client.StrategyValue, []string{"guest", "admin"}). @@ -525,13 +528,7 @@ assert.Equal(t, false, response.Result) assert.Equal(t, "Feature is disabled", response.Metadata["message"]) ``` -### Test Helpers (Under development) - -This area is under active development. The helper surface focuses on: - -- explicit test helpers instead of decorators -- automatic cleanup helpers for tests where useful -- mock isolation by client instance and, when needed, `context.Context` +Mocks stay scoped to the `sdk` instance. Remove them explicitly with `sdk.Forget("FEATURE01")` or register automatic test cleanup with `.Cleanup(t)`. ### Configuration Validation diff --git a/client.go b/client.go index ccaf6eb..8b78b48 100644 --- a/client.go +++ b/client.go @@ -22,6 +22,9 @@ type Client struct { switchers map[string]*Switcher snapshot *Snapshot + mockMu sync.RWMutex + mocks map[string]*mockDefinition + executionLogger *executionLogger throttleTokens chan struct{} @@ -47,6 +50,7 @@ func NewClient(ctx Context) *Client { return &Client{ context: defaulted, switchers: make(map[string]*Switcher), + mocks: make(map[string]*mockDefinition), executionLogger: newExecutionLogger(), throttleTokens: newThrottleTokens(defaulted.Options.ThrottleMaxWorkers), snapshotWatcher: newSnapshotWatcher(), diff --git a/mock.go b/mock.go new file mode 100644 index 0000000..b0b36f6 --- /dev/null +++ b/mock.go @@ -0,0 +1,202 @@ +package client + +import ( + "fmt" + "strings" +) + +const ( + mockReasonTrue = "Forced to True" + mockReasonFalse = "Forced to False" +) + +type cleanupRegistrar interface { + Cleanup(func()) + Helper() +} + +type MockAssumption struct { + client *Client + key string + entry *mockDefinition +} + +type mockDefinition struct { + result bool + reason string + metadata map[string]any + when []mockCondition +} + +type mockCondition struct { + Strategy string + Inputs []string +} + +// Assume registers a client-scoped mocked result for key and returns a fluent builder. +func (c *Client) Assume(key string) *MockAssumption { + c.mockMu.Lock() + defer c.mockMu.Unlock() + + entry := &mockDefinition{} + c.mocks[key] = entry + return &MockAssumption{ + client: c, + key: key, + entry: entry, + } +} + +// Forget removes a previously assumed mocked result for key from this client. +func (c *Client) Forget(key string) { + c.mockMu.Lock() + defer c.mockMu.Unlock() + + delete(c.mocks, key) +} + +// True forces the switcher result to true for this assumption. +func (a *MockAssumption) True() *MockAssumption { + a.client.mockMu.Lock() + a.entry.result = true + a.entry.reason = mockReasonTrue + a.client.mockMu.Unlock() + return a +} + +// False forces the switcher result to false for this assumption. +func (a *MockAssumption) False() *MockAssumption { + a.client.mockMu.Lock() + a.entry.result = false + a.entry.reason = mockReasonFalse + a.client.mockMu.Unlock() + return a +} + +// When limits the mock to specific strategy inputs. Unsupported strategies are ignored. +func (a *MockAssumption) When(strategy string, input any) *MockAssumption { + inputs := normalizeMockInputs(input) + + a.client.mockMu.Lock() + a.entry.when = upsertMockCondition(a.entry.when, mockCondition{ + Strategy: strategy, + Inputs: inputs, + }) + a.client.mockMu.Unlock() + return a +} + +// WithMetadata attaches response metadata to the mocked result. +func (a *MockAssumption) WithMetadata(metadata map[string]any) *MockAssumption { + a.client.mockMu.Lock() + a.entry.metadata = cloneMetadata(metadata) + a.client.mockMu.Unlock() + return a +} + +// Cleanup registers automatic cleanup for the assumption through a test-like cleanup registrar. +func (a *MockAssumption) Cleanup(t cleanupRegistrar) *MockAssumption { + t.Helper() + client := a.client + key := a.key + t.Cleanup(func() { + client.Forget(key) + }) + return a +} + +func (c *Client) mockedResult(switcher *Switcher) (ResultDetail, bool) { + c.mockMu.RLock() + entry, ok := c.mocks[switcher.key] + if !ok { + c.mockMu.RUnlock() + return ResultDetail{}, false + } + + definition := entry.clone() + c.mockMu.RUnlock() + + return definition.responseFor(switcher.entries), true +} + +func (m *mockDefinition) clone() mockDefinition { + cloned := mockDefinition{ + result: m.result, + reason: m.reason, + metadata: cloneMetadata(m.metadata), + when: make([]mockCondition, len(m.when)), + } + for i := range m.when { + cloned.when[i] = mockCondition{ + Strategy: m.when[i].Strategy, + Inputs: append([]string(nil), m.when[i].Inputs...), + } + } + + return cloned +} + +func (m mockDefinition) responseFor(entries []criteriaEntry) ResultDetail { + result := m.result + reason := m.reason + for _, condition := range m.when { + input, ok := findMockInput(entries, condition.Strategy) + if !ok || containsString(condition.Inputs, input) { + continue + } + + result = !m.result + reason = mismatchMockReason(result, condition.Inputs, input) + break + } + + return ResultDetail{ + Result: result, + Reason: reason, + Metadata: cloneMetadata(m.metadata), + } +} + +func findMockInput(entries []criteriaEntry, strategy string) (string, bool) { + for _, entry := range entries { + if entry.Strategy == strategy { + return entry.Input, true + } + } + + return "", false +} + +func upsertMockCondition(conditions []mockCondition, next mockCondition) []mockCondition { + for i := range conditions { + if conditions[i].Strategy == next.Strategy { + conditions[i] = next + return conditions + } + } + + return append(conditions, next) +} + +func normalizeMockInputs(input any) []string { + switch value := input.(type) { + case []string: + return append([]string(nil), value...) + default: + return []string{fmt.Sprint(value)} + } +} + +func mismatchMockReason(result bool, expected []string, input string) string { + label := "False" + if result { + label = "True" + } + + return fmt.Sprintf( + "Forced to %s when: [%s] - input: %s", + label, + strings.Join(expected, ", "), + input, + ) +} diff --git a/mock_test.go b/mock_test.go new file mode 100644 index 0000000..73b231d --- /dev/null +++ b/mock_test.go @@ -0,0 +1,184 @@ +package client + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +type cleanupRecorder struct { + callbacks []func() +} + +func (r *cleanupRecorder) Cleanup(callback func()) { + r.callbacks = append(r.callbacks, callback) +} + +func (r *cleanupRecorder) Helper() {} + +func (r *cleanupRecorder) Run() { + for _, callback := range r.callbacks { + callback() + } +} + +func TestClientAssume(t *testing.T) { + t.Run("should bypass evaluation with a forced true result", func(t *testing.T) { + client := NewClient(Context{}) + client.Assume("FEATURE01").True() + + enabled, err := client.GetSwitcher("FEATURE01").IsOn() + + assert.NoError(t, err) + assert.True(t, enabled) + }) + + t.Run("should return mocked details and metadata", func(t *testing.T) { + client := NewClient(Context{}) + client.Assume("FEATURE01").False().WithMetadata(map[string]any{ + "message": "Feature is disabled", + }) + + response, err := client.GetSwitcher("FEATURE01").IsOnWithDetails() + + assert.NoError(t, err) + assert.False(t, response.Result) + assert.Equal(t, mockReasonFalse, response.Reason) + assert.Equal(t, "Feature is disabled", response.Metadata["message"]) + }) + + t.Run("should support conditional mocks based on multiple strategies", func(t *testing.T) { + client := NewClient(Context{}) + client.Assume("FEATURE01").True(). + When(StrategyValue, []string{"guest", "admin"}). + When(StrategyNetwork, "10.0.0.3") + + response, err := client.GetSwitcher("FEATURE01"). + CheckValue("guest"). + CheckNetwork("10.0.0.3"). + IsOnWithDetails() + + assert.NoError(t, err) + assert.True(t, response.Result) + assert.Equal(t, mockReasonTrue, response.Reason) + }) + + t.Run("should invert the forced result when a when condition does not match", func(t *testing.T) { + client := NewClient(Context{}) + client.Assume("FEATURE01").True().When(StrategyValue, "Canada") + + response, err := client.GetSwitcher("FEATURE01").CheckValue("Brazil").IsOnWithDetails() + + assert.NoError(t, err) + assert.False(t, response.Result) + assert.Equal(t, "Forced to False when: [Canada] - input: Brazil", response.Reason) + }) + + t.Run("should report a true mismatch reason when a false assumption does not match", func(t *testing.T) { + client := NewClient(Context{}) + client.Assume("FEATURE01").False().When(StrategyValue, "Canada") + + response, err := client.GetSwitcher("FEATURE01").CheckValue("Brazil").IsOnWithDetails() + + assert.NoError(t, err) + assert.True(t, response.Result) + assert.Equal(t, "Forced to True when: [Canada] - input: Brazil", response.Reason) + }) + + t.Run("should keep the forced result when the configured strategy input is not provided", func(t *testing.T) { + client := NewClient(Context{}) + client.Assume("FEATURE01").True().When(StrategyNetwork, "10.0.0.3") + + response, err := client.GetSwitcher("FEATURE01").CheckValue("guest").IsOnWithDetails() + + assert.NoError(t, err) + assert.True(t, response.Result) + assert.Equal(t, mockReasonTrue, response.Reason) + }) + + t.Run("should accept non slice values in when and match them through string normalization", func(t *testing.T) { + client := NewClient(Context{}) + client.Assume("FEATURE01").True().When(StrategyValue, 123) + + response, err := client.GetSwitcher("FEATURE01").CheckValue("123").IsOnWithDetails() + + assert.NoError(t, err) + assert.True(t, response.Result) + assert.Equal(t, mockReasonTrue, response.Reason) + }) + + t.Run("should replace a previous when condition for the same strategy", func(t *testing.T) { + client := NewClient(Context{}) + client.Assume("FEATURE01").True(). + When(StrategyValue, "Canada"). + When(StrategyValue, "Brazil") + + response, err := client.GetSwitcher("FEATURE01").CheckValue("Canada").IsOnWithDetails() + + assert.NoError(t, err) + assert.False(t, response.Result) + assert.Equal(t, "Forced to False when: [Brazil] - input: Canada", response.Reason) + }) + + t.Run("should replace a previous assumption for the same key", func(t *testing.T) { + client := NewClient(Context{}) + client.Assume("FEATURE01").True() + client.Assume("FEATURE01").False() + + enabled, err := client.GetSwitcher("FEATURE01").IsOn() + + assert.NoError(t, err) + assert.False(t, enabled) + }) + + t.Run("should forget the mocked result and restore normal evaluation", func(t *testing.T) { + client := NewClient(Context{ + Domain: "My Domain", + Environment: "default", + Options: ContextOptions{ + Local: true, + SnapshotLocation: snapshotFixtureDir(), + }, + }) + _, err := client.LoadSnapshot(nil) + assert.NoError(t, err) + + client.Assume("FF2FOR2022").False() + + mocked, err := client.GetSwitcher("FF2FOR2022").IsOn() + assert.NoError(t, err) + assert.False(t, mocked) + + client.Forget("FF2FOR2022") + + enabled, err := client.GetSwitcher("FF2FOR2022").IsOn() + assert.NoError(t, err) + assert.True(t, enabled) + }) + + t.Run("should register cleanup through the Go test helper", func(t *testing.T) { + client := NewClient(Context{ + Domain: "My Domain", + Environment: "default", + Options: ContextOptions{ + Local: true, + SnapshotLocation: snapshotFixtureDir(), + }, + }) + _, err := client.LoadSnapshot(nil) + assert.NoError(t, err) + + recorder := &cleanupRecorder{} + client.Assume("FF2FOR2022").False().Cleanup(recorder) + + mocked, err := client.GetSwitcher("FF2FOR2022").IsOn() + assert.NoError(t, err) + assert.False(t, mocked) + + recorder.Run() + + enabled, err := client.GetSwitcher("FF2FOR2022").IsOn() + assert.NoError(t, err) + assert.True(t, enabled) + }) +} diff --git a/switcher.go b/switcher.go index 3d46869..3f0e3a3 100644 --- a/switcher.go +++ b/switcher.go @@ -177,6 +177,12 @@ func (s *Switcher) IsOnWithDetailsOrDefault(def ResultDetail) ResultDetail { func (s *Switcher) submit(showDetails bool) (ResultDetail, error) { execution := s.snapshotForExecution() + if mocked, ok := execution.client.mockedResult(execution); ok { + execution.logResult(mocked) + s.markFreshExecution() + return mocked, nil + } + if cached, ok := s.tryCachedResult(execution, showDetails); ok { return cached, nil }