Advanced Generator Patterns in Go: Test Data and Streams
In this piece
TL;DRGenerators compose: one produces values, the next stage consumes and transforms them. Seed your RNG for reproducible test data, always close channels, and use the
okidiom when draining a finite source. If the whole pipeline is synchronous,iter.Seqis simpler than channels.
Introduction
The introduction to the Generator pattern covered the mechanics: a function returns a receive-only channel, a goroutine sends values, the caller ranges over them. This article is the next step — two applications where generators earn their keep: generating test data for a web service, and composing generators into a streaming pipeline.
Example 1: Test Data for an E-commerce API
Stress-testing an API needs volume and variety: many users, many orders, varying numbers of products per order. Two generators compose to produce exactly that — one emits a product catalog, the other drains it into orders:
type Product struct {
ID int
Name string
Price float64
}
type Order struct {
ID int
UserID int
Products []Product
Total float64
}
func productGenerator(count int) <-chan Product {
out := make(chan Product)
go func() {
defer close(out)
for i := 0; i < count; i++ {
out <- Product{
ID: i + 1,
Name: fmt.Sprintf("Product-%d", i+1),
Price: 10.0 + float64(i),
}
}
}()
return out
}
func orderGenerator(userCount, ordersPerUser int, products <-chan Product) <-chan Order {
out := make(chan Order)
go func() {
defer close(out)
rng := rand.New(rand.NewSource(42)) // reproducible test data
var orderID int
for userID := 1; userID <= userCount; userID++ {
for i := 0; i < ordersPerUser; i++ {
orderID++
var orderProducts []Product
var total float64
for j := 0; j < rng.Intn(5)+1; j++ {
product, ok := <-products
if !ok {
break // catalog exhausted; ship the order as-is
}
orderProducts = append(orderProducts, product)
total += product.Price
}
out <- Order{
ID: orderID,
UserID: userID,
Products: orderProducts,
Total: total,
}
}
}
}()
return out
}
func main() {
products := productGenerator(1000)
orders := orderGenerator(100, 5, products)
// Simulate sending orders to an API
for order := range orders {
fmt.Printf("Sending order %d for user %d with total $%.2f\n", order.ID, order.UserID, order.Total)
}
}Two details matter here:
- Seeded randomness.
rand.New(rand.NewSource(42))makes the generated dataset reproducible — the same run twice produces the same orders, which is what you want when a failing test needs to fail twice. Since Go 1.20 the globalrandis auto-seeded, which is fine for demos and wrong for test fixtures. - The
okcheck. The order generator can draw up to 2,500 products from a catalog of 1,000. Receiving from a closed channel returns the zero value immediately, forever — without theokidiom you’d silently ship orders full ofProduct{}. With it, orders just come out shorter once the catalog is drained.
Example 2: Composing Generators into a Pipeline
Generators chain naturally: the output channel of one stage is the input of the next. Here a mock stream simulates a slow data source, and a processing stage transforms each item:
type DataItem struct {
ID int
Data string
}
// mockDataStream simulates a data source (file, queue, network stream)
func mockDataStream(count int) <-chan DataItem {
out := make(chan DataItem)
go func() {
defer close(out)
for i := 0; i < count; i++ {
time.Sleep(100 * time.Millisecond) // simulate source latency
out <- DataItem{ID: i + 1, Data: fmt.Sprintf("Data-%d", i+1)}
}
}()
return out
}
// process transforms each item as it arrives
func process(stream <-chan DataItem) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for item := range stream {
out <- fmt.Sprintf("Processed: %s (ID: %d)", item.Data, item.ID)
}
}()
return out
}
func main() {
stream := mockDataStream(10)
for result := range process(stream) {
fmt.Println(result)
}
}Each stage is a separate generator with its own goroutine, so stages run concurrently and stay independently testable: swap mockDataStream for a real source in production, or swap process for a recording stub in tests. This is the pipeline pattern in miniature — and when the processing stage becomes the bottleneck, you fan it out with a worker pool.
Channels or iter.Seq?
Both examples above are synchronous at heart — nothing except the artificial Sleep happens concurrently. Written with iter.Seq, they’d need no goroutine, no channel, and no close discipline at all. The honest reason to keep channels here is the shape of the real thing: once the source is a network stream, a message queue, or a pipeline with a worker pool in the middle, values genuinely arrive concurrently, and channels are the right primitive. The comparison in the introduction has the full decision criteria.
Best Practices and Pitfalls
Best practices:
- Seed random generators when test data must be reproducible
- Use buffered channels when a fast producer feeds a slow consumer
- Accept a
context.Contextin generators that run long or perform I/O, so callers can cancel - Keep stages small and composable — one transformation per generator
Pitfalls:
- Receiving from a closed channel without the
okidiom — you get zero values, silently, forever - Forgetting
defer close(out), leaking the consumer’srangeloop into an infinite block - Generating more data than the test actually exercises
- Mock streams so clean they hide the failure modes of the real source
Summary
Generators stop being a toy when they compose: a catalog generator feeding an order generator, a mock stream feeding a processing stage. That composability is what makes them useful for test data and streaming pipelines — and it’s the same shape as the pipeline and worker pool patterns further along in this series.
If you want to experiment with the code examples, you can find them on my GitHub repository.
Filed under
Explore this subject