DEV Community

Chris James
Chris James

Posted on • Edited on

Learn Go by writing tests

This post is the first in a WIP project called learn-go-with-tests.

From the README

  • Explore the Go language by writing tests
  • Get a grounding with TDD. Go is a good language for learning TDD because it is a simple language to learn and testing is built in
  • Be confident that you'll be able to start writing robust, well tested systems in Go

It is assumed that you have installed and setup Go and that you have some beginner knowledge of programming.

Hello, world

It is traditional for your first program in a new language to be Hello, world. Create a file called hello.go and write this code. To run it type go run hello.go.

package main

import "fmt"

func main() {
    fmt.Println("Hello, world")
}
Enter fullscreen mode Exit fullscreen mode

How it works

When you write a program in Go you will have a main package defined with a main func inside it. The func keyword is how you define a function with a name and a body.

With import "fmt" we are importing a package which contains the Println function that we use to print.

How to test

How do you test this? It is good to separate your "domain" code from the outside world (side-effects). The fmt.Println is a side effect (printing to stdout) and the string we send in is our domain.

So let's separate these concerns so it's easier to test

package main

import "fmt"

func Hello() string {
    return "Hello, world"
}

func main() {
    fmt.Println(Hello())
}
Enter fullscreen mode Exit fullscreen mode

We have created a new function again with func but this time we've added another keyword string in the definition. This means this function returns a string.

Now create a new file called hello_test.go where we are going to write a test for our Hello function

package main

import "testing"

func TestHello(t *testing.T) {
    got := Hello()
    want := "Hello, world"

    if got != want {
        t.Errorf("got '%s' want '%s'", got, want)
    }
}
Enter fullscreen mode Exit fullscreen mode

Before explaining, let's just run the code. Run go test in your terminal. It should've passed! Just to check, try deliberately breaking the test by changing the want string.

Notice how you have not had to pick between multiple testing frameworks or decipher a testing DSL to write a test. Everything you need is built in to the language and the syntax is the same as the rest of the code you will write.

Writing tests

Writing a test is just like writing a function, with a few rules

  • It needs to be in a file with a name like xxx_test.go
  • The test function must start with the word Test
  • The test function takes one argument only t *testing.T

For now it's enough to know that your t of type *testing.T is your "hook" into the testing framework so you can do things like t.Fail() when you want to fail.

New things

if

If statements in Go are very much like other programming languages.

Declaring variables

We're declaring some variables with the syntax varName := value, which lets us re-use some values in our test for readability

t.Errorf

We are calling the Errorf method on our t which will print out a message and fail the test. The F stands for format which allows us to build a string with values inserted into the placeholder values %s. When you made the test fail it should be clear how it works.

We will later explore the difference between methods and functions.

Go doc

Another quality of life feature of Go is the documenation. You can launch the docs locally by running godoc -http :8000. If you go to localhost:8000/pkg you will see all the packages installed on your system.

The vast majority of the standard library has excellent documentation with examples. Navigating to http://localhost:8000/pkg/testing/ would be worthwhile to see what's available to you.

Hello, YOU

Now that we have a test we can iterate on our software safely.

In the last example we wrote the test after the code had been written just so you could get an example of how to write a test and declare a function. From this point on we will be writing tests first

Our next requirement is to let us specify the recipient of the greeting.

Let's start by capturing these requirements in a test. This is basic test driven development and allows us to make sure our test is actually testing what we want. When you retrospectively write tests there is the risk that your test may continue to pass even if the code doesn't work as intended.

package main

import "testing"

func TestHello(t *testing.T) {
    got := Hello("Chris")
    want := "Hello, Chris"

    if got != want {
        t.Errorf("got '%s' want '%s'", got, want)
    }
}
Enter fullscreen mode Exit fullscreen mode

Now run go test, you should have a compilation error

./hello_test.go:6:18: too many arguments in call to Hello
    have (string)
    want ()
Enter fullscreen mode Exit fullscreen mode

When using a statically typed language like Go it is important to listen to the compiler. The compiler understands how your code should snap together and work so you don't have to.

In this case the compiler is telling you what you need to do to continue. We have to change our function Hello to accept an argument.

Edit the Hello function to accept an argument of type string

