Controller Testing

The IOTA SDK provides a minimalistic, fluent API for testing HTTP controllers with support for authentication, forms, file uploads, and HTMX interactions.

Overview

The Controller Test Suite enables:

  • Fluent Test Builder: Chain assertions for readable tests
  • HTTP Methods: GET, POST, PUT, DELETE with full request control
  • Form Testing: Submit forms with validation error checking
  • File Uploads: Test multipart file uploads
  • Authentication: Test with authenticated users
  • HTMX Testing: Verify HTMX request handling
  • HTML Assertions: XPath-based element assertions
  • Response Validation: Status codes, headers, redirects, content

Quick Start

Basic Test

import (
    "testing"
    "github.com/iota-uz/iota-sdk/pkg/testutils/controllertest"
)

func TestUserController(t *testing.T) {
    t.Parallel()

    suite := controllertest.New(t, userModule)
    suite.Register(userController)

    // Test GET request
    suite.GET("/users").
        Expect(t).
        Status(200).
        Contains("Users List")
}

Test Suite Setup

Creating a Suite

// Basic setup
suite := controllertest.New(t, userModule)

// Multiple modules
suite := controllertest.New(t, coreModule, userModule, financeModule)

// Register controllers
suite.Register(userController).
      Register(paymentController).
      Register(reportController)

Accessing Test Environment

// Get underlying environment for direct access
env := suite.Environment()

// Access database
db := env.DB()

// Access services
userService := env.GetService[*services.UserService]()

// Access context
ctx := env.Context()

HTTP Methods

All HTTP methods return a *Request for method chaining:

// GET request
suite.GET("/users").
    Expect(t).
    Status(200)

// POST request
suite.POST("/users").
    JSON(data).
    Expect(t).
    Status(201)

// PUT request
suite.PUT("/users/123").
    JSON(updateData).
    Expect(t).
    Status(200)

// DELETE request
suite.DELETE("/users/123").
    Expect(t).
    Status(204)

// PATCH request
suite.PATCH("/users/123").
    JSON(patchData).
    Expect(t).
    Status(200)

Request Building

JSON Payload

data := map[string]interface{}{
    "firstName": "John",
    "lastName": "Doe",
    "email": "john@example.com",
}

suite.POST("/users").
    JSON(data).
    Expect(t).
    Status(201)

Form Data

import "net/url"

values := url.Values{}
values.Set("firstName", "John")
values.Set("lastName", "Doe")
values.Set("email", "john@example.com")

suite.POST("/users").
    Form(values).
    Expect(t).
    Status(201)

Headers

suite.GET("/api/data").
    Header("Authorization", "Bearer token123").
    Header("Accept", "application/json").
    Expect(t).
    Status(200)

Cookies

suite.GET("/dashboard").
    Cookie("session_id", "abc123").
    Cookie("preference", "dark_mode").
    Expect(t).
    Status(200)

File Upload

fileContent := []byte("CSV file content")

suite.POST("/import").
    File("csv", "data.csv", fileContent).
    Expect(t).
    Status(200).
    Contains("Import successful")

HTMX Request

suite.POST("/search").
    HTMX().  // Adds HX-Request: true header
    Form(url.Values{"q": {"search query"}}).
    Expect(t).
    Status(200).
    HTML().
    Element("//div[@class='results']").
    Exists()

Response Assertions

Status Code

// Assert status
suite.GET("/users").
    Expect(t).
    Status(200)

// Multiple assertions in chain
suite.POST("/users").
    JSON(data).
    Expect(t).
    Status(201).
    Contains("Created")

Body Content

// Contains text
suite.GET("/users").
    Expect(t).
    Contains("John Doe")

// Not contains text
suite.GET("/users").
    Expect(t).
    NotContains("Admin Panel")

// Get raw body
body := suite.GET("/api/data").
    Expect(t).
    Body()

// Parse as JSON
var result map[string]interface{}
json.Unmarshal([]byte(body), &result)

Redirect

suite.POST("/login").
    Form(loginData).
    Expect(t).
    Status(302).
    RedirectTo("/dashboard")

Headers

contentType := suite.GET("/api/data").
    Expect(t).
    Header("Content-Type")

if contentType != "application/json" {
    t.Error("Expected JSON response")
}

Cookies

response := suite.POST("/login").
    Form(loginData).
    Expect(t).
    Status(302)

