Test-Driven Development (TDD), Building 'Hello World' in Go
Tags: tdd, tutorial, devops, go
Published: 2024-01-16 04:08:42

## 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:

```go
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

```bash
 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.

```go
// hello.go

package main

func Hello() string {
}
```

Run the test again and see another failure.

```go
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**

```go
// hello.go

package main

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

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

```basic
 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.

```go
// 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**

```go
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**

```go
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)

```go
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.
    

\&gt;💡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**

```go
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.

```bash
./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

```go
const englishHelloPrefix = "Hello, "

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

	return englishHelloPrefix + name
}
```

Run the test

```go
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

```go
	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
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.

```go
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.

```go
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**

```go
/// 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**

```go
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

```go
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.