Skip to content
Educora
Advanced25 min10 / 10

Modules, tests and a REST API project

Create modules and packages, explore the standard library, write table-driven tests and build a small REST API for tasks with `net/http`.

Check yourself
In this lesson you will learn
  • Create a module with go mod init and split code into packages
  • Use standard packages such as strings, strconv, os and time
  • Write and run table-driven tests with go test
  • Build an HTTP server that speaks JSON with net/http and encoding/json

You now know almost all of Go's core features. Let's bring them together in a real project: we will create our own module, split the code into packages, write tests and build a small REST API server that stores a to-do list. And Go's standard library alone will be enough for all of it.

Modules and packages

A module is a project with a go.mod file. go mod init example.com/tasks creates the module; example.com/tasks is the module path, usually the address where the code lives, such as github.com/aysel/tasks. Inside the module every folder is a package, imported as module path + folder name: example.com/tasks/store. From another package, only names starting with a capital letter are visible.

Text
tasks/
├── go.mod
├── main.go
└── store/
    ├── store.go
    └── store_test.go
The project layout
Text
module example.com/tasks

go 1.22
The go.mod file. The go line shows the minimum required version; yours may show a newer one.
CommandWhat it does
go mod init example.com/taskscreates a new module
go get golang.org/x/textadds a third-party package and records it in go.mod
go mod tidyadds missing dependencies and removes unused ones
go build ./...builds every package in the module (./... means this folder and all subfolders)
go vet ./...looks for suspicious code: bad format verbs, copied mutexes

The standard library

Go's standard library is very rich: text, numbers, files, time, JSON, an HTTP server and client, cryptography, testing — all of it comes with the language. That is why many small and medium projects are written without any third-party dependencies at all.

PackageWhat for
fmtformatted printing and building strings
strings, strconvworking with strings, string ↔ number conversion
osfiles, environment variables, arguments
timedates, times and durations
encoding/jsonencoding values to JSON and decoding JSON
net/httpHTTP server and client
testingautomated tests
Go
package main

import (
	"fmt"
	"strconv"
	"strings"
	"time"
)

func main() {
	fmt.Println(strings.Split("go,java,sql", ","))
	fmt.Println(strings.Repeat("ab", 3), strings.HasPrefix("golang", "go"))
	n, err := strconv.ParseFloat("2.5", 64)
	fmt.Println(n*2, err)
	d := 90 * time.Minute
	fmt.Println(d, d.Hours())
	day := time.Date(2026, time.September, 27, 0, 0, 0, 0, time.UTC)
	fmt.Println(day.Weekday(), day.Format("02.01.2006"))
}
Expected output
[go java sql]
ababab true
5 <nil>
1h30m0s 1.5
Sunday 27.09.2026

Project: the store package and tests

We move the logic that stores tasks into a separate store package. Tags such as json:"id" on the fields of Task tell the encoding/json package which names to use in JSON. The zero value of Store is ready to use straight away — no constructor needed. This is a favourite Go principle: “make the zero value useful”.

Go
package store

import (
	"errors"
	"strings"
	"sync"
)

var ErrEmptyTitle = errors.New("empty title")

type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
}

type Store struct {
	mu    sync.Mutex
	tasks []Task
}

