Test-Driven Development (TDD), Building 'Hello World' in Go

Test-Driven Development (TDD), Building 'Hello World' in Go

Mastering TDD: Building 'Hello World' in Go, One Test at a Time

What is Test-Driven Development?

Test-Driven Development (TDD) is a software development methodology that focuses on writing tests before writing the actual code. In TDD, you start by creating a test case that defines the expected behavior of a particular piece of code. Then, you write the code to make the test pass. TDD follows a cycle known as the "Red-Green-Refactor" cycle:

  1. Red: You begin by writing a failing test case (the "Red" phase) that specifies the desired functionality.

  2. Green: You then write the minimal code required to make the test pass (the "Green" phase). This ensures that the code meets the specified requirements.

  3. Refactor: After the test passes, you can refactor the code to improve its quality while keeping the test passing. This maintains the code's correctness as you make changes.

Before diving into our inaugural TDD Go application, it's crucial to emphasize another vital facet of TDD: setting up source control, such as Git. Git plays a pivotal role by allowing you to commit the latest functional iteration of your TDD process. This safeguards your progress, enabling you to revert to a previous version and embark on a fresh refactoring journey with confidence.

Lets start TDD with our hello world program again

Prerequisite:

  • Create new project folder e.g: hello-world

  • Create 2 files inside it hello_test.go and hello.go, its Ok if the contents are empty

  • Generate go module with go mod init hello.

And you should be ready to follow along.

RED

Open your hello_test.go file in your editor/IDE and write a test case first:

package main

import "testing"

func TestHello(t *testing.T) {
    t.Run("Say hello to John Doe", func(t *testing.T) {
        got := Hello("John Doe")
        expected := "Hello, John Doe"

        if got != expected {
            t.Errorf("expected %q but got %q", expected, got)
        }
    })
}

Run the go test -v and see it failing

 go test -v                                                                              ─╯
# hello [hello.test]
./hello_test.go:7:10: undefined: Hello
FAIL    hello [build failed]

GREEN

Open your hello.go file in editor and create a Hello function because our test case is complaining Hello is undefined.

// hello.go

package main

func Hello() string {
}

Run the test again and see another failure.

go test -v                                                                              ─╯
# hello [hello.test]
./hello.go:4:1: missing return
./hello_test.go:7:16: too many arguments in call to Hello
        have (string)
        want ()
FAIL    hello [build failed]

Running your tests at each step is essential to ensure that your test cases do not unexpectedly pass when they shouldn't.

Lets write enough code to make this test GREEN

// hello.go

package main

func Hello(name string) string {
    return "Hello, " + name
}

Run your test case again and it should pass this time.

 go test -v                                                                              ─╯
=== RUN   TestHello
=== RUN   TestHello/Say_hello_to_John_Doe
--- PASS: TestHello (0.00s)
    --- PASS: TestHello/Say_hello_to_John_Doe (0.00s)
PASS
ok      hello   1.417s

Source Control

💡At this point because we have workable version of the code, lets commit our changes to git git commit -am "Function to say hello to John Doe"

REFACTOR

Now that our test case is passing, let's refactor our code.

// hello.go

// ...

const englishHelloPrefix = "Hello, "

func Hello(name string) string {
    return englishHelloPrefix + name
}

Run the test again go test and everything should still pass. It might seem silly to make that change right now, trust me we need it.

In this step, we've extracted the "Hello, " message into a constant. It's worth considering the use of constants to convey the meaning of values and, in some cases, to optimize performance.

💡As a final step, because now we have all steps satisfied RED, GREEN, REFACTOR, it is time to commit your changes to git and also push it to the git repository.

Continue to add features with TDD in mind

Requirement: if name is "" empty string, return Hello, world instead.

RED

func TestHello(t *testing.T) {
    t.Run("Say hello to John Doe", func(t *testing.T) {
        got := Hello("John Doe")
        expected := "Hello, John Doe"

        if got != expected {
            t.Errorf("expected %q but got %q", expected, got)
        }
    })

    t.Run("say 'Hello, world' when an empty string is given", func(t *testing.T) {
        got := Hello("")
        expected := "Hello, world"

        if got != expected {
            t.Errorf("expected %q but got %q", expected, got)
        }
    })
}

Run the test go test it should fail with this message hello_test.go:20: expected "Hello, world" but got "Hello, " .

GREEN

const englishHelloPrefix = "Hello, "

func Hello(name string) string {
    if name == "" {
        name = "world"
    }

    return englishHelloPrefix + name
}

Run the test go test again and it should pass.

💡commit your code to git

REFACTOR

