Skip to content

Commit

Permalink
Add framework-agnostic MockContext for testing (#351)
Browse files Browse the repository at this point in the history
* 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
olisaagbafor and EwenQuim authored Feb 1, 2025
1 parent 0f9e5b4 commit b2965ee
Show file tree
Hide file tree
Showing 3 changed files with 504 additions and 0 deletions.
125 changes: 125 additions & 0 deletions documentation/docs/guides/testing.md
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.
182 changes: 182 additions & 0 deletions mock_context.go
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
}
Loading

0 comments on commit b2965ee

Please sign in to comment.