Skip to main content
This guide covers testing strategies for Kratos microservices, including unit tests, integration tests, and end-to-end tests.

Testing Overview

Kratos services should be tested at multiple levels:
  • Unit Tests: Test individual functions and business logic
  • Integration Tests: Test data layer and external dependencies
  • Service Tests: Test service layer and API contracts
  • End-to-End Tests: Test complete request flows

Unit Testing

1
Testing Business Logic
2
Test your business logic layer independently:
3
package biz

import (
	"context"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
)

// Mock repository
type MockUserRepo struct {
	mock.Mock
}

func (m *MockUserRepo) CreateUser(ctx context.Context, user *User) error {
	args := m.Called(ctx, user)
	return args.Error(0)
}

func (m *MockUserRepo) GetUser(ctx context.Context, id string) (*User, error) {
	args := m.Called(ctx, id)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(*User), args.Error(1)
}

func TestUserUseCase_CreateUser(t *testing.T) {
	mockRepo := new(MockUserRepo)
	useCase := NewUserUseCase(mockRepo, nil)

	tests := []struct {
		name    string
		user    *User
		mockFn  func()
		wantErr bool
	}{
		{
			name: "successful creation",
			user: &User{
				Name:  "John Doe",
				Email: "[email protected]",
			},
			mockFn: func() {
				mockRepo.On("CreateUser", mock.Anything, mock.AnythingOfType("*biz.User")).Return(nil)
			},
			wantErr: false,
		},
		{
			name: "duplicate email",
			user: &User{
				Name:  "Jane Doe",
				Email: "[email protected]",
			},
			mockFn: func() {
				mockRepo.On("CreateUser", mock.Anything, mock.AnythingOfType("*biz.User")).Return(ErrUserExists)
			},
			wantErr: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mockRepo.ExpectedCalls = nil
			tt.mockFn()

			err := useCase.CreateUser(context.Background(), tt.user)

			if tt.wantErr {
				assert.Error(t, err)
			} else {
				assert.NoError(t, err)
			}
			mockRepo.AssertExpectations(t)
		})
	}
}
4
Table-Driven Tests
5
Use table-driven tests for comprehensive coverage:
6
package biz

import "testing"

func TestValidateEmail(t *testing.T) {
	tests := []struct {
		name  string
		email string
		want  bool
	}{
		{"valid email", "[email protected]", true},
		{"missing @", "userexample.com", false},
		{"missing domain", "user@", false},
		{"empty string", "", false},
		{"with subdomain", "[email protected]", true},
		{"special characters", "[email protected]", true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := ValidateEmail(tt.email); got != tt.want {
				t.Errorf("ValidateEmail() = %v, want %v", got, tt.want)
			}
		})
	}
}

Integration Testing

Testing Data Layer

Test your data layer with a test database:
internal/data/user_test.go
package data

