DEV Community

Cover image for Building, Securing, and Deploying a Go App with GitLab CI/CD EP 2: Linting, and Coverage Test in Your Go Pipeline
Booranasak Kanthong
Booranasak Kanthong

Posted on • Edited on

Building, Securing, and Deploying a Go App with GitLab CI/CD EP 2: Linting, and Coverage Test in Your Go Pipeline

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()))
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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"`
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

bank-api\go.mod

module bank-api

go 1.22
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

How it works:

  • Installs golint using Go’s install command directly in the pipeline.
  • Updates your system’s PATH so the golint 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 to lint-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.

This picture are from my other project but you will see something similar here


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
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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)