Echo Unit Testing
Introduction
Unit testing is a fundamental practice in software development that involves testing individual components or "units" of code in isolation. In the context of Echo applications, unit testing allows you to verify that your API endpoints, middleware, and handlers behave as expected without needing to run the entire application.
In this guide, you'll learn how to write effective unit tests for your Echo applications. We'll cover setting up a testing environment, creating test cases for handlers, mocking dependencies, and ensuring your API behaves as expected.
Why Unit Testing Matters for Echo Applications
Before diving into the code, let's understand why unit testing is particularly important for Echo web applications:
- API Reliability: Tests ensure your endpoints behave correctly under various conditions
- Regression Prevention: Changes to your code won't break existing functionality
- Documentation: Tests serve as working examples of how your API is supposed to function
- Confidence: Well-tested code enables faster development with fewer bugs
Setting Up For Echo Unit Testing
To get started with Echo unit testing, you'll need to import the required packages:
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
)
Here's what each of these packages is used for:
net/http
andnet/http/httptest
: Create HTTP requests and record responsesstrings
: Manipulate strings (useful for request bodies)testing
: Go's built-in testing frameworkgithub.com/labstack/echo/v4
: The Echo web frameworkgithub.com/stretchr/testify/assert
: A helpful assertion library to make tests more readable
Basic Echo Unit Test Structure
Let's create a simple handler and its corresponding test:
// handlers.go
package handlers
import (
"github.com/labstack/echo/v4"
"net/http"
)
// HealthCheckHandler returns a simple status message
func HealthCheckHandler(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{
"status": "healthy",
})
}
Now, let's write a test for this handler:
// handlers_test.go
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
)
func TestHealthCheckHandler(t *testing.T) {
// Setup
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// Assertions
if assert.NoError(t, HealthCheckHandler(c)) {
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "healthy")
}
}
This test follows a simple pattern:
- Create a new Echo instance
- Create a test HTTP request
- Create a response recorder
- Create an Echo context with the request and recorder
- Call the handler function directly
- Assert that the response matches expectations
Testing Handlers with Path Parameters
Echo makes it easy to define routes with path parameters. Here's how to test them:
// handlers.go
func GetUserHandler(c echo.Context) error {
id := c.Param("id")
return c.JSON(http.StatusOK, map[string]string{
"id": id,
"name": "User " + id,
})
}
And its corresponding test:
func TestGetUserHandler(t *testing.T) {
// Setup
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// Set path parameter
c.SetPath("/users/:id")
c.SetParamNames("id")
c.SetParamValues("123")
// Assertions
if assert.NoError(t, GetUserHandler(c)) {
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "User 123")
assert.Contains(t, rec.Body.String(), `"id":"123"`)
}
}
Note how we set the path parameter values using SetParamNames
and SetParamValues
.
Testing POST Requests with JSON Body
For testing handlers that process JSON request bodies:
// handlers.go
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func CreateUserHandler(c echo.Context) error {
u := new(CreateUserRequest)
if err := c.Bind(u); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
// In a real app, you would save to a database and generate a real ID
return c.JSON(http.StatusCreated, User{
ID: "new-user-id",
Name: u.Name,
Email: u.Email,
})
}
Now the test:
func TestCreateUserHandler(t *testing.T) {
// Setup
e := echo.New()
jsonBody := `{"name":"John Doe","email":"[email protected]"}`
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(jsonBody))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// Assertions
if assert.NoError(t, CreateUserHandler(c)) {
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Contains(t, rec.Body.String(), "John Doe")
assert.Contains(t, rec.Body.String(), "[email protected]")
assert.Contains(t, rec.Body.String(), "new-user-id")
}
}
Testing Middleware
Echo middleware can also be unit tested. Here's an example of a simple authentication middleware and its test:
// middleware.go
func AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
token := c.Request().Header.Get("Authorization")
if token != "valid-token" {
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid token")
}
return next(c)
}
}
And its test:
func TestAuthMiddleware(t *testing.T) {
// Setup
e := echo.New()
// Test case 1: Valid token
req1 := httptest.NewRequest(http.MethodGet, "/", nil)
req1.Header.Set("Authorization", "valid-token")
rec1 := httptest.NewRecorder()
c1 := e.NewContext(req1, rec1)
// Create a handler to use with the middleware
handler := func(c echo.Context) error {
return c.String(http.StatusOK, "test")
}
// Test valid token
h := AuthMiddleware(handler)
assert.NoError(t, h(c1))
assert.Equal(t, http.StatusOK, rec1.Code)
// Test case 2: Invalid token
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
req2.Header.Set("Authorization", "invalid-token")
rec2 := httptest.NewRecorder()
c2 := e.NewContext(req2, rec2)
h = AuthMiddleware(handler)
err := h(c2)
// Check that it returns the correct error
httpErr, ok := err.(*echo.HTTPError)
assert.True(t, ok)
assert.Equal(t, http.StatusUnauthorized, httpErr.Code)
}
Testing with Database Mocks
In real applications, your handlers will often interact with a database. Here's how to test them using mocks:
// user_service.go
type UserService interface {
GetUserByID(id string) (*User, error)
}
// handlers.go
func GetUserDetailHandler(userService UserService) echo.HandlerFunc {
return func(c echo.Context) error {
id := c.Param("id")
user, err := userService.GetUserByID(id)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user")
}
if user == nil {
return echo.NewHTTPError(http.StatusNotFound, "User not found")
}
return c.JSON(http.StatusOK, user)
}
}
Let's create a mock for testing:
// mocks.go
type MockUserService struct {
GetUserByIDFunc func(id string) (*User, error)
}
func (m *MockUserService) GetUserByID(id string) (*User, error) {
return m.GetUserByIDFunc(id)
}
And now the test:
func TestGetUserDetailHandler(t *testing.T) {
// Setup
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetPath("/users/:id")
c.SetParamNames("id")
c.SetParamValues("123")
// Create a mock
mockService := &MockUserService{
GetUserByIDFunc: func(id string) (*User, error) {
return &User{
ID: "123",
Name: "Test User",
Email: "[email protected]",
}, nil
},
}
// Get the handler with injected mock
handler := GetUserDetailHandler(mockService)
// Test
if assert.NoError(t, handler(c)) {
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), `"id":"123"`)
assert.Contains(t, rec.Body.String(), "Test User")
}
// Test not found case
rec = httptest.NewRecorder()
c = e.NewContext(req, rec)
c.SetPath("/users/:id")
c.SetParamNames("id")
c.SetParamValues("999")
mockService.GetUserByIDFunc = func(id string) (*User, error) {
return nil, nil // User not found
}
handler = GetUserDetailHandler(mockService)
err := handler(c)
httpErr, ok := err.(*echo.HTTPError)
assert.True(t, ok)
assert.Equal(t, http.StatusNotFound, httpErr.Code)
}
Testing Error Handling
Proper error handling is important in web applications. Here's how to test error scenarios:
func TestCreateUserHandler_ValidationError(t *testing.T) {
// Setup with invalid JSON
e := echo.New()
invalidJSON := `{"name":"}` // Incomplete JSON
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(invalidJSON))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// Call handler
err := CreateUserHandler(c)
// Check that it returns a 400 Bad Request
httpErr, ok := err.(*echo.HTTPError)
assert.True(t, ok)
assert.Equal(t, http.StatusBadRequest, httpErr.Code)
}
Best Practices for Echo Unit Testing
To ensure your Echo tests are effective:
- Test both success and error paths: Don't just test the "happy path"
- Use descriptive test names: Test names should describe what is being tested
- Mock external dependencies: Database calls, external APIs, etc.
- Test all handler logic: Including validation, permission checks, etc.
- Use table-driven tests for similar test cases:
func TestHealthCheckEndpoint(t *testing.T) {
// Setup
e := echo.New()
tests := []struct {
name string
path string
expectedCode int
expectedBody string
}{
{
name: "Basic health check",
path: "/health",
expectedCode: http.StatusOK,
expectedBody: `{"status":"healthy"}`,
},
{
name: "Detailed health check",
path: "/health/detailed",
expectedCode: http.StatusOK,
expectedBody: `"version"`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// Call your handler based on the path
var err error
if tc.path == "/health" {
err = HealthCheckHandler(c)
} else {
err = DetailedHealthCheckHandler(c)
}
assert.NoError(t, err)
assert.Equal(t, tc.expectedCode, rec.Code)
assert.Contains(t, rec.Body.String(), tc.expectedBody)
})
}
}
Running Your Tests
To run your tests, use the Go test command:
go test ./... -v
For coverage reports:
go test ./... -cover
Or for a detailed coverage report:
go test ./... -coverprofile=coverage.out
go tool cover -html=coverage.out
Summary
In this guide, you've learned the fundamentals of unit testing Echo applications:
- Setting up the testing environment
- Testing basic handlers
- Testing handlers with path parameters
- Testing handlers that process JSON requests
- Testing middleware
- Mocking database dependencies
- Testing error scenarios
By applying these techniques, you'll be able to create robust, well-tested Echo applications that inspire confidence and are easier to maintain.
Further Resources
- Echo Framework Testing Documentation
- Go Testing Package Documentation
- Testify Assertion Library
- Table-Driven Testing in Go
Practice Exercises
- Write tests for an Echo handler that processes form data instead of JSON
- Create tests for a handler that uses query parameters
- Write a test for a complex middleware that checks user roles
- Implement tests for file upload functionality
- Create a complete test suite for a RESTful CRUD API with all endpoints
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)