Skip to content

Advanced Features v3.7.0

INFO

Minimum Requirements: Go 1.26 or later

Version history: Embedded SDK introduced in v3.7.0. Current API since v3.16.0; the legacy sdk.Run / mock.Stub / mock.Verify API was removed in v3.20.0. See the Upgrade Guide.

Learn about advanced features of the GripMock Embedded SDK.

Headers Matching

go
func TestAuthService_AuthenticatedEndpoint(t *testing.T) {
    // ARRANGE
    srv := sdk.NewServer(t, sdk.WithFileDescriptor(auth.File_auth_service_proto))
    defer srv.Close()

    srv.ExpectUnary(AuthService_ProtectedEndpoint_FullMethodName).
        Match("resource", "secret-data").
        WithHeader(sdk.Contains("authorization", "Bearer valid-token")).
        Return("data", "secret-content")

    srv.ExpectUnary(AuthService_ProtectedEndpoint_FullMethodName).
        Match("resource", "secret-data").
        WithHeader(sdk.Contains("authorization", "Bearer invalid-token")).
        ReturnError(codes.Unauthenticated, "Invalid token")

    client := NewAuthServiceClient(srv.Conn())

    // ACT - Valid token
    validCtx := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("authorization", "Bearer valid-token"))
    validReply, validErr := client.ProtectedEndpoint(validCtx, &ProtectedEndpointRequest{Resource: "secret-data"})

    // ACT - Invalid token
    invalidCtx := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("authorization", "Bearer invalid-token"))
    _, invalidErr := client.ProtectedEndpoint(invalidCtx, &ProtectedEndpointRequest{Resource: "secret-data"})

    // ASSERT
    require.NoError(t, validErr)
    require.Equal(t, "secret-content", validReply.GetData())
    require.Error(t, invalidErr)
    require.Equal(t, codes.Unauthenticated, status.Code(invalidErr))
}

Delays

go
func TestExternalService_SlowResponse(t *testing.T) {
    // ARRANGE
    srv := sdk.NewServer(t, sdk.WithFileDescriptor(external.File_external_service_proto))
    defer srv.Close()

    srv.ExpectUnary(ExternalService_Process_FullMethodName).
        Match("id", "slow-request").
        Return(sdk.Delay(500*time.Millisecond, "result", "processed"))

    client := NewExternalServiceClient(srv.Conn())

    // ACT
    start := time.Now()
    reply, err := client.Process(t.Context(), &ProcessRequest{Id: "slow-request"})
    elapsed := time.Since(start)

    // ASSERT
    require.NoError(t, err)
    require.Equal(t, "processed", reply.GetResult())
    require.GreaterOrEqual(t, elapsed, 500*time.Millisecond)
}

Priority

go
func TestUserService_GetUser_Priority(t *testing.T) {
    // ARRANGE
    srv := sdk.NewServer(t, sdk.WithFileDescriptor(user.File_user_service_proto))
    defer srv.Close()

    // High priority: Specific case
    srv.ExpectUnary(UserService_GetUser_FullMethodName).
        Match("id", "special-user").
        Priority(100).
        Return("name", "Special User", "role", "admin")

    // Lower priority: General case
    srv.ExpectUnary(UserService_GetUser_FullMethodName).
        Match(sdk.Contains("id", "")). // Matches any ID
        Priority(10).
        Return("name", "General User", "role", "user")

    client := NewUserServiceClient(srv.Conn())

    // ACT
    specialReply, err1 := client.GetUser(t.Context(), &GetUserRequest{Id: "special-user"})
    generalReply, err2 := client.GetUser(t.Context(), &GetUserRequest{Id: "regular-user"})

    // ASSERT
    require.NoError(t, err1)
    require.NoError(t, err2)
    require.Equal(t, "Special User", specialReply.GetName())
    require.Equal(t, "admin", specialReply.GetRole())
    require.Equal(t, "General User", generalReply.GetName())
    require.Equal(t, "user", generalReply.GetRole())
}

Call Limits (Times)

go
func TestRateLimitService_LimitedCalls(t *testing.T) {
    // ARRANGE
    srv := sdk.NewServer(t, sdk.WithFileDescriptor(rate.File_rate_limit_service_proto))
    defer srv.Close()

    // Stub matches exactly 3 times, then becomes unavailable
    srv.ExpectUnary(RateLimitService_Call_FullMethodName).
        Match("id", "limited").
        Times(3).
        Return("result", "ok")

    client := NewRateLimitServiceClient(srv.Conn())

    // ACT
    reply1, err1 := client.Call(t.Context(), &CallRequest{Id: "limited"})
    reply2, err2 := client.Call(t.Context(), &CallRequest{Id: "limited"})
    reply3, err3 := client.Call(t.Context(), &CallRequest{Id: "limited"})
    _, err4 := client.Call(t.Context(), &CallRequest{Id: "limited"}) // Should fail

    // ASSERT
    require.NoError(t, err1)
    require.NoError(t, err2)
    require.NoError(t, err3)
    require.Error(t, err4) // Should fail after 3 calls
    require.Equal(t, "ok", reply1.GetResult())
    require.Equal(t, "ok", reply2.GetResult())
    require.Equal(t, "ok", reply3.GetResult())
}

Streaming Support

