Skip to content

Commit

Permalink
feat: module unit tests can allow calls within module (#1373)
Browse files Browse the repository at this point in the history
We already have a way of providing verb mocks/fakes with
`ftltest.WhenVerb(...)` but sometimes with tests it can be useful to
allow calls to work fine within the module.
This PR enables this behaviour though it has to be explicitly enabled by
the test. This allows the person writing the test to better understand
what their unit test is touching.

---
Below is a rundown of how ftltest works for a test writer in practice:
_⌨️ Write module `echo` which has verb `Echo` and `InnerEcho`. `Echo`
calls `InnerEcho`, `InnerEcho` calls `time.Time`_

_⌨️ Write the following test:_
```
func TestEcho(t *testing.T) {
  ctx := ftltest.Context(
    ftltest.WithConfig(defaultName, "fallback"),
  )
  resp, err := Echo(ctx, EchoRequest{Name: ftl.Some("world")})
  assert.NoError(t, err)
  assert.Equal(t, "Hello, world!!! It is 2024-01-01 00:00:00 +0000 UTC!", resp.Message)
}
```
🤖 Error:
> echo.innerEcho: no mock found: provide a mock with
ftltest.WhenVerb(innerEcho, ...) or enable all calls within the module
with ftltest.WithCallsAllowedWithinModule()

_⌨️ Add `ftltest.WithCallsAllowedWithinModule()` to `ftltest.Context()`
options_
🤖 Error:
> echo.innerEcho: time.time: no mock found: provide a mock with
ftltest.WhenVerb(time.time, ...)

_⌨️ Add the following to `ftltest.Context()` options_:
```
ftltest.WhenVerb(time.Time, func(ctx context.Context, req time.TimeRequest) (time.TimeResponse, error) {
  return time.TimeResponse{Time: stdtime.Date(2024, 1, 1, 0, 0, 0, 0, stdtime.UTC)}, nil
})
```
🤖 success
  • Loading branch information
matt2e authored May 3, 2024
1 parent 5b621ea commit e2f75d4
Show file tree
Hide file tree
Showing 15 changed files with 377 additions and 27 deletions.
32 changes: 21 additions & 11 deletions go-runtime/ftl/call.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,7 @@ import (
"github.com/TBD54566975/ftl/internal/rpc"
)

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)
}