func Hello(name string) string {
    return "Hello, world"
}
Enter fullscreen mode Exit fullscreen mode

If you try and run your tests again your main.go will fail to compile because you're not passing an argument. Send in "world" to make it pass.

func main() {
    fmt.Println(Hello("world"))
}
Enter fullscreen mode Exit fullscreen mode

Now when you run your tests you should see something like

hello_test.go:10: got 'Hello, world' want 'Hello, Chris''
Enter fullscreen mode Exit fullscreen mode

We finally have a compiling program but it is not meeting our requirements according to the test.

Let's make the test pass by using the name argument and concatenate it with Hello,

func Hello(name string) string {
    return "Hello, " + name
}
Enter fullscreen mode Exit fullscreen mode

When you run the tests they should now pass. Normally as part of the TDD cycle we should now refactor.

There's not a lot to refactor here, but we can introduce another language feature constants

Constants

Constants are defined like so

const helloPrefix = "Hello, "
Enter fullscreen mode Exit fullscreen mode

We can now refactor our code

const helloPrefix = "Hello, "

func Hello(name string) string {
    return helloPrefix + name
}
Enter fullscreen mode Exit fullscreen mode

After refactoring, re-run your tests to make sure you haven't broken anything.

Constants should improve performance of your application as it saves you creating the "Hello, " string instance every time Hello is called.

To be clear, the performance boost is incredibly negligible for this example! But it's worth thinking about creating constants to capture the meaning of values and sometimes to aid performance.

Hello, world... again

The next requirement is when our function is called with an empty string it defaults to printing "Hello, World", rather than "Hello, "

Start by writing a new failing test

func TestHello(t *testing.T) {

    t.Run("saying hello to people", func(t *testing.T) {
        got := Hello("Chris")
        want := "Hello, Chris"

        if got != want {
            t.Errorf("got '%s' want '%s'", got, want)
        }
    })

    t.Run("say hello world when an empty string is supplied", func(t *testing.T) {
        got := Hello("")
        want := "Hello, World"

        if got != want {
            t.Errorf("got '%s' want '%s'", got, want)
        }
    })

}
Enter fullscreen mode Exit fullscreen mode

Here we are introducing another tool in our testing arsenal, subtests. Sometimes it is useful to group tests around a "thing" and then have subtests describing different scenarios.

A benefit of this approach is you can set up shared code that can be used in the other tests.

There is repeated code when we check if the message is what we expect.

Refactoring is not just for the production code!

It is important that your tests are clear specifications of what the code needs to do.

We can and should refactor our tests.

func TestHello(t *testing.T) {

    assertCorrectMessage := func(t *testing.T, got, want string) {
        t.Helper()
        if got != want {
            t.Errorf("got '%s' want '%s'", got, want)
        }
    }

    t.Run("saying hello to people", func(t *testing.T) {
        got := Hello("Chris")
        want := "Hello, Chris"
        assertCorrectMessage(t, got, want)
    })

    t.Run("empty string defaults to 'world'", func(t *testing.T) {
        got := Hello("")
        want := "Hello, World"
        assertCorrectMessage(t, got, want)
    })

}
Enter fullscreen mode Exit fullscreen mode

What have we done here?

We've refactored our assertion into a function. This reduces duplication and improves readability of our tests. In go you can declare functions inside other functions and assign them to variables. You can then call them, just like normal functions. We need to pass in t *testing.T so that we can tell the test code to fail when we need to.

t.Helper() is needed to tell the test suite that this method is a helper. By doing this when it fails the line number reported will be in our function call rather than inside our test helper. This will help other developers track down problems easier. If you still don't understand, comment it out, make a test fail and observe the test output.

Now that we have a well-written failing test, let's fix the code.

const helloPrefix = "Hello, "

func Hello(name string) string {
    if name == "" {
        name = "World"
    }
    return helloPrefix + name
}
Enter fullscreen mode Exit fullscreen mode

If we run our tests we should see it satisfies the new requirement and we haven't accidentally broken the other functionality

Discipline

Let's go over the cycle again

  • Write a test
  • Make the compiler pass
  • Run the test, see that it fails and check the error message is meaningful
  • Write enough code to make the test pass
  • Refactor