cookies := response.Cookies()
for _, cookie := range cookies {
    if cookie.Name == "session_id" {
        t.Log("Session created:", cookie.Value)
    }
}

HTML Testing

Find Elements

// Find single element
suite.GET("/form").
    Expect(t).
    Status(200).
    HTML().
    Element("//input[@name='email']").
    Exists()

// Find multiple elements
response := suite.GET("/users").
    Expect(t).
    HTML()

userRows := response.Elements("//tr[@class='user-row']")
if len(userRows) != 10 {
    t.Errorf("Expected 10 user rows, got %d", len(userRows))
}

Element Assertions

suite.GET("/form").
    Expect(t).
    HTML().
    Element("//h1[@class='title']").
    Text() // Returns element text

Element Attributes

href := suite.GET("/page").
    Expect(t).
    HTML().
    Element("//a[@class='download']").
    Attr("href")

if href != "/files/download.pdf" {
    t.Errorf("Expected /files/download.pdf, got %s", href)
}

Form Validation

// Test validation error display
suite.POST("/users").
    Form(url.Values{
        "email": {"invalid-email"},
    }).
    Expect(t).
    Status(422).
    HTML().
    HasErrorFor("email") // Returns true if error exists

Authentication Testing

Authenticated Requests

testUser := &user.User{
    ID: uuid.New(),
    Email: "test@example.com",
    FirstName: "Test",
    LastName: "User",
}

suite.AsUser(testUser).
    GET("/profile").
    Expect(t).
    Status(200).
    Contains(testUser.Email)

Testing Unauthorized Access

// Without authentication
suite.GET("/admin").
    Expect(t).
    Status(302).
    RedirectTo("/login")

// With regular user trying admin endpoint
regularUser := &user.User{ID: uuid.New(), Email: "user@example.com"}
suite.AsUser(regularUser).
    GET("/admin").
    Expect(t).
    Status(403) // Forbidden

Testing Multiple Users

func TestMultiUserAccess(t *testing.T) {
    suite := controllertest.New(t, userModule)

    adminUser := &user.User{ID: uuid.New(), Role: "admin"}
    regularUser := &user.User{ID: uuid.New(), Role: "user"}

    // Admin can access
    suite.AsUser(adminUser).
        GET("/admin").
        Expect(t).
        Status(200)

    // Regular user cannot
    suite.AsUser(regularUser).
        GET("/admin").
        Expect(t).
        Status(403)
}

Test Examples

CRUD Testing

func TestUserCRUD(t *testing.T) {
    t.Parallel()

    suite := controllertest.New(t, userModule)
    suite.Register(userController)

    // CREATE
    createData := map[string]string{
        "firstName": "Jane",
        "lastName": "Smith",
        "email": "jane@example.com",
    }

    createResponse := suite.POST("/users").
        JSON(createData).
        Expect(t).
        Status(201)

    // Extract ID from response (example)
    body := createResponse.Body()
    var created map[string]interface{}
    json.Unmarshal([]byte(body), &created)
    userID := created["id"].(string)

    // READ
    suite.GET("/users/" + userID).
        Expect(t).
        Status(200).
        Contains("jane@example.com")

    // UPDATE
    updateData := map[string]string{
        "firstName": "Janet",
    }

    suite.PUT("/users/" + userID).
        JSON(updateData).
        Expect(t).
        Status(200)

    // DELETE
    suite.DELETE("/users/" + userID).
        Expect(t).
        Status(204)

    // Verify deleted
    suite.GET("/users/" + userID).
        Expect(t).
        Status(404)
}

Form Validation Testing

func TestUserFormValidation(t *testing.T) {
    suite := controllertest.New(t, userModule)

    // Test required field
    suite.POST("/users").
        Form(url.Values{}).
        Expect(t).
        Status(422).
        HTML().
        HasErrorFor("email")

    // Test email format
    suite.POST("/users").
        Form(url.Values{
            "firstName": {"John"},
            "lastName": {"Doe"},
            "email": {"not-an-email"},
        }).
        Expect(t).
        Status(422).
        HTML().
        HasErrorFor("email")

    // Valid form
    suite.POST("/users").
        Form(url.Values{
            "firstName": {"John"},
            "lastName": {"Doe"},
            "email": {"john@example.com"},
        }).
        Expect(t).
        Status(201)
}

File Upload Testing

