technical-communication
TDD Is More Than Test-First: Three Real Feedback Loops in Go
A growing Go pricing example that makes TDD seams, Red–Green, vertical slices, mock boundaries, and common failure modes concrete.
Many teams say they practice TDD when their actual sequence is: finish the feature, add a few tests, and make CI green. That is still better than having no tests, but those tests did not participate in the design. They never told the team what to build next.
The important part of test-driven development is not that a test file happens to be saved before a production file. An observable behavior first appears as a failing example. We then write the smallest coherent implementation that makes that example pass. Repeating that short loop puts useful pressure on the code: is the public interface easy to use, is each responsibility in the right place, and are uncontrollable dependencies visible at the boundary?
Rather than another string-reversal or addition kata, this article grows a small Go order-pricing service through three cycles. Money stays in integer minor units so floating-point behavior does not distract from the design. The public result is an integer number of Japanese yen.
What TDD actually controls
Red–Green–Refactor is often drawn as a circle, but remembering three colors is not enough. The useful loop is closer to this:
choose one observable behavior
-> write one test that fails (Red)
-> implement only that behavior (Green)
-> review the structure while everything is green
-> choose the next behavior
The order controls three things.
First, a test must prove that it can fail. If we never see Red, we do not know whether the test detects missing behavior or passes regardless of the implementation.
Second, each cycle handles one behavior. Green does not mean implementing every future feature we can imagine. It means closing the current example with a complete, appropriately small solution.
Third, structural changes happen while the suite is green. If behavior changes afterward, the failure points toward the structural edit instead of getting mixed with unfinished feature work.
TDD is therefore closer to continuously calibrating a design with examples than to a technique for increasing coverage. Coverage can tell us that a line ran. It cannot tell us whether a test observed the right behavior.
Confirm the testing seam first
Before the first test, decide where the system will be observed. That public boundary is the testing seam.
Our seam begins as the exported pricing function. When an external exchange-rate dependency appears, it evolves into this exported method:
func (Calculator) TotalJPY(context.Context, Order) (int64, error)
Tests submit orders and read results only through that entry point. They do not call private helpers or inspect internal state. If the same input still produces the same observable result, we should be free to replace one loop with several modules without breaking the tests.
This is why agreeing on the seam matters. A system cannot test every layer with equal intensity. Focusing on stable public behavior prevents ordinary refactoring from shattering a large implementation-coupled suite.
The three behaviors in scope
We will accept exactly three behaviors, one at a time:
- A regular JPY order returns the sum of its line items.
- A Gold order receives a 10% discount.
- A USD order uses an exchange-rate boundary to return JPY.
We will not pre-design Silver tiers, coupon stacking, tax rules, banker’s rounding, or a rate cache. Those may matter later, but no failing test asks for them now.
Slice 1: make a regular order add up
The first test uses a result we can calculate independently: two items at JPY 1,200 plus one item at JPY 600 gives JPY 3,000.
func TestTotalJPYReturnsSumForRegularOrder(t *testing.T) {
order := Order{
Currency: "JPY",
Items: []LineItem{
{UnitPriceMinor: 1200, Quantity: 2},
{UnitPriceMinor: 600, Quantity: 1},
},
}
got, err := TotalJPY(order)
if err != nil {
t.Fatalf("TotalJPY() error = %v", err)
}
if got != 3000 {
t.Fatalf("TotalJPY() = %d; want 3000", got)
}
}
Neither the types nor the function exists yet. Running go test first produces a compile failure:
undefined: Order
That is a valid Red. It proves the Go toolchain found the test and reached compilation. Go’s built-in test mechanics use _test.go files, TestXxx functions, and the go test command. The official tutorial also demonstrates deliberately breaking an implementation to prove that a test detects the failure.1
Making this test green requires only the order types and summation:
type LineItem struct {
UnitPriceMinor int64
Quantity int64
}
type Order struct {
Currency string
Items []LineItem
}
func TotalJPY(order Order) (int64, error) {
var total int64
for _, item := range order.Items {
total += item.UnitPriceMinor * item.Quantity
}
return total, nil
}
The literal 3000 matters. It was worked out before the implementation and acts as an independent oracle. If the test repeated the production loop to calculate expected, both sides could contain the same mistake. The test would only prove that two copies of one algorithm agree. That is a tautological test.
Slice 2: add one discount behavior
The next requirement is a 10% Gold discount. We add CustomerTier to Order, then choose a transparent example: JPY 12,000 discounted by 10% should be JPY 10,800.
func TestTotalJPYAppliesGoldDiscount(t *testing.T) {
order := Order{
Currency: "JPY",
CustomerTier: "gold",
Items: []LineItem{{
UnitPriceMinor: 12000,
Quantity: 1,
}},
}
got, err := TotalJPY(order)
if err != nil {
t.Fatalf("TotalJPY() error = %v", err)
}
if got != 10800 {
t.Fatalf("TotalJPY() = %d; want 10800", got)
}
}
The old implementation compiles, but now Red is more informative:
TotalJPY() = 12000; want 10800
Green adds only the current rule:
if order.CustomerTier == "gold" {
total = total * 90 / 100
}
There is no reason yet to turn this into a configurable strategy table. TDD does not forbid abstraction. It asks existing change pressure to justify the abstraction. One branch currently expresses the rule clearly.
Slice 3: an external rate makes the interface evolve
The third behavior changes the nature of the problem. JPY pricing is pure calculation, while USD conversion needs an external rate. That operation may block, fail, or be cancelled, so context.Context and an error result now have a concrete purpose.
We did not put this structure into Slice 1 as a prediction. The public function evolves into a Calculator that owns a dependency only when a test needs the external boundary:
type Rate struct {
Numerator int64
Denominator int64
}
type ExchangeRateProvider interface {
Rate(ctx context.Context, from, to string) (Rate, error)
}
type Calculator struct {
rates ExchangeRateProvider
}
The test places a small fake at the system boundary. It replaces the real rate service, not one of our pricing modules:
type stubRates struct {
rate Rate
}
func (s stubRates) Rate(
_ context.Context,
_, _ string,
) (Rate, error) {
return s.rate, nil
}
func TestCalculatorConvertsUSDOrderToJPY(t *testing.T) {
calculator := NewCalculator(stubRates{
rate: Rate{Numerator: 150, Denominator: 100},
})
order := Order{
Currency: "USD",
Items: []LineItem{{
UnitPriceMinor: 2000,
Quantity: 1,
}},
}
got, err := calculator.TotalJPY(context.Background(), order)
if err != nil {
t.Fatalf("TotalJPY() error = %v", err)
}
if got != 3000 {
t.Fatalf("TotalJPY() = %d; want 3000", got)
}
}
In this deliberately narrow example, 150/100 converts USD minor units into whole JPY: 2,000 cents multiplied by 150 and divided by 100 gives JPY 3,000. A real payment system must also define currency precision, rounding policy, rate timestamp, and overflow handling. Those concerns are outside this example because they would obscure the testing boundary.
The test does not assert how many times Rate was called or inspect a helper. The returned 3000 already proves that the boundary value affected public behavior.
The complete implementation after three cycles
After the three behaviors, the production code looks like this:
package pricing
import "context"
type LineItem struct {
UnitPriceMinor int64
Quantity int64
}
type Order struct {
Currency string
CustomerTier string
Items []LineItem
}
type Rate struct {
Numerator int64
Denominator int64
}
type ExchangeRateProvider interface {
Rate(ctx context.Context, from, to string) (Rate, error)
}
type Calculator struct {
rates ExchangeRateProvider
}
func NewCalculator(rates ExchangeRateProvider) Calculator {
return Calculator{rates: rates}
}
func (c Calculator) TotalJPY(
ctx context.Context,
order Order,
) (int64, error) {
var total int64
for _, item := range order.Items {
total += item.UnitPriceMinor * item.Quantity
}
if order.CustomerTier == "gold" {
total = total * 90 / 100
}
if order.Currency == "" || order.Currency == "JPY" {
return total, nil
}
rate, err := c.rates.Rate(ctx, order.Currency, "JPY")
if err != nil {
return 0, err
}
return total * rate.Numerator / rate.Denominator, nil
}
With everything green, the three examples can be organized as table-driven subtests. Go’s t.Run gives each case a distinct name and allows one case to be selected with -run.2
package pricing
import (
"context"
"testing"
)
type stubRates struct {
rate Rate
}
func (s stubRates) Rate(
_ context.Context,
_, _ string,
) (Rate, error) {
return s.rate, nil
}
func TestCalculatorTotalJPY(t *testing.T) {
tests := []struct {
name string
calculator Calculator
order Order
want int64
}{
{
name: "regular JPY order",
calculator: NewCalculator(nil),
order: Order{Currency: "JPY", Items: []LineItem{
{UnitPriceMinor: 1200, Quantity: 2},
{UnitPriceMinor: 600, Quantity: 1},
}},
want: 3000,
},
{
name: "Gold discount",
calculator: NewCalculator(nil),
order: Order{Currency: "JPY", CustomerTier: "gold", Items: []LineItem{
{UnitPriceMinor: 12000, Quantity: 1},
}},
want: 10800,
},
{
name: "USD conversion",
calculator: NewCalculator(stubRates{
rate: Rate{Numerator: 150, Denominator: 100},
}),
order: Order{Currency: "USD", Items: []LineItem{
{UnitPriceMinor: 2000, Quantity: 1},
}},
want: 3000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.calculator.TotalJPY(
context.Background(),
tt.order,
)
if err != nil {
t.Fatalf("TotalJPY() error = %v", err)
}
if got != tt.want {
t.Fatalf("TotalJPY() = %d; want %d", got, tt.want)
}
})
}
}
Turning the tests into a table is a structural review performed after the suite is green. It is not the same as writing every future test in one batch. Behavior stayed fixed while its expression became more compact.
Why these tests are durable
Four properties are worth preserving:
- Test names describe business behavior, not an internal call sequence.
- Every result is read through the public seam.
- Expected values come from independently worked examples.
- Only the exchange-rate system boundary is replaced.
If discounting moves into a private function, summation changes algorithms, or files are reorganized, these tests should keep passing as long as the three public behaviors remain the same.
Three common forms of fake TDD
Testing implementation details
Examples include requiring discountService.Apply to be called exactly once or testing a private method directly. Those tests record today’s structure rather than product behavior. A harmless refactor breaks them even when the result is unchanged.
A boundary interaction can itself be behavior—for example, a payment request may be required to carry an idempotency key. In this example, however, the caller cares about the order total, not the number of internal method calls.
Tautological expected values
If production code sums with a loop and the test copies the same loop to calculate want, both can be wrong in the same way. A fixed specification example, a hand calculation, or another independent source of truth is stronger.
Horizontal slicing
Writing a dozen tests first and implementing the entire feature afterward may look test-first, but it validates an imagined system shape. None of those tests has learned from implementation feedback, so interfaces and fixtures tend to be over-designed.
A vertical slice is one test, one implementation, and one piece of feedback. The second test uses what the first cycle taught us. The third allows a real new constraint to change the boundary.
Green does not mean intentionally bad code
“Write only enough code to pass” is sometimes interpreted as permission to hard-code results or ignore clarity. A better reading is: write the smallest coherent implementation of the current behavior.
Green still requires clear naming, suitable data types, and correct error propagation. What it excludes is expansion without behavioral evidence: a plugin system, speculative discount abstractions, or untested configuration switches.
Keep mocks at system boundaries
Useful replacements usually sit outside the process or outside our control:
- third-party APIs
- databases and message queues
- clocks and randomness
- filesystems
- payment, email, and exchange-rate services
Modules we own should normally be composed with their real implementations. Mocking many internal objects turns a test into a call graph. The graph becomes obsolete as soon as the code is reorganized.
A mock is not the goal. A test database, an in-memory implementation, or a controlled clock may provide a more faithful boundary. The same criterion applies: observe the target behavior with the least structural coupling.
When not to force TDD
TDD is not the default answer for every kind of work. These tasks often need another way to reduce uncertainty first:
- a disposable technical spike where the feasible path is still unknown
- purely visual iteration that must be judged on screen
- generated glue with almost no business decision in it
- behavior that cannot yet be stated and must be discovered through a prototype
That does not mean the resulting production code should never be tested. Run a spike with an explicit intention to discard it, learn the interface and constraints, then begin TDD from the first stable behavior. The crucial boundary is not allowing exploratory code to quietly become production code.
A team checklist
Before starting the next cycle, ask:
- Which public behavior are we observing?
- Has the team agreed on the testing seam?
- Did we see the test fail for the expected reason?
- Is this cycle adding exactly one behavior?
- Does the expected value come from an independent source?
- Are replacements limited to uncontrollable system boundaries?
- After Green, do structural edits keep the whole suite passing?
Kent Beck’s Test-Driven Development: By Example develops this style through a sequence of small examples.3 The real practice is not memorizing the loop’s name. It is shrinking each step until every failure explains something and every Green closes one feedback loop.
References
- Go Documentation, Add a test
- Go Blog, Using Subtests and Sub-benchmarks
- Kent Beck, Test-Driven Development: By Example, Addison-Wesley