Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add framework-agnostic mock context for testing #351

Merged
merged 25 commits into from
Feb 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
dc235ba
feat: add framework-agnostic mock context for testing
olisaagbafor Jan 13, 2025
82afb5a
feat(test): add framework-agnostic mock context
olisaagbafor Jan 13, 2025
94d151c
feat(test): add realistic mock context example
olisaagbafor Jan 13, 2025
6693115
Resolved the review comments
olisaagbafor Jan 14, 2025
6ab9051
Merge branch 'workflow' into feature/mock-context-for-testing
olisaagbafor Jan 14, 2025
50d594c
Removed the TESTING.md file
olisaagbafor Jan 14, 2025
13dda83
Documentation and testing updates
EwenQuim Jan 15, 2025
3cea539
Merge branch 'main' into pr/olisaagbafor/351
EwenQuim Jan 15, 2025
f051446
Use internal.CommonContext to provide most functions in MockContext
EwenQuim Jan 15, 2025
e3237c3
refactor(mock): make MockContext fields public and leverage CommonCon…
olisaagbafor Jan 16, 2025
110137e
Merge branch 'main' into feature/mock-context-for-testing
olisaagbafor Jan 16, 2025
4792732
Merge branch 'dev-' into feature/mock-context-for-testing
olisaagbafor Jan 16, 2025
20732df
Merge branch 'feature/mock-context-for-testing' of github.com:olisaag…
olisaagbafor Jan 16, 2025
c2f039b
Merge branch 'feature/mock-context-for-testing' of github.com:olisaag…
olisaagbafor Jan 16, 2025
488feb9
feat(testing): Expose MockContext fields for simpler test setup
olisaagbafor Jan 16, 2025
08e3251
Merge branch 'Olisa' into feature/mock-context-for-testing
olisaagbafor Jan 16, 2025
d8c2637
fix(mock): Initialize OpenAPIParams in MockContext constructor
olisaagbafor Jan 16, 2025
5383101
Merge branch 'feature/mock-context-for-testing' of github.com:olisaag…
olisaagbafor Jan 16, 2025
bc99b13
Removed unused import
olisaagbafor Jan 16, 2025
50e87a7
feat(mock): Add helper methods for setting query params with OpenAPI …
olisaagbafor Jan 17, 2025
ccfa704
Merge branch 'main' into pr/olisaagbafor/351
EwenQuim Jan 29, 2025
8357ed8
Removed unused comment
EwenQuim Jan 31, 2025
be830f5
Linting
EwenQuim Jan 31, 2025
f2ad6cc
Adds `NewMockContextNoBody` to create a new MockContext suitable for …
EwenQuim Jan 31, 2025
97f21ef
Removed the available fields and methods section from the testing guide
EwenQuim Feb 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same - need it's associated Param

}

// 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]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cookie needs it OpenAPIParam input

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I guess these are fine there. Unlike the other Params we don't really throw warning or anything here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it will be useful to set default values for example. Because defaults are defined in the route registration, not the controller.
But they'll be defined in 2 places : route registration & tests and it would be weird because nothing guarantees that it will be the same in these 2 different places.

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