func call[Req, Resp any](ctx context.Context, callee Ref, req Req, inline Verb[Req, Resp]) (resp Resp, err error) {
behavior, err := modulecontext.FromContext(ctx).BehaviorForVerb(modulecontext.Ref(callee))
if err != nil {
return resp, fmt.Errorf("%s: %w", callee, err)
Expand All @@ -35,8 +30,17 @@ func call[Req, Resp any](ctx context.Context, callee Ref, req Req) (resp Resp, e
}
return resp, fmt.Errorf("%s: overridden verb had invalid response type %T, expected %v", callee, uncheckedResp, reflect.TypeFor[Resp]())
case modulecontext.DirectBehavior:
panic("not implemented")
resp, err = inline(ctx, req)
if err != nil {
return resp, fmt.Errorf("%s: %w", callee, err)
}
return resp, nil
case modulecontext.StandardBehavior:
reqData, err := encoding.Marshal(req)
if err != nil {
return resp, fmt.Errorf("%s: failed to marshal request: %w", callee, err)
}

client := rpc.ClientFromContext[ftlv1connect.VerbServiceClient](ctx)
cresp, err := client.Call(ctx, connect.NewRequest(&ftlv1.CallRequest{Verb: callee.ToProto(), Body: reqData}))
if err != nil {
Expand All @@ -63,22 +67,28 @@ func call[Req, Resp any](ctx context.Context, callee Ref, req Req) (resp Resp, e

// 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, FuncRef(verb), req)
return call[Req, Resp](ctx, FuncRef(verb), req, verb)
}

// 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, FuncRef(sink), req)
_, err := call[Req, Unit](ctx, FuncRef(sink), req, func(ctx context.Context, req Req) (Unit, error) {
return Unit{}, sink(ctx, 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, FuncRef(source), Unit{})
return call[Unit, Resp](ctx, FuncRef(source), Unit{}, func(ctx context.Context, req Unit) (Resp, error) {
return source(ctx)
})
}

// 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, FuncRef(empty), Unit{})
_, err := call[Unit, Unit](ctx, FuncRef(empty), Unit{}, func(ctx context.Context, req Unit) (Unit, error) {
return Unit{}, empty(ctx)
})
return err
}
2 changes: 1 addition & 1 deletion go-runtime/ftl/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
func TestConfig(t *testing.T) {
ctx := log.ContextWithNewDefaultLogger(context.Background())

moduleCtx := modulecontext.New()
moduleCtx := modulecontext.New("test")
ctx = moduleCtx.ApplyToContext(ctx)

type C struct {
Expand Down
10 changes: 10 additions & 0 deletions go-runtime/ftl/ftltest/ftltest.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,13 @@ func WhenVerb[Req any, Resp any](verb ftl.Verb[Req, Resp], fake func(ctx context
return nil
}
}

// WithCallsAllowedWithinModule allows tests to enable calls to all verbs within the current module
//
// Any overrides provided by calling WhenVerb(...) will take precedence
func WithCallsAllowedWithinModule() func(context.Context) error {
return func(ctx context.Context) error {
modulecontext.FromContext(ctx).AllowDirectVerbBehaviorWithinModule()
return nil
}
}
2 changes: 1 addition & 1 deletion go-runtime/ftl/secrets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
func TestSecret(t *testing.T) {
ctx := log.ContextWithNewDefaultLogger(context.Background())

moduleCtx := modulecontext.New()
moduleCtx := modulecontext.New("test")
ctx = moduleCtx.ApplyToContext(ctx)

type C struct {
Expand Down
4 changes: 2 additions & 2 deletions go-runtime/modulecontext/from_environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ func FromEnvironment(ctx context.Context, module string, isTesting bool) (*Modul
// TODO: split this func into separate purposes: explicitly reading a particular project file, and reading DSNs from environment
var moduleCtx *ModuleContext
if isTesting {
moduleCtx = NewForTesting()
moduleCtx = NewForTesting(module)
} else {
moduleCtx = New()
moduleCtx = New(module)
}

cm, err := cf.NewDefaultConfigurationManagerFromEnvironment(ctx)
Expand Down
2 changes: 1 addition & 1 deletion go-runtime/modulecontext/from_proto.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
)

func FromProto(response *ftlv1.ModuleContextResponse) (*ModuleContext, error) {
moduleCtx := New()
moduleCtx := New(response.Module)
for name, data := range response.Configs {
moduleCtx.configs[name] = data
}
Expand Down
31 changes: 22 additions & 9 deletions go-runtime/modulecontext/module_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import (
"encoding/json"
"fmt"
"strconv"
"strings"

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

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

Expand Down Expand Up @@ -41,26 +43,30 @@ func (x DBType) String() string {

// ModuleContext holds the context needed for a module, including configs, secrets and DSNs
type ModuleContext struct {
isTesting bool
module string
configs map[string][]byte
secrets map[string][]byte
databases map[string]dbEntry
mockVerbs map[Ref]MockVerb

isTesting bool
mockVerbs map[Ref]MockVerb
allowDirectVerbBehavior bool
}

type contextKeyModuleContext struct{}

func New() *ModuleContext {
func New(module string) *ModuleContext {
return &ModuleContext{
module: module,
configs: map[string][]byte{},
secrets: map[string][]byte{},
databases: map[string]dbEntry{},
mockVerbs: map[Ref]MockVerb{},
}
}

func NewForTesting() *ModuleContext {
moduleCtx := New()
func NewForTesting(module string) *ModuleContext {
moduleCtx := New(module)
moduleCtx.isTesting = true
return moduleCtx
}
Expand Down Expand Up @@ -164,10 +170,13 @@ func (m *ModuleContext) GetDatabase(name string, dbType DBType) (*sql.DB, error)
func (m *ModuleContext) BehaviorForVerb(ref Ref) (VerbBehavior, error) {
if mock, ok := m.mockVerbs[ref]; ok {
return MockBehavior{Mock: mock}, nil
}
// TODO: add logic here for when to do direct behavior
if m.isTesting {
return StandardBehavior{}, fmt.Errorf("no mock found")
} else if m.allowDirectVerbBehavior && ref.Module == m.module {
return DirectBehavior{}, nil
} else if m.isTesting {
if ref.Module == m.module {
return StandardBehavior{}, 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 StandardBehavior{}, fmt.Errorf("no mock found: provide a mock with ftltest.WhenVerb(%s.%s, ...)", ref.Module, strings.ToUpper(ref.Name[:1])+ref.Name[1:])
}
return StandardBehavior{}, nil
}
Expand All @@ -176,6 +185,10 @@ func (m *ModuleContext) SetMockVerb(ref Ref, mock MockVerb) {
m.mockVerbs[ref] = mock
}

func (m *ModuleContext) AllowDirectVerbBehaviorWithinModule() {
m.allowDirectVerbBehavior = true
}

// VerbBehavior indicates how to execute a verb
//
//sumtype:decl
Expand Down
2 changes: 1 addition & 1 deletion go-runtime/modulecontext/module_context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (

func TestGettingAndSettingFromContext(t *testing.T) {
ctx := log.ContextWithNewDefaultLogger(context.Background())
moduleCtx := New()
moduleCtx := New("test")
ctx = moduleCtx.ApplyToContext(ctx)
assert.Equal(t, moduleCtx, FromContext(ctx), "module context should be the same when read from context")
}
11 changes: 11 additions & 0 deletions integration/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,13 @@ func deploy(module string) action {
)
}

// Build modules from the working directory and wait for it to become available.
func build(modules ...string) action {
args := []string{"build"}
args = append(args, modules...)
return exec("ftl", args...)
}

// wait for the given module to deploy.
func wait(module string) action {
return func(t testing.TB, ic testContext) error {
Expand Down Expand Up @@ -346,3 +353,7 @@ func httpCall(method string, path string, body []byte, onResponse func(resp *htt
})
}
}

func testModule(module string) action {
return chdir(module, exec("go", "test", "-v", "."))
}
11 changes: 10 additions & 1 deletion integration/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,15 @@ func TestHttpIngress(t *testing.T) {
func TestRuntimeReflection(t *testing.T) {
run(t,
copyModule("runtimereflection"),
chdir("runtimereflection", exec("go", "test", "-v", ".")),
testModule("runtimereflection"),
)
}

func TestModuleUnitTests(t *testing.T) {
run(t,
copyModule("time"),
copyModule("wrapped"),
build("time", "wrapped"),
testModule("wrapped"),
)
}
2 changes: 2 additions & 0 deletions integration/testdata/go/wrapped/ftl.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module = "wrapped"
language = "go"
51 changes: 51 additions & 0 deletions integration/testdata/go/wrapped/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
module ftl/wrapped

go 1.22.2

require (
github.com/TBD54566975/ftl v0.193.0
github.com/alecthomas/assert/v2 v2.9.0
)

require (
connectrpc.com/connect v1.14.0 // indirect
connectrpc.com/grpcreflect v1.2.0 // indirect
connectrpc.com/otelconnect v0.7.0 // indirect
github.com/BurntSushi/toml v1.3.2 // indirect
github.com/TBD54566975/scaffolder v0.8.0 // indirect
github.com/alecthomas/concurrency v0.0.2 // indirect
github.com/alecthomas/kong v0.9.0 // indirect
github.com/alecthomas/participle/v2 v2.1.1 // indirect
github.com/alecthomas/repr v0.4.0 // indirect
github.com/alecthomas/types v0.14.0 // indirect
github.com/alessio/shellescape v1.4.2 // indirect
github.com/danieljoos/wincred v1.2.0 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jpillora/backoff v1.0.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/multiformats/go-base36 v0.2.0 // indirect
github.com/swaggest/jsonschema-go v0.3.70 // indirect
github.com/swaggest/refl v1.3.0 // indirect
github.com/zalando/go-keyring v0.2.4 // indirect
go.opentelemetry.io/otel v1.26.0 // indirect
go.opentelemetry.io/otel/metric v1.26.0 // indirect
go.opentelemetry.io/otel/trace v1.26.0 // indirect
golang.org/x/crypto v0.22.0 // indirect
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/net v0.24.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.33.0 // indirect
)

replace github.com/TBD54566975/ftl => ../../../..
Loading

0 comments on commit e2f75d4

Please sign in to comment.