Day 7: Testing & Deployment
Goal: Write tests and deploy your API to production
Time: ~4-5 hours
What you'll build: Tested, production-ready API with deployment
1. Testing Basics in Go (1 hour)
Go's Built-in Testing
Go has testing built into the language - no PHPUnit needed!
Test file naming: *_test.go
Your First Test
Create utils/password_test.go
:
package utils
import "testing"
func TestHashPassword(t *testing.T) {
password := "mySecretPassword123"
hash, err := HashPassword(password)
if err != nil {
t.Fatalf("HashPassword failed: %v", err)
}
if hash == "" {
t.Error("Hash should not be empty")
}
if hash == password {
t.Error("Hash should not equal plain password")
}
}
func TestCheckPassword(t *testing.T) {
password := "mySecretPassword123"
hash, _ := HashPassword(password)
// Test correct password
if !CheckPassword(password, hash) {
t.Error("CheckPassword should return true for correct password")
}
// Test wrong password
if CheckPassword("wrongPassword", hash) {
t.Error("CheckPassword should return false for wrong password")
}
}
func TestHashPasswordTooShort(t *testing.T) {
password := "short"
_, err := HashPassword(password)
if err == nil {
t.Error("Expected error for short password")
}
}
Running Tests
# Run all tests
go test ./...
# Run tests in specific package
go test ./utils
# Verbose output
go test -v ./utils
# Run specific test
go test -v -run TestHashPassword ./utils
# With coverage
go test -cover ./...
# Coverage report
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
Table-Driven Tests (Best Practice)
func TestValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
want bool
}{
{"valid email", "john@example.com", true},
{"valid with subdomain", "john@mail.example.com", true},
{"missing @", "johnexample.com", false},
{"missing domain", "john@", false},
{"empty string", "", false},
{"spaces", "john @example.com", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ValidateEmail(tt.email)
if got != tt.want {
t.Errorf("ValidateEmail(%q) = %v, want %v",
tt.email, got, tt.want)
}
})
}
}
Test Assertions Helper
Create testutils/assertions.go
:
package testutils
import "testing"
func AssertEqual(t *testing.T, got, want interface{}) {
t.Helper()
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}
func AssertNotEqual(t *testing.T, got, want interface{}) {
t.Helper()
if got == want {
t.Errorf("got %v, should not equal %v", got, want)
}
}
func AssertNil(t *testing.T, got interface{}) {
t.Helper()
if got != nil {
t.Errorf("expected nil, got %v", got)
}
}
func AssertNotNil(t *testing.T, got interface{}) {
t.Helper()
if got == nil {
t.Error("expected not nil, got nil")
}
}
2. Testing HTTP Handlers (1 hour)
HTTP Test Basics
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthCheck(t *testing.T) {
// Create request
req := httptest.NewRequest(http.MethodGet, "/health", nil)
// Create response recorder
w := httptest.NewRecorder()
// Call handler
healthCheckHandler(w, req)
// Check status code
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Check response body
var response map[string]string
json.NewDecoder(w.Body).Decode(&response)
if response["status"] != "healthy" {
t.Errorf("Expected healthy status, got %s", response["status"])
}
}
Testing JSON APIs
func TestRegisterUser(t *testing.T) {
// Setup test database
db := setupTestDB(t)
defer teardownTestDB(db)
handler := NewAuthHandler(repository.NewUserRepository(db))
// Prepare request body
reqBody := map[string]string{
"name": "John Doe",
"email": "john@example.com",
"password": "password123",
}
bodyBytes, _ := json.Marshal(reqBody)
// Create request
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/register",
bytes.NewBuffer(bodyBytes),
)
req.Header.Set("Content-Type", "application/json")
// Create response recorder
w := httptest.NewRecorder()
// Call handler
handler.Register(w, req)
// Check status
if w.Code != http.StatusCreated {
t.Fatalf("Expected status 201, got %d", w.Code)
}
// Parse response
var response struct {
Token string `json:"token"`
User models.User `json:"user"`
}
json.NewDecoder(w.Body).Decode(&response)
// Assertions
if response.Token == "" {
t.Error("Expected token in response")
}
if response.User.Email != "john@example.com" {
t.Errorf("Expected email john@example.com, got %s", response.User.Email)
}
}
Test Database Setup
package handlers
import (
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"your-project/models"
)
func setupTestDB(t *testing.T) *gorm.DB {
// Use in-memory SQLite for tests
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal("Failed to connect to test database:", err)
}
// Auto migrate
err = db.AutoMigrate(&models.User{}, &models.Post{})
if err != nil {
t.Fatal("Failed to migrate test database:", err)
}
return db
}
func teardownTestDB(db *gorm.DB) {
sqlDB, _ := db.DB()
sqlDB.Close()
}
// Helper to create test user
func createTestUser(t *testing.T, db *gorm.DB) *models.User {
user := &models.User{
Name: "Test User",
Email: "test@example.com",
Password: "hashed_password",
}
if err := db.Create(user).Error; err != nil {
t.Fatal("Failed to create test user:", err)
}
return user
}
Testing Protected Endpoints
func TestGetProfile_Authenticated(t *testing.T) {
db := setupTestDB(t)
defer teardownTestDB(db)
// Create test user
user := createTestUser(t, db)
// Generate token
token, _ := utils.GenerateToken(user.ID, user.Email)
handler := NewAuthHandler(repository.NewUserRepository(db))
// Create request with auth header
req := httptest.NewRequest(http.MethodGet, "/api/v1/profile", nil)
req.Header.Set("Authorization", "Bearer "+token)
// Add user to context (normally done by middleware)
claims := &utils.Claims{UserID: user.ID, Email: user.Email}
ctx := context.WithValue(req.Context(), middleware.UserContextKey, claims)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.GetProfile(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
}
func TestGetProfile_Unauthenticated(t *testing.T) {
db := setupTestDB(t)
defer teardownTestDB(db)
handler := NewAuthHandler(repository.NewUserRepository(db))
// Request without auth
req := httptest.NewRequest(http.MethodGet, "/api/v1/profile", nil)
w := httptest.NewRecorder()
handler.GetProfile(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status 401, got %d", w.Code)
}
}
Testing Middleware
func TestAuthMiddleware(t *testing.T) {
// Create a test handler
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Wrap with auth middleware
handler := middleware.AuthMiddleware(nextHandler)
t.Run("Valid Token", func(t *testing.T) {
token, _ := utils.GenerateToken(1, "test@example.com")
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
})
t.Run("Missing Token", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/test", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status 401, got %d", w.Code)
}
})
t.Run("Invalid Token", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer invalid-token")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status 401, got %d", w.Code)
}
})
}
Benchmark Tests
func BenchmarkHashPassword(b *testing.B) {
password := "mySecretPassword123"
for i := 0; i < b.N; i++ {
HashPassword(password)
}
}
func BenchmarkCheckPassword(b *testing.B) {
password := "mySecretPassword123"
hash, _ := HashPassword(password)
for i := 0; i < b.N; i++ {
CheckPassword(password, hash)
}
}
// Run benchmarks
// go test -bench=. -benchmem ./utils
3. Building for Production (1 hour)
Building Your Application
# Build for current platform
go build -o blog-api
# Build for Linux
GOOS=linux GOARCH=amd64 go build -o blog-api-linux
# Build for Windows
GOOS=windows GOARCH=amd64 go build -o blog-api.exe
# Build with optimization (smaller binary)
go build -ldflags="-s -w" -o blog-api
# Run the binary
./blog-api
Dockerfile
Create Dockerfile
:
# Build stage
FROM golang:1.21-alpine AS builder
WORKDIR /app
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build application
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o blog-api .
# Run stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# Copy binary from builder
COPY --from=builder /app/blog-api .
# Expose port
EXPOSE 8080
# Run
CMD ["./blog-api"]
Docker Compose
Create docker-compose.yml
:
version: '3.8'
services:
api:
build: .
ports:
- "8080:8080"
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/blog_db?sslmode=disable
- JWT_SECRET=your-super-secret-key
- PORT=8080
depends_on:
- db
restart: unless-stopped
db:
image: postgres:15-alpine
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=blog_db
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
volumes:
postgres_data:
Run with Docker
# Build and run
docker-compose up -d
# View logs
docker-compose logs -f api
# Stop
docker-compose down
# Rebuild
docker-compose up -d --build
.dockerignore
Create .dockerignore
:
.env
.git
.gitignore
*.md
*.test
coverage.out
blog-api
blog-api-linux
blog-api.exe
4. Environment Configuration (30 min)
Production-Ready Config
Update config/config.go
:
package config
import (
"fmt"
"os"
"strconv"
)
type Config struct {
// Server
Port string
Environment string // development, staging, production
// Database
DatabaseURL string
// JWT
JWTSecret string
JWTExpiration int // hours
// CORS
AllowedOrigins []string
// Rate Limiting
RateLimitEnabled bool
RateLimit int
}
func Load() *Config {
return &Config{
Port: getEnv("PORT", "8080"),
Environment: getEnv("ENVIRONMENT", "development"),
DatabaseURL: getEnv("DATABASE_URL", ""),
JWTSecret: getEnv("JWT_SECRET", ""),
JWTExpiration: getEnvAsInt("JWT_EXPIRATION", 24),
AllowedOrigins: getEnvAsSlice("ALLOWED_ORIGINS", []string{"*"}),
RateLimitEnabled: getEnvAsBool("RATE_LIMIT_ENABLED", true),
RateLimit: getEnvAsInt("RATE_LIMIT", 100),
}
}
func (c *Config) Validate() error {
if c.DatabaseURL == "" {
return fmt.Errorf("DATABASE_URL is required")
}
if c.JWTSecret == "" {
return fmt.Errorf("JWT_SECRET is required")
}
if c.Environment == "production" && c.JWTSecret == "default-secret" {
return fmt.Errorf("JWT_SECRET must be changed in production")
}
return nil
}
func (c *Config) IsProduction() bool {
return c.Environment == "production"
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getEnvAsInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
if intVal, err := strconv.Atoi(value); err == nil {
return intVal
}
}
return defaultValue
}
func getEnvAsBool(key string, defaultValue bool) bool {
if value := os.Getenv(key); value != "" {
if boolVal, err := strconv.ParseBool(value); err == nil {
return boolVal
}
}
return defaultValue
}
func getEnvAsSlice(key string, defaultValue []string) []string {
if value := os.Getenv(key); value != "" {
return strings.Split(value, ",")
}
return defaultValue
}
Multiple Environment Files
Create .env.development
:
ENVIRONMENT=development
PORT=8080
DATABASE_URL=postgres://postgres:postgres@localhost:5432/blog_db_dev?sslmode=disable
JWT_SECRET=dev-secret-key
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
Create .env.production
:
ENVIRONMENT=production
PORT=8080
DATABASE_URL=postgres://user:password@prod-db:5432/blog_db?sslmode=require
JWT_SECRET=super-secret-production-key-change-this
ALLOWED_ORIGINS=https://yourapp.com,https://www.yourapp.com
RATE_LIMIT_ENABLED=true
RATE_LIMIT=100
5. Deployment Options (1 hour)
Option 1: Deploy to Railway (Easiest)
- Create account at https://railway.app
- Install Railway CLI:
npm install -g @railway/cli
# or
brew install railway
- Login and deploy:
railway login
railway init
railway up
- Add PostgreSQL:
railway add
# Select PostgreSQL
Set environment variables in Railway dashboard
-
Railway automatically:
- Detects Go project
- Builds your app
- Provides HTTPS URL
- Auto-deploys on git push
Option 2: Deploy to Fly.io
- Install flyctl:
# Mac
brew install flyctl
# Linux
curl -L https://fly.io/install.sh | sh
- Login:
fly auth login
- Initialize:
fly launch
- Create fly.toml:
app = "your-blog-api"
primary_region = "sin"
[build]
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
[[vm]]
cpu_kind = "shared"
cpus = 1
memory_mb = 256
- Add PostgreSQL:
fly postgres create
fly postgres attach --app your-blog-api
- Set secrets:
fly secrets set JWT_SECRET=your-secret-key
- Deploy:
fly deploy
Option 3: Deploy to VPS (DigitalOcean/Linode)
Create droplet/VPS
Install Go on server:
ssh root@your-server-ip
wget https://go.dev/dl/go1.21.0.linux-amd64.tar.gz
tar -C /usr/local -xzf go1.21.0.linux-amd64.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
source ~/.bashrc
- Install PostgreSQL:
sudo apt update
sudo apt install postgresql postgresql-contrib
sudo -u postgres psql
CREATE DATABASE blog_db;
CREATE USER blog_user WITH PASSWORD 'secure_password';
GRANT ALL PRIVILEGES ON DATABASE blog_db TO blog_user;
\q
- Clone and build:
git clone your-repo
cd your-blog-api
go build -o blog-api
-
Create systemd service
/etc/systemd/system/blog-api.service
:
[Unit]
Description=Blog API
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/root/blog-api
ExecStart=/root/blog-api/blog-api
Restart=on-failure
Environment="DATABASE_URL=postgres://blog_user:secure_password@localhost:5432/blog_db"
Environment="JWT_SECRET=your-secret-key"
Environment="PORT=8080"
[Install]
WantedBy=multi-user.target
- Start service:
sudo systemctl daemon-reload
sudo systemctl enable blog-api
sudo systemctl start blog-api
sudo systemctl status blog-api
-
Setup Nginx reverse proxy
/etc/nginx/sites-available/blog-api
:
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
- Enable site:
sudo ln -s /etc/nginx/sites-available/blog-api /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
- Setup SSL with Let's Encrypt:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d your-domain.com
Option 4: Deploy with Docker
# Build image
docker build -t blog-api .
# Run container
docker run -d \
-p 8080:8080 \
-e DATABASE_URL="postgres://..." \
-e JWT_SECRET="your-secret" \
--name blog-api \
blog-api
# Or use docker-compose
docker-compose -f docker-compose.prod.yml up -d
6. Production Checklist (30 min)
Security Checklist
// ✅ HTTPS only in production
if config.IsProduction() {
// Redirect HTTP to HTTPS
go http.ListenAndServe(":80", http.HandlerFunc(redirectToHTTPS))
}
func redirectToHTTPS(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "https://"+r.Host+r.RequestURI,
http.StatusMovedPermanently)
}
// ✅ Secure headers middleware
func SecurityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Strict-Transport-Security",
"max-age=31536000; includeSubDomains")
next.ServeHTTP(w, r)
})
}
// ✅ Rate limiting
r.Use(middleware.Throttle(100)) // Chi built-in
// ✅ Request size limit
r.Use(middleware.RequestSize(10 * 1024 * 1024)) // 10MB max
// ✅ Timeout
r.Use(middleware.Timeout(60 * time.Second))
Monitoring & Logging
// Structured logging
import "log/slog"
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("Server starting",
"port", config.Port,
"environment", config.Environment,
)
logger.Error("Database connection failed",
"error", err,
)
Health Check Endpoint
func (s *Server) healthCheck(w http.ResponseWriter, r *http.Request) {
// Check database
sqlDB, _ := s.db.DB()
if err := sqlDB.Ping(); err != nil {
s.sendJSON(w, http.StatusServiceUnavailable, map[string]string{
"status": "unhealthy",
"database": "down",
})
return
}
s.sendJSON(w, http.StatusOK, map[string]string{
"status": "healthy",
"database": "up",
"version": "1.0.0",
})
}
Graceful Shutdown
func main() {
// ... setup code ...
server := &http.Server{
Addr: ":8080",
Handler: router,
}
// Start server in goroutine
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal("Server failed:", err)
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// Graceful shutdown with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Fatal("Server forced to shutdown:", err)
}
log.Println("Server exited")
}
Final Project Structure
blog-api/
├── .env
├── .env.production
├── .gitignore
├── .dockerignore
├── Dockerfile
├── docker-compose.yml
├── go.mod
├── go.sum
├── main.go
├── README.md
├── config/
│ └── config.go
├── models/
│ ├── user.go
│ ├── user_test.go
│ ├── post.go
│ └── post_test.go
├── handlers/
│ ├── auth.go
│ ├── auth_test.go
│ ├── user.go
│ ├── user_test.go
│ ├── post.go
│ └── post_test.go
├── middleware/
│ ├── auth.go
│ ├── auth_test.go
│ ├── logging.go
│ └── security.go
├── repository/
│ ├── user_repository.go
│ ├── user_repository_test.go
│ ├── post_repository.go
│ └── post_repository_test.go
├── utils/
│ ├── jwt.go
│ ├── jwt_test.go
│ ├── password.go
│ ├── password_test.go
│ ├── validation.go
│ └── validation_test.go
├── routes/
│ └── routes.go
└── testutils/
├── database.go
└── assertions.go
Key Takeaways
Testing
- Test files end with
_test.go
- Use table-driven tests
- Test with in-memory SQLite
- Use
httptest
for HTTP handlers - Aim for >70% coverage
Building
-
go build
creates single binary - Cross-compile with GOOS/GOARCH
- Docker for containerized deployment
- No runtime dependencies needed!
Deployment
- Railway/Fly.io: Easiest, free tier
- VPS: Full control, requires setup
- Docker: Portable, consistent
- Always use environment variables
- Enable HTTPS in production
Production
- Graceful shutdown
- Health checks
- Rate limiting
- Security headers
- Structured logging
- Request timeouts
🎉 Congratulations!
You've completed the 7-day Go learning journey!
What you've learned:
- ✅ Go basics and syntax
- ✅ Structs, interfaces, and OOP
- ✅ Error handling
- ✅ HTTP servers and REST APIs
- ✅ Chi router and middleware
- ✅ Database with GORM
- ✅ JWT authentication
- ✅ Testing and deployment
You can now:
- Build production-ready REST APIs
- Implement secure authentication
- Work with databases
- Test your code
- Deploy to production
Next Steps
-
Add more features:
- Pagination with cursor
- File uploads
- Email notifications
- WebSockets
- GraphQL
-
Learn advanced topics:
- Goroutines and channels (concurrency)
- gRPC
- Microservices
- Event-driven architecture
-
Practice:
- Build a real project
- Contribute to open source
- Read others' Go code
-
Resources:
You're now a Go developer! 🚀
Top comments (0)