Skip to main content
Bits & Bytes

Go Drills to Become an Excellent Coder

Recently I built a Write-Ahead Log (WAL) in Go. At times it was slow and tedious to work with files, because I constantly had to refer to the standard library. While referring to documentation is a skill every developer needs, I felt like some of the operations were things that I should have ready at hand as tools.

There’s some debate these days and some would say that AI code completion can save the day here. I disagree for a couple of reasons. First of all, you have to know that the AI is leading you down a good path. The AI is probabilistic at best. More importantly, I need the right moves burned in, so that I can use the tools creatively - without modern technology getting in the way.

So I put together a comprehensive set of drills to practice that would ensure I could implement simple solutions quickly. My thought is that with mastery of these fundamental building blocks I can easily extend them on the fly to build more complex solutions. Of course I will extend my set of exercises as I master these fundamentals.

A comprehensive set of drills designed to help Go developers enhance their skills and move toward excellence. These drills cover everything from basic syntax and best practices to concurrency, testing strategies, and advanced patterns.

Practice Routine

My practice routine for these drills is designed like best practice routines for sports from the book Make It Stick, which combines the latest research in learning to recommend ways to approach the practice. I’ll write more about that in another post, but for now I’ll just outline the principles and applications.

  1. Frequent testing: Testing isn’t just for identifying weak spots. Testing should be done early and often as a recall exercise. My testing for this material literally includes Go tests. That way I can easily validate that solutions perform as expected.
  2. Effortful Practice: Struggling to recall and getting feedback afterward is better than simple review. Research shows that active recall with testing is a learning tool. It isn’t just for identifying weak spots, even though that’s another benefit.
  3. Interleaving: Practicing multiple related things in succession is better than massing practice in a single drill. Research shows that mass practice may be better for short-term retention. However, interleaving helps with long-term retention and generalization.
  4. Variation: Think of the quarterback who practices the same pass from the same position. Research shows that variation helps accelerate learning for generalization.
  5. Spacing: Breaking up sessions on the same topic by hours, days, and eventually by weeks is better than massed practice for any given topic.

Based mostly on these findings I put together a practice schedule to build these skills in 20 minutes per day.

First of all, to practice like I play, all of the exercises include some initial thinking and then some coding. In the beginning of a session I consider the inputs and outputs, then a sequence steps in the code example. Then I practice rewriting the code to perform for the use case. Finally, I attempt subtle variations combining my other knowledge of Go with the material that I’m learning.

Second, if I’m able to complete a drill from memory in any session then I won’t return to it for at least one day, but really for as long I think that I can easily retain it. This is where interleaving comes in. By practicing multiple related skills in succession instead of striving to learn one thing cold, I’m training better for making connections as well as long-term memory. There’s a period of consolidation in breaks and especially in sleep that helps to retain more in the long-term.

Table of Contents

  1. Fundamentals
    • Basic Functions and Testing
    • Structs, Interfaces, and Methods
    • Error Handling and Custom Errors
  2. Intermediate Techniques
    • Working with Slices, Maps, and Ranges
    • Using Go Modules and Understanding Project Structure
    • Handling Files and I/O
  3. Advanced Patterns
    • Concurrency: Goroutines, Channels, and Synchronization
    • Contexts and Cancellation
    • Applying Generics (Go 1.18+)
  4. Testing and Tooling
    • Writing Table-Driven Tests
    • Benchmarking and Profiling
    • Using Mocks and Interfaces for Testability

Fundamentals

1.1. Basic Functions and Testing

Objective:

Write a simple function that computes the factorial of a number using iteration. The drill ensures you understand basic syntax, package structure, and the Go testing framework.

Example Code (factorial.go):

package mathutil

// Factorial calculates the factorial of a non-negative integer n.
// If n is 0 or 1, return 1. Otherwise, return n * (n-1) * ... * 1.
func Factorial(n int) int {
    if n < 0 {
        // Ideally you'd handle this as an error or special case,
        // but let's just return 0 for negative inputs to keep it simple.
        return 0
    }
    result := 1
    for i := 2; i <= n; i++ {
        result *= i
    }
    return result
}

