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", "[email protected]", true},
{"valid with subdomain", "[email protected]", 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": "[email protected]",
"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 != "[email protected]" {
t.Errorf("Expected email [email protected], 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: "[email protected]",
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, "[email protected]")
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://awesome-go.com
You're now a Go developer! 🚀
SOCIAL SHARE CARD GENERATOR