iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
😽

Practical Guide to Splitting Go Test Files

に公開

Introduction

As you continue developing in Go, it becomes easier for the question of "what to write where" to become ambiguous as the number of tests increases.
Especially when handling unit tests, integration tests using a database, and scenario tests for business flows in the same place, the burden on execution time and maintainability tends to increase.

In practice, it is easier to manage if you divide tests into three types for a single implementation file:

  • Scenario tests
  • Integration tests (including DB)
  • Pure unit tests

In this article, I will organize a structure based on this three-way split.

The sample code that you can actually run is available in the following repository:

https://github.com/tonbiattack/go-test-splitting-sample

Conclusion First

For an implementation like user_service.go, prepare the following three files:

  • user_service_unit_test.go
  • user_service_integration_test.go
  • user_service_scenario_test.go

Adopting this form makes it faster to isolate issues when a failure occurs.
This is because you can determine at which layer the failure happened just by the filename.
The fact that it becomes easier to run heavy and light tests separately is also a major advantage in daily development.

Why Split into Three?

Mixing tests into a single file often causes the following problems:

  • Slow tests get mixed in, making local execution heavy.
  • The boundary of the cause becomes ambiguous when a failure occurs.
  • You end up re-reading unnecessary cases when making changes.

By splitting into three, responsibilities become clear:

  • Unit tests: Logic correctness
  • Integration tests: DB consistency
  • Scenario tests: Entire business flow

Concerns about the Three-way Split

When proposing this configuration, the opinion that "implementation and tests should be grouped 1-to-1" sometimes arises.

There are several rational backgrounds to this point.

First, in Go, the unit of test execution is the package, not the file.
Therefore, dividing files does not automatically separate execution boundaries.
Separation is merely a structural organization and lacks enforcement as a mechanism.

Additionally, splitting tests too finely can sometimes increase search costs.
The scope of "which files should be checked" when changing implementation becomes dispersed, making it potentially harder for unfamiliar members to follow.

Furthermore, there is a view that the separation of heavy and light tests should be controlled by execution options like -short or build tags, rather than file structure.
This stance prioritizes execution strategy over structure.

These are well-understood concerns.

Still, Why Choose the Three-way Split

That being said, the three-way split proposed in this article is not the "correct answer for Go," but rather an optimization for operations.

The purpose is narrowed down to the following two points:

  • Instantly isolating the boundary of the cause when a failure occurs.
  • Visually clarifying the existence of heavy and light tests.

By clarifying responsibilities through filenames:

  • Look at unit tests first.
  • Look here for DB-related issues.
  • Look here for business flows.

These kinds of judgments become faster.

This is not a matter of language specification, but a matter of a team's operational design.

Comparison with Other Patterns

In actual teams, the following configurations are also commonly seen:

  • Grouping all tests into a single *_test.go.
  • Dividing by directories like unit/ and integration/.

While the method of grouping everything into one file is easy to start with, it becomes harder to read as the number of cases increases, and mixed heavy tests make the execution time less predictable.
Directory splitting is easy to organize, but the correspondence with implementation files tends to become distant, often widening the scope that needs to be tracked during changes.

By splitting only by test type based on the implementation filename like user_service_*.go, you can separate responsibilities while maintaining the correspondence with the implementation.
This structure works well with Go's *_test.go culture and is less likely to cause confusion during maintenance.

Guidelines for considering the three-way split

Deciding "when to split" in advance makes it less likely for the rule to become a mere formality. It is worth considering the three-way split if any of the following apply:

  • The number of tests for a single implementation file exceeds 10, making it heavy to re-read during reviews.
  • go test ./... takes more than 60 seconds locally, causing daily wait times.
  • It takes time to isolate whether the cause is unit, DB, or business flow during a failure or CI failure.

Conversely, there is no need to force a split at a stage where the number of tests is small and the entire picture can be grasped in a short amount of time.

Boundary conditions where you shouldn't split into three

While the three-way split is effective, it is not always the correct answer. In the following situations, it is easier to operate by not splitting too much at first:

  • There are few test cases, and visibility is already good enough with a single file.
  • It is a small package, making it easy to track the scope of changes and impacts.
  • There are no DB dependencies, and the practical benefit of isolating integration tests is small.
  • The infrastructure for E2E or scenario execution is not yet set up, and only the operations would become complex prematurely.