Test Code (factorial_test.go):

package mathutil

import "testing"

func TestFactorial(t *testing.T) {
    tests := []struct {
        name  string
        input int
        want  int
    }{
        {"Zero", 0, 1},
        {"One", 1, 1},
        {"Five", 5, 120},
        {"Negative", -3, 0},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := Factorial(tt.input)
            if got != tt.want {
                t.Errorf("Factorial(%d) = %d; want %d", tt.input, got, tt.want)
            }
        })
    }
}

Run the Tests:

go test ./... -v

1.2. Structs, Interfaces, and Methods

Objective:

Create a Rectangle struct with methods to compute its area and perimeter. Define an interface Shape that Rectangle implements. Test the correctness of these methods.

Example Code (shapes.go):

package shapes

type Shape interface {
    Area() float64
    Perimeter() float64
}

type Rectangle struct {
    Width  float64
    Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

func (r Rectangle) Perimeter() float64 {
    return 2*(r.Width + r.Height)
}

Test Code (shapes_test.go):

package shapes

import "testing"

func TestRectangle(t *testing.T) {
    rect := Rectangle{Width: 10, Height: 5}
    var s Shape = rect

    if got, want := s.Area(), 50.0; got != want {
        t.Errorf("Area() = %f; want %f", got, want)
    }

    if got, want := s.Perimeter(), 30.0; got != want {
        t.Errorf("Perimeter() = %f; want %f", got, want)
    }
}

1.3. Error Handling and Custom Errors

Objective:

Write a function that performs division and returns an error if the divisor is zero. Introduce a custom error type. Test for both success and failure scenarios.

Example Code (divider.go):

package mathutil

import "fmt"

// DivisionByZeroError is a custom error type.
type DivisionByZeroError struct {
    Divisor int
}

func (e DivisionByZeroError) Error() string {
    return fmt.Sprintf("cannot divide by zero: divisor was %d", e.Divisor)
}

// Divide performs integer division and returns an error if divisor is zero.
func Divide(numerator, divisor int) (int, error) {
    if divisor == 0 {
        return 0, DivisionByZeroError{Divisor: divisor}
    }
    return numerator / divisor, nil
}

Test Code (divider_test.go):

package mathutil

import "testing"

func TestDivide(t *testing.T) {
    t.Run("Successful division", func(t *testing.T) {
        got, err := Divide(10, 2)
        if err != nil {
            t.Errorf("Unexpected error: %v", err)
        }
        if got != 5 {
            t.Errorf("Divide(10,2) = %d; want 5", got)
        }
    })

    t.Run("Division by zero", func(t *testing.T) {
        _, err := Divide(10, 0)
        if err == nil {
            t.Error("Expected an error but got nil")
        }
        var zeroErr DivisionByZeroError
        if err != nil && err.Error() != (DivisionByZeroError{Divisor: 0}).Error() {
            t.Errorf("Unexpected error message: %v", err)
        }
    })
}

Intermediate Techniques

2.1. Working with Slices, Maps, and Ranges

Objective:

Write a function that takes a slice of integers and returns a map of each integer to its frequency count. Then test it with various inputs.

Example Code (freq.go):

package collections

func FrequencyCount(nums []int) map[int]int {
    freq := make(map[int]int)
    for _, num := range nums {
        freq[num]++
    }
    return freq
}

Test Code (freq_test.go):

package collections

import (
    "reflect"
    "testing"
)

func TestFrequencyCount(t *testing.T) {
    tests := []struct {
        name  string
        input []int
        want  map[int]int
    }{
        {"Empty slice", []int{}, map[int]int{}},
        {"Single element", []int{5}, map[int]int{5: 1}},
        {"Multiple elements", []int{1,2,2,3,3,3}, map[int]int{1:1, 2:2, 3:3}},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := FrequencyCount(tt.input)
            if !reflect.DeepEqual(got, tt.want) {
                t.Errorf("got = %v; want %v", got, tt.want)
            }
        })
    }
}

