Skip to content

Best Practices 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.

Recommended patterns and practices for using GripMock Embedded SDK.

Test Helper Functions

Create reusable helper functions for common mock setup:

go
func runMyServiceMock(t *testing.T, opts ...sdk.Option) *sdk.Server {
    t.Helper()

    // ARRANGE
    // Add default options
    allOpts := []sdk.Option{
        sdk.WithFileDescriptor(service.File_service_proto),
    }
    allOpts = append(allOpts, opts...)

    return sdk.NewServer(t, allOpts...)
}

func TestMyService_WithHelper(t *testing.T) {
    // ARRANGE
    srv := runMyServiceMock(t)

    // Define stubs in the Arrange phase
    srv.ExpectUnary(MyService_MyMethod_FullMethodName).
        Match("id", "test").
        Return("result", "success")

    client := NewMyServiceClient(srv.Conn())

    // ACT
    resp, err := client.MyMethod(t.Context(), &MyRequest{Id: "test"})

    // ASSERT
    require.NoError(t, err)
    require.Equal(t, "success", resp.Result)
}

Parallel Tests

Use sessions for parallel tests when using remote mode:

go
func TestMyService_Parallel(t *testing.T) {
    t.Parallel()

    // ARRANGE
    srv := sdk.NewServer(t,
        sdk.WithRemote("localhost:4770", "http://localhost:4771"),
        sdk.WithFileDescriptor(service.File_service_proto),
        sdk.WithSession(t.Name()), // Isolate this test's stubs
    )

    srv.ExpectUnary(MyService_MyMethod_FullMethodName).
        Match("id", "parallel").
        Return("result", "parallel-success")

    client := NewMyServiceClient(srv.Conn())

    // ACT
    resp, err := client.MyMethod(t.Context(), &MyRequest{Id: "parallel"})

    // ASSERT
    require.NoError(t, err)
    require.Equal(t, "parallel-success", resp.Result)
}

Proper Cleanup

Always pass t to NewServer. The SDK registers cleanup automatically and verifies Times(...) expectations:

go
func TestCleanupIsAutomatic(t *testing.T) {
    // ARRANGE
    srv := sdk.NewServer(t, sdk.WithFileDescriptor(service.File_service_proto))

    srv.ExpectUnary(MyService_MyMethod_FullMethodName).
        Match("id", "manual").
        Return("result", "manual-success")

    client := NewMyServiceClient(srv.Conn())

    // ACT
    resp, err := client.MyMethod(t.Context(), &MyRequest{Id: "manual"})

    // ASSERT
    require.NoError(t, err)
    require.Equal(t, "manual-success", resp.Result)

    // No explicit srv.Close() is required in tests.
}

Verify Expected Calls

Always verify that your code makes the expected calls:

go
func TestMyService_WithVerification(t *testing.T) {
    // ARRANGE
    srv := sdk.NewServer(t, sdk.WithFileDescriptor(service.File_service_proto))

    srv.ExpectUnary(MyService_MyMethod_FullMethodName).
        Match("id", "test").
        Return("result", "success")

    client := NewMyServiceClient(srv.Conn())

    // ACT
    _, _ = client.MyMethod(t.Context(), &MyRequest{Id: "test"})
    _, _ = client.MyMethod(t.Context(), &MyRequest{Id: "test"})

    // ASSERT - Verify the expected call was made exactly 2 times
    require.Equal(t, 2, srv.Called(MyService_MyMethod_FullMethodName))
}

One Stub per Scenario

Give each branch its own stub, matched on the field that distinguishes it, rather than one stub with conditional logic:

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

    // Existing user stub
    srv.ExpectUnary(UserService_GetUser_FullMethodName).
        Match("id", "existing-user").
        Return("name", "John Doe", "email", "john@example.com")

    // Missing user stub
    srv.ExpectUnary(UserService_GetUser_FullMethodName).
        Match("id", "missing-user").
        ReturnError(codes.NotFound, "User not found")

    client := NewUserServiceClient(srv.Conn())

    // ACT
    existingResp, _ := client.GetUser(t.Context(), &GetUserRequest{Id: "existing-user"})
    _, missingErr := client.GetUser(t.Context(), &GetUserRequest{Id: "missing-user"})

    // ASSERT
    require.Equal(t, "John Doe", existingResp.GetName())
    require.Equal(t, "john@example.com", existingResp.GetEmail())
    require.Error(t, missingErr)
    require.Equal(t, codes.NotFound, status.Code(missingErr))
}

