iTranslated by AI
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:
Conclusion First
For an implementation like user_service.go, prepare the following three files:
user_service_unit_test.gouser_service_integration_test.gouser_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/andintegration/.
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.gouser_service_unit_test.gouser_service_integration_test.gouser_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
- Start with implementation files that have a high change frequency.
- Distribute existing tests into the three types.
- Standardize filenames to
*_unit_test.go, etc. - 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