Skip to content

Commit

Permalink
feat: module tests can declare config/secrets/DSNs and add verb mocks (
Browse files Browse the repository at this point in the history
…#1317)

Part of: https://hackmd.io/zNiBPIhVQN2hkeNHg2r8YA

This PR makes it easy to set up the following:
- config
- secrets
- DSNs
- mocks for verbs

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

func TestEcho(t *testing.T) {
    ctx := ftltest.Context (
        ftltest.WithConfig("default", "anonymous"),
        ftltest.WithSecret("example", "test123"),
        ftltest.WithDSN("db", ftltest.DBTypePostgres, "..."),
        ftltest.WhenVerb(time.Time, func(ctx context. Context, req time. TimeRequest) (time.TimeResponse, error) {
            return time.TimeResponse{Time: stdtime.Date(2021, 9, 1, 0, 0, 0, 0, stdtime.UTC)}, nil
        },
    )

    ...
}
```

Another improvement is when doing unit tests, a simple error message
will be returned when attempting to make a `ftl.Call` when there isn't
any rpc client set up. This error will be returned:
> time.time: no mock found
  • Loading branch information
matt2e authored Apr 30, 2024
1 parent 87c5913 commit 03ec498
Show file tree
Hide file tree
Showing 7 changed files with 187 additions and 21 deletions.
12 changes: 10 additions & 2 deletions common/configuration/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,22 @@ func configFromEnvironment() []string {
// the default ftl-project.toml.
func NewDefaultSecretsManagerFromEnvironment(ctx context.Context) (*Manager[Secrets], error) {
var cr Resolver[Secrets] = ProjectConfigResolver[Secrets]{Config: configFromEnvironment()}
return (DefaultSecretsMixin{}).NewSecretsManager(ctx, cr)
return DefaultSecretsMixin{
InlineProvider: InlineProvider[Secrets]{
Inline: true,
},
}.NewSecretsManager(ctx, cr)
}

// NewDefaultConfigurationManagerFromEnvironment creates a new configuration
// manager from the default ftl-project.toml.
func NewDefaultConfigurationManagerFromEnvironment(ctx context.Context) (*Manager[Configuration], error) {
cr := ProjectConfigResolver[Configuration]{Config: configFromEnvironment()}
return (DefaultConfigMixin{}).NewConfigurationManager(ctx, cr)
return DefaultConfigMixin{
InlineProvider: InlineProvider[Configuration]{
Inline: true,
},
}.NewConfigurationManager(ctx, cr)
}

// New configuration manager.
Expand Down
35 changes: 21 additions & 14 deletions go-runtime/ftl/call.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,32 @@ import (

ftlv1 "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1"
"github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect"
schemapb "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/schema"
"github.com/TBD54566975/ftl/backend/schema/strcase"
"github.com/TBD54566975/ftl/go-runtime/encoding"
"github.com/TBD54566975/ftl/internal/rpc"
)

func call[Req, Resp any](ctx context.Context, callee *schemapb.Ref, req Req) (resp Resp, err error) {
client := rpc.ClientFromContext[ftlv1connect.VerbServiceClient](ctx)
func call[Req, Resp any](ctx context.Context, callee Ref, req Req) (resp Resp, err error) {
reqData, err := encoding.Marshal(req)
if err != nil {
return resp, fmt.Errorf("%s: failed to marshal request: %w", callee, err)
}
cresp, err := client.Call(ctx, connect.NewRequest(&ftlv1.CallRequest{Verb: callee, Body: reqData}))

if overrider, ok := CallOverriderFromContext(ctx); ok {
override, uncheckedResp, err := overrider.OverrideCall(ctx, callee, req)
if err != nil {
return resp, fmt.Errorf("%s: %w", callee, err)
}
if override {
if resp, ok = uncheckedResp.(Resp); ok {
return resp, nil
}
return resp, fmt.Errorf("%s: overridden verb had invalid response type %T, expected %v", callee, uncheckedResp, reflect.TypeFor[Resp]())
}
}

client := rpc.ClientFromContext[ftlv1connect.VerbServiceClient](ctx)
cresp, err := client.Call(ctx, connect.NewRequest(&ftlv1.CallRequest{Verb: callee.ToProto(), Body: reqData}))
if err != nil {
return resp, fmt.Errorf("%s: failed to call Verb: %w", callee, err)
}
Expand All @@ -45,23 +58,23 @@ func call[Req, Resp any](ctx context.Context, callee *schemapb.Ref, req Req) (re

// Call a Verb through the FTL Controller.
func Call[Req, Resp any](ctx context.Context, verb Verb[Req, Resp], req Req) (Resp, error) {
return call[Req, Resp](ctx, CallToSchemaRef(verb), req)
return call[Req, Resp](ctx, CallToRef(verb), req)
}

// CallSink calls a Sink through the FTL controller.
func CallSink[Req any](ctx context.Context, sink Sink[Req], req Req) error {
_, err := call[Req, Unit](ctx, CallToSchemaRef(sink), req)
_, err := call[Req, Unit](ctx, CallToRef(sink), req)
return err
}

// CallSource calls a Source through the FTL controller.
func CallSource[Resp any](ctx context.Context, source Source[Resp]) (Resp, error) {
return call[Unit, Resp](ctx, CallToSchemaRef(source), Unit{})
return call[Unit, Resp](ctx, CallToRef(source), Unit{})
}

// CallEmpty calls a Verb with no request or response through the FTL controller.
func CallEmpty(ctx context.Context, empty Empty) error {
_, err := call[Unit, Unit](ctx, CallToSchemaRef(empty), Unit{})
_, err := call[Unit, Unit](ctx, CallToRef(empty), Unit{})
return err
}

Expand All @@ -71,12 +84,6 @@ func CallToRef(call any) Ref {
return goRefToFTLRef(ref)
}

// CallToSchemaRef returns the Ref for a Verb, Sink, Source, or Empty as a Schema Ref.
func CallToSchemaRef(call any) *schemapb.Ref {
ref := CallToRef(call)
return ref.ToProto()
}

func goRefToFTLRef(ref string) Ref {
parts := strings.Split(ref[strings.LastIndex(ref, "/")+1:], ".")
return Ref{parts[len(parts)-2], strcase.ToLowerCamel(parts[len(parts)-1])}
Expand Down
21 changes: 21 additions & 0 deletions go-runtime/ftl/call_overrider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package ftl

import (
"context"
)

type CallOverrider interface {
OverrideCall(ctx context.Context, callee Ref, req any) (override bool, resp any, err error)
}
type contextCallOverriderKey struct{}

func ApplyCallOverriderToContext(ctx context.Context, overrider CallOverrider) context.Context {
return context.WithValue(ctx, contextCallOverriderKey{}, overrider)
}

func CallOverriderFromContext(ctx context.Context) (CallOverrider, bool) {
if overrider, ok := ctx.Value(contextCallOverriderKey{}).(CallOverrider); ok {
return overrider, true
}
return nil, false
}
90 changes: 87 additions & 3 deletions go-runtime/ftl/ftltest/ftltest.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,102 @@ package ftltest

import (
"context"
"fmt"
"reflect"

cf "github.com/TBD54566975/ftl/common/configuration"
"github.com/TBD54566975/ftl/go-runtime/ftl"
"github.com/TBD54566975/ftl/go-runtime/modulecontext"
"github.com/TBD54566975/ftl/internal/log"
"github.com/alecthomas/types/optional"
)

// Context suitable for use in testing FTL verbs.
func Context() context.Context {
type DBType int32

const (
DBTypePostgres = DBType(modulecontext.DBTypePostgres)
)

// Context suitable for use in testing FTL verbs with provided options
func Context(options ...func(context.Context) error) context.Context {
ctx := log.ContextWithNewDefaultLogger(context.Background())
context, err := modulecontext.FromEnvironment(ctx, ftl.Module())
if err != nil {
panic(err)
}
return context.ApplyToContext(ctx)
ctx = context.ApplyToContext(ctx)

mockProvider := newMockVerbProvider()
ctx = ftl.ApplyCallOverriderToContext(ctx, mockProvider)

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

// WithConfig sets a configuration for the current module
//
// To be used with Context(...)
func WithConfig(name string, value any) func(context.Context) error {
return func(ctx context.Context) error {
cm := cf.ConfigFromContext(ctx)
return cm.Set(ctx, cf.Ref{Module: optional.Some(ftl.Module()), Name: name}, value)
}
}

// WithSecret sets a secret for the current module
//
// To be used with Context(...)
func WithSecret(name string, value any) func(context.Context) error {
return func(ctx context.Context) error {
cm := cf.SecretsFromContext(ctx)
return cm.Set(ctx, cf.Ref{Module: optional.Some(ftl.Module()), Name: name}, value)
}
}

// WithDSN sets a DSN for the current module
//
// To be used with Context(...)
func WithDSN(name string, dbType DBType, dsn string) func(context.Context) error {
return func(ctx context.Context) error {
dbProvider := modulecontext.DBProviderFromContext(ctx)
return dbProvider.Add(name, modulecontext.DBType(dbType), dsn)
}
}

// WhenVerb replaces an implementation for a verb
//
// To be used when setting up a context for a test:
// ctx := ftltest.Context(
//
// ftltest.WhenVerb(Example.Verb, func(ctx context.Context, req Example.Req) (Example.Resp, error) {
// ...
// }),
// ... other options
//
// )
func WhenVerb[Req any, Resp any](verb ftl.Verb[Req, Resp], fake func(ctx context.Context, req Req) (resp Resp, err error)) func(context.Context) error {
return func(ctx context.Context) error {
ref := ftl.CallToRef(verb)
overrider, ok := ftl.CallOverriderFromContext(ctx)
if !ok {
return fmt.Errorf("could not override %v with a fake, context not set up with call overrider", ref)
}
mockProvider, ok := overrider.(*mockVerbProvider)
if !ok {
return fmt.Errorf("could not override %v with a fake, call overrider is not a MockProvider", ref)
}
mockProvider.mocks[ref] = func(ctx context.Context, req any) (resp any, err error) {
request, ok := req.(Req)
if !ok {
return nil, fmt.Errorf("invalid request type %T for %v, expected %v", req, ref, reflect.TypeFor[Req]())
}
return fake(ctx, request)
}
return nil
}
}
42 changes: 42 additions & 0 deletions go-runtime/ftl/ftltest/mock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package ftltest

import (
"context"
"fmt"

"github.com/TBD54566975/ftl/go-runtime/ftl"
"github.com/TBD54566975/ftl/internal/rpc"

"github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect"
)

type mockFunc func(ctx context.Context, req any) (resp any, err error)

// mockVerbProvider keeps a mapping of verb references to mock functions.
//
// It implements the CallOverrider interface to intercept calls with the mock functions.
type mockVerbProvider struct {
mocks map[ftl.Ref]mockFunc
}

var _ = (ftl.CallOverrider)(&mockVerbProvider{})

func newMockVerbProvider() *mockVerbProvider {
provider := &mockVerbProvider{
mocks: map[ftl.Ref]mockFunc{},
}
return provider
}

func (m *mockVerbProvider) OverrideCall(ctx context.Context, ref ftl.Ref, req any) (override bool, resp any, err error) {
mock, ok := m.mocks[ref]
if ok {
resp, err = mock(ctx, req)
return true, resp, err
}
if rpc.IsClientAvailableInContext[ftlv1connect.VerbServiceClient](ctx) {
return false, nil, nil
}
// Return a clean error for testing because we know the client is not available to make real calls
return false, nil, fmt.Errorf("no mock found")
}
4 changes: 2 additions & 2 deletions go-runtime/ftl/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ func (v *Ref) UnmarshalText(text []byte) error {
return nil
}

func (v *Ref) String() string { return v.Module + "." + v.Name }
func (v *Ref) ToProto() *schemapb.Ref {
func (v Ref) String() string { return v.Module + "." + v.Name }
func (v Ref) ToProto() *schemapb.Ref {
return &schemapb.Ref{Module: v.Module, Name: v.Name}
}

Expand Down
4 changes: 4 additions & 0 deletions internal/rpc/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@ func ClientFromContext[Client Pingable](ctx context.Context) Client {
return value.(Client) //nolint:forcetypeassert
}

func IsClientAvailableInContext[Client Pingable](ctx context.Context) bool {
return ctx.Value(clientKey[Client]{}) != nil
}

func propagateHeaders(ctx context.Context, isClient bool, header http.Header) (context.Context, error) {
if isClient {
if IsDirectRouted(ctx) {
Expand Down

0 comments on commit 03ec498

Please sign in to comment.