import (
	"context"
	"testing"

	"github.com/stretchr/testify/assert"
	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

func setupTestDB(t *testing.T) *gorm.DB {
	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
	if err != nil {
		t.Fatalf("failed to connect database: %v", err)
	}

	// Auto migrate tables
	err = db.AutoMigrate(&User{})
	if err != nil {
		t.Fatalf("failed to migrate: %v", err)
	}

	return db
}

func TestUserRepo_Create(t *testing.T) {
	db := setupTestDB(t)
	repo := NewUserRepo(db, nil)

	user := &User{
		Name:  "Test User",
		Email: "[email protected]",
	}

	err := repo.CreateUser(context.Background(), user)
	assert.NoError(t, err)
	assert.NotEmpty(t, user.ID)

	// Verify in database
	var found User
	err = db.Where("email = ?", user.Email).First(&found).Error
	assert.NoError(t, err)
	assert.Equal(t, user.Name, found.Name)
}

func TestUserRepo_GetByID(t *testing.T) {
	db := setupTestDB(t)
	repo := NewUserRepo(db, nil)

	// Create test user
	user := &User{
		Name:  "Test User",
		Email: "[email protected]",
	}
	db.Create(user)

	// Test retrieval
	found, err := repo.GetUser(context.Background(), user.ID)
	assert.NoError(t, err)
	assert.Equal(t, user.ID, found.ID)
	assert.Equal(t, user.Email, found.Email)
}

Using Test Containers

Use real databases with testcontainers:
import (
	"context"
	"testing"

	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/wait"
)

func setupPostgresContainer(t *testing.T) (string, func()) {
	ctx := context.Background()

	req := testcontainers.ContainerRequest{
		Image:        "postgres:15-alpine",
		ExposedPorts: []string{"5432/tcp"},
		Env: map[string]string{
			"POSTGRES_PASSWORD": "password",
			"POSTGRES_DB":       "testdb",
		},
		WaitingFor: wait.ForLog("database system is ready to accept connections"),
	}

	container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
	})
	if err != nil {
		t.Fatalf("failed to start container: %v", err)
	}

	host, _ := container.Host(ctx)
	port, _ := container.MappedPort(ctx, "5432")
	dsn := fmt.Sprintf("host=%s port=%s user=postgres password=password dbname=testdb sslmode=disable",
		host, port.Port())

	cleanup := func() {
		container.Terminate(ctx)
	}

	return dsn, cleanup
}

Service Testing

Testing Service Layer

Test your service implementation:
internal/service/user_test.go
package service

import (
	"context"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"

	v1 "yourproject/api/user/v1"
)

type MockUserUseCase struct {
	mock.Mock
}

func (m *MockUserUseCase) CreateUser(ctx context.Context, user *biz.User) error {
	args := m.Called(ctx, user)
	return args.Error(0)
}

func TestUserService_CreateUser(t *testing.T) {
	mockUC := new(MockUserUseCase)
	svc := NewUserService(mockUC)

	req := &v1.CreateUserRequest{
		Name:  "John Doe",
		Email: "[email protected]",
	}

	mockUC.On("CreateUser", mock.Anything, mock.MatchedBy(func(u *biz.User) bool {
		return u.Name == req.Name && u.Email == req.Email
	})).Return(nil)

	resp, err := svc.CreateUser(context.Background(), req)

	assert.NoError(t, err)
	assert.NotNil(t, resp)
	assert.NotEmpty(t, resp.Id)
	mockUC.AssertExpectations(t)
}

Testing HTTP Handlers

Test HTTP endpoints directly:
internal/server/http_test.go
package server