On the face of it this may seem tedious but sticking to the feedback loop is important.

Not only does it ensure that you have relevant tests it helps ensure you design good software by refactoring with the safety of tests.

Seeing the test fail is an important check because it also lets you see what the error message looks like. As a developer it can be very hard to work with a codebase when failing tests do not give a clear idea as to what the problem is.

By ensuring your tests are fast and setting up your tools so that running tests is simple you can get in to a state of flow when writing your code.

By not writing tests you are committing to manually checking your code by running your software which breaks your state of flow and you wont be saving yourself any time, especially in the long run.

Keep going! More requirements

Goodness me, we have more requirements. We now need to support a second parameter, specifying the language of the greeting. If a language is passed in that we do not recognise, just default to English.

We should be confident that we can use TDD to flesh out this functionality easily!

Write a test for a user passing in Spanish. Add it to the existing suite.

    t.Run("in Spanish", func(t *testing.T) {
        got := Hello("Elodie", "Spanish")
        want := "Hola, Elodie"
        assertCorrectMessage(t, got, want)
    })
Enter fullscreen mode Exit fullscreen mode

Remember not to cheat! Test first. When you try and run the test, the compiler should complain because you are calling Hello with two arguments rather than one.

./hello_test.go:27:19: too many arguments in call to Hello
    have (string, string)
    want (string)
Enter fullscreen mode Exit fullscreen mode

Fix the compilation problems by adding another string argument to Hello

func Hello(name string, language string) string {
    if name == "" {
        name = "World"
    }
    return helloPrefix + name
}
Enter fullscreen mode Exit fullscreen mode

When you try and run the test again it will complain about not passing through enough arguments to Hello in your other tests and in main.go

./hello.go:15:19: not enough arguments in call to Hello
    have (string)
    want (string, string)
Enter fullscreen mode Exit fullscreen mode

Fix them by passing through empty strings. Now all your tests should compile and pass, apart from our new scenario

hello_test.go:29: got 'Hola, Elodie' want 'Hello, Elodie'
Enter fullscreen mode Exit fullscreen mode

We can use if here to check the language is equal to "Spanish" and if so change the message

func Hello(name string, language string) string {
    if name == "" {
        name = "World"
    }

    if language == "Spanish" {
        return "Hola, " + name
    }

    return helloPrefix + name
}
Enter fullscreen mode Exit fullscreen mode

The tests should now pass.

Now it is time to refactor. You should see some problems in the code, "magic" strings, some of which are repeated. Try and refactor it yourself, with every change make sure you re-run the tests to make sure your refactoring isn't breaking anything.

const spanish = "Spanish"
const helloPrefix = "Hello, "
const spanishHelloPrefix = "Hola, "

func Hello(name string, language string) string {
    if name == "" {
        name = "World"
    }

    if language == spanish {
        return spanishHelloPrefix + name
    }

    return helloPrefix + name
}
Enter fullscreen mode Exit fullscreen mode

French

  • Write a test asserting that if you pass in "French" you get "Bonjour, "
  • See it fail, check the error message is easy to read
  • Do the smallest reasonable change in the code

You may have written something that looks roughly like this

func Hello(name string, language string) string {
    if name == "" {
        name = "World"
    }

    if language == spanish {
        return spanishHelloPrefix + name
    }

    if language == french {
        return frenchHelloPrefix + name
    }

    return helloPrefix + name
}
Enter fullscreen mode Exit fullscreen mode

switch

When you have lots of if statements checking a particular value it is common to use a switch statement instead. We can use switch to refactor the code to make it easier to read and more extensible if we wish to add more language support later

func Hello(name string, language string) string {
    if name == "" {
        name = "World"
    }

    prefix := helloPrefix

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

    return prefix + name
}
Enter fullscreen mode Exit fullscreen mode

Write a test to now include a greeting in the language of your choice and you should see how simple it is to extend our amazing function.

one...last...refactor?

You could argue that maybe our function is getting a little big. The simplest refactor for this would be to extract out some functionality into another function and you already know how to declare functions.

func Hello(name string, language string) string {
    if name == "" {
        name = "World"
    }

    return greetingPrefix(language) + name
}