2.2. Using Go Modules and Understanding Project Structure

Objective:

Convert your project into a module, organize code into packages, and test them.

Steps:

  1. Run go mod init github.com/yourusername/godrills (replace with your path)
  2. Put code in mathutil/, shapes/, and collections/ directories
  3. Run go test ./... to verify all tests still pass

2.3. Handling Files and I/O

Objective:

Write a function that reads lines from a text file and returns them as a slice of strings. Write a test that uses a temporary file and cleans up after itself.

Example Code (fileio.go):

package fileio

import (
    "bufio"
    "os"
)

func ReadLines(filename string) ([]string, error) {
    file, err := os.Open(filename)
    if err != nil {
        return nil, err
    }
    defer file.Close()

    var lines []string
    sc := bufio.NewScanner(file)
    for sc.Scan() {
        lines = append(lines, sc.Text())
    }
    if err := sc.Err(); err != nil {
        return nil, err
    }
    return lines, nil
}

Test Code (fileio_test.go):

package fileio

import (
    "io/ioutil"
    "os"
    "testing"
)

func TestReadLines(t *testing.T) {
    // Create a temporary file
    tmpFile, err := ioutil.TempFile("", "test*.txt")
    if err != nil {
        t.Fatal(err)
    }
    defer os.Remove(tmpFile.Name())
    defer tmpFile.Close()

    content := "line1\\nline2\\nline3"
    if _, err := tmpFile.Write([]byte(content)); err != nil {
        t.Fatal(err)
    }
    tmpFile.Close() // Reopen for reading

    lines, err := ReadLines(tmpFile.Name())
    if err != nil {
        t.Fatalf("Unexpected error: %v", err)
    }

    if len(lines) != 3 {
        t.Errorf("Expected 3 lines, got %d", len(lines))
    }
    if lines[0] != "line1" || lines[1] != "line2" || lines[2] != "line3" {
        t.Errorf("Lines do not match expected content: %v", lines)
    }
}

Advanced Patterns

3.1. Concurrency: Goroutines, Channels, and Synchronization

Objective:

Write a function that concurrently sums chunks of a large slice using goroutines and channels, then aggregates the results.

Example Code (concurrent_sum.go):

package concurrency

func ConcurrentSum(nums []int, goroutines int) int {
    if goroutines <= 0 {
        goroutines = 1
    }
    chunkSize := (len(nums) + goroutines - 1) / goroutines

    results := make(chan int, goroutines)
    for i := 0; i < goroutines; i++ {
        start := i * chunkSize
        end := start + chunkSize
        if end > len(nums) {
            end = len(nums)
        }
        go func(slice []int) {
            sum := 0
            for _, n := range slice {
                sum += n
            }
            results <- sum
        }(nums[start:end])
    }

    total := 0
    for i := 0; i < goroutines; i++ {
        total += <-results
    }
    return total
}

Test Code (concurrent_sum_test.go):

package concurrency

import "testing"

func TestConcurrentSum(t *testing.T) {
    nums := make([]int, 1000)
    expected := 0
    for i := 0; i < 1000; i++ {
        nums[i] = i + 1
        expected += (i + 1)
    }

    got := ConcurrentSum(nums, 10)
    if got != expected {
        t.Errorf("ConcurrentSum got %d; want %d", got, expected)
    }

    gotSingle := ConcurrentSum(nums, 1) // Should match the normal sum
    if gotSingle != expected {
        t.Errorf("ConcurrentSum single goroutine got %d; want %d", gotSingle, expected)
    }
}

3.2. Contexts and Cancellation

Objective:

Write a function that simulates a long-running job and can be canceled via a context.Context.

Example Code (context_job.go):

package concurrency

import (
    "context"
    "time"
)

