diff --git a/documentation/docs/guides/testing.md b/documentation/docs/guides/testing.md new file mode 100644 index 00000000..d47b1080 --- /dev/null +++ b/documentation/docs/guides/testing.md @@ -0,0 +1,125 @@ +# Testing Fuego Controllers + +Fuego provides a `MockContext` type that makes it easy to test your controllers without using httptest, allowing you to focus on your business logic instead of the HTTP layer. + +## Using MockContext + +The `MockContext` type implements the `ContextWithBody` interface. Here's a simple example: + +```go +func TestMyController(t *testing.T) { + // Create a new mock context with the request body + ctx := fuego.NewMockContext(MyRequestType{ + Name: "John", + Age: 30, + }) + + // Add query parameters + ctx.SetQueryParamInt("page", 1) + + // Call your controller + response, err := MyController(ctx) + + // Assert the results + assert.NoError(t, err) + assert.Equal(t, expectedResponse, response) +} +``` + +## Complete Example + +Here's a more complete example showing how to test a controller that uses request body, query parameters, and validation: + +```go +// UserSearchRequest represents the search criteria +type UserSearchRequest struct { + MinAge int `json:"minAge" validate:"gte=0,lte=150"` + MaxAge int `json:"maxAge" validate:"gte=0,lte=150"` + NameQuery string `json:"nameQuery" validate:"required"` +} + +// SearchUsersController is our controller to test +func SearchUsersController(c fuego.ContextWithBody[UserSearchRequest]) (UserSearchResponse, error) { + body, err := c.Body() + if err != nil { + return UserSearchResponse{}, err + } + + // Get pagination from query params + page := c.QueryParamInt("page") + if page < 1 { + page = 1 + } + + // Business logic validation + if body.MinAge > body.MaxAge { + return UserSearchResponse{}, errors.New("minAge cannot be greater than maxAge") + } + + // ... rest of the controller logic +} + +func TestSearchUsersController(t *testing.T) { + tests := []struct { + name string + body UserSearchRequest + setupContext func(*fuego.MockContext[UserSearchRequest]) + expectedError string + expected UserSearchResponse + }{ + { + name: "successful search", + body: UserSearchRequest{ + MinAge: 20, + MaxAge: 35, + NameQuery: "John", + }, + setupContext: func(ctx *fuego.MockContext[UserSearchRequest]) { + // Add query parameters with OpenAPI validation + ctx.WithQueryParamInt("page", 1, + fuego.ParamDescription("Page number"), + fuego.ParamDefault(1)) + ctx.WithQueryParamInt("perPage", 20, + fuego.ParamDescription("Items per page"), + fuego.ParamDefault(20)) + }, + expected: UserSearchResponse{ + // ... expected response + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock context with the test body + ctx := fuego.NewMockContext[UserSearchRequest](tt.body) + + // Set up context with query parameters + if tt.setupContext != nil { + tt.setupContext(ctx) + } + + // Call the controller + response, err := SearchUsersController(ctx) + + // Check error cases + if tt.expectedError != "" { + assert.EqualError(t, err, tt.expectedError) + return + } + + // Check success cases + assert.NoError(t, err) + assert.Equal(t, tt.expected, response) + }) + } +} +``` + +## Best Practices + +1. **Test Edge Cases**: Test both valid and invalid inputs, including validation errors. +2. **Use Table-Driven Tests**: Structure your tests as a slice of test cases for better organization. +3. **Mock using interfaces**: Use interfaces to mock dependencies and make your controllers testable. +4. **Test Business Logic**: Focus on testing your business logic rather than the framework itself. +5. **Fuzz Testing**: Use fuzz testing to automatically find edge cases that you might have missed. diff --git a/mock_context.go b/mock_context.go new file mode 100644 index 00000000..2cf5cabf --- /dev/null +++ b/mock_context.go @@ -0,0 +1,182 @@ +package fuego + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/go-fuego/fuego/internal" +) + +// MockContext provides a framework-agnostic implementation of ContextWithBody +// for testing purposes. It allows testing controllers without depending on +// specific web frameworks like Gin or Echo. +type MockContext[B any] struct { + internal.CommonContext[B] + + RequestBody B + Headers http.Header + PathParams map[string]string + response http.ResponseWriter + request *http.Request + Cookies map[string]*http.Cookie +} + +// NewMockContext creates a new MockContext instance with the provided body +func NewMockContext[B any](body B) *MockContext[B] { + return &MockContext[B]{ + CommonContext: internal.CommonContext[B]{ + CommonCtx: context.Background(), + UrlValues: make(url.Values), + OpenAPIParams: make(map[string]internal.OpenAPIParam), + DefaultStatusCode: http.StatusOK, + }, + RequestBody: body, + Headers: make(http.Header), + PathParams: make(map[string]string), + Cookies: make(map[string]*http.Cookie), + } +} + +// NewMockContextNoBody creates a new MockContext suitable for a request & controller with no body +func NewMockContextNoBody() *MockContext[any] { + return NewMockContext[any](nil) +} + +var _ ContextWithBody[string] = &MockContext[string]{} + +// Body returns the previously set body value +func (m *MockContext[B]) Body() (B, error) { + return m.RequestBody, nil +} + +// MustBody returns the body or panics if there's an error +func (m *MockContext[B]) MustBody() B { + return m.RequestBody +} + +// HasHeader checks if a header exists +func (m *MockContext[B]) HasHeader(key string) bool { + _, exists := m.Headers[key] + return exists +} + +// HasCookie checks if a cookie exists +func (m *MockContext[B]) HasCookie(key string) bool { + _, exists := m.Cookies[key] + return exists +} + +// Header returns the value of the specified header +func (m *MockContext[B]) Header(key string) string { + return m.Headers.Get(key) +} + +// SetHeader sets a header in the mock context +func (m *MockContext[B]) SetHeader(key, value string) { + m.Headers.Set(key, value) +} + +// PathParam returns a mock path parameter +func (m *MockContext[B]) PathParam(name string) string { + return m.PathParams[name] +} + +// Request returns the mock request +func (m *MockContext[B]) Request() *http.Request { + return m.request +} + +// Response returns the mock response writer +func (m *MockContext[B]) Response() http.ResponseWriter { + return m.response +} + +// SetStatus sets the response status code +func (m *MockContext[B]) SetStatus(code int) { + if m.response != nil { + m.response.WriteHeader(code) + } +} + +// Cookie returns a mock cookie +func (m *MockContext[B]) Cookie(name string) (*http.Cookie, error) { + cookie, exists := m.Cookies[name] + if !exists { + return nil, http.ErrNoCookie + } + return cookie, nil +} + +// SetCookie sets a cookie in the mock context +func (m *MockContext[B]) SetCookie(cookie http.Cookie) { + m.Cookies[cookie.Name] = &cookie +} + +// MainLang returns the main language from Accept-Language header +func (m *MockContext[B]) MainLang() string { + lang := m.Headers.Get("Accept-Language") + if lang == "" { + return "" + } + return strings.Split(strings.Split(lang, ",")[0], "-")[0] +} + +// MainLocale returns the main locale from Accept-Language header +func (m *MockContext[B]) MainLocale() string { + return m.Headers.Get("Accept-Language") +} + +// Redirect returns a redirect response +func (m *MockContext[B]) Redirect(code int, url string) (any, error) { + if m.response != nil { + http.Redirect(m.response, m.request, url, code) + } + return nil, nil +} + +// Render is a mock implementation that does nothing +func (m *MockContext[B]) Render(templateToExecute string, data any, templateGlobsToOverride ...string) (CtxRenderer, error) { + panic("not implemented") +} + +// SetQueryParam adds a query parameter to the mock context with OpenAPI validation +func (m *MockContext[B]) SetQueryParam(name, value string) *MockContext[B] { + param := OpenAPIParam{ + Name: name, + GoType: "string", + Type: "query", + } + + m.CommonContext.OpenAPIParams[name] = param + m.CommonContext.UrlValues.Set(name, value) + return m +} + +// SetQueryParamInt adds an integer query parameter to the mock context with OpenAPI validation +func (m *MockContext[B]) SetQueryParamInt(name string, value int) *MockContext[B] { + param := OpenAPIParam{ + Name: name, + GoType: "integer", + Type: "query", + } + + m.CommonContext.OpenAPIParams[name] = param + m.CommonContext.UrlValues.Set(name, fmt.Sprintf("%d", value)) + return m +} + +// SetQueryParamBool adds a boolean query parameter to the mock context with OpenAPI validation +func (m *MockContext[B]) SetQueryParamBool(name string, value bool) *MockContext[B] { + param := OpenAPIParam{ + Name: name, + GoType: "boolean", + Type: "query", + } + + m.CommonContext.OpenAPIParams[name] = param + m.CommonContext.UrlValues.Set(name, fmt.Sprintf("%t", value)) + return m +} diff --git a/mock_context_test.go b/mock_context_test.go new file mode 100644 index 00000000..be8925cd --- /dev/null +++ b/mock_context_test.go @@ -0,0 +1,197 @@ +package fuego_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/go-fuego/fuego" + "github.com/go-fuego/fuego/option" + "github.com/go-fuego/fuego/param" +) + +// UserSearchRequest represents the search criteria for users +type UserSearchRequest struct { + MinAge int `json:"minAge" validate:"gte=0,lte=150"` + MaxAge int `json:"maxAge" validate:"gte=0,lte=150"` + NameQuery string `json:"nameQuery" validate:"required"` +} + +// UserSearchResponse represents the search results +type UserSearchResponse struct { + Users []UserProfile `json:"users"` + TotalCount int `json:"totalCount"` + CurrentPage int `json:"currentPage"` +} + +// UserProfile represents a user in the system +type UserProfile struct { + ID string `json:"id"` + Name string `json:"name" validate:"required"` + Age int `json:"age" validate:"gte=0,lte=150"` + Email string `json:"email" validate:"required,email"` +} + +// SearchUsersController is an example of a real controller that would be used in a Fuego app +func SearchUsersController(c fuego.ContextWithBody[UserSearchRequest]) (UserSearchResponse, error) { + // Get and validate the request body + body, err := c.Body() + if err != nil { + return UserSearchResponse{}, err + } + + // Get pagination parameters from query + page := c.QueryParamInt("page") + if page < 1 { + page = 1 + } + perPage := c.QueryParamInt("perPage") + if perPage < 1 || perPage > 100 { + perPage = 20 + } + + // Example validation beyond struct tags + if body.MinAge > body.MaxAge { + return UserSearchResponse{}, errors.New("minAge cannot be greater than maxAge") + } + + // In a real app, this would query a database + // Here we just return mock data that matches the criteria + users := []UserProfile{ + {ID: "user_1", Name: "John Doe", Age: 25, Email: "john@example.com"}, + {ID: "user_2", Name: "Jane Smith", Age: 30, Email: "jane@example.com"}, + } + + // Filter users based on criteria (simplified example) + var filteredUsers []UserProfile + for _, user := range users { + if user.Age >= body.MinAge && user.Age <= body.MaxAge { + filteredUsers = append(filteredUsers, user) + } + } + + return UserSearchResponse{ + Users: filteredUsers, + TotalCount: len(filteredUsers), + CurrentPage: page, + }, nil +} + +func TestSearchUsersController(t *testing.T) { + tests := []struct { + name string + body UserSearchRequest + setupContext func(*fuego.MockContext[UserSearchRequest]) + expectedError string + expected UserSearchResponse + }{ + { + name: "successful search with age range", + body: UserSearchRequest{ + MinAge: 20, + MaxAge: 35, + NameQuery: "John", + }, + setupContext: func(ctx *fuego.MockContext[UserSearchRequest]) { + ctx.SetQueryParamInt("page", 1) + ctx.SetQueryParamInt("perPage", 20) + }, + expected: UserSearchResponse{ + Users: []UserProfile{ + {ID: "user_1", Name: "John Doe", Age: 25, Email: "john@example.com"}, + {ID: "user_2", Name: "Jane Smith", Age: 30, Email: "jane@example.com"}, + }, + TotalCount: 2, + CurrentPage: 1, + }, + }, + { + name: "invalid age range", + body: UserSearchRequest{ + MinAge: 40, + MaxAge: 20, + NameQuery: "John", + }, + expectedError: "minAge cannot be greater than maxAge", + }, + { + name: "default pagination values", + body: UserSearchRequest{ + MinAge: 20, + MaxAge: 35, + NameQuery: "John", + }, + expected: UserSearchResponse{ + Users: []UserProfile{ + {ID: "user_1", Name: "John Doe", Age: 25, Email: "john@example.com"}, + {ID: "user_2", Name: "Jane Smith", Age: 30, Email: "jane@example.com"}, + }, + TotalCount: 2, + CurrentPage: 1, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock context with the test body + ctx := fuego.NewMockContext(tt.body) + + // Set up context with query parameters if provided + if tt.setupContext != nil { + tt.setupContext(ctx) + } + + // Call the controller + response, err := SearchUsersController(ctx) + + // Check error cases + if tt.expectedError != "" { + assert.EqualError(t, err, tt.expectedError) + return + } + + // Check success cases + assert.NoError(t, err) + assert.Equal(t, tt.expected, response) + }) + } +} + +func TestMockContextNoBody(t *testing.T) { + myController := func(c fuego.ContextNoBody) (string, error) { + return "Hello, " + c.QueryParam("name"), nil + } + + // Just check that `myController` is indeed an acceptable Fuego controller + s := fuego.NewServer() + fuego.Get(s, "/route", myController, + option.Query("name", "Name given to be greeted", param.Default("World")), + ) + + t.Run("TestMockContextNoBody", func(t *testing.T) { + ctx := fuego.NewMockContextNoBody() + assert.NotNil(t, ctx) + + ctx.SetQueryParam("name", "You") + + // Call the controller + response, err := myController(ctx) + + require.NoError(t, err) + require.Equal(t, "Hello, You", response) + }) + + t.Run("Does not use the default params from the route declaration", func(t *testing.T) { + ctx := fuego.NewMockContextNoBody() + assert.NotNil(t, ctx) + + // Call the controller + response, err := myController(ctx) + + require.NoError(t, err) + require.Equal(t, "Hello, ", response) + }) +}