/go-tests

Make tests with go lang

Primary LanguageGo

pt-br en
go version 1.22.1

Go tests

Automated tests in GO, in short, it will be a function that will test another function of yours and see if its result is what you are really expecting. A very common practice, so you can guarantee the behavior of things.

Imagine you have a function that takes two parameters and it must return a specific value or type. The tests exist to ensure that your function, receiving these parameters, will actually return the result you are expecting.

It's a way for you to ensure that what you implemented is right, and that it will continue to be right over time, tests give you great security in the code, imagine that you have a great function that is working and returns what I expect that returns, and tomorrow we make a change, if it stops returning the result due to the change, our test will report it to us, making you reevaluate the new behavior or any unforeseen side effect.

Introduction to testing

Doing a very simple test, let's create a go-tests directory, create the main.go file, fill it with the basic code or use the extension go-fast-snippets available for vscode, with it you would just need to start writing gomain and the code will be generated, otherwise This is the basic code:

package main

func main() {
}

Now create an internal directory called addresses and create a file addresses.go in it, if it has our extension go-fast-snippets just start typing gofile in the blank file and the code will be generated, if you don't see:

package addresses

func addressType(address string) string {
  validTypes := []string{
    "street", "avenue", "road", "highway",
  }
}

In it we will create this function that will check if the passed address contains any of the pre-defined validTypes at the beginning.

Now let's finish the function:

package addresses

import "strings"

func addressType(address string) string {
	validTypes := []string{
		"street", "avenue", "road", "highway",
	}
	// address in lowercase
	lowercaseAddress := strings.ToLower(address)
	// Split text in array separing peer empty spaces
	// ex split with empty space result 0-RUA 1-ABC 2-DEF
	// and set in firstWordAddress recovering position 0
	// of the created array
	firstWordAddress := strings.Split(lowercaseAddress, " ")[0]

	isValid := false //first word is valid or not

	for _, t := range validTypes { //iterate with validTypes and check is valid
		if t == firstWordAddress { // if compatible 
			isValid = true //isvalid is true
		}
	}

	if isValid {
		return firstWordAddress // return type "first word of address"
	}
	return "Invalid type" // case not match return message
}

Here we initially change our validTypes values ​​so that all words are lowercase, then we create the variable lowercaseAddress converting the word in our address parameter so that all letters are lowercase, then we create the variable firstWordAddress which receives the first word of our lowercaseAddress, after doing a split on it, separating each word separated by space, then the index [0] is the first word. Now we create the variable isValid with an initial value false, then we iterate validTypes and check if one of its values ​​is compatible with firstWordAddress , if it is isValid it receives true, and is checked when exiting the loop, and if it is valid we return firstWordAddress, otherwise we return a message. And it's our job.

Now at the root directory level in our terminal, let's create a module:

go mod init go-tests

And you will have a go.mod file with this content initially:

module go-tests

go 1.22.1

Remembering that as we are not using external packages there is no problem creating go.mod after creating our internal package, in this case addresses.go, but problems could occur if we used external packages the app.
One correction, as our addressType function will be imported, we have to start its name with the first letter UPPERCASE, that is, our A for address:

package addresses
....
// Verify if address contains a valid type in first word
func AddressType(address string) string {
...
}

Now going back to our main.go and let's call the package:

package main

import (
	"fmt"
	"go-tests/addresses"
)

func main() {
	typeAddress := addresses.AddressType("Street dos bobos")
	// typeAddress := addresses.AddressType("abc dos bobos") // output Invalid Type

	fmt.Println(typeAddress)
}

We also have some changes to our AddressType function:
First in your terminal install the following external package:

go get golang.org/x/text/cases

Now inside the function look for these lines and edit:

...
import (
	"strings"
	"golang.org/x/text/cases"
	"golang.org/x/text/language"
)
...
if isValid {
		caser := cases.Title(language.BrazilianPortuguese) //set language
		return caser.String(firstWordAddress) // To uppercase first letter
	}
...

We use this package to make the first letter of firstWordAddress capitalized and to get used to external packages Now let's move on to testing.

Basic unit testing

We are going to use a Go package called test, and for this package to work correctly with our functions there are some rules to be followed.
1 - The test of a function is never in the same file as the function itself.
2 - To be recognized by go, the test files must have a specific name, the name of the arquivo.go must be changed, when we are creating a test function for arquivo_test.go`` `, this is because to run all the tests, we will run it through the command line, and this command will enter the files that have arquivos_test.go``` and start executing the test functions within it, so this nomenclature is mandatory.

Creating unit test file

Inside the address directory itself, which has the addresses.go file, create a file called addresses_test.go.
A unit test is a test that will test the smallest unit of your code, in our case our AddressType function, there are also integration tests, which cover a slightly larger scope, several functions, complete flows , we will see later.
Signing a test code