import (
	"bytes"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestHTTPServer_CreateUser(t *testing.T) {
	// Setup
	mockService := new(MockUserService)
	server := NewHTTPServer(mockService)

	// Prepare request
	body := map[string]string{
		"name":  "John Doe",
		"email": "[email protected]",
	}
	jsonBody, _ := json.Marshal(body)

	req := httptest.NewRequest("POST", "/v1/users", bytes.NewReader(jsonBody))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()

	// Mock expectations
	mockService.On("CreateUser", mock.Anything, mock.Anything).Return(&v1.User{
		Id:    "123",
		Name:  body["name"],
		Email: body["email"],
	}, nil)

	// Execute
	server.ServeHTTP(w, req)

	// Assert
	assert.Equal(t, http.StatusOK, w.Code)

	var resp v1.User
	json.Unmarshal(w.Body.Bytes(), &resp)
	assert.Equal(t, "123", resp.Id)
	assert.Equal(t, body["name"], resp.Name)
}

gRPC Testing

Testing gRPC Services

Test gRPC services with bufconn:
internal/server/grpc_test.go
package server

import (
	"context"
	"net"
	"testing"

	"github.com/stretchr/testify/assert"
	"google.golang.org/grpc"
	"google.golang.org/grpc/test/bufconn"

	v1 "yourproject/api/user/v1"
)

const bufSize = 1024 * 1024

var lis *bufconn.Listener

func setupGRPCServer(t *testing.T) (*grpc.Server, func(context.Context, string) (net.Conn, error)) {
	lis = bufconn.Listen(bufSize)
	s := grpc.NewServer()

	// Register your service
	userService := NewUserService(nil)
	v1.RegisterUserServiceServer(s, userService)

	go func() {
		if err := s.Serve(lis); err != nil {
			t.Logf("Server exited with error: %v", err)
		}
	}()

	bufDialer := func(context.Context, string) (net.Conn, error) {
		return lis.Dial()
	}

	return s, bufDialer
}

func TestGRPCServer_CreateUser(t *testing.T) {
	s, bufDialer := setupGRPCServer(t)
	defer s.Stop()

	ctx := context.Background()
	conn, err := grpc.DialContext(ctx, "bufnet",
		grpc.WithContextDialer(bufDialer),
		grpc.WithInsecure(),
	)
	assert.NoError(t, err)
	defer conn.Close()

	client := v1.NewUserServiceClient(conn)

	resp, err := client.CreateUser(ctx, &v1.CreateUserRequest{
		Name:  "John Doe",
		Email: "[email protected]",
	})

	assert.NoError(t, err)
	assert.NotNil(t, resp)
	assert.NotEmpty(t, resp.Id)
}

End-to-End Testing

Full Integration Tests

Test complete request flows:
test/e2e/user_test.go
package e2e

import (
	"context"
	"testing"
	"time"

	"github.com/stretchr/testify/suite"
	"google.golang.org/grpc"

	v1 "yourproject/api/user/v1"
)

type E2ETestSuite struct {
	suite.Suite
	client v1.UserServiceClient
	conn   *grpc.ClientConn
}

func (s *E2ETestSuite) SetupSuite() {
	// Connect to running service
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	conn, err := grpc.DialContext(ctx, "localhost:9000", grpc.WithInsecure())
	s.Require().NoError(err)

	s.conn = conn
	s.client = v1.NewUserServiceClient(conn)
}

func (s *E2ETestSuite) TearDownSuite() {
	s.conn.Close()
}

func (s *E2ETestSuite) TestUserLifecycle() {
	ctx := context.Background()

	// Create user
	createResp, err := s.client.CreateUser(ctx, &v1.CreateUserRequest{
		Name:  "E2E Test User",
		Email: "[email protected]",
	})
	s.NoError(err)
	s.NotEmpty(createResp.Id)

	// Get user
	getResp, err := s.client.GetUser(ctx, &v1.GetUserRequest{
		Id: createResp.Id,
	})
	s.NoError(err)
	s.Equal(createResp.Id, getResp.Id)
	s.Equal("E2E Test User", getResp.Name)

	// Update user
	updateResp, err := s.client.UpdateUser(ctx, &v1.UpdateUserRequest{
		Id:   createResp.Id,
		Name: "Updated Name",
	})
	s.NoError(err)
	s.Equal("Updated Name", updateResp.Name)

	// Delete user
	_, err = s.client.DeleteUser(ctx, &v1.DeleteUserRequest{
		Id: createResp.Id,
	})
	s.NoError(err)
}

func TestE2ESuite(t *testing.T) {
	suite.Run(t, new(E2ETestSuite))
}

Test Helpers

Common Test Utilities

test/helper/helper.go
package helper

import (
	"context"
	"testing"
	"time"
)

func WaitForCondition(t *testing.T, condition func() bool, timeout time.Duration) {
	ctx, cancel := context.WithTimeout(context.Background(), timeout)
	defer cancel()

	ticker := time.NewTicker(100 * time.Millisecond)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			t.Fatal("timeout waiting for condition")
		case <-ticker.C:
			if condition() {
				return
			}
		}
	}
}

func RandomString(n int) string {
	const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
	b := make([]byte, n)
	for i := range b {
		b[i] = letters[rand.Intn(len(letters))]
	}
	return string(b)
}

Best Practices

Isolation

Keep tests isolated and independent of each other

Coverage

Aim for high test coverage, especially for business logic

Fast Tests

Keep unit tests fast; use integration tests sparingly

Clear Names

Use descriptive test names that explain what is being tested

Next Steps

Deployment

Deploy your tested service

CI/CD

Set up continuous integration

Build docs developers (and LLMs) love