Building & Testing a REST API in Go using httpserver, httptest, testing & testify

 · 24 min read
 · emkeyen
Table of contents

In this post, we’ll build a minimal (just for testing purposes) REST API in Go with in-memory storage using httpserver and test it using Go’s standard testing package, httptest as a part of httpserver and the testify library.

The complete source code is available on GitHub:
https://github.com/emkeyen/go_server_test_api

Just clone the repo and run: go mod tidy

This installs all dependencies listed in the go.mod and go.sum files.

HTTP Server Code

This simple Go server keeps all user data in memory using a map protected by a mutex to avoid race conditions. It’s fast and lightweight since there’s no database - everything lives in RAM and resets when the server restarts.

For testing, it starts with one user (ID 1, "Test User1"). You get three main endpoints:

  • / - just a quick welcome message.
  • /hello - says hello, only accepts GET.
  • /user - handles user creation, reading, updating, and deleting via JSON and query params.

The server carefully checks HTTP methods and input data, returning proper errors if something’s off. Using a mutex means multiple requests won’t mess up the user data at the same time.

This setup is perfect for quick testing or learning HTTP in Go without setting up a database.

Here’s the code:

package httpserver

import (
    "encoding/json"
    "net/http"
    "strconv"
    "sync"
)

var (
    Users  = make(map[int]User)
    Mu     sync.RWMutex
    NextID = 1
)

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

func GetRoot(w http.ResponseWriter, r *http.Request) {
    if r.URL.Path != "/" {
        http.NotFound(w, r)
        return
    }
    w.Write([]byte("This is a simple Go http server :)\n"))
}

func GetHello(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }
    w.Write([]byte("Hello, HTTP!\n"))
}

func UserHandler(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case http.MethodGet:
        handleGetUser(w, r)
    case http.MethodPost:
        handleCreateUser(w, r)
    case http.MethodPatch:
        handleUpdateUser(w, r)
    case http.MethodDelete:
        handleDeleteUser(w, r)
    default:
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }
}