As a rule of thumb, it is safer to start primarily with *_unit_test.go and gradually split out integration and scenario as DB dependencies and business flow verifications increase.

Role of Each Test

Pure Unit Test

These tests have no external dependencies and run the fastest.
They ensure the core of the logic, such as branching, calculations, and validation.

Integration Test (Including DB)

These check the repository layer, transactions, and SQL results.
They detect issues originating from the DB schema or queries that cannot be caught in isolation.

Scenario Test

These verify the flow from entry point to exit point for each use case.
It is easier to manage by not increasing the number of cases too much and focusing on critical flows.

Naming and Placement Rules

  • Use the implementation filename as a prefix.
  • Explicitly state the test type at the end.
  • Do not use confusing names.

Example

  • user_service.go
  • user_service_unit_test.go
  • user_service_integration_test.go
  • user_service_scenario_test.go

Standardizing this mapping makes it easy to determine which tests to check when the implementation changes.

Execution Policy and Command Examples

  • Always run unit tests during development
  • Run unit + integration tests for PRs
  • Run scenario tests during major changes or for periodic execution

This approach makes it easier to balance speed and detection capability.

Example

  • Run all: go test ./...
  • Run primarily unit tests: go test ./... -run 'TestUserService_Unit'
  • Run primarily integration tests: go test ./... -run 'TestUserService_Integration'

While -run is intuitive, it depends heavily on test naming conventions. To stabilize team operations, it is safer to also allow the use of -short or build tags.

Example using -short

  • During development (excluding heavy tests): go test ./... -short
  • Entire CI: go test ./...

Example using build tags

  • Primarily unit tests: go test ./...
  • Including integration tests: go test ./... -tags=integration

Add tags to the integration test side as follows:

//go:build integration
// +build integration

If you manage execution using a Makefile, fixing the entry points like this will stabilize operations:

test:
	go test ./...

test-unit:
	go test ./... -short

test-integration:
	go test ./... -tags=integration

By managing visually with filenames while separating execution with -short or tags, you become less susceptible to the impact of naming changes.

Minimal code example

Here, only the skeleton is shown.

// user_service.go
package user

import "context"

type UserService struct {
	repo UserRepository
}

type UserRepository interface {
	Exists(ctx context.Context, id string) (bool, error)
}

func (s UserService) CanLogin(ctx context.Context, id string) (bool, error) {
	return s.repo.Exists(ctx, id)
}
// user_service_unit_test.go
package user

import (
	"context"
	"testing"
)

type stubRepo struct {
	exists bool
	err    error
}

func (r stubRepo) Exists(context.Context, string) (bool, error) {
	return r.exists, r.err
}

func TestUserService_Unit_CanLogin(t *testing.T) {
	svc := UserService{repo: stubRepo{exists: true}}
	ok, err := svc.CanLogin(context.Background(), "u1")
	if err != nil || !ok {
		t.Fatalf("ok=%v err=%v", ok, err)
	}
}
// user_service_integration_test.go
package user

import "testing"

func TestUserService_Integration_CanLoginWithDB(t *testing.T) {
	// Start the test DB and verify by injecting the Repository implementation
	t.Skip("example")
}
// user_service_scenario_test.go
package user

import "testing"

func TestUserService_Scenario_LoginFlow(t *testing.T) {
	// Verify from the API or UseCase entry point through to login completion
	t.Skip("example")
}

Migration procedure

  1. Start with implementation files that have a high change frequency.
  2. Distribute existing tests into the three types.
  3. Standardize filenames to *_unit_test.go, etc.
  4. Organize CI execution jobs to match the three types.

Rather than fixing everything at once, it's more realistic to migrate gradually starting from areas where changes are being made.

Summary

The three-way split is not an absolute correct answer.

However, it offers operational benefits such as:

  • The boundaries of causes become clear.
  • It's easy to design execution strategies.
  • Confusion during maintenance is reduced.

The important thing is not the "splitting" itself, but rather:

  • Are the roles clear?
  • Is the execution time healthy?
  • Is the isolation fast?

My position is that if these conditions are met, you can choose the configuration that best fits your team.

Discussion