Skip to content

Commit

Permalink
feat: pass a testing.TB to ftltest.Context()
Browse files Browse the repository at this point in the history
This allows us to fail using the test framework's `Fatalf()` calls,
rather than using `panic()`.
  • Loading branch information
alecthomas committed Jul 5, 2024
1 parent c38eb92 commit 791e94b
Show file tree
Hide file tree
Showing 10 changed files with 77 additions and 39 deletions.
2 changes: 1 addition & 1 deletion backend/controller/dal/testdata/go/fsm/fsm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
)

func TestFSM(t *testing.T) {
ctx := ftltest.Context()
ctx := ftltest.Context(t)

err := fsm.Send(ctx, "one", Two{Instance: "one"}) // No start transition on Two
assert.Error(t, err)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
)

func TestLease(t *testing.T) {
ctx := ftltest.Context()
ctx := ftltest.Context(t)
// test that we can acquire a lease in a test environment
wg := errgroup.Group{}
wg.Go(func() error {
Expand Down
58 changes: 40 additions & 18 deletions go-runtime/ftl/ftltest/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import (
"fmt"
"reflect"
"strings"
"testing"

"github.com/alecthomas/types/optional"

"github.com/TBD54566975/ftl/backend/schema"
"github.com/TBD54566975/ftl/common/configuration"
"github.com/TBD54566975/ftl/go-runtime/ftl"
"github.com/TBD54566975/ftl/go-runtime/internal"
"github.com/alecthomas/types/optional"
)

// pubSubEvent is a sum type for all events that can be published to the pubsub system.
Expand Down Expand Up @@ -50,6 +52,7 @@ type subscription struct {
type subscriber func(context.Context, any) error

type fakeFTL struct {
t testing.TB
fsm *fakeFSMManager

mockMaps map[uintptr]mapImpl
Expand All @@ -63,14 +66,16 @@ type fakeFTL struct {
// type but is not constrained by input/output type like ftl.Map.
type mapImpl func(context.Context) (any, error)

func newFakeFTL(ctx context.Context) *fakeFTL {
func newFakeFTL(ctx context.Context, t testing.TB) *fakeFTL {
t.Helper()
fake := &fakeFTL{
t: t,
fsm: newFakeFSMManager(),
mockMaps: map[uintptr]mapImpl{},
allowMapCalls: false,
configValues: map[string][]byte{},
secretValues: map[string][]byte{},
pubSub: newFakePubSub(ctx),
pubSub: newFakePubSub(ctx, t),
}

return fake
Expand All @@ -90,7 +95,7 @@ func (f *fakeFTL) setConfig(name string, value any) error {
func (f *fakeFTL) GetConfig(ctx context.Context, name string, dest any) error {
data, ok := f.configValues[name]
if !ok {
return fmt.Errorf("secret value %q not found, did you remember to ctx := ftltest.Context(ftltest.WithDefaultProjectFile()) ?: %w", name, configuration.ErrNotFound)
return fmt.Errorf("config value %q not found, did you remember to ctx := ftltest.Context(ftltest.WithDefaultProjectFile()) ?: %w", name, configuration.ErrNotFound)
}
return json.Unmarshal(data, dest)
}
Expand All @@ -107,7 +112,7 @@ func (f *fakeFTL) setSecret(name string, value any) error {
func (f *fakeFTL) GetSecret(ctx context.Context, name string, dest any) error {
data, ok := f.secretValues[name]
if !ok {
return fmt.Errorf("config value %q not found, did you remember to ctx := ftltest.Context(ftltest.WithDefaultProjectFile()) ?: %w", name, configuration.ErrNotFound)
return fmt.Errorf("secret value %q not found, did you remember to ctx := ftltest.Context(ftltest.WithDefaultProjectFile()) ?: %w", name, configuration.ErrNotFound)
}
return json.Unmarshal(data, dest)
}
Expand All @@ -120,47 +125,64 @@ func (f *fakeFTL) FSMSend(ctx context.Context, fsm string, instance string, even
//
// mockMap provides the whole mock implemention, so it gets called in place of both `fn`
// and `getter` in ftl.Map.
func addMapMock[T, U any](f *fakeFTL, mapper *ftl.MapHandle[T, U], mockMap func(context.Context) (U, error)) {
key := makeMapKey(mapper)
func addMapMock[T, U any](f *fakeFTL, mapper *ftl.MapHandle[T, U], mockMap func(context.Context) (U, error)) error {
key, err := makeMapKey(mapper)
if err != nil {
return err
}
f.mockMaps[key] = func(ctx context.Context) (any, error) {
return mockMap(ctx)
}
return nil
}

func (f *fakeFTL) startAllowingMapCalls() {
f.allowMapCalls = true
}

func (f *fakeFTL) CallMap(ctx context.Context, mapper any, value any, mapImpl func(context.Context) (any, error)) any {
key := makeMapKey(mapper)
f.t.Helper()
key, err := makeMapKey(mapper)
if err != nil {
f.t.Fatalf("failed to call map: %v", err)
}
mockMap, ok := f.mockMaps[key]
if ok {
return actuallyCallMap(ctx, mockMap)
value, err := actuallyCallMap(ctx, mockMap)
if err != nil {
f.t.Fatalf("failed to call fake map: %v", err)
}
return value
}
if f.allowMapCalls {
return actuallyCallMap(ctx, mapImpl)
value, err := actuallyCallMap(ctx, mapImpl)
if err != nil {
f.t.Fatalf("failed to call map: %v", err)
}
return value
}
panic("map calls not allowed in tests by default, ftltest.Context should be instantiated with either ftltest.WithMapsAllowed() or a mock for the specific map being called using ftltest.WhenMap(...)")
f.t.Fatalf("map calls not allowed in tests by default: ftltest.Context should be instantiated with either ftltest.WithMapsAllowed() or a mock for the specific map being called using ftltest.WhenMap(...)")
return nil
}

func makeMapKey(mapper any) uintptr {
func makeMapKey(mapper any) (uintptr, error) {
v := reflect.ValueOf(mapper)
if v.Kind() != reflect.Pointer {
panic("fakeFTL received object that was not a pointer, expected *MapHandle")
return 0, fmt.Errorf("fakeFTL received object that was not a pointer, expected *MapHandle")
}
underlying := v.Elem().Type().Name()
if !strings.HasPrefix(underlying, "MapHandle[") {
panic(fmt.Sprintf("fakeFTL received *%s, expected *MapHandle", underlying))
return 0, fmt.Errorf("fakeFTL received *%s, expected *MapHandle", underlying)
}
return v.Pointer()
return v.Pointer(), nil
}

func actuallyCallMap(ctx context.Context, impl mapImpl) any {
func actuallyCallMap(ctx context.Context, impl mapImpl) (any, error) {
out, err := impl(ctx)
if err != nil {
panic(err)
return nil, err
}
return out
return out, nil
}

func (f *fakeFTL) PublishEvent(ctx context.Context, topic *schema.Ref, event any) error {
Expand Down
14 changes: 10 additions & 4 deletions go-runtime/ftl/ftltest/ftltest.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os"
"reflect"
"strings"
"testing"

_ "github.com/jackc/pgx/v5/stdlib" // SQL driver

Expand All @@ -33,20 +34,21 @@ type OptionsState struct {
type Option func(context.Context, *OptionsState) error

// Context suitable for use in testing FTL verbs with provided options
func Context(options ...Option) context.Context {
func Context(t testing.TB, options ...Option) context.Context {
t.Helper()
state := &OptionsState{
databases: make(map[string]modulecontext.Database),
mockVerbs: make(map[schema.RefKey]modulecontext.Verb),
}

ctx := log.ContextWithNewDefaultLogger(context.Background())
ctx = internal.WithContext(ctx, newFakeFTL(ctx))
ctx = internal.WithContext(ctx, newFakeFTL(ctx, t))
name := reflection.Module()

for _, option := range options {
err := option(ctx, state)
if err != nil {
panic(fmt.Sprintf("error applying option: %v", err))
t.Fatalf("error applying option: %v", err)
}
}

Expand Down Expand Up @@ -336,7 +338,11 @@ func WithCallsAllowedWithinModule() Option {
func WhenMap[T, U any](mapper *ftl.MapHandle[T, U], fake func(context.Context) (U, error)) Option {
return func(ctx context.Context, state *OptionsState) error {
fftl := internal.FromContext(ctx).(*fakeFTL) //nolint:forcetypeassert
addMapMock(fftl, mapper, fake)
fftl.t.Helper()
err := addMapMock(fftl, mapper, fake)
if err != nil {
fftl.t.Fatalf("failed to add map fake: %v", err)
}
return nil
}
}
Expand Down
5 changes: 3 additions & 2 deletions go-runtime/ftl/ftltest/ftltest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import (
"fmt"
"testing"

"github.com/alecthomas/assert/v2"

"github.com/TBD54566975/ftl/go-runtime/ftl"
"github.com/TBD54566975/ftl/go-runtime/internal"
"github.com/TBD54566975/ftl/internal/log"
"github.com/alecthomas/assert/v2"
)

func PanicsWithErr(t testing.TB, substr string, fn func()) {
Expand All @@ -27,7 +28,7 @@ func PanicsWithErr(t testing.TB, substr string, fn func()) {

func TestFtlTestProjectNotLoadedInContext(t *testing.T) {
ctx := log.ContextWithNewDefaultLogger(context.Background())
ctx = internal.WithContext(ctx, newFakeFTL(ctx))
ctx = internal.WithContext(ctx, newFakeFTL(ctx, t))

// This should panic suggesting to use ftltest.WithDefaultProjectFile()
PanicsWithErr(t, "ftltest.WithDefaultProjectFile()", func() {
Expand Down
14 changes: 10 additions & 4 deletions go-runtime/ftl/ftltest/pubsub.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,20 @@ import (
"math/rand"
"strings"
"sync"
"testing"
"time"

"github.com/alecthomas/types/optional"
"github.com/alecthomas/types/pubsub"

"github.com/TBD54566975/ftl/backend/schema"
"github.com/TBD54566975/ftl/go-runtime/ftl"
"github.com/TBD54566975/ftl/internal/log"
"github.com/TBD54566975/ftl/internal/slices"
"github.com/alecthomas/types/optional"
"github.com/alecthomas/types/pubsub"
)

type fakePubSub struct {
t testing.TB
// all pubsub events are processed through globalTopic
globalTopic *pubsub.Topic[pubSubEvent]
// publishWaitGroup can be used to wait for all events to be published
Expand All @@ -29,8 +32,10 @@ type fakePubSub struct {
subscribers map[string][]subscriber
}

func newFakePubSub(ctx context.Context) *fakePubSub {
func newFakePubSub(ctx context.Context, t testing.TB) *fakePubSub {
t.Helper()
f := &fakePubSub{
t: t,
globalTopic: pubsub.New[pubSubEvent](),
topics: map[schema.RefKey][]any{},
subscriptions: map[string]*subscription{},
Expand Down Expand Up @@ -146,6 +151,7 @@ func (f *fakePubSub) watchPubSub(ctx context.Context) {
}

func (f *fakePubSub) handlePubSubEvent(ctx context.Context, e pubSubEvent) {
f.t.Helper()
f.pubSubLock.Lock()
defer f.pubSubLock.Unlock()

Expand All @@ -163,7 +169,7 @@ func (f *fakePubSub) handlePubSubEvent(ctx context.Context, e pubSubEvent) {
case subscriptionDidConsumeEvent:
sub, ok := f.subscriptions[event.subscription]
if !ok {
panic(fmt.Sprintf("subscription %q not found", event.subscription))
f.t.Fatalf("subscription %q not found", event.subscription)
}
if event.err != nil {
sub.errors[sub.cursor.MustGet()] = event.err
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import (
"ftl/pubsub"
"testing"

"github.com/TBD54566975/ftl/go-runtime/ftl/ftltest"
"github.com/alecthomas/assert/v2"

"github.com/TBD54566975/ftl/go-runtime/ftl/ftltest"
)

func TestPublishToExternalModule(t *testing.T) {
ctx := ftltest.Context()
ctx := ftltest.Context(t)
assert.NoError(t, pubsub.Topic.Publish(ctx, pubsub.Event{Value: "external"}))
assert.Equal(t, 1, len(ftltest.EventsForTopic(ctx, pubsub.Topic)))

Expand Down
4 changes: 2 additions & 2 deletions go-runtime/ftl/ftltest/testdata/go/wrapped/wrapped_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func TestWrapped(t *testing.T) {
},
configValue: "helloworld",
secretValue: "shhhhh",
expectedError: ftl.Some("wrapped.inner: no mock found: provide a mock with ftltest.WhenVerb(Inner, ...) or enable all calls within the module with ftltest.WithCallsAllowedWithinModule()"),
expectedError: ftl.Some("wrapped.inner: no fake found: provide a mock with ftltest.WhenVerb(Inner, ...) or enable all calls within the module with ftltest.WithCallsAllowedWithinModule()"),
},
{
name: "AllowCallsWithinModule",
Expand All @@ -87,7 +87,7 @@ func TestWrapped(t *testing.T) {
},
configValue: "helloworld",
secretValue: "shhhhh",
expectedError: ftl.Some("wrapped.inner: time.time: no mock found: provide a mock with ftltest.WhenVerb(time.Time, ...)"),
expectedError: ftl.Some("wrapped.inner: time.time: no fake found: provide a mock with ftltest.WhenVerb(time.Time, ...)"),
},
{
name: "WithExternalVerbMock",
Expand Down
5 changes: 3 additions & 2 deletions go-runtime/ftl/testdata/go/mapper/mapper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import (
"context"
"testing"

"github.com/TBD54566975/ftl/go-runtime/ftl/ftltest"
"github.com/alecthomas/assert/v2"

"github.com/TBD54566975/ftl/go-runtime/ftl/ftltest"
)

func TestGet(t *testing.T) {
Expand All @@ -15,7 +16,7 @@ func TestGet(t *testing.T) {
}

func TestPanicsWithoutExplicitlyAllowingMaps(t *testing.T) {
ctx := ftltest.Context()
ctx := ftltest.Context(t)
assert.Panics(t, func() {
m.Get(ctx)
})
Expand Down
7 changes: 4 additions & 3 deletions internal/modulecontext/module_context.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package modulecontext

import (
"connectrpc.com/connect"
"context"
"database/sql"
"encoding/json"
Expand All @@ -11,6 +10,8 @@ import (
"sync"
"time"

"connectrpc.com/connect"

"github.com/alecthomas/atomic"
"github.com/jpillora/backoff"
"golang.org/x/sync/errgroup"
Expand Down Expand Up @@ -174,9 +175,9 @@ func (m ModuleContext) BehaviorForVerb(ref schema.Ref) (optional.Option[VerbBeha
return optional.Some(VerbBehavior(DirectBehavior{})), nil
} else if m.isTesting {
if ref.Module == m.module {
return optional.None[VerbBehavior](), fmt.Errorf("no mock found: provide a mock with ftltest.WhenVerb(%s, ...) or enable all calls within the module with ftltest.WithCallsAllowedWithinModule()", strings.ToUpper(ref.Name[:1])+ref.Name[1:])
return optional.None[VerbBehavior](), fmt.Errorf("no fake found: provide a fake with ftltest.WhenVerb(%s, ...) or enable all calls within the module with ftltest.WithCallsAllowedWithinModule()", strings.ToUpper(ref.Name[:1])+ref.Name[1:])
}
return optional.None[VerbBehavior](), fmt.Errorf("no mock found: provide a mock with ftltest.WhenVerb(%s.%s, ...)", ref.Module, strings.ToUpper(ref.Name[:1])+ref.Name[1:])
return optional.None[VerbBehavior](), fmt.Errorf("no fake found: provide a fake with ftltest.WhenVerb(%s.%s, ...)", ref.Module, strings.ToUpper(ref.Name[:1])+ref.Name[1:])
}
return optional.None[VerbBehavior](), nil
}
Expand Down

0 comments on commit 791e94b

Please sign in to comment.