Introduction
Welcome back! In the last episode, we learned what CI/CD is and how to set up a simple pipeline in GitLab using Docker-in-Docker. Now, let’s make things more interesting by adding real Go code to our project.
In this post, you’ll:
- Create a new file and paste in the Go code below:
- Set up a pipeline to lint (check) your code style automatically
- Run tests with coverage and see how well your code is tested
- Learn how to store and view these results in GitLab
By the end, you’ll have a working Go app in CI/CD with code quality checks—just like a real pro!
Step 1: Get the Example Go Project
So you can follow along with exactly what I’m doing, here’s the same code I use:
File Stucture
Create a new file and paste in the Go code below:
bank-api\cmd\server\main.go
package main
import (
"bank-api/internal/handler"
"log"
"net/http"
)
func main() {
h := handler.New()
log.Println("✅ Bank API listening on :8508")
log.Fatal(http.ListenAndServe(":8508", h.Router()))
}
bank-api\internal\handler\handler_test.go
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
)
// helper to open a new account and return its ID
func createAccount(t *testing.T, router http.Handler, name string) int64 {
body := `{"name":"` + name + `"}`
req := httptest.NewRequest(http.MethodPost, "/accounts", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("open account: want 201 got %d", rec.Code)
}
var resp struct{ ID int64 }
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode id: %v", err)
}
return resp.ID
}
func TestGetAllAccounts(t *testing.T) {
h := New()
r := h.Router()
// we seeded 10 mock accounts in NewMemory()
req := httptest.NewRequest(http.MethodGet, "/accounts", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("get all accounts: want 200 got %d", rec.Code)
}
var list []struct {
ID int64
Name string
Balance float64
}
if err := json.NewDecoder(rec.Body).Decode(&list); err != nil {
t.Fatalf("decode list: %v", err)
}
if len(list) != 10 {
t.Errorf("expected 10 accounts, got %d", len(list))
}
// spot-check first seeded account
if list[0].ID != 1 || list[0].Name != "Alice" {
t.Errorf("first account = %+v; want ID=1, Name=Alice", list[0])
}
}
func TestOpenGetDepositWithdrawFlow(t *testing.T) {
h := New()
r := h.Router()
id := createAccount(t, r, "Alice")
// deposit
depBody := `{"amount":100}`
req := httptest.NewRequest(http.MethodPost,
"/accounts/"+strconv.FormatInt(id, 10)+"/deposit",
bytes.NewBufferString(depBody),
)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
// withdraw
withBody := `{"amount":30}`
req = httptest.NewRequest(http.MethodPost,
"/accounts/"+strconv.FormatInt(id, 10)+"/withdraw",
bytes.NewBufferString(withBody),
)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
// get balance
req = httptest.NewRequest(http.MethodGet,
"/accounts/"+strconv.FormatInt(id, 10),
nil,
)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
var acc struct{ Balance float64 }
if err := json.NewDecoder(rec.Body).Decode(&acc); err != nil {
t.Fatalf("decode account: %v", err)
}
if acc.Balance != 70 {
t.Errorf("expected balance 70 got %f", acc.Balance)
}
}
func TestDepositInvalidAmount(t *testing.T) {
h := New()
r := h.Router()
id := createAccount(t, r, "Bob")
neg := `{"amount":-5}`
req := httptest.NewRequest(http.MethodPost,
"/accounts/"+strconv.FormatInt(id, 10)+"/deposit",
bytes.NewBufferString(neg),
)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("neg deposit: want 400 got %d", rec.Code)
}
}
func TestWithdrawInsufficientFunds(t *testing.T) {
h := New()
r := h.Router()
id := createAccount(t, r, "Charlie")
withBody := `{"amount":50}`
req := httptest.NewRequest(http.MethodPost,
"/accounts/"+strconv.FormatInt(id, 10)+"/withdraw",
bytes.NewBufferString(withBody),
)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("withdraw no money: want 400 got %d", rec.Code)
}
}
func TestInvalidAccountID(t *testing.T) {
h := New()
r := h.Router()
req := httptest.NewRequest(http.MethodGet, "/accounts/abc", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("invalid id: want 400 got %d", rec.Code)
}
}
func TestMethodNotAllowed(t *testing.T) {
h := New()
r := h.Router()
// PUT /accounts is not supported (only GET, POST)
req := httptest.NewRequest(http.MethodPut, "/accounts", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("method not allowed: want 405 got %d", rec.Code)
}
}
func TestInvalidJSONInOpenAccount(t *testing.T) {
h := New()
r := h.Router()
req := httptest.NewRequest(http.MethodPost, "/accounts", bytes.NewBufferString("{"))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400 for bad JSON, got %d", rec.Code)
}
}
func TestInvalidJSONInDeposit(t *testing.T) {
h := New()
r := h.Router()
id := createAccount(t, r, "BrokenDeposit")
req := httptest.NewRequest(http.MethodPost,
"/accounts/"+strconv.FormatInt(id, 10)+"/deposit",
bytes.NewBufferString("{"),
)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400 for bad JSON, got %d", rec.Code)
}
}
func TestInvalidSubRoute(t *testing.T) {
h := New()
r := h.Router()
id := createAccount(t, r, "UnknownPath")
req := httptest.NewRequest(http.MethodPost,
"/accounts/"+strconv.FormatInt(id, 10)+"/unexpected",
nil,
)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("expected 404 for unknown subroute, got %d", rec.Code)
}
}
func TestReadRootAllowed(t *testing.T) {
h := New()
r := h.Router()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rec.Code)
}
if body := rec.Body.String(); body != "👋 Welcome to the Bank API!" {
t.Errorf("unexpected body: %s", body)
}
}
func TestReadRootMethodNotAllowed(t *testing.T) {
h := New()
r := h.Router()
req := httptest.NewRequest(http.MethodPost, "/", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", rec.Code)
}
}
func TestOpenAccountWrongMethod(t *testing.T) {
h := New()
req := httptest.NewRequest(http.MethodGet, "/accounts", nil)
rec := httptest.NewRecorder()
// simulate direct call to openAccount handler method
h.openAccount(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", rec.Code)
}
}
func TestAccountsMethodNotAllowed(t *testing.T) {
h := New()
r := h.Router()
req := httptest.NewRequest(http.MethodPut, "/accounts", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", rec.Code)
}
}
bank-api\internal\handler\handler.go
// internal/handler/handler.go
package handler
import (
"bank-api/internal/repository"
"bank-api/internal/service"
"encoding/json"
"net/http"
"strconv"
"strings"
)
const errMethodNotAllowed = "method not allowed"
type Handler struct{ bank *service.Bank }
// New returns Handler with in-memory backend and seeds 10 mock accounts.
func New() *Handler {
mem := repository.NewMemory()
b := service.NewBank(mem)
names := []string{
"Alice", "Bob", "Carol", "Dave", "Eve",
"Frank", "Grace", "Heidi", "Ivan", "Judy",
}
for i, name := range names {
acc := b.Open(name)
_, _ = b.Deposit(acc.ID, float64((i+1)*1000))
}
return &Handler{bank: b}
}
// Router wires endpoints in an organized way.
func (h *Handler) Router() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/", h.readRoot) // GET /
mux.HandleFunc("/accounts", h.accounts) // GET /accounts, POST /accounts
mux.HandleFunc("/accounts/", h.accountActions) // GET /accounts/{id}, POST …/deposit, …/withdraw
return mux
}
func (h *Handler) readRoot(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, errMethodNotAllowed, http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte("👋 Welcome to the Bank API!"))
}
// accounts handles GET all and POST create on /accounts
func (h *Handler) accounts(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
list, err := h.bank.All()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, list)
case http.MethodPost:
h.openAccount(w, r)
default:
http.Error(w, errMethodNotAllowed, http.StatusMethodNotAllowed)
}
}
func (h *Handler) openAccount(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, errMethodNotAllowed, http.StatusMethodNotAllowed)
return
}
var req struct{ Name string }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
acc := h.bank.Open(req.Name)
writeJSON(w, http.StatusCreated, acc)
}
func (h *Handler) accountActions(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/accounts/"), "/")
id, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
switch {
case r.Method == http.MethodGet && len(parts) == 1:
acc, err := h.bank.Lookup(id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, acc)
case r.Method == http.MethodPost && len(parts) == 2 && parts[1] == "deposit":
var body struct{ Amount float64 }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
acc, err := h.bank.Deposit(id, body.Amount)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, acc)
case r.Method == http.MethodPost && len(parts) == 2 && parts[1] == "withdraw":
var body struct{ Amount float64 }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
acc, err := h.bank.Withdraw(id, body.Amount)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, acc)
default:
http.Error(w, "not found", http.StatusNotFound)
}
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
bank-api\internal\model\account.go
package model
// Account represents a simple bank account.
type Account struct {
ID int64 `json:"id"`
Name string `json:"name"`
Balance float64 `json:"balance"`
}
bank-api\internal\repository\memory.go
// internal/repository/memory.go
package repository
import (
"errors"
"sort"
"sync"
"bank-api/internal/model"
)
var (
ErrNotFound = errors.New("account not found")
ErrInvalidAmount = errors.New("amount must be positive")
ErrInsufficientFund = errors.New("insufficient funds")
)
// MemoryRepo is an in-memory store implementing basic CRUD for accounts.
type MemoryRepo struct {
mu sync.RWMutex
nextID int64
records map[int64]*model.Account
}
// NewMemory returns a fresh, empty MemoryRepo.
func NewMemory() *MemoryRepo {
return &MemoryRepo{
records: make(map[int64]*model.Account),
nextID: 1,
}
}
// Create opens a new account with zero balance.
func (m *MemoryRepo) Create(name string) *model.Account {
m.mu.Lock()
defer m.mu.Unlock()
acc := &model.Account{ID: m.nextID, Name: name}
m.records[acc.ID] = acc
m.nextID++
return acc
}
// Get fetches account by id.
func (m *MemoryRepo) Get(id int64) (*model.Account, error) {
m.mu.RLock()
defer m.mu.RUnlock()
acc, ok := m.records[id]
if !ok {
return nil, ErrNotFound
}
return acc, nil
}
// All returns all accounts, sorted by ascending ID.
func (m *MemoryRepo) All() ([]*model.Account, error) {
m.mu.RLock()
defer m.mu.RUnlock()
list := make([]*model.Account, 0, len(m.records))
for _, acc := range m.records {
list = append(list, acc)
}
// sort by ID so callers always see [ID=1,2,3…]
sort.Slice(list, func(i, j int) bool {
return list[i].ID < list[j].ID
})
return list, nil
}
// Deposit increases balance.
func (m *MemoryRepo) Deposit(id int64, amt float64) (*model.Account, error) {
if amt <= 0 {
return nil, ErrInvalidAmount
}
m.mu.Lock()
defer m.mu.Unlock()
acc, ok := m.records[id]
if !ok {
return nil, ErrNotFound
}
acc.Balance += amt
return acc, nil
}
// Withdraw decreases balance.
func (m *MemoryRepo) Withdraw(id int64, amt float64) (*model.Account, error) {
if amt <= 0 {
return nil, ErrInvalidAmount
}
m.mu.Lock()
defer m.mu.Unlock()
acc, ok := m.records[id]
if !ok {
return nil, ErrNotFound
}
if acc.Balance < amt {
return nil, ErrInsufficientFund
}
acc.Balance -= amt
return acc, nil
}
bank-api\internal\repository\repository_test.go
package repository
import (
"testing"
)
// createTestRepo returns a new MemoryRepo with one account.
func createTestRepo() (*MemoryRepo, int64) {
repo := NewMemory()
acc := repo.Create("Test")
return repo, acc.ID
}
func TestCreateAndGet(t *testing.T) {
repo := NewMemory()
acc := repo.Create("Alice")
// Check Create()
if acc.ID != 1 || acc.Name != "Alice" || acc.Balance != 0 {
t.Errorf("unexpected account after Create(): %+v", acc)
}
// Check Get() returns the same pointer
got, err := repo.Get(acc.ID)
if err != nil {
t.Fatalf("Get() failed: %v", err)
}
if got != acc {
t.Error("Get() returned a different pointer than Create()")
}
}
func TestGetNotFound(t *testing.T) {
repo := NewMemory()
_, err := repo.Get(999)
if err != ErrNotFound {
t.Errorf("Get(999): want ErrNotFound, got %v", err)
}
}
func TestDepositSuccess(t *testing.T) {
repo, id := createTestRepo()
_, err := repo.Deposit(id, 50)
if err != nil {
t.Errorf("Deposit() error: %v", err)
}
got, _ := repo.Get(id)
if got.Balance != 50 {
t.Errorf("after Deposit, Balance = %f; want 50", got.Balance)
}
}
func TestDepositNegative(t *testing.T) {
repo, id := createTestRepo()
_, err := repo.Deposit(id, -10)
if err != ErrInvalidAmount {
t.Errorf("Deposit(-10): want ErrInvalidAmount, got %v", err)
}
}
func TestDepositNotFound(t *testing.T) {
repo := NewMemory()
_, err := repo.Deposit(999, 10)
if err != ErrNotFound {
t.Errorf("Deposit on missing ID: want ErrNotFound, got %v", err)
}
}
func TestWithdrawSuccess(t *testing.T) {
repo, id := createTestRepo()
repo.Deposit(id, 100)
_, err := repo.Withdraw(id, 30)
if err != nil {
t.Errorf("Withdraw() error: %v", err)
}
got, _ := repo.Get(id)
if got.Balance != 70 {
t.Errorf("after Withdraw, Balance = %f; want 70", got.Balance)
}
}
func TestWithdrawNegative(t *testing.T) {
repo, id := createTestRepo()
_, err := repo.Withdraw(id, -5)
if err != ErrInvalidAmount {
t.Errorf("Withdraw(-5): want ErrInvalidAmount, got %v", err)
}
}
func TestWithdrawNotFound(t *testing.T) {
repo := NewMemory()
_, err := repo.Withdraw(999, 10)
if err != ErrNotFound {
t.Errorf("Withdraw on missing ID: want ErrNotFound, got %v", err)
}
}
func TestWithdrawInsufficientFund(t *testing.T) {
repo, id := createTestRepo()
repo.Deposit(id, 20)
_, err := repo.Withdraw(id, 50)
if err != ErrInsufficientFund {
t.Errorf("Withdraw(50) with only 20 balance: want ErrInsufficientFund, got %v", err)
}
}
// --- New tests for All() ---
func TestAllEmpty(t *testing.T) {
repo := NewMemory()
list, err := repo.All()
if err != nil {
t.Fatalf("All() on empty repo returned error: %v", err)
}
if len(list) != 0 {
t.Errorf("All() on empty repo = %d accounts; want 0", len(list))
}
}
func TestAllSortedByID(t *testing.T) {
repo := NewMemory()
// Create three accounts; IDs will be 1,2,3
a := repo.Create("Zeta")
b := repo.Create("Alpha")
c := repo.Create("Omega")
list, err := repo.All()
if err != nil {
t.Fatalf("All() returned error: %v", err)
}
if len(list) != 3 {
t.Fatalf("All() = %d accounts; want 3", len(list))
}
// Expect ascending ID order: [1,2,3]
for i, acc := range list {
wantID := int64(i + 1)
if acc.ID != wantID {
t.Errorf("All()[%d].ID = %d; want %d", i, acc.ID, wantID)
}
}
// Ensure pointers match the ones returned by Create
if list[0] != a || list[1] != b || list[2] != c {
t.Error("All() did not preserve account pointers in ID order")
}
}
bank-api\internal\service\service.go
package service
import (
"bank-api/internal/model"
"bank-api/internal/repository"
)
// Bank provides high-level business operations.
type Bank struct {
repo *repository.MemoryRepo
}
// NewBank creates a Bank with the supplied repo.
func NewBank(r *repository.MemoryRepo) *Bank { return &Bank{repo: r} }
// Open opens a new account.
func (b *Bank) Open(name string) *model.Account {
return b.repo.Create(name)
}
// Lookup fetches account.
func (b *Bank) Lookup(id int64) (*model.Account, error) {
return b.repo.Get(id)
}
// Deposit adds amount.
func (b *Bank) Deposit(id int64, amt float64) (*model.Account, error) {
return b.repo.Deposit(id, amt)
}
// Withdraw subtracts amount.
func (b *Bank) Withdraw(id int64, amt float64) (*model.Account, error) {
return b.repo.Withdraw(id, amt)
}
// All fetches all accounts.
func (b *Bank) All() ([]*model.Account, error) {
return b.repo.All()
}
bank-api\internal\service\service_test.go
package service
import (
"bank-api/internal/repository"
"testing"
)
func newBank() *Bank {
return NewBank(repository.NewMemory())
}
func TestDepositWithdrawFlow(t *testing.T) {
b := newBank()
acc := b.Open("Alice")
if _, err := b.Deposit(acc.ID, 100); err != nil {
t.Fatalf("deposit error: %v", err)
}
if _, err := b.Withdraw(acc.ID, 30); err != nil {
t.Fatalf("withdraw error: %v", err)
}
got, _ := b.Lookup(acc.ID)
if got.Balance != 70 {
t.Errorf("expected balance 70 got %f", got.Balance)
}
}
func TestInvalidAmount(t *testing.T) {
b := newBank()
acc := b.Open("Bob")
if _, err := b.Deposit(acc.ID, -1); err == nil {
t.Error("expected error on negative deposit")
}
}
func TestInsufficientFund(t *testing.T) {
b := newBank()
acc := b.Open("Charlie")
if _, err := b.Withdraw(acc.ID, 10); err == nil {
t.Error("expected insufficient funds error")
}
}
bank-api\go.mod
module bank-api
go 1.22
bank-api/.gitlab-ci.yml
image: docker:latest
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2375/
DOCKER_TLS_CERTDIR: ""
GO_VERSION: "1.24.3"
GIT_DEPTH: "0"
.go-job-template: &go-job-template
image: debian:bullseye
before_script:
- apt update && apt install -y curl git tar gzip
- curl -LO https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz
- rm -rf /usr/local/go && tar -C /usr/local -xzf go${GO_VERSION}.linux-amd64.tar.gz
- export PATH="/usr/local/go/bin:$PATH"
- go version
How it works:
- Installs
golint
using Go’s install command directly in the pipeline. - Updates your system’s
PATH
so thegolint
binary can be used right away. - Searches for all
.go
files in your project (just for info). - Runs
golint
on your entire codebase and saves the output tolint-report.txt
. - Shows a quick preview of the lint report in the job logs—even if the report is empty, you’ll get a message.
- Marks the job as optional (
allow_failure: true
), so lint warnings won’t break your pipeline while you’re still getting set up. - Stores the lint report as an artifact, so you can view or download it from GitLab after the pipeline completes.
Viewing lint results:
After the pipeline runs, open the pipeline details, click on the lint job, and download or preview lint-report.txt
to see your linting feedback.
Step 3: Automated Testing with Coverage
Why test with coverage?
Tests make sure your code works as expected. Coverage shows what percent of your code is tested.
Add a Test and Coverage Job
Add this job under the test
stage in .gitlab-ci.yml
:
unit_test_and_coverage:
stage: test
<<: *go-job-template
script:
- export PATH="/usr/local/go/bin:$PATH"
- go mod tidy
- go test -v -cover ./...
- go test -v -coverprofile=coverage.out ./...
artifacts:
paths:
- coverage.out
expire_in: 1 hour
README.md
README.md
How it works:
- Runs all your unit tests with detailed output, so you can see exactly what’s happening.
- Calculates your test coverage and prints it right in the pipeline logs.
- Generates a coverage.out file, which is saved as an artifact—handy for later steps like code quality checks or security scans.
Where to find it:
After the pipeline finishes, click into the test job to check the logs and download coverage.out if you want a closer look.
How Artifacts are Shared
GitLab artifacts let you share files between jobs. For example, the coverage.out
file will be used by SAST tools in the next episode.
-
Lint job produces
lint-report.txt
-
Test job produces
coverage.out
- Both are available for download and use in later jobs
Tips & Best Practices
- allow_failure: true lets you try linting without breaking the pipeline if there are issues. This is great for beginners or teams just getting started.
- Always review lint and test results! Fixing issues early saves lots of pain later.
- Keep your pipeline simple at first—add complexity as you go.
And when you merge everything together, It will look like this
image: docker:latest
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2375/
DOCKER_TLS_CERTDIR: ""
GO_VERSION: "1.24.3"
GIT_DEPTH: "0"
stages:
- lint
- test
- sast
.go-job-template: &go-job-template
image: debian:bullseye
before_script:
- apt update && apt install -y curl git tar gzip
- curl -LO https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz
- rm -rf /usr/local/go && tar -C /usr/local -xzf go${GO_VERSION}.linux-amd64.tar.gz
- export PATH="/usr/local/go/bin:$PATH"
- go version
lint_golint:
stage: lint
<<: *go-job-template
script:
- export PATH="/usr/local/go/bin:$PATH"
- go install golang.org/x/lint/golint@latest
- export PATH="$PATH:$(go env GOPATH)/bin"
- echo "Linting files:"
- find . -name '*.go'
- golint ./... | tee lint-report.txt
- echo "--- Lint report preview ---"
- cat lint-report.txt || echo "lint-report.txt is empty"
allow_failure: true
artifacts:
name: "golint-report"
paths:
- lint-report.txt
expire_in: 1 week
unit_test_and_coverage:
stage: test
<<: *go-job-template
script:
- export PATH="/usr/local/go/bin:$PATH"
- go mod tidy
- go test -v -cover ./...
- go test -v -coverprofile=coverage.out ./...
artifacts:
paths:
- coverage.out
expire_in: 1 hour
Pipeline Example
Here’s what your pipeline might look like after adding these jobs:
Preview: Next Episode
Awesome! You now have a Go project with real code, automatic style checks, and testing with coverage—all running in GitLab CI/CD.
In the next episode, we’ll:
- Add SonarQube to check your code for bugs and security issues
- Use your coverage reports to get even better insights
Stay tuned, and happy coding!
Top comments (0)