- Introduction
- What is TDD?
- Architecture
- Getting Started
- API Endpoints
- Project Structure
- Technologies Used
- TDD Workflow
- Service Methods
- Testing
- TDD Best Practices
- Development
- Deployment
- Roadmap
- Contributing
- License
This repository demonstrates production-ready Test-Driven Development (TDD) practices in Golang. The project showcases how to build an HTTP server and user management system by writing tests first, following the Red-Green-Refactor cycle. Every feature in this codebase was developed test-first, making the tests serve as both specification and documentation.
Test-Driven Development is a software development methodology where tests are written before the implementation code. The process follows a simple three-step cycle:
- 🔴 Red: Write a failing test that defines the desired behavior
- 🟢 Green: Write the minimum code necessary to make the test pass
- ♻️ Refactor: Improve the code while ensuring all tests remain passing
- Better Design: Writing tests first leads to more modular, testable code
- Documentation: Tests serve as living documentation of expected behavior
- Confidence: Comprehensive test coverage from the start
- Fewer Bugs: Edge cases are considered upfront, not as afterthoughts
- Faster Debugging: When tests fail, you know exactly what broke
The architecture demonstrates TDD principles across multiple layers:
- HTTP Handlers: Route handlers driven by HTTP test specifications
- User Management: Business logic developed through unit tests
- Validation Layer: Input validation implemented test-first
- Error Handling: Error cases defined and tested before implementation
- API Design: API contracts established through test cases
Before you begin, ensure you have the following installed:
- Golang 1.25 or higher
- Basic understanding of Go testing framework
- Familiarity with HTTP concepts
-
Clone the repository:
git clone https://github.com/kunalkumar-1/go-http.git cd go-http -
Install dependencies:
go mod download
-
Verify installation by running tests:
go test ./...
-
Start the HTTP server:
go run ./cmd/server/main.go
-
The service will be available at:
- API Server:
http://localhost:4000
- API Server:
-
Test the server is running:
curl http://localhost:4000/
GET /Response:
Welcome to our HomePage!
Test Coverage:
TestHandleRoot- Validates welcome message and status code
GET /goodbyeResponse:
Goodbye world is served at goodbye
Test Coverage:
TestHandleGoodbye- Validates goodbye message
GET /hello/?user=JohnQuery Parameters:
user(optional): Username to greet. Defaults to "User" if not provided.
Response:
Hello John!
Test Coverage:
TestHandleHelloParameterized- With user parameterTestHandleHelloNoParameterized- Without user parameter (defaults)TestHandleHelloWrongParameterized- Invalid parameter handling
Example:
# With parameter
curl "http://localhost:4000/hello/?user=Alice"
# Without parameter (uses default)
curl "http://localhost:4000/hello/"GET /responses/{user}/hello/Path Parameters:
user(required): Username to greet
Response:
Hello Alice!
Test Coverage:
TestHandleUserResponsesHello- Path variable extraction and response
Example:
curl http://localhost:4000/responses/Bob/hello/GET /user/hello
Headers:
user: CharlieHeaders:
user(required): Username to greet
Response:
Hello Charlie!
Error Response (400 Bad Request):
invalid username provided
Test Coverage:
TestHandleHelloHeader- Valid header handlingTestHandleHelloNoHeader- Missing header error case
Example:
# Success case
curl -H "user: Charlie" http://localhost:4000/user/hello
# Error case (missing header)
curl http://localhost:4000/user/helloPOST /json
Content-Type: application/json
{
"Name": "David"
}Request Body:
{
"Name": "string (required)"
}Response:
Hello David!
Error Responses:
400 Bad Request - Empty request body:
empty request body
400 Bad Request - Invalid JSON or missing Name field:
invalid request body!
Test Coverage:
TestHandleJSON- Valid JSON payloadTestHandleJSONEmptyBody- Empty body error handlingTestHandleJSONEmptyNameFeild- Missing Name field validation
Example:
# Success case
curl -X POST http://localhost:4000/json \
-H "Content-Type: application/json" \
-d '{"Name":"David"}'
# Error case (empty body)
curl -X POST http://localhost:4000/json \
-H "Content-Type: application/json" \
-d '{}'
# Error case (invalid JSON)
curl -X POST http://localhost:4000/json \
-H "Content-Type: application/json" \
-d 'invalid'go-http/
├── cmd/
│ └── server/
│ ├── main.go # HTTP server implementation
│ └── main_test.go # HTTP handler tests (written first)
├── internal/
│ └── users/
│ ├── users.go # User management implementation
│ └── users_test.go # User management tests (written first)
├── go.mod # Go module dependencies
├── go.sum # Go module checksums
└── README.md # Project documentation
main_test.go: HTTP handler tests that drove the API designmain.go: HTTP server implementation written to satisfy testsusers_test.go: User management tests defining business logicusers.go: User management implementation
- Golang: Primary language (1.25+)
- net/http: Standard library HTTP server
- net/http/httptest: HTTP testing utilities
- testing: Go's built-in testing framework
- net/mail: Email validation
Step 1: 🔴 Red - Write a Failing Test
// cmd/server/main_test.go
func TestHandleRoot(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
handleRoot(w, r)
desiredCode := http.StatusOK
if w.Code != desiredCode {
t.Errorf("bad response code: expected %d, got %d", desiredCode, w.Code)
}
expectedMessage := []byte("Welcome to our HomePage!\n")
if !bytes.Equal(w.Body.Bytes(), expectedMessage) {
t.Errorf("bad response body: expected %s, got %s",
string(expectedMessage), string(w.Body.Bytes()))
}
}Run test: go test ./cmd/server - FAILS ❌ (handleRoot doesn't exist)
Step 2: 🟢 Green - Write Minimum Code to Pass
// cmd/server/main.go
func handleRoot(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Welcome to our HomePage!\n"))
}Run test: go test ./cmd/server - PASSES ✅
Step 3: ♻️ Refactor - Improve Code Quality
// cmd/server/main.go
func handleRoot(w http.ResponseWriter, r *http.Request) {
fmt.Println("Requested path:", r.URL.Path)
_, err := w.Write([]byte("Welcome to our HomePage!\n"))
if err != nil {
slog.Error("Error serving the root handler: " + err.Error())
return
}
}Run test: go test ./cmd/server - STILL PASSES ✅
Step 1: 🔴 Red - Test Adding a User
// internal/users/users_test.go
func TestAddUser(t *testing.T) {
testManager := NewManager()
err := testManager.AddUser("John", "Doe", "[email protected]")
if err != nil {
t.Fatalf("failed to add user: %v", err)
}
if len(testManager.users) != 1 {
t.Fatalf("expected 1 user, got %v", len(testManager.users))
}
}Run test: go test ./internal/users - FAILS ❌
Step 2: 🟢 Green - Implement AddUser
// internal/users/users.go
func (m *Manager) AddUser(firstName string, lastName string, email string) error {
newUser := User{
FirstName: firstName,
LastName: lastName,
Email: mail.Address{Address: email},
}
m.users = append(m.users, newUser)
return nil
}Run test: go test ./internal/users - PASSES ✅
Step 3: ♻️ Refactor - Add Validation
First, write a test for email validation:
// internal/users/users_test.go
func TestAddUserInvalidEmail(t *testing.T) {
testManager := NewManager()
err := testManager.AddUser("John", "Doe", "invalid-email")
if err == nil {
t.Errorf("expected error for invalid email")
}
}Then refactor AddUser to include validation:
// internal/users/users.go
func (m *Manager) AddUser(firstName string, lastName string, email string) error {
// Validate email
_, err := mail.ParseAddress(email)
if err != nil {
return fmt.Errorf("invalid email address: %w", err)
}
newUser := User{
FirstName: firstName,
LastName: lastName,
Email: mail.Address{Address: email},
}
m.users = append(m.users, newUser)
return nil
}Run tests: go test ./internal/users - ALL PASS ✅
func handleRoot(w http.ResponseWriter, r *http.Request)
func handleGoodbye(w http.ResponseWriter, r *http.Request)func handleHelloParameterized(w http.ResponseWriter, r *http.Request)
func handleUserResponsesHello(w http.ResponseWriter, r *http.Request)func handleHelloNoHeader(w http.ResponseWriter, r *http.Request)func handleJSON(w http.ResponseWriter, r *http.Request)func NewManager() *Manager
func (m *Manager) AddUser(firstName string, lastName string, email string) error
func (m *Manager) GetUserByName(firstName string, lastName string) (*User, error)
func (m *Manager) GetAllUsers() []User
func (m *Manager) DeleteUser(firstName string, lastName string) errorfunc setupTestRequest(method string, path string, body io.Reader) (*httptest.ResponseRecorder, *http.Request)
func assertStatusCode(t *testing.T, got int, want int)
func assertResponseBody(t *testing.T, got []byte, want []byte)# Run all tests
go test ./...
# Run tests with coverage
go test -cover ./...
# Run tests with verbose output
go test -v ./...
# Run specific package tests
go test ./cmd/server
go test ./internal/users
# Run specific test
go test ./cmd/server -run TestHandleRoot
# Run tests with race detection
go test -race ./...# Generate coverage profile
go test -coverprofile=coverage.out ./...
# View coverage in terminal
go tool cover -func=coverage.out
# Generate HTML coverage report
go tool cover -html=coverage.outPASS
coverage: 95.2% of statements
ok github.com/kunalkumar-1/go-http/cmd/server 0.156s
ok github.com/kunalkumar-1/go-http/internal/users 0.089s
go-http/
├── cmd/server/
│ ├── main.go
│ └── main_test.go # HTTP handler tests
│ ├── TestHandleRoot
│ ├── TestHandleGoodbye
│ ├── TestHandleHelloParameterized
│ ├── TestHandleHelloNoParameterized
│ ├── TestHandleHelloWrongParameterized
│ ├── TestHandleUserResponsesHello
│ ├── TestHandleHelloHeader
│ ├── TestHandleHelloNoHeader
│ ├── TestHandleJSON
│ ├── TestHandleJSONEmptyBody
│ └── TestHandleJSONEmptyNameFeild
└── internal/users/
├── users.go
└── users_test.go # User management tests
├── TestAddUser
├── TestAddUserInvalidEmail
├── TestAddUserFirstName
├── TestAddUserLastName
├── TestAddUserDuplicateName
├── TestGetUserByName
└── TestGetAllUsers
Tests are named to clearly describe what behavior they're testing:
TestHandleRoot // Root handler behavior
TestHandleHelloNoHeader // Error case: missing header
TestAddUserInvalidEmail // Validation: invalid email
TestHandleJSONEmptyBody // Error case: empty request bodyUse table-driven tests for comprehensive scenario coverage:
tests := map[string]struct {
firstName string
lastName string
expected *User
expectedError error
}{
"simple lookup": {
firstName: "John",
lastName: "Doe",
expected: &User{...},
expectedError: nil,
},
"no match lookup": {
firstName: "NonExistent",
lastName: "User",
expected: nil,
expectedError: ErrNoResultFound,
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
result, err := testManager.GetUserByName(test.firstName, test.lastName)
// assertions...
})
}Error cases and edge cases are tested alongside happy paths:
- Missing required fields
- Invalid input formats
- Empty request bodies
- Duplicate entries
- Boundary conditions
All HTTP handlers use net/http/httptest for fast, isolated tests:
func TestHandleJSON(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/json", bytes.NewBuffer(payload))
handleJSON(w, r)
// Assertions on w.Code and w.Body
}Each test focuses on a single behavior or scenario:
TestAddUser // Happy path: successful addition
TestAddUserInvalidEmail // Error path: email validation
TestAddUserFirstName // Validation: first name requirements
TestAddUserDuplicateName // Error path: duplicate preventionThe test files serve as the specification:
main_test.godefines the HTTP API contractusers_test.godefines user management behavior- Reading tests tells you exactly what the system does
For New Features:
-
Write the Test First
# Create test in appropriate *_test.go file # Run: go test ./path/to/package # Should FAIL (🔴 Red)
-
Implement Minimum Code
# Write just enough code to pass # Run: go test ./path/to/package # Should PASS (🟢 Green)
-
Refactor
# Improve code quality # Run: go test ./... # All tests should still PASS (♻️ Refactor)
Install Air for development with live reload:
go install github.com/cosmtrek/air@latestRun with live reload:
airRun tests automatically on file changes:
# Using entr (Unix/Mac)
brew install entr
ls *.go **/*.go | entr -r go test ./...
# Or use gotestsum
go install gotest.tools/gotestsum@latest
gotestsum --watch# Format all Go files
go fmt ./...
# Using goimports (recommended)
go install golang.org/x/tools/cmd/goimports@latest
goimports -w .# Install golangci-lint
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
# Run linter
golangci-lint runCreate a Dockerfile:
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/server .
EXPOSE 4000
CMD ["./server"]Build and run:
# Build image
docker build -t go-http-tdd .
# Run container
docker run -p 4000:4000 go-http-tddCreate a docker-compose.yml:
version: '3.8'
services:
app:
build: .
ports:
- "4000:4000"
environment:
- PORT=4000
restart: unless-stoppedRun with Docker Compose:
docker-compose up -d --build- Reverse Proxy: Use nginx or Caddy as reverse proxy
- HTTPS/TLS: Configure SSL certificates (Let's Encrypt)
- Environment Variables: Externalize configuration
- Graceful Shutdown: Implement proper shutdown handling
- Health Checks: Add
/healthendpoint for monitoring - Logging: Structured logging with log levels
- Rate Limiting: Add rate limiting middleware
- CORS: Configure CORS for API access
- Metrics: Add Prometheus metrics endpoint
- TDD-driven HTTP handlers
- User management with validation
- Comprehensive test coverage
- Table-driven tests
- HTTP testing with httptest
- Middleware support (logging, recovery)
- Database integration (PostgreSQL)
- JWT authentication
- API versioning
- OpenAPI/Swagger documentation
- Integration tests
- Benchmark tests
- CI/CD pipeline (GitHub Actions)
- Docker multi-stage builds
- Kubernetes deployment manifests
- Monitoring and observability (Prometheus/Grafana)
- Load testing suite
- Security scanning
Contributions are welcome! When contributing, please follow TDD principles:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Write tests first for your feature
- Implement the feature to make tests pass
- Ensure all tests pass (
go test ./...) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
- Always write tests first - Follow the Red-Green-Refactor cycle
- All tests must pass before submitting PR
- Maintain or improve test coverage
- Follow Go code style guidelines (
gofmt,goimports) - Write meaningful commit messages
- Update documentation for new features
- Include examples in tests
- Tests written before implementation
- All tests passing
- Code coverage maintained/improved
- No commented-out code
- Clear, descriptive test names
- Edge cases covered
- Documentation updated
This project is licensed under the MIT License - see the LICENSE file for details.
Built with Test-Driven Development 🔴 🟢 ♻️
Every line of code in this project was written to satisfy a test.