// LongRunningJob simulates work by sleeping for a given duration.
// It checks for context cancellation and returns early if canceled.
func LongRunningJob(ctx context.Context, duration time.Duration) error {
    select {
    case <-time.After(duration):
        // Completed successfully
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

Test Code (context_job_test.go):

package concurrency

import (
    "context"
    "testing"
    "time"
)

func TestLongRunningJob(t *testing.T) {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
    defer cancel()

    err := LongRunningJob(ctx, 1*time.Second)
    if err == nil {
        t.Errorf("Expected cancellation error, got nil")
    }

    // Test success
    ctx2 := context.Background()
    err = LongRunningJob(ctx2, 10*time.Millisecond)
    if err != nil {
        t.Errorf("Unexpected error: %v", err)
    }
}

3.3. Applying Generics (Go 1.18+)

Objective:

Write a generic function MapSlice that applies a function to each element of a slice and returns a new slice of transformed elements.

Example Code (generic_map.go):

package generics

func MapSlice[T any, R any](input []T, fn func(T) R) []R {
    result := make([]R, len(input))
    for i, val := range input {
        result[i] = fn(val)
    }
    return result
}

Test Code (generic_map_test.go):

package generics

import (
    "reflect"
    "strconv"
    "testing"
)

func TestMapSlice(t *testing.T) {
    ints := []int{1,2,3,4}
    strs := MapSlice(ints, func(i int) string {
        return strconv.Itoa(i)
    })
    want := []string{"1", "2", "3", "4"}

    if !reflect.DeepEqual(strs, want) {
        t.Errorf("got %v; want %v", strs, want)
    }
}

Testing and Tooling

4.1. Writing Table-Driven Tests

Most of the above tests already used table-driven patterns. Continue to use them for clarity and maintainability.

4.2. Benchmarking and Profiling

Objective:

Add a benchmark test for Factorial to understand its performance characteristics.

Benchmark Code (factorial_benchmark_test.go):

package mathutil

import "testing"

func BenchmarkFactorial(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Factorial(20)
    }
}

Run the Benchmark:

go test -bench=.

Profile with CPU and Memory tools:

go test -cpuprofile=cpu.out -memprofile=mem.out
go tool pprof cpu.out

4.3. Using Mocks and Interfaces for Testability

Objective:

Define an interface for a service that fetches data and write a mock implementation for testing.

Example Code (service.go):

package service

type DataFetcher interface {
    FetchData(id int) (string, error)
}

func ProcessData(df DataFetcher, id int) (string, error) {
    data, err := df.FetchData(id)
    if err != nil {
        return "", err
    }
    // Just an example: convert fetched data to uppercase
    return strings.ToUpper(data), nil
}

Mock and Test (service_test.go):

package service

import (
    "errors"
    "strings"
    "testing"
)

type mockDataFetcher struct {
    data map[int]string
    err  error
}

func (m mockDataFetcher) FetchData(id int) (string, error) {
    if m.err != nil {
        return "", m.err
    }
    return m.data[id], nil
}

func TestProcessData(t *testing.T) {
    mock := mockDataFetcher{
        data: map[int]string{1: "hello"},
    }
    got, err := ProcessData(mock, 1)
    if err != nil {
        t.Fatalf("Unexpected error: %v", err)
    }
    if got != "HELLO" {
        t.Errorf("got %s; want HELLO", got)
    }

    mockErr := mockDataFetcher{
        err: errors.New("network error"),
    }
    _, err = ProcessData(mockErr, 2)
    if err == nil {
        t.Errorf("Expected error, got nil")
    }
}

Conclusion

By working through these drills, you will have:

  1. Practiced the fundamentals of Go:
    • Functions, structs, interfaces, and error handling
    • Core language features and syntax
    • Package organization and testing patterns
  2. Worked with intermediate concepts:
    • Slices, maps, and range operations
    • Module management and project structure
    • File I/O and system interactions
  3. Mastered advanced topics:
    • Concurrency patterns with goroutines and channels
    • Context usage and cancellation patterns
    • Generic programming techniques
    • Testing strategies including mocks and benchmarks

Continue to iterate on these drills, refactor code as you learn new best practices, and expand your test coverage. Over time, this practice will refine your Go skills, making you a more proficient and confident Go developer.