func greetingPrefix(language string) (prefix string) {
    switch language {
    case french:
        prefix = frenchHelloPrefix
    case spanish:
        prefix = spanishHelloPrefix
    default:
        prefix = englishPrefix
    }
    return
}
Enter fullscreen mode Exit fullscreen mode

A few new concepts:

  • In our function signature we have made a named return value (prefix string).
  • This will create a variable called prefix in your function
    • It will be assigned the "zero" value. This depends on the type, for example ints are 0 and for strings it is ""
      • You can return whatever it's set to by just calling return rather than return prefix.
    • This will display in the Go Doc for your function so it can make the intent of your code clearer.
  • default in the switch case will be branched to if none of the other case statements match
  • The function name starts with a lowercase letter. In Go public functions start with a capital letter and private ones start with a lowercase. We dont want the internals of our algorithm to be exposes to the world so we made this function private.

Wrapping up

Who knew you could get so much out of Hello, world ?

By now you should have some understanding of

Some of Go's syntax around

  • Writing tests
  • Declaring functions, with arguments and return types
  • if, else, switch
  • Declaring variables and constants

An understanding of the TDD process and why the steps are important

  • Write a failing test and see it fail so we know we have written a relevant test for our requirements and seen that it produces an easy to understand description of the failure
  • Writing the smallest amount of code to make it pass so we know we have working software
  • Then refactor, backed with the safety of our tests to ensure we have well-crafted code that is easy to work with

In our case we've gone from Hello() to Hello("name"), to Hello("name", "french") in small, easy to understand steps.

This is of course trivial compared to "real world" software but the principles still stand. TDD is a skill that needs practice to develop but by being able to break problems down into smaller components that you can test you will have a much easier time writing software.

Top comments (39)

Collapse
 
ben profile image
Ben Halpern

Wow super great post. Thanks Chris!

Collapse
 
alexmartin profile image
Alex Martin • Edited

Wo Wo Wo... Well done Chris!
This blog helps me out in solving my coding assignments on C, C++, Java, Python in my graduation level.

Cheers
Alex Martin
Content/Copy Writer
EssayCorp: essaycorp.com

Collapse
 
quii profile image
Chris James

What a lovely comment

Collapse
 
onecuteliz profile image
Elizabeth Jenerson

This was awesome. Thanks, Chris!!

Now, turning attention to the audience - if I needed a peer to help w/ creating tests:

  1. Is anyone here willing to be said peer for one hour OR
  2. Where can I go to get a better understanding?

I understand why to test, how to set up my tests (using Rspec to test RoR), but I can't wrap my brain on how to unit test logging in via Google or FB. 😟

If inappropriately posted totally delete and tell me where to go 🤪

Collapse
 
quii profile image
Chris James

I have very limited experience with Ruby.

But the principles of TDD should still be helpful

When you say

unit test logging in via Google or FB

You probably dont want to unit test this. This sounds like if anything an integration test.

You need to think about what you want to test. You dont need to test a 3rd party as you (hopefully) trust it. Even if the test failed, what could you do? You cant edit their code!

What you can test is the way you interact with the 3rd party. You would probably want to read about "mocking" to achieve this.

In short you use something called a "Spy" instead of the "real" Google/FB thing you're using. Your test runs your code and then asks the Spy "Did Liz send through params x, y & z as I expect?" That should give you enough confidence that you're calling the 3rd party correctly.

I will eventually make a post about mocking things, watch this space.

Collapse
 
onecuteliz profile image
Elizabeth Jenerson

You are correct, Chris, that I'm looking for whether the right output came through as expected... Learned that's an integration test today. 🙂

I will definitely watch for that topic.
I've briefy read how some consider mocking the devil... I'm too junior to truly understand why but it'd be great if you could speak to that point ... Maybe folk's incorrect use of it makes mocking appear horrible or speaking to cases where it may not be best to mock. 🤷🏾‍♀️

Either way, thanks immensely, your explanations were wonderful and I'll keep an eye out.

Thread Thread
 
quii profile image
Chris James

Hi Liz

Hope this helps: dev.to/quii/learn-go-by-writing-te...

Collapse
 
chancedev profile image
Chance