go
func TestChatService_ServerStreaming(t *testing.T) {
    // ARRANGE
    srv := sdk.NewServer(t, sdk.WithFileDescriptor(chat.File_chat_service_proto))
    defer srv.Close()

    srv.ExpectServerStream(ChatService_ChatStream_FullMethodName).
        Match("roomId", "room-123").
        SendStream(
            map[string]any{"message": "Hello", "sender": "Alice"},
            map[string]any{"message": "Hi there", "sender": "Bob"},
            map[string]any{"message": "Goodbye", "sender": "Alice"},
        )

    client := NewChatServiceClient(srv.Conn())

    // ACT
    stream, err := client.ChatStream(t.Context(), &ChatStreamRequest{RoomId: "room-123"})
    require.NoError(t, err)

    var messages []ChatMessage
    for {
        msg, err := stream.Recv()
        if err == io.EOF {
            break
        }
        require.NoError(t, err)
        messages = append(messages, *msg)
    }

    // ASSERT
    require.Len(t, messages, 3)
    require.Equal(t, "Hello", messages[0].GetMessage())
    require.Equal(t, "Alice", messages[0].GetSender())
    require.Equal(t, "Hi there", messages[1].GetMessage())
    require.Equal(t, "Bob", messages[1].GetSender())
    require.Equal(t, "Goodbye", messages[2].GetMessage())
    require.Equal(t, "Alice", messages[2].GetSender())
}

Error Responses

go
func TestPaymentService_Failure(t *testing.T) {
    // ARRANGE
    srv := sdk.NewServer(t, sdk.WithFileDescriptor(payment.File_payment_service_proto))
    defer srv.Close()

    srv.ExpectUnary(PaymentService_Charge_FullMethodName).
        Match("amount", 0).
        ReturnError(codes.InvalidArgument, "Amount must be greater than 0")

    client := NewPaymentServiceClient(srv.Conn())

    // ACT
    _, err := client.Charge(t.Context(), &ChargeRequest{Amount: 0})

    // ASSERT
    require.Error(t, err)
    require.Equal(t, codes.InvalidArgument, status.Code(err))
    require.Contains(t, err.Error(), "Amount must be greater than 0")
}

Response Metadata v3.20.0

Every expectation type can set the metadata the call answers with. Values go through the template engine, so they can reference the request.

go
srv.ExpectUnary(FullMethod).
    Match("id", "42").
    ReturnHeaders(map[string]string{"x-trace-id": "{{.Request.id}}"}).
    ReturnTrailers(map[string]string{"x-cost": "7"}).
    Return("name", "Alex")

Fixed Stub IDs v3.20.0

WithID pins the identifier instead of generating one, so an effect or a later test step can target the same stub deterministically.

go
id := uuid.MustParse("11111111-1111-1111-1111-111111111111")

srv.ExpectUnary(FullMethod).WithID(id).Match("id", "42").Return("name", "Alex")

Scalar and Status-Only Responses v3.20.0

Return builds a message from key-value pairs. A method whose response is a top-level well-known type needs ReturnValue, and a bare status needs ReturnStatus.

go
srv.ExpectUnary("/wkt.TypeService/GetName").Match("id", "1").ReturnValue("plain string")
srv.ExpectUnary(FullMethod).Match("id", "gone").ReturnStatus(codes.Unavailable)

A client-stream call answers with one message too, so it takes the same terminal methods. ReturnStatus, ReturnError and Delay work on all four shapes; Delay pauses the reply, or every message of a stream.

go
srv.ExpectClientStream("/calc.Calculator/SumNumbers").Match(sdk.Matches("value", ".*")).ReturnStatus(codes.ResourceExhausted)
srv.ExpectBidirectionalStream("/chat.ChatService/Chat").Match("text", "ping").ReturnError(codes.FailedPrecondition, "room closed")
srv.ExpectUnary(FullMethod).Match("id", "slow").Delay(80 * time.Millisecond).Return("name", "late")

Positional Stream Matching v3.20.0

Match merges its matchers and applies the result to every message. MatchSequence matches each message against its own matcher.

go
srv.ExpectClientStream("/calc.Calculator/SumNumbers").
    MatchSequence(sdk.Equals("value", 1.0), sdk.Equals("value", 2.0)).
    Return("result", 3.0, "count", 2)

Aborting a Stream v3.20.0

A stream can end in an error either from the start or partway through.

go
// Fails immediately.
srv.ExpectServerStream(FullMethod).Match("q", "boom").
    ReturnError(codes.ResourceExhausted, "quota exceeded")

// Delivers one message, then fails.
srv.ExpectServerStream(FullMethod).Match("q", "half").
    SendStream(
        map[string]any{"id": "1"},
        sdk.StreamError(codes.Aborted, "cut short"),
    )

Delay(d) on a server-stream expectation pauses before every message; SendStream(sdk.Delay(d, ...)) delays a single one.

Static Bidirectional Stubs v3.20.0

Run installs an in-process handler and therefore only works against an embedded server. A static bidi stub works in both modes.

go
srv.ExpectBidirectionalStream("/chat.ChatService/Chat").
    Match("text", "ping").
    SendStream(map[string]any{"text": "pong"})

Handlers v3.20.0

Unary, server-stream and client-stream expectations accept a handler when a static response is not enough. Handlers run in-process and panic in remote mode.

go
srv.ExpectUnary(FullMethod).
    Match("name", "Alex").
    Run(func(ctx context.Context, in any) (any, error) {
        req := in.(map[string]any)

        return map[string]any{"message": "hello " + req["name"].(string)}, nil
    })

A client-stream handler receives the request messages already decoded — the engine has to drain the stream before it can pick a stub, so the handler cannot read it again.

go
srv.ExpectClientStream("/calc.Calculator/SumNumbers").
    Match(sdk.Matches("value", ".*")).
    Run(func(ctx context.Context, messages []any) (any, error) {
        return map[string]any{"count": len(messages)}, nil
    })