Using Constants in TDD with Go
Posted on December 23, 2025 • 6 min read • 1,145 wordsHow to correctly use constants in Test-Driven Development with Go.

Test-Driven Development (TDD) is not only about writing tests.
Its primary purpose is to design software.
In Go, the use of constants is often misunderstood in TDD:
This article proposes a pragmatic and idiomatic Go approach, based on real-world experience, to understand when a constant represents good design… and when it hides a deeper problem.

The TDD cycle is simple:
Constants usually appear during the refactor phase.
They are rarely an optimization: they are design decisions.
const Really Means in Go
In Go, a constant is:
const MaxRetries = 3 // untyped
const MaxRetriesInt int = 3 // typedtime.Now, environment variables, etc.)If a value depends on time or the environment, it is not a constant.
These are functional invariants:
const MaxLoginAttempts = 5Characteristics:
In TDD, these are the most important constants.
Examples:
const bufferSize = 32 * 1024Characteristics:
Tests should verify behavior, not values.
// production
const MaxRetries = 3
// test
const maxRetries = 3Duplicating a constant between production code and tests is dangerous because it introduces two sources of truth.
A business rule may change while tests remain green, even though the actual behavior is no longer correctly validated. The test stops verifying the business rule and merely confirms a frozen implementation.
Tests should use the production constant:
if !ShouldStop(MaxRetries + 1) {
t.Fatal("expected stop")
}In contrast, a good TDD design directly reuses the production constant in tests. This allows the test to explicitly express the expected business rule, for example by verifying that exceeding the limit correctly stops processing. The result is a more robust and expressive test, aligned with the domain language: it validates an intent, not a numeric value.
Example in a test:
for i := 0; i < 5; i++ {
...
}When a value deserves an explicit name in code, it means it carries design meaning. In that case, it should be either a business constant or a configurable parameter. Using 5 as a loop boundary in a test is a good example: before extracting it into a constant, you should ask whether this number represents a real business rule. If it does, it should become a clearly named constant expressing the functional intent. If it has no business meaning, it is better left inline to avoid unnecessary conceptual noise.
Finally, if this value is expected to change frequently across contexts or test scenarios, it should not be frozen as a constant but modeled as a parameter or option, preserving design flexibility.
In summary:
5 a business rule?If this value changes often:
A constant that changes often is a bad sign:
const Timeout = 2 * time.SecondIf you modify it regularly, you will see:
Result: it should be injected, not frozen.
Default + Options
const DefaultRetryLimit = 3
type Retrier struct {
limit int
}
type Option func(*Retrier)
func WithLimit(n int) Option {
return func(r *Retrier) { r.limit = n }
}
func NewRetrier(opts ...Option) *Retrier {
r := &Retrier{limit: DefaultRetryLimit}
for _, opt := range opts {
opt(r)
}
return r
}Benefits in TDD:
var ErrTooManyRetries = errors.New("too many retries")Test:
if !errors.Is(err, ErrTooManyRetries) {
t.Fatalf("unexpected error")
}Result: stable, readable, refactor-friendly.
type RetryError struct {
Limit int
}
func (e RetryError) Error() string {
return "too many retries"
}Test:
var re RetryError
if errors.As(err, &re) {
if re.Limit != DefaultRetryLimit {
t.Fatal("unexpected limit")
}
}Result: constants become observable invariants.
Timeouts are enemies of TDD when hardcoded.
const Timeout = 2 * time.Second
ctx, cancel := context.WithTimeout(ctx, Timeout)Values with no business meaning:
{"spaces", " ", false}Consequences:
time.Sleeptype Client struct {
timeout time.Duration
}
func NewClient(timeout time.Duration) *Client {
return &Client{timeout: timeout}
}With a default value:
const DefaultTimeout = 2 * time.Second
func NewDefaultClient() *Client {
return NewClient(DefaultTimeout)
}Tests:
sleepUse constants for:
const (
ValidUserID = "user-123"
InvalidUserID = ""
)Values with no business meaning:
{"spaces", " ", false}Result: maximum readability, minimal noise.
const DefaultLimitPerMinute = 100
var ErrRateLimited = errors.New("rate limited")
type Limiter struct {
limit int
used int
}
func WithLimit(n int) Option {
return func(l *Limiter) { l.limit = n }
}
func NewLimiter(opts ...Option) *Limiter {
l := &Limiter{limit: DefaultLimitPerMinute}
for _, opt := range opts {
opt(l)
}
return l
}Tests:
Result: robust, testable, idiomatic Go design.
In TDD, constants are not about “cleaning up” code.
They exist to make design explicit.
In Go, when used correctly, they become:
And when a constant starts causing friction, TDD provides the most valuable signal:
your design needs to evolve.
Dave Farley – Modern TDD & Continuous Delivery (YouTube)
Practical talks and real-world feedback on applying TDD to real systems.
Effective Go – Constants & Design
Official Go documentation explaining idiomatic language choices.
https://go.dev/doc/effective_go