From 94d151ce57706bda6249f628d2204835fdf7ad68 Mon Sep 17 00:00:00 2001 From: Olisa Agbafor Date: Mon, 13 Jan 2025 21:09:08 +0100 Subject: [PATCH] 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 --- mock_context.go | 235 ++++++++++++++++++++++++++++++- mock_context_test.go | 322 +++++++++++++++++++++++++------------------ 2 files changed, 417 insertions(+), 140 deletions(-) diff --git a/mock_context.go b/mock_context.go index db4a304a..3c12d0e5 100644 --- a/mock_context.go +++ b/mock_context.go @@ -2,8 +2,14 @@ package fuego import ( "context" + "fmt" + "io" "net/http" "net/url" + "strconv" + "time" + + "github.com/go-fuego/fuego/internal" ) // MockContext provides a framework-agnostic implementation of ContextWithBody @@ -17,6 +23,7 @@ type MockContext[B any] struct { ctx context.Context response http.ResponseWriter request *http.Request + cookies map[string]*http.Cookie } // NewMockContext creates a new MockContext instance with initialized maps @@ -28,6 +35,7 @@ func NewMockContext[B any]() *MockContext[B] { headers: make(http.Header), pathParams: make(map[string]string), ctx: context.Background(), + cookies: make(map[string]*http.Cookie), } } @@ -54,9 +62,9 @@ func (m *MockContext[B]) SetURLValues(values url.Values) { m.urlValues = values } -// Header returns the mock headers -func (m *MockContext[B]) Header() http.Header { - return m.headers +// Header returns the value of the header with the given key +func (m *MockContext[B]) Header(key string) string { + return m.headers.Get(key) } // SetHeader sets a mock header @@ -64,6 +72,11 @@ func (m *MockContext[B]) SetHeader(key, value string) { m.headers.Set(key, value) } +// GetHeaders returns all headers (helper method for testing) +func (m *MockContext[B]) GetHeaders() http.Header { + return m.headers +} + // PathParam returns a mock path parameter func (m *MockContext[B]) PathParam(name string) string { return m.pathParams[name] @@ -103,3 +116,219 @@ func (m *MockContext[B]) Request() *http.Request { func (m *MockContext[B]) SetRequest(r *http.Request) { m.request = r } + +// Cookie returns a cookie by name +func (m *MockContext[B]) Cookie(name string) (*http.Cookie, error) { + if cookie, exists := m.cookies[name]; exists { + return cookie, nil + } + return nil, http.ErrNoCookie +} + +// SetCookie sets a cookie for testing +func (m *MockContext[B]) SetCookie(cookie http.Cookie) { + m.cookies[cookie.Name] = &cookie +} + +// Deadline returns the time when work done on behalf of this context +// should be canceled. In this mock implementation, we return no deadline. +func (m *MockContext[B]) Deadline() (deadline time.Time, ok bool) { + return time.Time{}, false +} + +// Done returns a channel that's closed when work done on behalf of this +// context should be canceled. In this mock implementation, we return nil +// which means the context can never be canceled. +func (m *MockContext[B]) Done() <-chan struct{} { + return nil +} + +// Err returns nil since this mock context never returns errors +func (m *MockContext[B]) Err() error { + return nil +} + +// EmptyBody represents an empty request body +type EmptyBody struct{} + +// GetOpenAPIParams returns an empty map since this is just a mock +func (m *MockContext[B]) GetOpenAPIParams() map[string]internal.OpenAPIParam { + return make(map[string]internal.OpenAPIParam) +} + +// HasCookie checks if a cookie with the given name exists +func (m *MockContext[B]) HasCookie(name string) bool { + _, exists := m.cookies[name] + return exists +} + +// HasHeader checks if a header with the given key exists +func (m *MockContext[B]) HasHeader(key string) bool { + _, exists := m.headers[key] + return exists +} + +// HasQueryParam checks if a query parameter with the given key exists +func (m *MockContext[B]) HasQueryParam(key string) bool { + _, exists := m.urlValues[key] + return exists +} + +// MainLang returns the main language for the request (e.g., "en"). +// In this mock implementation, we'll return "en" as default. +func (m *MockContext[B]) MainLang() string { + // Get language from Accept-Language header or return default + if lang := m.headers.Get("Accept-Language"); lang != "" { + return lang[:2] // Take first two chars for language code + } + return "en" // Default to English +} + +// MainLocale returns the main locale for the request (e.g., "en-US"). +// In this mock implementation, we'll return "en-US" as default. +func (m *MockContext[B]) MainLocale() string { + // Get locale from Accept-Language header or return default + if locale := m.headers.Get("Accept-Language"); locale != "" { + return locale + } + return "en-US" // Default to English (US) +} + +// MustBody returns the body directly, without error handling. +// In this mock implementation, we simply return the body since we know it's valid. +func (m *MockContext[B]) MustBody() B { + return m.body +} + +// QueryParam returns the value of the query parameter with the given key. +// If there are multiple values, it returns the first one. +// If the parameter doesn't exist, it returns an empty string. +func (m *MockContext[B]) QueryParam(key string) string { + return m.urlValues.Get(key) +} + +// QueryParamArr returns all values for the query parameter with the given key. +// If the parameter doesn't exist, it returns an empty slice. +func (m *MockContext[B]) QueryParamArr(key string) []string { + return m.urlValues[key] +} + +// QueryParamBool returns the boolean value of the query parameter with the given key. +// Returns true for "1", "t", "T", "true", "TRUE", "True" +// Returns false for "0", "f", "F", "false", "FALSE", "False" +// Returns false for any other value +func (m *MockContext[B]) QueryParamBool(key string) bool { + v := m.urlValues.Get(key) + switch v { + case "1", "t", "T", "true", "TRUE", "True": + return true + default: + return false + } +} + +// QueryParamBoolErr returns the boolean value of the query parameter with the given key +// and an error if the value is not a valid boolean. +func (m *MockContext[B]) QueryParamBoolErr(key string) (bool, error) { + v := m.urlValues.Get(key) + switch v { + case "1", "t", "T", "true", "TRUE", "True": + return true, nil + case "0", "f", "F", "false", "FALSE", "False": + return false, nil + case "": + return false, nil // Parameter not found + default: + return false, fmt.Errorf("invalid boolean value: %s", v) + } +} + +// QueryParamInt returns the integer value of the query parameter with the given key. +// Returns 0 if the parameter doesn't exist or cannot be parsed as an integer. +func (m *MockContext[B]) QueryParamInt(key string) int { + v := m.urlValues.Get(key) + if v == "" { + return 0 + } + i, err := strconv.Atoi(v) + if err != nil { + return 0 + } + return i +} + +// QueryParamIntErr returns the integer value of the query parameter with the given key +// and an error if the value cannot be parsed as an integer. +func (m *MockContext[B]) QueryParamIntErr(key string) (int, error) { + v := m.urlValues.Get(key) + if v == "" { + return 0, nil // Parameter not found + } + i, err := strconv.Atoi(v) + if err != nil { + return 0, fmt.Errorf("invalid integer value: %s", v) + } + return i, nil +} + +// QueryParams returns all query parameters. +// This is an alias for URLValues() for compatibility with the interface. +func (m *MockContext[B]) QueryParams() url.Values { + return m.urlValues +} + +// Redirect performs a redirect to the specified URL with the given status code. +// In this mock implementation, we store the redirect information for testing. +func (m *MockContext[B]) Redirect(code int, url string) (any, error) { + if m.response != nil { + m.response.Header().Set("Location", url) + m.response.WriteHeader(code) + } + return nil, nil +} + +// mockRenderer implements CtxRenderer for testing +type mockRenderer struct { + data any + template string + layouts []string +} + +// Render implements the CtxRenderer interface +func (r *mockRenderer) Render(ctx context.Context, w io.Writer) error { + // In a real implementation, this would render the template + // For testing, we just write a success status + if hw, ok := w.(http.ResponseWriter); ok { + hw.WriteHeader(http.StatusOK) + } + return nil +} + +// Render renders the template with the given name and data. +// In this mock implementation, we just store the data for testing. +func (m *MockContext[B]) Render(templateName string, data any, layouts ...string) (CtxRenderer, error) { + if m.response != nil { + // In a real implementation, this would render the template with layouts + // For testing, we just store the data + } + return &mockRenderer{ + data: data, + template: templateName, + layouts: layouts, + }, nil +} + +// SetStatus sets the HTTP status code for the response. +// In this mock implementation, we set the status code if a response writer is available. +func (m *MockContext[B]) SetStatus(code int) { + if m.response != nil { + m.response.WriteHeader(code) + } +} + +// Value returns the value associated with this context for key, or nil +// if no value is associated with key. In this mock implementation, +// we delegate to the underlying context. +func (m *MockContext[B]) Value(key any) any { + return m.ctx.Value(key) +} diff --git a/mock_context_test.go b/mock_context_test.go index 182f2234..a66b01aa 100644 --- a/mock_context_test.go +++ b/mock_context_test.go @@ -2,173 +2,221 @@ package fuego_test import ( "errors" + "net/http" + "net/http/httptest" "testing" "github.com/go-fuego/fuego" "github.com/stretchr/testify/assert" ) -// UserRequest represents the incoming request body -type UserRequest struct { - Name string `json:"name" validate:"required"` - Email string `json:"email" validate:"required,email"` - Password string `json:"password" validate:"required,min=8"` -} - -// UserResponse represents the API response -type UserResponse struct { +// UserProfile represents a user in our system +type UserProfile struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` } -// CreateUserController is a typical controller that creates a user -func CreateUserController(c fuego.ContextWithBody[UserRequest]) (UserResponse, error) { - // Get and validate the request body - body, err := c.Body() - if err != nil { - return UserResponse{}, err - } +// UserService simulates a real service layer +type UserService interface { + CreateUser(name, email string) (UserProfile, error) + GetUserByID(id string) (UserProfile, error) +} - // Check if email is already taken (simulating DB check) - if body.Email == "taken@example.com" { - return UserResponse{}, errors.New("email already taken") - } +// mockUserService is a mock implementation of UserService +type mockUserService struct { + users map[string]UserProfile +} - // In a real app, you would: - // 1. Hash the password - // 2. Save to database - // 3. Generate ID - // Here we'll simulate that: - user := UserResponse{ - ID: "user_123", // Simulated generated ID - Name: body.Name, - Email: body.Email, +func newMockUserService() *mockUserService { + return &mockUserService{ + users: map[string]UserProfile{ + "123": {ID: "123", Name: "John Doe", Email: "john@example.com"}, + }, } +} +func (s *mockUserService) CreateUser(name, email string) (UserProfile, error) { + if email == "taken@example.com" { + return UserProfile{}, errors.New("email already taken") + } + user := UserProfile{ + ID: "new_id", + Name: name, + Email: email, + } + s.users[user.ID] = user return user, nil } -func TestCreateUserController(t *testing.T) { - tests := []struct { - name string - request UserRequest - want UserResponse - wantErr string - }{ - { - name: "successful creation", - request: UserRequest{ - Name: "John Doe", - Email: "john@example.com", - Password: "secure123", - }, - want: UserResponse{ - ID: "user_123", - Name: "John Doe", - Email: "john@example.com", - }, - }, - { - name: "email already taken", - request: UserRequest{ - Name: "Jane Doe", - Email: "taken@example.com", - Password: "secure123", - }, - wantErr: "email already taken", - }, +func (s *mockUserService) GetUserByID(id string) (UserProfile, error) { + user, exists := s.users[id] + if !exists { + return UserProfile{}, errors.New("user not found") } + return user, nil +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Setup - ctx := fuego.NewMockContext[UserRequest]() - ctx.SetBody(tt.request) - - // Execute - got, err := CreateUserController(ctx) - - // Assert - if tt.wantErr != "" { - assert.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - return - } - - assert.NoError(t, err) - assert.Equal(t, tt.want, got) - }) - } +// CreateUserRequest represents the request body for user creation +type CreateUserRequest struct { + Name string `json:"name" validate:"required"` + Email string `json:"email" validate:"required,email"` +} + +// UserController handles user-related HTTP endpoints +type UserController struct { + service UserService } -// Example of testing a controller that uses path parameters -func GetUserController(c fuego.ContextNoBody) (UserResponse, error) { - userID := c.PathParam("id") - if userID == "" { - return UserResponse{}, errors.New("user ID is required") +func NewUserController(service UserService) *UserController { + return &UserController{service: service} +} + +// Create handles user creation +func (c *UserController) Create(ctx fuego.ContextWithBody[CreateUserRequest]) (UserProfile, error) { + req, err := ctx.Body() + if err != nil { + return UserProfile{}, err } - // Simulate fetching user from database - if userID == "not_found" { - return UserResponse{}, errors.New("user not found") + user, err := c.service.CreateUser(req.Name, req.Email) + if err != nil { + return UserProfile{}, err } - return UserResponse{ - ID: userID, - Name: "John Doe", - Email: "john@example.com", - }, nil + ctx.SetStatus(http.StatusCreated) + return user, nil } -func TestGetUserController(t *testing.T) { - tests := []struct { - name string - userID string - want UserResponse - wantErr string - }{ - { - name: "user found", - userID: "user_123", - want: UserResponse{ - ID: "user_123", - Name: "John Doe", - Email: "john@example.com", - }, - }, - { - name: "user not found", - userID: "not_found", - wantErr: "user not found", - }, - { - name: "missing user ID", - userID: "", - wantErr: "user ID is required", - }, +// GetByID handles fetching a user by ID +func (c *UserController) GetByID(ctx fuego.ContextWithBody[any]) (UserProfile, error) { + id := ctx.PathParam("id") + if id == "" { + return UserProfile{}, errors.New("id is required") } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Setup - ctx := fuego.NewMockContext[struct{}]() - if tt.userID != "" { - ctx.SetPathParam("id", tt.userID) - } + user, err := c.service.GetUserByID(id) + if err != nil { + if err.Error() == "user not found" { + ctx.SetStatus(http.StatusNotFound) + } + return UserProfile{}, err + } - // Execute - got, err := GetUserController(ctx) + return user, nil +} - // Assert - if tt.wantErr != "" { - assert.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - return - } +func TestUserController(t *testing.T) { + // Setup + service := newMockUserService() + controller := NewUserController(service) + + t.Run("create user", func(t *testing.T) { + tests := []struct { + name string + request CreateUserRequest + want UserProfile + wantErr string + status int + }{ + { + name: "successful creation", + request: CreateUserRequest{ + Name: "Jane Doe", + Email: "jane@example.com", + }, + want: UserProfile{ + ID: "new_id", + Name: "Jane Doe", + Email: "jane@example.com", + }, + status: http.StatusCreated, + }, + { + name: "email taken", + request: CreateUserRequest{ + Name: "Another User", + Email: "taken@example.com", + }, + wantErr: "email already taken", + status: http.StatusOK, // Default status when not set + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup mock context + w := httptest.NewRecorder() + ctx := fuego.NewMockContext[CreateUserRequest]() + ctx.SetBody(tt.request) + ctx.SetResponse(w) + + // Call controller + got, err := controller.Create(ctx) + + // Assert results + if tt.wantErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.status, w.Code) + }) + } + }) + + t.Run("get user by id", func(t *testing.T) { + tests := []struct { + name string + userID string + want UserProfile + wantErr string + status int + }{ + { + name: "user found", + userID: "123", + want: UserProfile{ + ID: "123", + Name: "John Doe", + Email: "john@example.com", + }, + status: http.StatusOK, + }, + { + name: "user not found", + userID: "999", + wantErr: "user not found", + status: http.StatusNotFound, + }, + } - assert.NoError(t, err) - assert.Equal(t, tt.want, got) - }) - } -} \ No newline at end of file + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup mock context + w := httptest.NewRecorder() + ctx := fuego.NewMockContext[any]() + ctx.SetPathParam("id", tt.userID) + ctx.SetResponse(w) + + // Call controller + got, err := controller.GetByID(ctx) + + // Assert results + if tt.wantErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + assert.Equal(t, tt.status, w.Code) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.status, w.Code) + }) + } + }) +}