func handleGetUser(w http.ResponseWriter, r *http.Request) {
    idStr := r.URL.Query().Get("id")
    if idStr == "" {
        http.Error(w, "Missing user ID", http.StatusBadRequest)
        return
    }

    id, err := strconv.Atoi(idStr)
    if err != nil {
        http.Error(w, "Invalid user ID", http.StatusBadRequest)
        return
    }

    Mu.RLock()
    user, exists := Users[id]
    Mu.RUnlock()

    if !exists {
        http.Error(w, "User not found", http.StatusNotFound)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

func handleCreateUser(w http.ResponseWriter, r *http.Request) {
    var user User
    if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
        http.Error(w, "Invalid user data", http.StatusBadRequest)
        return
    }

    if user.Name == "" {
        http.Error(w, "Name is required", http.StatusBadRequest)
        return
    }

    Mu.Lock()
    defer Mu.Unlock()

    if user.ID == 0 {
        user.ID = NextID
        NextID++
    }

    if _, exists := Users[user.ID]; exists {
        http.Error(w, "User already exists", http.StatusConflict)
        return
    }

    Users[user.ID] = user
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(user)
}

func handleUpdateUser(w http.ResponseWriter, r *http.Request) {
    var user User
    if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
        http.Error(w, "Invalid user data", http.StatusBadRequest)
        return
    }

    Mu.Lock()
    defer Mu.Unlock()

    _, exists := Users[user.ID]
    if !exists {
        http.Error(w, "User not found", http.StatusNotFound)
        return
    }

    Users[user.ID] = user
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

func handleDeleteUser(w http.ResponseWriter, r *http.Request) {
    idStr := r.URL.Query().Get("id")
    if idStr == "" {
        http.Error(w, "Missing user ID", http.StatusBadRequest)
        return
    }

    id, err := strconv.Atoi(idStr)
    if err != nil {
        http.Error(w, "Invalid user ID", http.StatusBadRequest)
        return
    }

    Mu.Lock()
    defer Mu.Unlock()

    if _, exists := Users[id]; !exists {
        http.Error(w, "User not found :<", http.StatusNotFound)
        return
    }

    delete(Users, id)
    w.WriteHeader(http.StatusNoContent)
}

Test File

The test file below uses Go’s built-in testing package to organize and run tests, and the httptest package to fake HTTP requests without starting a real server, so tests run fast and isolated. The testify library helps write clear and expressive assertions, making tests easier to read and maintain.

The tests cover all main user actions (create, read, update, delete) plus edge cases like missing or invalid data. The testing package runs the test functions, while httptest mocks requests and responses. Importantly, the server never actually runs during tests, so they’re quick and safe.

This setup ensures your API behaves as expected before you deploy or use it for real.

These tests cover all key endpoints and edge cases - creating, reading, updating, and deleting users - making sure your API works smoothly.

Just run go test ./httpserver -v and see it in action.

package httpserver

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "os"
    "strconv"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestMain(m *testing.M) {
    Mu.Lock()
    Users = make(map[int]User)
    Users[1] = User{ID: 1, Name: "Test User1"}
    NextID = 2
    Mu.Unlock()
    os.Exit(m.Run())
}

func TestRootEndpoint(t *testing.T) {
    req := httptest.NewRequest("GET", "/", nil)
    rr := httptest.NewRecorder()
    GetRoot(rr, req)

    assert.Equal(t, http.StatusOK, rr.Code)
    assert.Contains(t, rr.Body.String(), "This is a simple Go http server")
}

func TestHelloEndpoint(t *testing.T) {
    req := httptest.NewRequest("GET", "/hello", nil)
    rr := httptest.NewRecorder()
    GetHello(rr, req)

    assert.Equal(t, http.StatusOK, rr.Code)
    assert.Contains(t, rr.Body.String(), "Hello, HTTP!")
}

func TestCreateUser(t *testing.T) {
    newUser := User{Name: "Integration Test User"}
    jsonData, err := json.Marshal(newUser)
    require.NoError(t, err)

    req := httptest.NewRequest("POST", "/user", bytes.NewBuffer(jsonData))
    rr := httptest.NewRecorder()
    UserHandler(rr, req)

    assert.Equal(t, http.StatusCreated, rr.Code)

    var createdUser User
    err = json.Unmarshal(rr.Body.Bytes(), &createdUser)
    require.NoError(t, err)

    assert.NotZero(t, createdUser.ID)
    assert.Equal(t, newUser.Name, createdUser.Name)

    Mu.RLock()
    defer Mu.RUnlock()
    storedUser, exists := Users[createdUser.ID]
    assert.True(t, exists)
    assert.Equal(t, createdUser, storedUser)
}

func TestGetUser(t *testing.T) {
    Mu.Lock()
    testUser := User{ID: 100, Name: "Test Get User"}
    Users[testUser.ID] = testUser
    Mu.Unlock()

    req := httptest.NewRequest("GET", "/user?id="+strconv.Itoa(testUser.ID), nil)
    rr := httptest.NewRecorder()
    UserHandler(rr, req)

    assert.Equal(t, http.StatusOK, rr.Code)

    var retrievedUser User
    err := json.Unmarshal(rr.Body.Bytes(), &retrievedUser)
    require.NoError(t, err)

    assert.Equal(t, testUser, retrievedUser)
}

func TestGetUserNotFound(t *testing.T) {
    req := httptest.NewRequest("GET", "/user?id=9999", nil)
    rr := httptest.NewRecorder()
    UserHandler(rr, req)

    assert.Equal(t, http.StatusNotFound, rr.Code)
}

func TestUpdateUser(t *testing.T) {
    Mu.Lock()
    testUser := User{ID: 200, Name: "Before Update"}
    Users[testUser.ID] = testUser
    Mu.Unlock()

    updatedUser := User{ID: 200, Name: "After Update"}
    jsonData, err := json.Marshal(updatedUser)
    require.NoError(t, err)

    req := httptest.NewRequest("PATCH", "/user", bytes.NewBuffer(jsonData))
    rr := httptest.NewRecorder()
    UserHandler(rr, req)

    assert.Equal(t, http.StatusOK, rr.Code)

    var responseUser User
    err = json.Unmarshal(rr.Body.Bytes(), &responseUser)
    require.NoError(t, err)

    assert.Equal(t, updatedUser, responseUser)

    Mu.RLock()
    defer Mu.RUnlock()
    storedUser, exists := Users[200]
    assert.True(t, exists)
    assert.Equal(t, updatedUser, storedUser)
}

func TestUpdateUserNotFound(t *testing.T) {
    nonExistentUser := User{ID: 9999, Name: "Non-existent"}
    jsonData, err := json.Marshal(nonExistentUser)
    require.NoError(t, err)

    req := httptest.NewRequest("PATCH", "/user", bytes.NewBuffer(jsonData))
    rr := httptest.NewRecorder()
    UserHandler(rr, req)

    assert.Equal(t, http.StatusNotFound, rr.Code)
}

func TestDeleteUser(t *testing.T) {
    Mu.Lock()
    testUser := User{ID: 300, Name: "To Be Deleted"}
    Users[testUser.ID] = testUser
    Mu.Unlock()

    req := httptest.NewRequest("DELETE", "/user?id="+strconv.Itoa(testUser.ID), nil)
    rr := httptest.NewRecorder()
    UserHandler(rr, req)

    assert.Equal(t, http.StatusNoContent, rr.Code)

    Mu.RLock()
    defer Mu.RUnlock()
    _, exists := Users[300]
    assert.False(t, exists)
}

func TestDeleteUserNotFound(t *testing.T) {
    req := httptest.NewRequest("DELETE", "/user?id=9999", nil)
    rr := httptest.NewRecorder()
    UserHandler(rr, req)

    assert.Equal(t, http.StatusNotFound, rr.Code)
    assert.Contains(t, rr.Body.String(), "User not found :<")
}

func TestCreateUserWithExistingID(t *testing.T) {
    Mu.Lock()
    testUser := User{ID: 400, Name: "Existing User"}
    Users[testUser.ID] = testUser
    Mu.Unlock()

    duplicateUser := User{ID: 400, Name: "Duplicate"}
    jsonData, err := json.Marshal(duplicateUser)
    require.NoError(t, err)

    req := httptest.NewRequest("POST", "/user", bytes.NewBuffer(jsonData))
    rr := httptest.NewRecorder()
    UserHandler(rr, req)

    assert.Equal(t, http.StatusConflict, rr.Code)
}

func TestCreateUserInvalidData(t *testing.T) {
    invalidUser := User{Name: ""}
    jsonData, err := json.Marshal(invalidUser)
    require.NoError(t, err)

    req := httptest.NewRequest("POST", "/user", bytes.NewBuffer(jsonData))
    rr := httptest.NewRecorder()
    UserHandler(rr, req)

    assert.Equal(t, http.StatusBadRequest, rr.Code)
}

func TestMethodNotAllowed(t *testing.T) {
    req := httptest.NewRequest("PUT", "/user", nil)
    rr := httptest.NewRecorder()
    UserHandler(rr, req)

    assert.Equal(t, http.StatusMethodNotAllowed, rr.Code)
}

func TestUserHandlerInvalidPath(t *testing.T) {
    req := httptest.NewRequest("GET", "/invalid", nil)
    rr := httptest.NewRecorder()
    UserHandler(rr, req)

    assert.Equal(t, http.StatusBadRequest, rr.Code)
}

func TestGetUserMissingID(t *testing.T) {
    req := httptest.NewRequest("GET", "/user", nil)
    rr := httptest.NewRecorder()
    UserHandler(rr, req)

    assert.Equal(t, http.StatusBadRequest, rr.Code)
    assert.Contains(t, rr.Body.String(), "Missing user ID")
}

func TestDeleteUserMissingID(t *testing.T) {
    req := httptest.NewRequest("DELETE", "/user", nil)
    rr := httptest.NewRecorder()
    UserHandler(rr, req)

    assert.Equal(t, http.StatusBadRequest, rr.Code)
    assert.Contains(t, rr.Body.String(), "Missing user ID")
}

func TestGetUserInvalidID(t *testing.T) {
    req := httptest.NewRequest("GET", "/user?id=invalid", nil)
    rr := httptest.NewRecorder()
    UserHandler(rr, req)

    assert.Equal(t, http.StatusBadRequest, rr.Code)
    assert.Contains(t, rr.Body.String(), "Invalid user ID")
}

Main

To wire everything up, we just need a main.go that sets up the initial user data and registers the HTTP handlers. The server listens on port :3333 and logs when it starts. Here’s what main.go looks like:

package main

import (
    "log"
    "net/http"

    "github.com/emkeyen/go_server_test_api/httpserver"
)

func main() {
    // init with test data
    httpserver.Mu.Lock()
    httpserver.Users[1] = httpserver.User{ID: 1, Name: "Test User1"}
    httpserver.NextID = 2
    httpserver.Mu.Unlock()

    // register handlers
    http.HandleFunc("/", httpserver.GetRoot)
    http.HandleFunc("/hello", httpserver.GetHello)
    http.HandleFunc("/user", httpserver.UserHandler)

    log.Println("Starting server on :3333")
    log.Fatal(http.ListenAndServe(":3333", nil))
}

Run Tests

go test ./httpserver -v

Start the Server

go run main.go
# Server running on http://localhost:3333

API Endpoints

User CRUD Operations

Create User (POST)

curl -X POST http://localhost:3333/user \
  -H "Content-Type: application/json" \
  -d '{"name":"New User"}'

Get User (GET)

curl "http://localhost:3333/user?id=1"

Update User (PATCH)

curl -X PATCH http://localhost:3333/user \
  -H "Content-Type: application/json" \
  -d '{"id":1,"name":"Updated Name"}'

Delete User (DELETE)

curl -X DELETE "http://localhost:3333/user?id=1"

Utility Endpoints

Root Endpoint

curl http://localhost:3333/

Hello Endpoint

curl http://localhost:3333/hello