-
-
Notifications
You must be signed in to change notification settings - Fork 78
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add framework-agnostic
MockContext
for testing (#351)
* feat: add framework-agnostic mock context for testing Implements a MockContext[B] type that allows testing controllers without framework dependencies. Includes comprehensive tests and documentation. * feat(test): add framework-agnostic mock context Add MockContext[B] to help users test their controllers without framework dependencies. Includes realistic examples and documentation. - Move testing docs from README.md to TESTING.md - Add mock context with getters/setters - Add example user controller tests - Use fuego_test package for end-user perspective Closes #349 * feat(test): add realistic mock context example Replace basic getter/setter tests with a real-world example: - Add UserController with Create and GetByID endpoints - Add UserService with mock implementation - Show proper separation of concerns - Demonstrate practical testing patterns - Move testing docs from README.md to TESTING.md This provides users with a clear example of how to test their controllers using the mock context in a real-world scenario. Closes #349 * Resolved the review comments * Removed the TESTING.md file * Documentation and testing updates * Use internal.CommonContext to provide most functions in MockContext * refactor(mock): make MockContext fields public and leverage CommonContext functionality * feat(testing): Expose MockContext fields for simpler test setup Convert private fields to public and update docs with examples * fix(mock): Initialize OpenAPIParams in MockContext constructor Properly initialize CommonContext fields to prevent potential panics * Removed unused import * feat(mock): Add helper methods for setting query params with OpenAPI validation * Removed unused comment * Linting * Adds `NewMockContextNoBody` to create a new MockContext suitable for a request & controller with no body Changed WithXxx to SetXxx for query parameters. Removed the ...option pattern for declaring query parameters in the MockContext. * Removed the available fields and methods section from the testing guide It's not necessary to have the available fields and methods section in the testing guide, as it's already documented in the codebase & the godoc reference. --------- Co-authored-by: EwenQuim <ewen.quimerch@gmail.com>
- Loading branch information
1 parent
0f9e5b4
commit b2965ee
Showing
3 changed files
with
504 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
Oops, something went wrong.