Just seeing this now, but really enjoyed this. As someone who is looking to pick up Go as another language, I found your style of explanation really easy to follow and frankly hammered home some concepts that were a little fuzzy to me.

I hope you'll keep writing. Thanks!

Collapse
 
dhaubert profile image
Douglas Haubert

Thanks for sharing the knowledge Chris.
I spent some hours reading godoc, that I have never heard before.

Loved the format of learning a new language such as go through TDD, because I learned testing and syntax in parallel.

Collapse
 
hgisinger profile image
Hernando Gisinger

Great post.

I think that t.ErrorF must be t.Errorf (We are calling the ErrorF method on our t...)

Thanks for sharing

Collapse
 
quii profile image
Chris James

Thanks, and good spot :)

Collapse
 
rolizon profile image
Don Elder

Unreal experience, diving is my favorite thing, now I will officially list it in one of my hobbies. redball4.us

Collapse
 
rhymes profile image
rhymes

Just read it, great post! :-)

Still a bummer there's no "default argument value" in Go, but I understand the point, especially in a strictly typed system.

You can still cheat with structs and default values though

Collapse
 
quii profile image
Chris James

Thanks!

Im not sure it being statically typed is a reason for no default values, kotlin, scala and many others have it.

I guess in Go's eyes they value explicitness over developer convenience in this case. That's not to say either is right or wrong, just an opinion on language design

Collapse
 
shajorjita profile image
RJ • Edited

I hate to sound negative, but want to caution that anyone who is planning to learn Go should take a look at this page:
github.com/ksimka/go-is-not-good

Collapse
 
northbear profile image
northbear • Edited

Some time ago I read all critic articles about Go. And now I have to say that most of them are about of matching of programming habits of authors. Most of them are pretty subjective.
By example, common place for Go critics is absence of generics. But actually some generics are not so generics as they named. They sometimes provide some requirements to object in container. Then what's difference against Go interfaces? So...
I also miss some things in Go, but I prefer to leave a place to grow. Go is pretty young.

Collapse
 
quii profile image
Chris James

I am not going to really engage with this but I had a browse of the lists and i saw

is compiled

Is a pretty interesting reason not to use a language ;)

Collapse
 
shajorjita profile image
RJ

That point definitely sounds ridiculous, however, there are genuine criticisms on that page, it's better for any newbee to skim through the list once.

Thread Thread
 
rhymes profile image
rhymes

Yeah, some of Go's criticism is definitely on point but I guess advantages and drawbacks have to be considered in each language.

I wouldn't pick Go as my first programming language :-)

Collapse
 
k_penguin_sato profile image
K-Sato • Edited

I know this is very subjective. But to me, this was one of the best posts I've read on Dev.to.
Thanks for the post, Chris!!!

Collapse
 
quii profile image
Chris James

Thanks!

Collapse
 
cumirror profile image
tongjin • Edited

great post. Thanks Chris!

and i am puzzled by the granularity of test. For example, whether we needed a test for greetingPrefix when we have had TestHello already.

Collapse
 
quii profile image
Chris James

It's interesting because Go does let you test private functions but I think it is best to avoid doing that.

The reason being is you should try to avoid testing the underlying algorithm in tests. What I mean by that is test outcomes, not the way it is done.

Why is that important?

A lot of code bases suffer when developers want to refactor, but end up having to change a load of test code so it feels like a pain.

But the very definition of refactoring should be that the behaviour of the code doesnt change! If you are testing your algorithms you are making refactoring harder.

Coming back to our example, "greetingPrefix" is just an internals of our public API. Let's pretend I did write a test, but then decided I actually preferred to not have it as a separate function (for some reason). I would have to change test code, even though I'm only refactoring.

I've waffled on a bit there, but i hope it makes sense!

Collapse
 
cumirror profile image
tongjin

It help a lot that the behaviour of the code is more importance than the way how it is done.

Usually, we reduce the internal coupling through the interface(public API), so the behavior of the interface is crucial, and the test should focus on the interface to ensure it behaves as expected. At the angle of efficiency in testing, it is not necessary to pay much attention to the internal implementation.

Thread Thread
 
hippookool profile image
hippookool

I am very interested in this topic, thanks! paperminecraft.io