Testing Boundaries: From Pure Domain to gRPC Contracts
How LZStock utilizes Interface Mocking, Table-Driven Tests, and Testcontainers to achieve high coverage without sacrificing pipeline speed.
- Zero-Leakage Error Firewall: Mapped raw internal domain errors to safe gRPC status codes at the Controller layer, translating them into sanitized HTTP JSON at the Gateway to guarantee absolute client-side security.
- Pristine Traceability via Error Wrapping: Leveraged Go's
fmt.Errorfwith%wto construct a detailed error chain (Domain ➡️ UseCase ➡️ Controller), preserving the exact execution context and parameters for internal APM logs. - Bulletproof Panic Recovery: Engineered a custom deferred recovery utility to gracefully intercept fatal crashes, actively stripping sensitive absolute server paths from the dumped stack trace to localize the blast radius and assist rapid debugging.
The Objective
In a distributed microservices environment, End-to-End (E2E) tests are notoriously slow, flaky, and hard to maintain. If a developer has to spin up PostgreSQL, Redis, and 3 gRPC microservices just to test a single business rule, development velocity drops to zero.
The objective of LZStock's testing strategy is to Shift Left. By strictly adhering to Dependency Inversion, over 80% of the system's core financial logic can be exhaustively verified in milliseconds using in-memory mocks and pure unit tests, reserving slow integration tests only for the actual I/O boundaries.
The Mental Model & Testing Pyramid
LZStock enforces a strict testing pyramid where the type of test is dictated directly by the architectural layer it targets.
| Architectural Layer | Test Focus | Primary Mechanism | Target Coverage |
|---|---|---|---|
| 1. Domain | Pure Business Rules, Invariants, Domain Events Generation | Pure Go Unit Tests (Table-Driven) | 95%+ |
| 2. UseCase | Orchestration, Error Handling, Transaction Boundaries | Unit Tests with In-Memory Mocks | 85-90%+ (Focus on Branching) |
| 3. Controller (API) | API Contract, Routing, Middleware, DTO Mapping | API Component Tests (grpc/test/bufconn) with Mock UseCases | 80-90%+ |
| 4. Infra / Repository | SQL/NoSQL Execution, Cache, External API Integrations | Integration Tests (Testcontainers) | 80-85%+ |
| 5. E2E / System | Cross-Service Flows, Security, Deployment Configs | Black-Box API Tests / UI Automation | Critical Path Only |
Codebase Anatomy:
mods
└── bc1-indicator-insights
├── controllers
│ ├── dashboard_test.go
│ └── index_test.go
├── domain
│ ├── DashboardName_test.go
│ ├── DashboardRepository.go
│ ├── ...
├── dtos
│ └── Dashboard.go
└── useCases
├── Dashboard_test.go
└── index.go
Core Implementation
Below are curated examples demonstrating how the architectural design inherently enables efficient testing across the top three layers of our pyramid.
The Domain (Table-Driven Tests & Invariants)
Domain entities have zero external dependencies. To exhaustively test edge cases (like invalid characters) and verify that Domain Events are correctly generated upon state changes, LZStock utilizes Go's standard Table-Driven Testing pattern. This guarantees 95%+ coverage executed in sub-milliseconds.
// mods/bc1-indicator-insights/domain/DashboardName_test.go
func TestDashboardName(t *testing.T) {
tests := []struct {
name string
input string
expectErr bool
}{
{"Valid Name", "Tech Portfolio 2024", false},
{"Empty String", "", true},
{"Invalid Chars", "Portfolio!@#", true},
{"Max Length Exceeded", strings.Repeat("a", 256), true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := domain.NewDashboardName(tt.input)
if tt.expectErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
The UseCase (Mocking the I/O Boundary)
To test business orchestration and branch logic at lightning speed, the UseCase must never connect to a real database. We inject gomock representations of the Infrastructure layer. This allows us to simulate strict Transaction Boundaries (e.g., triggering a simulated DB timeout to ensure the UseCase correctly orchestrates the rollback and error formatting).
The Refactored Architecture:
// mods/bc1-indicator-insights/useCases/index.go
type UseCase struct {
// Strictly depend on Interfaces, NOT concrete database structs
dashboardCommandRepo domain.DashboardCommandRepository
}
// Inject dependencies via the constructor (Dependency Injection)
func NewUseCase(cmdRepo domain.DashboardCommandRepository) *UseCase {
return &UseCase{dashboardCommandRepo: cmdRepo}
}
By injecting a Mock Repository, we completely eliminate the need for test database setup, teardown scripts (DELETE FROM...), and slow Disk I/O. We can now test DB timeouts and Domain Validation failures in less than 5 milliseconds.
// mods/bc1-indicator-insights/useCases/Dashboard_test.go
func TestCreateDashboard_MockDB(t *testing.T) {
// 1. Initialize gomock controller
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// 2. Generate the Mock Repository
mockCmdRepo := mock_domain.NewMockDashboardCommandRepository(ctrl)
// 3. Inject the mock into the UseCase
uc := NewUseCase(mockCmdRepo, nil, nil) // Pass nil for unused repos
tests := []struct {
name string
req *pb.CreateDashboardReq
setupMock func()
expectedError bool
}{
{
name: "Happy Path: Successful creation",
req: &pb.CreateDashboardReq{
DashboardName: "Mocked Dashboard",
InvestorId: "01234567-89ab-cdef-0123-456789abcdef",
ToolTemplate: pb.ToolTemplate_TOOL_TEMPLATE_STOCK,
PeriodType: pb.PeriodType_PERIOD_TYPE_1Y,
},
setupMock: func() {
// Assert Save() is called exactly once and succeeds
mockCmdRepo.EXPECT().Save(gomock.Any()).Return(nil).Times(1)
},
expectedError: false,
},
{
name: "Failure: Database connection timeout",
req: &pb.CreateDashboardReq{
DashboardName: "Failing Dashboard",
InvestorId: "01234567-89ab-cdef-0123-456789abcdef",
ToolTemplate: pb.ToolTemplate_TOOL_TEMPLATE_STOCK,
PeriodType: pb.PeriodType_PERIOD_TYPE_1Y,
},
setupMock: func() {
// Simulate a database failure without spinning up PostgreSQL
mockCmdRepo.EXPECT().Save(gomock.Any()).Return(fmt.Errorf("db timeout")).Times(1)
},
expectedError: true,
},
{
name: "Failure: Domain validation fails (Empty Name)",
req: &pb.CreateDashboardReq{
DashboardName: "", // Invalid empty name
InvestorId: "01234567-89ab-cdef-0123-456789abcdef",
},
setupMock: func() {
// Assert Save() is NEVER called because domain validation fails first
mockCmdRepo.EXPECT().Save(gomock.Any()).Times(0)
},
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.setupMock() // Configure mock behavior for this specific test
dtoReq := dto.NewCreateDashboard(context.Background(), tt.req)
err := uc.CreateDashboard(dtoReq)
if tt.expectedError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.NotEmpty(t, dtoReq.Res.Dashboard.Id)
}
})
}
}
The Controller (API Component Tests via bufconn)
Testing gRPC Controllers traditionally requires opening real TCP ports, which leads to port conflicts and flaky CI pipelines during parallel testing. To achieve fast, isolated API testing, LZStock leverages google.golang.org/grpc/test/bufconn. This creates an in-memory network listener, allowing the test client to dial the gRPC server entirely within RAM, bypassing the OS network stack.
// mods/bc1-indicator-insights/controllers/dashboard_test.go
func TestCreateDashboardAPI_ErrorMapping(t *testing.T) {
// 1. Setup in-memory listener (Bufconn)
lis := bufconn.Listen(1024 * 1024)
t.Cleanup(func() { lis.Close() })
// 2. Mock the UseCase
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockUC := mock_usecase.NewMockDashboardUseCase(ctrl)
// 3. Start the gRPC server bound to bufconn
grpcServer := grpc.NewServer()
pb.RegisterDashboardsServer(grpcServer, NewController(mockUC))
go grpcServer.Serve(lis)
t.Cleanup(func() { grpcServer.Stop() })
// 4. Dial the in-memory server
conn, _ := grpc.DialContext(context.Background(), "bufnet",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return lis.Dial() }),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
t.Cleanup(func() { conn.Close() })
client := pb.NewDashboardsClient(conn)
// 5. The Core Test: Verifying Protocol Translation
t.Run("Maps Domain Error to gRPC FailedPrecondition", func(t *testing.T) {
// Arrange: Force the Mock UseCase to return a specific internal Domain Error
mockUC.EXPECT().
CreateDashboard(gomock.Any()).
Return(apperrs.ErrMaxWatchlists)
// Act: Execute the gRPC Request
_, err := client.CreateDashboard(context.Background(), &pb.CreateDashboardReq{})
// Assert: Verify the Controller intercepted and translated the error correctly
require.Error(t, err)
// Extract the gRPC Status payload
st, ok := status.FromError(err)
require.True(t, ok, "Expected a structured gRPC status error")
// Assert the exact mapping logic defined in the Controller's switch statement
assert.Equal(t, codes.FailedPrecondition, st.Code())
assert.Equal(t, "exceed max watchlists", st.Message())
})
}
Edge Cases & Trade-offs
- The Mocking Trap (Testing Implementation, not Behavior): Extensive use of mocks in the UseCase layer allows for high-speed testing, but it introduces a trade-off. If you over-specify mock expectations (e.g., forcing a specific order of internal method calls), the tests become brittle. A simple refactoring of internal logic will break the test, even if the final business outcome is correct. LZStock mandates that mocks should only be used to simulate I/O boundaries (Failures/Successes), not to assert exact internal state mutations.
- Repository Integration Tests (Speed vs. Reality): Mocking a database is perfect for the UseCase, but you must test the actual SQL queries somewhere. Using an in-memory SQLite database for repository tests is fast, but it doesn't support advanced PostgreSQL features like specific index types or JSONB operations.
- The Architectural Solution: LZStock's CI pipeline spins up ephemeral PostgreSQL Docker containers (via Testcontainers) for Repository tests. This guarantees 100% SQL compatibility with production, at the accepted trade-off of slightly slower test execution times compared to SQLite.
The Outcome
By aligning the testing strategy directly with Clean Architecture boundaries—and heavily utilizing Dependency Injection—LZStock achieves over 90% test coverage on core financial logic with CI pipeline executions completing in under 30 seconds, enabling fearless and rapid deployments.