There is nothing to refactor in the actual program, but we can extract duplicate code from our test case to keep it DRY (Don't Repeat Yourself)

func TestHello(t *testing.T) {
    t.Run("Say hello to John Doe", func(t *testing.T) {
        got := Hello("John Doe")
        expected := "Hello, John Doe"

        assert(t, expected, got)
    })

    t.Run("say 'Hello, world' when an empty string is given", func(t *testing.T) {
        got := Hello("")
        expected := "Hello, world"

        assert(t, expected, got)
    })
}

func assert(t testing.TB, expected, got string) {
    t.Helper()

    if expected != got {
        t.Errorf("Expected %q but got %q", expected, got)
    }
}

Run your test again and it should still pass.

We have created assert function that expects:

  • testing.TB object as first argument, this will allow us to pass either *testing.T testing or *testing.B benchmarking.

  • we also said this is a helper function by calling t.Helper(). We did this because if the test case fail, it will show the exact line number where it failed from the actual test function rather then showing line number of the assert() function. You can change one of your test case with wrong assertion and see it for yourself by keeping and removing t.Helper() from assert() function.

\>💡commit your changes to git, and push your code to git repo

Enjoying so far? 😍lets keep moving forward.

Requirement: our Hello function should support additional parameter to specify the language of the greeting. Example Hola, in Spanish, Bonjour, in French, and you can choose your own language, example नमस्ते, (Nameste) in Nepali.

RED

func TestHello(t *testing.T) {
    // old code

    t.Run("greeting in Spanish", func(t *testing.T) {
        got := Hello("John", "es")
        expected := "Hola, John"

        assert(t, expected, got)
    })
}

Run the test and it should fail. BTW because go is Statically typed language, if your Editor/IDE is correctly configured, you should see compiler yelling at you at this point.

./hello_test.go:21:24: too many arguments in call to Hello
        have (string, string)
        want (string)
FAIL    hello [build failed]

GREEN

Add lang string parameter to your Hello() function

const englishHelloPrefix = "Hello, "

func Hello(name, lang string) string {
    if name == "" {
        name = "world"
    }

    return englishHelloPrefix + name
}

Run the test

go test                                                                                 ─╯
# hello [hello.test]
./hello_test.go:7:16: not enough arguments in call to Hello
        have (string)
        want (string, string)
./hello_test.go:14:16: not enough arguments in call to Hello
        have (string)
        want (string, string)
FAIL    hello [build failed]

As you can see, by writing TDD you know exactly when your code breaks, lets fix our old tests

    t.Run("Say hello to John Doe", func(t *testing.T) {
        got := Hello("John Doe", "") // added "" as second argument
        expected := "Hello, John Doe"

        assert(t, expected, got)
    })

    t.Run("say 'Hello, world' when an empty string is given", func(t *testing.T) {
        got := Hello("", "") // added "" as second argument
        expected := "Hello, world"

        assert(t, expected, got)
    })

    t.Run("greeting in Spanish", func(t *testing.T) {
        got := Hello("John", "es")
        expected := "Hola, John"

        assert(t, expected, got)
    })

Run the test again

go test                                                                                 ─╯
--- FAIL: TestHello (0.00s)
    --- FAIL: TestHello/greeting_in_Spanish (0.00s)
        hello_test.go:24: Expected "Hola, John" but got "Hello, John"
FAIL
exit status 1
FAIL    hello   0.804s

Now you should see, we are expecting Hola, John but our function is returning Hello, John. Lets fix it.

const englishHelloPrefix = "Hello, "

func Hello(name, lang string) string {
    if name == "" {
        name = "world"
    }

    if lang == "es" {
        return "Hola, " + name
    }

    return englishHelloPrefix + name
}

Run the test and you should see everything passing, 🎉

💡commit your changes to git

REFACTOR

Let's extract Spanish greeting to it's own constant and also extract lang value to its own constant.

const (
    englishHelloPrefix = "Hello, "
    spanishHelloPrefix = "Hola, "
)

const spanish = "es"

func Hello(name, lang string) string {
    if name == "" {
        name = "world"
    }

    if lang == spanish {
        return spanishHelloPrefix + name
    }

    return englishHelloPrefix + name
}

Run the test again and everything should pass.

💡 Commit your changes to git and push to git repo

Exercise: Lets add French language support

Solution: RED

/// old code


    t.Run("greeting in French", func(t *testing.T) {
        got := Hello("John", "fr")
        expected := "Bonjour, John"

        assert(t, expected, got)
    })

Run the test you should see hello_test.go:31: Expected "Bonjour, John" but got "Hello, John"

GREEN

const (
    englishHelloPrefix = "Hello, "
    spanishHelloPrefix = "Hola, "
    frenchHelloPrefix  = "Bonjour, "
)

const (
    spanish = "es"
    french  = "fr"
)

func Hello(name, lang string) string {
    if name == "" {
        name = "world"
    }

    if lang == spanish {
        return spanishHelloPrefix + name
    }

    if lang == french {
        return frenchHelloPrefix + name
    }

    return englishHelloPrefix + name
}

Now run the test, it should pass again.

REFACTOR

Now lets use switch statement to replace our if statements

const (
    englishHelloPrefix = "Hello, "
    spanishHelloPrefix = "Hola, "
    frenchHelloPrefix  = "Bonjour, "
)

const (
    spanish = "es"
    french  = "fr"
)

func Hello(name, lang string) string {
    if name == "" {
        name = "world"
    }

    prefix := englishHelloPrefix

    switch lang {
    case spanish:
        prefix = spanishHelloPrefix
    case french:
        prefix = frenchHelloPrefix
    }

    return prefix + name
}

And the test should still pass.

What we practiced here?

  • Write a test first and see it fail (RED)

  • Make the code and make compiler happy

  • Run the test case and see it pass (GREEN)

  • Commit your code to git at this point, so that further Refactor becomes seemless

  • Refactor.

  • Commit your code to git and push your changes to git repository.

Hope you enjoyed this TDD approach.