func TestFileUpload(t *testing.T) {
    suite := controllertest.New(t, uploadModule)
    suite.Register(uploadController)

    // Test with valid file
    validContent := []byte("valid CSV content")
    suite.POST("/import").
        File("csv", "data.csv", validContent).
        Expect(t).
        Status(200).
        Contains("File imported successfully")

    // Test with invalid file type
    invalidContent := []byte("not a csv file")
    suite.POST("/import").
        File("csv", "data.txt", invalidContent).
        Expect(t).
        Status(400).
        Contains("Invalid file type")
}

HTMX Testing

func TestHTMXSearch(t *testing.T) {
    suite := controllertest.New(t, searchModule)
    suite.Register(searchController)

    // Test HTMX partial response
    suite.POST("/search").
        HTMX().
        Form(url.Values{"q": {"test"}}).
        Expect(t).
        Status(200).
        HTML().
        Element("//div[@class='search-results']").
        Exists()

    // Regular request returns full page
    suite.GET("/search").
        Expect(t).
        Status(200).
        Contains("<html")
}

Best Practices

1. Use Table-Driven Tests

func TestUserController(t *testing.T) {
    tests := []struct {
        name       string
        method     string
        path       string
        status     int
        authUser   *user.User
        expectErr  bool
    }{
        {
            name:   "GET users as admin",
            method: "GET",
            path:   "/users",
            status: 200,
            authUser: &user.User{Role: "admin"},
        },
        {
            name:     "GET admin panel as regular user",
            method:   "GET",
            path:     "/admin",
            status:   403,
            authUser: &user.User{Role: "user"},
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            suite := controllertest.New(t, userModule)
            if tt.authUser != nil {
                suite = suite.AsUser(tt.authUser)
            }

            suite.GET(tt.path).
                Expect(t).
                Status(tt.status)
        })
    }
}

2. Keep Tests Focused

// Good: Test one thing
func TestCreateUserValidation(t *testing.T) {
    suite := controllertest.New(t, userModule)

    suite.POST("/users").
        Form(url.Values{"email": {"invalid"}}).
        Expect(t).
        Status(422).
        HTML().
        HasErrorFor("email")
}

// Bad: Test multiple things
func TestUserController(t *testing.T) {
    // Tests create, read, update, delete, validation, auth...
}

3. Use Descriptive Test Names

// Good
func TestCreateUserWithValidEmail(t *testing.T) {}
func TestDeleteUnauthorizedReturns403(t *testing.T) {}
func TestSearchWithEmptyQueryReturnsEmpty(t *testing.T) {}

// Bad
func TestUser(t *testing.T) {}
func TestCreate(t *testing.T) {}
func TestAPI(t *testing.T) {}

4. Test Both Success and Failure

func TestUserForm(t *testing.T) {
    suite := controllertest.New(t, userModule)

    // Success path
    suite.POST("/users").
        JSON(validData).
        Expect(t).
        Status(201)

    // Validation error
    suite.POST("/users").
        JSON(invalidData).
        Expect(t).
        Status(422)

    // Conflict error
    suite.POST("/users").
        JSON(duplicateEmail).
        Expect(t).
        Status(409)
}

5. Verify Edge Cases

func TestPaginationEdgeCases(t *testing.T) {
    suite := controllertest.New(t, userModule)

    // Test limit=0
    suite.GET("/users?limit=0").
        Expect(t).
        Status(400)

    // Test offset larger than results
    suite.GET("/users?offset=1000&limit=10").
        Expect(t).
        Status(200).
        Body() // Should return empty list

    // Test negative values
    suite.GET("/users?limit=-1").
        Expect(t).
        Status(400)
}

Debugging Failed Tests

response := suite.GET("/users").Expect(t)
fmt.Println("Status:", response.Raw().StatusCode)
fmt.Println("Body:", response.Body())
fmt.Println("Headers:", response.Raw().Header)

Check HTML Structure

html := suite.GET("/form").
    Expect(t).
    HTML()

// Debug: print all form inputs
inputs := html.Elements("//input")
for _, input := range inputs {
    // Inspect input...
}

Verify Request Was Made

// If test fails, check if controller was called at all
// by examining logs or adding debug prints
logger.Info("Controller called") // Added for debugging

For more information, see the Advanced Features Overview or the Controller Test Suite documentation.


Back to top

IOTA SDK - Multi-tenant Business Management Platform