Vjerci.com

A personal blog by software engineer

January, 5-th, 2026

Clean Architecture -> Testability



This tutorial series assumes you are familiar with golang, as we will be using it thorough this article series.


About testability

Testability implies that the architecture ought to ease the testing of business rules. It means you should be able to unit test your business rules without involving external dependencies.

Testability in Go (Golang) is tightly connected to how well you apply clean architecture principles—because it is fundamentally about separation of concerns and dependency control, which directly enable easy testing.

Unit testing

We shall now apply what we’ve learned in previous articles to unit testing.

Based on previous articles we have a good base for test.

This is how our code should look like for our service

	// user/service.go
	type UserService struct {
		repo UserRepository
	}

	type UserRepository interface {
		FindByID(id string) (*User, error)
	}

	func (s *UserService) GetUser(id string) (*User, error) {
		if id == "" {
			return nil, errors.New("invalid id")
		}

		return s.repo.FindByID(id)
	}

Now unit testing is simple.

We can simply mock the UserRepository and test our service/business rule GetUser method.

	// user/service_test.go
	type MockUserRepository struct {}

	func (m *MockUserRepository) FindByID(id string) (*User, error) {
		return nil, nil
	}

	func TestGetUser(t *testing.T) {
		repo := &MockUserRepository{}
		service := UserService{repo: repo}

		err := service.Get("")

		if err == nil {
			t.Fatal("expected error")
		}
	}

Small interfaces

We often want small interfaces, and there are 2 reasons for it.

  1. It allows us to replace simple bits and pieces, making our code more extendable
  1. Mocking tests is easier because we don’t need to mock the whole thing, just the simple, small interface. It makes our tests small and to the point, testing only behavior we need.

This is a good example:

	type UserGetter interface {
		GetUser(id string) (User, error)
	}

This is a bad example… giant interfaces, that would look like this:

	type UserRepository interface {
		SaveUser(...)
		UpdateUser(...)
		DeleteUser(...)
		GetUser(...)
	}

To conclude, smaller interfaces… they lead to easier mocking which equals to better tests.

Integration test example

Integration tests focus on how multiple components work together. So for example we would test how our userRepository works with database(we can use real db or mock it).

Here is an example of integration test, notice how we are only testing repository/db interaction, and not testing our handler nor our service:

	// models/user.go
	type User struct {
		ID string
		Name string
	}

	// repository/user.go
	type userRepo struct {
		db *sql.DB
	}

	func NewUserRepository(db *sql.DB) *userRepo {
		return userRepo{db: db}
	}

	func (r *userRepo) Create(user *user.User) (*user.User, error) {
		stmt, err := db.Prepare("INSERT INTO users (id, name) VALUES (?, ?)")
		if err != nil {
			return err
		}
		defer stmt.Close()

		_, err = stmt.Exec(user.ID, user.Name)
		if err != nil {
			return err
		}

		return user, nil
	}

	func (r *userRepo) FindByID(id string) (*User, error) {
		row := r.db.QueryRow("SELECT id, name FROM users WHERE id = ?", id)

		var user User
		err := row.Scan(&user.ID, &user.Name)
		return &user, err
	}

	// repository/user_test.go
	// a simple integration, testing user creation and retrieval
	func TestCreateUser(t *testing.T) {
		db := setupTestDB() // real or containerized DB
		repo := NewUserRepository(db)

		user := user.User{Name: "Alice"}
		err := repo.Create(user)

		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		saved, _ := repo.FindByID("Alice")
		if saved.Name != "Alice" {
			t.Errorf("expected Alice, got %s", saved.Name)
		}
	}

E2E test example:

E2e tests on the other hand test the complete flow, in our example we setup a server, its dependencies… like db, and test by sending the request.

Here is an example:

	// e2e/user_test.go
	// a simple e2e, testing user creation 
	func TestCreateUserE2E(t *testing.T) {
		server := setupTestServer()
		defer server.Close()

		payload := `{"name":"Alice"}`
		resp, err := http.Post(server.URL+"/users", "application/json", strings.NewReader(payload))
		if err != nil {
			t.Fatalf("request failed: %v", err)
		}
		defer resp.Body.Close()

		if resp.StatusCode != http.StatusCreated {
			t.Errorf("expected 201, got %d", resp.StatusCode)
		}
	}

Testing levels

The general rule is to favour unit tests over integration tests, and favor integration tests over e2e tests.

Unit tests

  1. they are fast and most important
  2. They test use cases and mock interfaces

Integration tests

  1. they use real DB or API
  2. they test adapters

E2E tests

  1. they are slow
  2. they are brittle/easy to break.
  3. they test the complete flow

Rule of thumb

If you are thinking what’s the amount of each tests i should have… some rule of thumb is to have:

Test TypeRecommended Percentage
Unit70–80%
Integration10–25%
E2E5–10%

Summary

We’ve learned a bit about testing in golang, how we should use small interfaces, small tests and preference amount of each tests. We’ve also learned how unit, integration and e2e tests might look in practice.