package addresses_test

import "testing"

func TestAddressType(t *testing.T) {
  address_to_test := "Avenue Paulista" // address used to testing
	expected_address_type := "Avenue" // expected type
  // run tested function
	receivedAddressType := addresses.AddressType(address_to_test) 

	if receivedAddressType != expected_address_type {
		// param t method, it calls an error in your test
    // the error will be logged in the terminal and it will be considered that
    // broken or not doing what we expect
		t.Error("Received type invalid")
	}
}

Note that the package name is the same as the file addresses.go, in this directory, go gives this exception that you can have two different packages within the same folder. Another detail is the use of the go package testing, when creating our function TestAddressType it receives a parameter, commonly t, and its type is a pointer of (t * testing.T).

The function must also start with the word Test with a capital T, in English, and the name of the function we are going to test, starting with the capital letter, in our case TestAddressType, after ` ``Test```, the next letter must be capitalized. Using this file naming along with the function syntax, go will identify this function to be tested.

In this function we add a value to the variable that will be tested address_to_test, we also define an expected type in expected_address_type, our variable receivedAddressType receives the result of our function AddressType(address_to_test).
After obtaining the result, we check in our if if receivedAddressType is different from our expected result expected_address_type and if it is different, an unexpected return signal from the function, we call t. Error() which will log the error in the terminal and go will consider that your test broke, if it doesn't show an error, we will consider that the test passed.
Now a small change in the if check of our test to display what you expected and what you received:

...
func TestAddressType(t *testing.T) {
  if receivedAddressType != expected_address_type {
		// param t method, it calls an error in your test
    // the error will be logged in the terminal and it will be considered that
    // broken or not doing what we expect
		t.Errorf("Received type invalid, wait %s and receive %s",
			expected_address_type,
			receivedAddressType,
		)
	}
}
...

Now open the terminal inside the package directory /addresses and run the command go test

Unit testing with more than one scenario

Let's do this now by refactoring the past test:

...
type test_scenario struct {
	address_inserted string
	expected_return  string
}

func TestAddressType(t *testing.T) {
	scenarios_of_Test := []test_scenario{
		{"Street Abc", "Street"},
		{"Avenue xyz", "Avenue"},
		{"Road 138", "Road"},
		{"Square park", "Invalid type"},
		{"HiGhway dbo", "Highway"},
		{"", "Invalid type"},
	}

	for _, scenario := range scenarios_of_Test {
		receivedAddressType := AddressType(scenario.address_inserted)

		if receivedAddressType != scenario.expected_return {
			// param t method, it calls an error in your test
      // the error will be logged in the terminal and it will be considered that
      // broken or not doing what we expect
			t.Errorf("Received type invalid, wait %s and receive %s",
				scenario.expected_return,
				receivedAddressType,
			)
		}
	}
}

Run in the terminal inside /addresses go test and see the result.

Tips

go test ./... goes into all packages checking the test files
go test -v detailed test mode
To run in parallel we can add t.Parallel() at the beginning of the function, if there is more than one test function in your test file and it must be added to all functions that you want to run in parallel.

go test --cover Shows if your scenario is being covered 100%, shows the % coverage of its states / executions, that is, all lines of the function we are testing are covered.

go test --coverprofile doc.txt contains a report of lines that are covered and not

go tool cover --func=doc.txt will read the txt file, understand it and play it in the terminal

go tool cover --html=doc.txt shows an html file that will have a nice look of all the lines not covered

Subtests

Create a new directory called forms with two form.go files,form_test.go:
form.go

package form

import (
	"math"
)

func (r Rectangle) Area() float64 {
	return r.Height * r.Width
}
func (c Circle) Area() float64 {
	return math.Pi * (c.Rad * c.Rad)
}

type Circle struct {
	Rad float64
}

type Rectangle struct {
	Height float64
	Width  float64
}

type Form interface {
	Area() float64
}

form_test.go

package form

import (
	"math"
	"testing"
)

func TestArea(t *testing.T) {
	t.Run("Rectangle area", func(t *testing.T) {
		r := Rectangle{10, 12}
		expectedArea := float64(120)
		receivedArea := r.Area()

		if expectedArea != receivedArea {
			//fatal vai parar os testes
			t.Fatalf("Received area %f, expected is %f", receivedArea, expectedArea)
		}
	})
	t.Run("Circle area", func(t *testing.T) {
		c := Circle{10}

		expectedArea := float64(math.Pi * 100)
		receivedArea := c.Area()
		if expectedArea != receivedArea {
			//fatal vai parar os testes
			t.Fatalf("Received area %f, expected is %f", receivedArea, expectedArea)
		}
	})
}

Here we make a group of tests, running one after the other in sequence, if you don't want your application to stop the tests when giving an error, just change t.Fatalf to t.Errof