Startup Failures

sdk.NewServer returns no error — a bad descriptor or an unavailable port panics, which fails the test at the line that caused it. There is nothing to check for.

Use Times for Exact Call Verification

When you need to verify exact call counts, use the Times feature:

go
func TestRetryLogic_WithTimes(t *testing.T) {
    // ARRANGE
    srv := sdk.NewServer(t, sdk.WithFileDescriptor(service.File_service_proto))

    // First 2 calls fail, 3rd succeeds (simulating retry logic)
    srv.ExpectUnary(ExternalService_Call_FullMethodName).
        Match("attempt", "fail").
        Times(2). // Allow this stub to match exactly 2 times
        ReturnError(codes.Unavailable, "Service unavailable")

    srv.ExpectUnary(ExternalService_Call_FullMethodName).
        Match("attempt", "success").
        Return("result", "success")

    client := NewExternalServiceClient(srv.Conn())

    // ACT
    // First two calls will fail (triggering retries)
    _, err1 := client.Call(t.Context(), &CallRequest{Attempt: "fail"})
    _, err2 := client.Call(t.Context(), &CallRequest{Attempt: "fail"})

    // Third call will succeed
    successResp, err3 := client.Call(t.Context(), &CallRequest{Attempt: "success"})

    // ASSERT
    require.Error(t, err1)
    require.Error(t, err2)
    require.NoError(t, err3)
    require.Equal(t, "success", successResp.GetResult())

    // Verification happens automatically due to Times(2) and passing t to NewServer
}

Avoid Over-Mocking

Only mock what you need to test:

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

    // Good: Only mock the service you're testing
    srv.ExpectUnary(PaymentService_Charge_FullMethodName).
        Match("amount", 100).
        Return("transactionId", "tx-123")

    client := NewPaymentServiceClient(srv.Conn())

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

    // ASSERT
    require.NoError(t, err)
    require.Equal(t, "tx-123", resp.GetTransactionId())
}

Everything Together

The patterns above in one test:

go
func TestOrderService_Comprehensive(t *testing.T) {
    // ARRANGE
    srv := sdk.NewServer(t, sdk.WithFileDescriptor(order.File_order_service_proto))

    // Setup multiple stubs with different behaviors
    srv.ExpectUnary(OrderService_CreateOrder_FullMethodName).
        Match("userId", "premium").
        Return("orderId", "ORD-001", "status", "created")

    srv.ExpectUnary(OrderService_GetOrder_FullMethodName).
        Match("orderId", "ORD-001").
        Times(2). // Expected to be called exactly 2 times
        Return("status", "processing", "total", 99.99)

    srv.ExpectUnary(OrderService_CancelOrder_FullMethodName).
        Match("orderId", "ORD-001").
        Return("status", "cancelled")

    client := NewOrderServiceClient(srv.Conn())

    // ACT
    // Create an order
    createResp, err := client.CreateOrder(t.Context(), &CreateOrderRequest{UserId: "premium"})
    require.NoError(t, err)

    // Check order status twice
    status1, err := client.GetOrder(t.Context(), &GetOrderRequest{OrderId: "ORD-001"})
    require.NoError(t, err)
    status2, err := client.GetOrder(t.Context(), &GetOrderRequest{OrderId: "ORD-001"})
    require.NoError(t, err)

    // Cancel the order
    cancelResp, err := client.CancelOrder(t.Context(), &CancelOrderRequest{OrderId: "ORD-001"})
    require.NoError(t, err)

    // ASSERT
    require.Equal(t, "ORD-001", createResp.GetOrderId())
    require.Equal(t, "created", createResp.GetStatus())
    require.Equal(t, "processing", status1.GetStatus())
    require.Equal(t, 99.99, status1.GetTotal())
    require.Equal(t, "processing", status2.GetStatus())
    require.Equal(t, 99.99, status2.GetTotal())
    require.Equal(t, "cancelled", cancelResp.GetStatus())

    // Verify call counts
    require.Equal(t, 1, srv.Called(OrderService_CreateOrder_FullMethodName))
    require.Equal(t, 2, srv.Called(OrderService_GetOrder_FullMethodName)) // Due to Times(2)
    require.Equal(t, 1, srv.Called(OrderService_CancelOrder_FullMethodName))
}