func (s *Store) Add(title string) (Task, error) {
	title = strings.TrimSpace(title)
	if title == "" {
		return Task{}, ErrEmptyTitle
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	t := Task{ID: len(s.tasks) + 1, Title: title}
	s.tasks = append(s.tasks, t)
	return t, nil
}

func (s *Store) All() []Task {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := make([]Task, len(s.tasks))
	copy(out, s.tasks)
	return out
}
store/store.go

All returns a copy of the internal slice, not the slice itself, so the caller cannot change the list outside the mutex. A test file's name ends in _test.go, and it sits in the same folder as the package under test. A test function starts with the word Test and takes a *testing.T: t.Errorf records a failure and carries on, while t.Fatalf stops the test immediately. In Go the most common style is table-driven tests: the cases are listed in a slice of structs, and one loop runs each of them as a separate subtest with t.Run.

Go
package store

import (
	"errors"
	"testing"
)

func TestAdd(t *testing.T) {
	tests := []struct {
		name    string
		title   string
		want    string
		wantErr error
	}{
		{"simple", "Buy bread", "Buy bread", nil},
		{"trims spaces", "  Learn Go  ", "Learn Go", nil},
		{"empty", "   ", "", ErrEmptyTitle},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			var s Store
			got, err := s.Add(tt.title)
			if !errors.Is(err, tt.wantErr) {
				t.Fatalf("Add(%q) error = %v, want %v", tt.title, err, tt.wantErr)
			}
			if got.Title != tt.want {
				t.Errorf("Add(%q).Title = %q, want %q", tt.title, got.Title, tt.want)
			}
		})
	}
}
store/store_test.go
Text
$ go test ./...
?   	example.com/tasks	[no test files]
ok  	example.com/tasks/store	0.004s
The time at the end will differ on every run. go test -v ./store lists each subtest (TestAdd/trims_spaces — spaces become underscores), and go test -run TestAdd/empty ./store runs just one case.
Says nothing useful
if got.Title != "Learn Go" {
	t.Error("wrong title")
}
Shows what was expected and what came back
if got.Title != tt.want {
	t.Errorf("Add(%q).Title = %q, want %q",
		tt.title, got.Title, tt.want)
}
In Go a test message usually follows the got X, want Y shape: when a test fails, you see why without opening the code.

The HTTP server: net/http

In main.go, http.NewServeMux() creates a router. Since Go 1.22 you can put the HTTP method right into the route: "GET /tasks" and "POST /tasks". Every handler takes two parameters: an http.ResponseWriter for writing the response and the incoming *http.Request. json.NewEncoder(w).Encode(...) turns a value into JSON and writes it to the response, and json.NewDecoder(r.Body).Decode(&in) reads the request body. The port comes from the PORT environment variable (os.Getenv), with 8080 as the fallback.

Go
package main

import (
	"encoding/json"
	"log"
	"net/http"
	"os"

	"example.com/tasks/store"
)

func main() {
	var tasks store.Store
	mux := http.NewServeMux()

	mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(tasks.All())
	})

	mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
		var in struct {
			Title string `json:"title"`
		}
		if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
			http.Error(w, "invalid JSON", http.StatusBadRequest)
			return
		}
		task, err := tasks.Add(in.Title)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusCreated)
		json.NewEncoder(w).Encode(task)
	})

	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}
	log.Println("listening on :" + port)
	log.Fatal(http.ListenAndServe(":"+port, mux))
}
main.go

Start the server with go run . and send requests from another terminal with curl. POST creates a new task and returns JSON with the status 201 Created, an empty title gives 400 Bad Request, and GET returns the whole list. If you send a request with a method that has no route (for example DELETE /tasks), ServeMux answers 405 Method Not Allowed by itself.

Text
$ curl -X POST localhost:8080/tasks -d '{"title":"Learn Go"}'
{"id":1,"title":"Learn Go"}
$ curl -X POST localhost:8080/tasks -d '{"title":"   "}'
empty title
$ curl localhost:8080/tasks
[{"id":1,"title":"Learn Go"}]
Requests and the server's responses in a Linux, macOS or Git Bash terminal

Key points

  • go mod init creates a module; every folder is a package, imported as module path/folder.
  • The standard library (fmt, strings, strconv, os, time, encoding/json, net/http) covers most work without third-party dependencies.
  • Tests are TestXxx(t *testing.T) functions in _test.go files; table-driven tests with t.Run are the Go style.
  • Since Go 1.22, ServeMux supports routes with methods ("GET /tasks") and path parameters ({id}).
  • Handlers run concurrently, so protect shared state with a mutex.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
The module path is example.com/tasks and the package lives in the store folder. How do you import it?